diff --git a/python/src/aiconfig/editor/server/error_handling.ipynb b/python/src/aiconfig/editor/server/error_handling.ipynb new file mode 100644 index 000000000..74e0288cb --- /dev/null +++ b/python/src/aiconfig/editor/server/error_handling.ipynb @@ -0,0 +1,352 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Motivation\n", + "Let's say we're building a local editor that allows you to load an AIConfig\n", + "from a local file and then run methods on it.\n", + "\n", + "In the (simplified) code below, we do just that." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded AIConfig: NYC Trip Planner\n", + "\n", + "\n" + ] + } + ], + "source": [ + "import json\n", + "from typing import Any\n", + "\n", + "\n", + "def read_json_from_file(path: str) -> dict[str, Any]:\n", + " with open(path, \"r\") as f:\n", + " return json.loads(f.read())\n", + " \n", + "\n", + "def start_app(path: str):\n", + " \"\"\"Load an AIConfig from a local path and do something with it.\"\"\"\n", + " aiconfig = read_json_from_file(path)\n", + " print(f\"Loaded AIConfig: {aiconfig['name']}\\n\")\n", + "\n", + "\n", + "start_app(\"cookbooks/Getting-Started/travel.aiconfig.json\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cool, LGTM, ship it!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# A few hours later..." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Issue #9000 Editor crashes on new file path\n", + "### opened 2 hours ago by lastmile-biggest-fan\n", + "\n", + "Dear LastMile team,\n", + "I really like the editor, but when I give it a new file path, it crashes!\n", + "I was hoping it would create a new AIConfig for me and write it to the file..." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# OK, what happened?" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "ename": "FileNotFoundError", + "evalue": "[Errno 2] No such file or directory: 'i-dont-exist-yet-please-create-me.json'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mFileNotFoundError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[17], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mstart_app\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mi-dont-exist-yet-please-create-me.json\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", + "Cell \u001b[0;32mIn[16], line 11\u001b[0m, in \u001b[0;36mstart_app\u001b[0;34m(path)\u001b[0m\n\u001b[1;32m 9\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mstart_app\u001b[39m(path: \u001b[38;5;28mstr\u001b[39m):\n\u001b[1;32m 10\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Load an AIConfig from a local path and do something with it.\"\"\"\u001b[39;00m\n\u001b[0;32m---> 11\u001b[0m aiconfig \u001b[38;5;241m=\u001b[39m json\u001b[38;5;241m.\u001b[39mloads(\u001b[43mread_file\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpath\u001b[49m\u001b[43m)\u001b[49m)\n\u001b[1;32m 12\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mLoaded AIConfig: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00maiconfig[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mname\u001b[39m\u001b[38;5;124m'\u001b[39m]\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 13\u001b[0m \u001b[38;5;28mprint\u001b[39m()\n", + "Cell \u001b[0;32mIn[16], line 5\u001b[0m, in \u001b[0;36mread_file\u001b[0;34m(path)\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mread_file\u001b[39m(path: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mstr\u001b[39m:\n\u001b[0;32m----> 5\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28;43mopen\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mpath\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mr\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;28;01mas\u001b[39;00m f:\n\u001b[1;32m 6\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m f\u001b[38;5;241m.\u001b[39mread()\n", + "File \u001b[0;32m/opt/homebrew/Caskroom/miniconda/base/envs/aiconfig/lib/python3.10/site-packages/IPython/core/interactiveshell.py:310\u001b[0m, in \u001b[0;36m_modified_open\u001b[0;34m(file, *args, **kwargs)\u001b[0m\n\u001b[1;32m 303\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m file \u001b[38;5;129;01min\u001b[39;00m {\u001b[38;5;241m0\u001b[39m, \u001b[38;5;241m1\u001b[39m, \u001b[38;5;241m2\u001b[39m}:\n\u001b[1;32m 304\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\n\u001b[1;32m 305\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mIPython won\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mt let you open fd=\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mfile\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m by default \u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 306\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mas it is likely to crash IPython. If you know what you are doing, \u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 307\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124myou can use builtins\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m open.\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 308\u001b[0m )\n\u001b[0;32m--> 310\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mio_open\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfile\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n", + "\u001b[0;31mFileNotFoundError\u001b[0m: [Errno 2] No such file or directory: 'i-dont-exist-yet-please-create-me.json'" + ] + } + ], + "source": [ + "start_app(\"i-dont-exist-yet-please-create-me.json\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Oops" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ok, let's diagnose the problem here. We forgot to handle the case where the path doesn't exist.\n", + "\n", + "That's understandable. As programmers, we don't always write perfect code.\n", + "Sometimes it's helpful to bring new tools into the workflow to prevent this kind of problem in the future.\n", + "\n", + "\n", + "Hmm, ok. Wouldn't it be nice if we had a static analyzer that could have caught this problem immediately? That way we could have fixed it before the initial PR was merged.\n", + "\n", + "Let's analyze some tools." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## V2: Optional\n", + "\n", + "First, let's fix the root cause and catch exceptions. Now, what do we do in the `except` block? \n", + "\n", + "Well, we can reraise, but that brings us right back to the previous case and doesn't achieve anything helpful. \n", + "\n", + "Instead, notice what happens if we return None and type hint the function accordingly (Optional[...])." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "[Pyright] Object of type \"None\" is not subscriptable\n", + "PylancereportOptionalSubscript\n", + "(variable) aiconfig: dict[str, Any] | None\n", + "\n" + ] + } + ], + "source": [ + "from typing import Any, Optional\n", + "\n", + "\n", + "def read_json_from_file(path: str) -> Optional[dict[str, Any]]:\n", + " try:\n", + " with open(path, \"r\") as f:\n", + " return json.loads(f.read())\n", + " except Exception as e:\n", + " return None\n", + " \n", + "\n", + "def start_app(path: str):\n", + " \"\"\"Load an AIConfig from a local path and do something with it.\"\"\"\n", + " aiconfig = read_json_from_file(path)\n", + " print(f\"Loaded AIConfig: {aiconfig['name']}\\n\")\n", + "\n", + "print(\"\"\"\n", + "[Pyright] Object of type \"None\" is not subscriptable\n", + "PylancereportOptionalSubscript\n", + "(variable) aiconfig: dict[str, Any] | None\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Aha!\n", + "\n", + "Now, Pyright immediately tells us that `None` is a possibility, and we have to handle this case. Let's do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded AIConfig: NYC Trip Planner\n", + "\n", + "Loaded AIConfig: \n", + "\n" + ] + } + ], + "source": [ + "from typing import Optional\n", + "from aiconfig.Config import AIConfigRuntime\n", + "\n", + "\n", + "\n", + "def read_json_from_file(path: str) -> Optional[dict[str, Any]]:\n", + " try:\n", + " with open(path, \"r\") as f:\n", + " return json.loads(f.read())\n", + " except FileNotFoundError:\n", + " return None\n", + "\n", + "def start_app(path: str):\n", + " \"\"\"Load an AIConfig from a local path and do something with it.\"\"\"\n", + " aiconfig = read_json_from_file(path)\n", + " if aiconfig is None:\n", + " print(f\"Could not load AIConfig from path: {path}. Creating and saving.\")\n", + " aiconfig = json.dumps(AIConfigRuntime.create())\n", + " # [save the aiconfig to the path] \n", + " print(f\"Loaded and saved new AIConfig\\n\")\n", + " else:\n", + " print(f\"Loaded AIConfig: {aiconfig}\\n\")\n", + "\n", + "start_app(\"cookbooks/Getting-Started/travel.aiconfig.json\")\n", + "start_app(\"i-dont-exist-yet-please-create-me.json\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ok, cool, much better. But wait, it would be nice to retain some information about what went wrong. My `None` value doesn't tell me anything about why the AIConfig couldn't be loaded. Does the file not exist? Was it a permission problem, networked filesystem problem? etc." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# V3: Result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The result library (https://github.com/rustedpy/result) provides a neat type\n", + "called `Result`, which is a bit like Optional. It's parametrized by the value type just like optional, but also by a second type for the error case.\n", + "\n", + "We can use it like optional, but store an arbitrary value with information about what went wrong." + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded AIConfig: NYC Trip Planner\n", + "\n", + "Could not load AIConfig from path: i-dont-exist-yet-please-create-me.json (File not found at path: i-dont-exist-yet-please-create-me.json). Creating and saving.\n", + "Created and saved new AIConfig: \n", + "\n" + ] + } + ], + "source": [ + "from aiconfig.Config import AIConfigRuntime\n", + "from result import Result, Ok, Err\n", + "from typing import Any\n", + "\n", + "from json import JSONDecodeError\n", + "\n", + "\n", + "def read_json_from_file(path: str) -> Result[dict[str, Any], str]:\n", + " \"\"\"Use `str` in the error case to contain a helpful error message.\"\"\"\n", + " try:\n", + " with open(path, \"r\") as f:\n", + " return Ok(json.loads(f.read()))\n", + " except FileNotFoundError:\n", + " return Err(f\"File not found at path: {path}\")\n", + " except OSError as e:\n", + " return Err(f\"Could not read file at path: {path}: {e}\")\n", + " except JSONDecodeError as e:\n", + " return Err(f\"Could not parse JSON at path: {path}: {e}\")\n", + "\n", + "def start_app(path: str):\n", + " \"\"\"Load an AIConfig from a local path and do something with it.\"\"\"\n", + " file_contents = read_json_from_file(path)\n", + " match file_contents:\n", + " case Ok(aiconfig_ok):\n", + " print(f\"Loaded AIConfig: {aiconfig_ok['name']}\\n\")\n", + " case Err(e):\n", + " print(f\"Could not load AIConfig from path: {path} ({e}). Creating and saving.\")\n", + " aiconfig = AIConfigRuntime.create().model_dump(exclude=\"callback_manager\")\n", + " # [Save to file path]\n", + " # aiconfig.save(path)\n", + " print(f\"Created and saved new AIConfig: {aiconfig['name']}\\n\")\n", + "\n", + "start_app(\"cookbooks/Getting-Started/travel.aiconfig.json\")\n", + "start_app(\"i-dont-exist-yet-please-create-me.json\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are several nice things about this pattern:\n", + "* You get static errors similar to the Optional case unless you check for None\n", + "* You also get specific, useful error information\n", + "* Structural pattern matching: When matching the cases, you can elegantly and safely unbox the data inside the result.\n", + "* Because of pyright's ability to check for exhaustive pattern matching, it will yell at you if you don't handle the Err case. Try it! Comment out the Err case." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Part 2: Composition (To be continued)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "aiconfig", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}