{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Decorators\n",
    "\n",
    "**Agentic AI** · playground\n",
    "\n",
    "*A decorator is a function that receives a function and returns a replacement for it. The notebook derives the `@` syntax from an ordinary assignment, writes a decorator with arguments, and builds by hand the registration decorator behind `@tool`, `@agent.tool` and `@g.step`.*"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1",
   "metadata": {},
   "source": [
    "## Decorator\n",
    "\n",
    "**A decorator is a callable that takes a function (or a class) and returns the object that replaces it under the same name.** The glossary of the Python documentation defines it as *\"a function returning another function, usually applied as a function transformation using the `@wrapper` syntax\"* ([Python Software Foundation, 2023, *Glossary*](https://docs.python.org/3.12/glossary.html#term-decorator)).\n",
    "\n",
    "A decorator solves the problem of adding the same behaviour to many functions without changing their bodies: logging every call, measuring its duration, limiting its rate, or — the case that matters most for agents — **registering** the function as a tool. Frameworks like LangChain or PydanticAI declare tools and graph nodes with decorators (`@tool` in LangChain, `@agent.tool` and `@g.step` in PydanticAI).\n",
    "\n",
    "The `@` line is an abbreviation. [Smith et al. (2003, PEP 318)](https://peps.python.org/pep-0318/) define the form\n",
    "\n",
    "```python\n",
    "@dec\n",
    "def func(arg):\n",
    "    ...\n",
    "```\n",
    "\n",
    "as equivalent to\n",
    "\n",
    "```python\n",
    "def func(arg):\n",
    "    ...\n",
    "func = dec(func)\n",
    "```\n",
    "\n",
    "Nothing else happens: the decorator is called **once, when the `def` statement is executed**, with the function object as its argument, and the name is bound to the result."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def shout(fn):\n",
    "    print(f\"decorating {fn.__name__}\")       # executed at definition time\n",
    "    return fn                                 # the function is returned unchanged\n",
    "\n",
    "\n",
    "@shout\n",
    "def greet(name):\n",
    "    return f\"hello {name}\"\n",
    "\n",
    "\n",
    "print(greet(\"Ada\"))                           # the call itself prints nothing additional"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3",
   "metadata": {},
   "source": [
    "## Wrapper Functions\n",
    "\n",
    "**A wrapper is a new function that the decorator returns in place of the original; it calls the original and adds behaviour before or after the call.** The wrapper is a closure (`01-functions-as-values.ipynb`): it keeps access to the original function `fn`. It accepts `*args, **kwargs` so that it fits a function with any signature.\n",
    "\n",
    "The example records every call. An agent uses the same construction to write each tool call into its trace."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4",
   "metadata": {},
   "outputs": [],
   "source": [
    "def logged(fn):\n",
    "    def wrapper(*args, **kwargs):\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"call {fn.__name__}{args} -> {result!r}\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@logged\n",
    "def add(a, b):\n",
    "    \"\"\"Return the sum of a and b.\"\"\"\n",
    "    return a + b\n",
    "\n",
    "\n",
    "add(2, 3)\n",
    "print(add.__name__, \"|\", add.__doc__)         # the name and docstring of the wrapper, not of add"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5",
   "metadata": {},
   "source": [
    "The last line shows a defect: the name `add` now refers to `wrapper`, so `__name__` is `\"wrapper\"` and the docstring is lost. For a tool this is fatal, because name and docstring are what the model receives. `functools.wraps` copies `__name__`, `__doc__` and the annotations from the original function to the wrapper."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6",
   "metadata": {},
   "outputs": [],
   "source": [
    "import functools\n",
    "\n",
    "\n",
    "def logged(fn):\n",
    "    @functools.wraps(fn)\n",
    "    def wrapper(*args, **kwargs):\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"call {fn.__name__}{args} -> {result!r}\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@logged\n",
    "def add(a, b):\n",
    "    \"\"\"Return the sum of a and b.\"\"\"\n",
    "    return a + b\n",
    "\n",
    "\n",
    "print(add.__name__, \"|\", add.__doc__)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7",
   "metadata": {},
   "source": [
    "## Decorators with Arguments\n",
    "\n",
    "**A decorator with arguments is a function that returns a decorator.** The expression after `@` is evaluated first, and its result is then called with the function. PEP 318 states the equivalence: `@decomaker(argA, argB)` above `def func` means `func = decomaker(argA, argB)(func)`.\n",
    "\n",
    "The definition therefore nests three functions: the factory that receives the arguments, the decorator that receives the function, and the wrapper that receives the call. The example repeats a call that raises an exception, which is the usual treatment of a failed request to a model."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8",
   "metadata": {},
   "outputs": [],
   "source": [
    "def retry(times):                              # level 1: receives the arguments\n",
    "    def decorator(fn):                         # level 2: receives the function\n",
    "        @functools.wraps(fn)\n",
    "        def wrapper(*args, **kwargs):          # level 3: receives the call\n",
    "            for attempt in range(1, times + 1):\n",
    "                try:\n",
    "                    return fn(*args, **kwargs)\n",
    "                except ConnectionError as e:\n",
    "                    print(f\"attempt {attempt} failed: {e}\")\n",
    "            raise ConnectionError(f\"{fn.__name__} failed {times} times\")\n",
    "        return wrapper\n",
    "    return decorator\n",
    "\n",
    "\n",
    "attempts = 0\n",
    "\n",
    "\n",
    "@retry(times=3)\n",
    "def query_model(prompt):\n",
    "    global attempts\n",
    "    attempts += 1\n",
    "    if attempts < 3:\n",
    "        raise ConnectionError(\"server not reachable\")\n",
    "    return f\"answer to {prompt!r}\"\n",
    "\n",
    "\n",
    "print(query_model(\"2 + 2\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9",
   "metadata": {},
   "source": [
    "## Registration Decorators\n",
    "\n",
    "**A registration decorator stores the function in a data structure and returns the function unchanged.** No wrapper is involved: the purpose is the side effect at definition time. The tool registry of `01-functions-as-values.ipynb` is filled in this way, so that the declaration of a tool and its registration cannot diverge."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10",
   "metadata": {},
   "outputs": [],
   "source": [
    "TOOLS = {}\n",
    "\n",
    "\n",
    "def tool(fn):\n",
    "    TOOLS[fn.__name__] = fn\n",
    "    return fn\n",
    "\n",
    "\n",
    "@tool\n",
    "def multiply(a: float, b: float) -> float:\n",
    "    \"\"\"Return the product of a and b.\"\"\"\n",
    "    return a * b\n",
    "\n",
    "\n",
    "@tool\n",
    "def word_count(text: str) -> int:\n",
    "    \"\"\"Return the number of words in text.\"\"\"\n",
    "    return len(text.split())\n",
    "\n",
    "\n",
    "print(sorted(TOOLS))\n",
    "print(TOOLS[\"word_count\"](text=\"the agent loop\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11",
   "metadata": {},
   "source": [
    "### Decorators as Methods of an Object\n",
    "\n",
    "A module-level registry is shared by the whole program. A framework needs one registry **per agent**, so the decorator is a method of the agent object: `@agent.tool` is the call `agent.tool(fn)`, and the function is stored in that agent. \n",
    "\n",
    "The method also accepts the form with arguments, `@agent.tool(requires_approval=True)`. One function serves both forms by a test: called with a function, it registers the function; called without one, it returns the decorator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12",
   "metadata": {},
   "outputs": [],
   "source": [
    "class MiniAgent:\n",
    "    def __init__(self, name):\n",
    "        self.name = name\n",
    "        self.tools = {}                        # tool name -> (function, options)\n",
    "\n",
    "    def tool(self, fn=None, *, requires_approval=False):\n",
    "        def register(fn):\n",
    "            self.tools[fn.__name__] = (fn, {\"requires_approval\": requires_approval})\n",
    "            return fn\n",
    "        if fn is not None:                     # used as @agent.tool\n",
    "            return register(fn)\n",
    "        return register                        # used as @agent.tool(...)\n",
    "\n",
    "    def call(self, tool_name, **arguments):\n",
    "        fn, options = self.tools[tool_name]\n",
    "        if options[\"requires_approval\"]:\n",
    "            return f\"{tool_name}: waiting for approval\"\n",
    "        return fn(**arguments)\n",
    "\n",
    "\n",
    "file_agent = MiniAgent(\"files\")\n",
    "\n",
    "\n",
    "@file_agent.tool\n",
    "def list_files(directory: str) -> list[str]:\n",
    "    \"\"\"List the files of a directory.\"\"\"\n",
    "    return [\"a.txt\", \"b.txt\"]\n",
    "\n",
    "\n",
    "@file_agent.tool(requires_approval=True)\n",
    "def delete_file(path: str) -> str:\n",
    "    \"\"\"Delete a file.\"\"\"\n",
    "    return f\"deleted {path}\"\n",
    "\n",
    "\n",
    "print(file_agent.call(\"list_files\", directory=\".\"))\n",
    "print(file_agent.call(\"delete_file\", path=\"a.txt\"))\n",
    "print(delete_file(\"a.txt\"))                    # the function itself is unchanged and still callable"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "kwonly-0",
   "metadata": {},
   "source": [
    "#### Keyword-Only Parameters\n",
    "\n",
    "**A keyword-only parameter is a parameter that a call can fill only by name, never by position.** In the signature `tool(self, fn=None, *, requires_approval=False)`, the bare `*` is not a parameter and receives no value. It separates the parameters before it, which a call may fill by position, from the parameters after it, which a call must name. [Talin (2006, PEP 3102)](https://peps.python.org/pep-3102/) introduced the form in Python 3.0: a keyword-only argument *\"will never be automatically filled in by a positional argument\"*, and the bare `*` means *\"don't allow any positional arguments beyond this point\"*. It differs from `*args`, which collects surplus positional arguments into a tuple; after a bare `*`, a surplus positional argument raises `TypeError`.\n",
    "\n",
    "The separator gives each argument of `tool` one fixed role. The single positional slot after `self` holds the function to register, and every option is written with its name. The decorator line `@file_agent.tool(requires_approval=True)` therefore states the option at the place of the declaration, and the call `file_agent.tool(delete_file, True)`, in which `True` is meant as the option, is rejected with `TypeError` instead of being accepted. PydanticAI declares `Agent.tool` in the same way: the function is the only positional parameter, and `requires_approval` and all further options follow the `*` (pydantic-ai 2.54.0, [`agent/__init__.py`](https://github.com/pydantic/pydantic-ai/blob/v2.54.0/pydantic_ai_slim/pydantic_ai/agent/__init__.py))."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "kwonly-1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import inspect\n",
    "\n",
    "for name, parameter in inspect.signature(MiniAgent.tool).parameters.items():\n",
    "    print(f\"{name:18} {parameter.kind.name}\")    # how a call may fill each parameter\n",
    "\n",
    "try:\n",
    "    file_agent.tool(delete_file, True)            # the option given by position\n",
    "except TypeError as e:\n",
    "    print(\"TypeError:\", e)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "levels-0",
   "metadata": {},
   "source": [
    "#### Nesting Levels of a Registration Decorator\n",
    "\n",
    "**A decorator needs one nesting level for each point in time at which it receives something.** `retry` receives three things at three times: the options when the expression `retry(times=3)` is evaluated, the function when the `def` statement is executed, and the arguments at every call of `query_model`. Each of the three arrives as an argument list of its own, so each needs a function of its own. The third level, the wrapper, exists because `retry` changes what a call of the decorated function does.\n",
    "\n",
    "A registration decorator does not change what a call does: it stores the function and returns it unchanged (see [*Registration Decorators*](#Registration-Decorators)). The level that receives the call is therefore absent, and two levels remain.\n",
    "\n",
    "| Level | `@retry(times=3)` | `@file_agent.tool(requires_approval=True)` |\n",
    "|---|---|---|\n",
    "| receives the options | `retry` | the method `tool` |\n",
    "| receives the function | `decorator` | `register` |\n",
    "| receives each call | `wrapper` | — : the stored function itself is called |\n",
    "\n",
    "`self` does not add a level. The attribute access `file_agent.tool` yields a **bound method**, a method object that already holds `file_agent` and passes it as `self`; the call supplies only the options.\n",
    "\n",
    "The method `tool` serves both decorator lines with one function, because `@expression` above `def f` means `f = expression(f)` ([Smith et al., 2003, PEP 318](https://peps.python.org/pep-0318/)):\n",
    "\n",
    "- `@file_agent.tool` above `def list_files` means `list_files = file_agent.tool(list_files)`. `fn` is the function, so `tool` calls `register(fn)` at once and returns the function.\n",
    "- `@file_agent.tool(requires_approval=True)` above `def delete_file` means `delete_file = file_agent.tool(requires_approval=True)(delete_file)`. The inner call passes no function, so `fn` is `None`, and `tool` returns `register`, a closure that holds `self` and `requires_approval`. The outer call `register(delete_file)` stores the function together with its option.\n",
    "\n",
    "A decorator that **wraps** the function and accepts options needs the third level again, in the same form: `def timed(fn=None, *, unit=\"ms\")` returns `decorator(fn)` or `decorator`, and `decorator` defines the wrapper."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "regcall-0",
   "metadata": {},
   "source": [
    "### Registration by a Call\n",
    "\n",
    "**A function that exists only as a value is registered by calling the decorator with it.** `@` requires a `def` in the source text, but `@dec` above `def f` is the assignment `f = dec(f)` ([Smith et al., 2003](https://peps.python.org/pep-0318/)), so the call `dec(f)` performs the same registration. The case arises when functions are created at run time — one step function per step of a plan, built by a factory function (`01-functions-as-values.ipynb`, *Factory Function*).\n",
    "\n",
    "All functions created by one factory have the same `__name__`, the name of the inner `def`. A registry that uses `__name__` as key keeps only the last of them, so the key is passed as an argument. pydantic-graph's `GraphBuilder.step` accepts both forms — used as a decorator or called with the function — and a keyword `node_id`; without it, the id of the node is the function's name (pydantic-graph 2.54.0, [`graph_builder.py`](https://github.com/pydantic/pydantic-ai/blob/v2.54.0/pydantic_graph/pydantic_graph/graph_builder.py)). The `L-Decomp` lab registers one step per plan step with `g.step(run, node_id=f\"E{s}\")`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "regcall-1",
   "metadata": {},
   "outputs": [],
   "source": [
    "class MiniGraph:\n",
    "    def __init__(self):\n",
    "        self.steps = {}                        # node id -> step function\n",
    "\n",
    "    def step(self, fn=None, *, node_id=None):\n",
    "        def register(fn):\n",
    "            self.steps[node_id or fn.__name__] = fn\n",
    "            return fn\n",
    "        return register(fn) if fn is not None else register\n",
    "\n",
    "\n",
    "def make_step(company):\n",
    "    def run():\n",
    "        return f\"search(revenue {company})\"\n",
    "    return run\n",
    "\n",
    "\n",
    "g = MiniGraph()\n",
    "for company in [\"acme\", \"globex\"]:\n",
    "    g.step(make_step(company))                 # key: __name__, i.e. \"run\" for both\n",
    "print({key: fn() for key, fn in g.steps.items()})\n",
    "\n",
    "g = MiniGraph()\n",
    "for company in [\"acme\", \"globex\"]:\n",
    "    g.step(make_step(company), node_id=f\"search_{company}\")\n",
    "print({key: fn() for key, fn in g.steps.items()})"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "13",
   "metadata": {},
   "source": [
    "## Decorators and Coroutine Functions\n",
    "\n",
    "The tools and graph nodes of PydanticAI are declared with `async def` (`08-async-coroutines.ipynb`). A registration decorator is not affected, since it does not call the function. A **wrapper** is affected: calling a coroutine function returns a coroutine object and executes nothing, so a wrapper written with `def` would measure or log the creation of that object and not the execution. The wrapper of a coroutine function is itself declared `async def` and awaits the call. `inspect.iscoroutinefunction` distinguishes the two kinds, so that one decorator serves both."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "14",
   "metadata": {},
   "outputs": [],
   "source": [
    "import asyncio\n",
    "import inspect\n",
    "\n",
    "\n",
    "def logged_any(fn):\n",
    "    if inspect.iscoroutinefunction(fn):\n",
    "        @functools.wraps(fn)\n",
    "        async def wrapper(*args, **kwargs):\n",
    "            result = await fn(*args, **kwargs)           # the execution happens here\n",
    "            print(f\"call {fn.__name__}{args} -> {result!r} (coroutine)\")\n",
    "            return result\n",
    "    else:\n",
    "        @functools.wraps(fn)\n",
    "        def wrapper(*args, **kwargs):\n",
    "            result = fn(*args, **kwargs)\n",
    "            print(f\"call {fn.__name__}{args} -> {result!r}\")\n",
    "            return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@logged_any\n",
    "async def fetch(url):\n",
    "    await asyncio.sleep(0.01)\n",
    "    return f\"content of {url}\"\n",
    "\n",
    "\n",
    "@logged_any\n",
    "def length(text):\n",
    "    return len(text)\n",
    "\n",
    "\n",
    "length(await fetch(\"https://example.org\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "15",
   "metadata": {},
   "source": [
    "## Class Decorators\n",
    "\n",
    "A decorator may also receive a **class**. `@dataclass` is the example used throughout the course: it reads the annotated attributes of the class, adds generated methods such as `__init__` and `__repr__`, and returns the same class (`03-dataclasses.ipynb`). The mechanism is the one above — `C = dataclass(C)`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "16",
   "metadata": {},
   "outputs": [],
   "source": [
    "def describe(cls):\n",
    "    \"\"\"A class decorator that adds a method listing the annotated attributes.\"\"\"\n",
    "    cls.fields = classmethod(lambda c: list(c.__annotations__))\n",
    "    return cls\n",
    "\n",
    "\n",
    "@describe\n",
    "class Step:\n",
    "    tool: str\n",
    "    arguments: dict\n",
    "\n",
    "\n",
    "print(Step.fields())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "17",
   "metadata": {},
   "source": [
    "## Summary\n",
    "\n",
    "| Written | Meaning | Executed |\n",
    "|---|---|---|\n",
    "| `@dec` above `def f` | `f = dec(f)` | once, at definition time |\n",
    "| `@dec(x)` above `def f` | `f = dec(x)(f)` | `dec(x)` and the returned decorator, once, at definition time |\n",
    "| `@a` above `@b` above `def f` | `f = a(b(f))` | `b` first, then `a` |\n",
    "| wrapper | the function that replaces `f`; calls the original | at every call of `f` |\n",
    "| `functools.wraps(fn)` | copies name, docstring and annotations to the wrapper | at definition time |\n",
    "| registration decorator | stores `f` and returns it unchanged | at definition time |\n",
    "| `@obj.method` | `f = obj.method(f)`: the registry belongs to the object | at definition time |\n",
    "| `def m(self, fn=None, *, opt=False)` | the parameters after `*` are keyword-only; `@obj.m` registers at once, `@obj.m(opt=…)` returns the decorator: two levels, no wrapper | at definition time |\n",
    "| `obj.method(f, node_id=k)` | registration of a function created at run time, under an explicit key | at the call |\n",
    "\n",
    "## Use in the Course\n",
    "\n",
    "| Decorator | Kind | Notebooks |\n",
    "|---|---|---|\n",
    "| `@dataclass` | class decorator | `course/lecture-Decomp/lecture-Decomp-exercises.ipynb`, `course/lecture-Eval/lecture-Eval-exercises.ipynb`, both notebooks of `playground/pydantic-ai/` |\n",
    "| `@tool` | registration, builds the tool schema from signature and docstring | `playground/langgraph/01-langgraph-basics.ipynb`, `playground/memory/06-write-paths.ipynb`, `playground/memory/02-file-memory.ipynb` |\n",
    "| `@agent.tool`, `@agent.tool_plain(requires_approval=True)` | registration as a method of an object, with and without arguments | `playground/pydantic-ai/01-pydantic-ai-basics.ipynb` |\n",
    "| `@g.step` | registration of a graph node | both notebooks of `playground/pydantic-ai/` |\n",
    "| `@g.step`, `g.step(run, node_id=…)`, `@agent.output_validator` | registration of a graph node, also by a call for steps built by a factory; registration of an output check | `course/lecture-Decomp/lecture-Decomp-lab.ipynb` |"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "## Tasks\n",
    "\n",
    "**Task 1.** Write the decorator `timed`, whose wrapper prints the duration of each call in milliseconds (`time.perf_counter()` returns a time in seconds) and returns the result. Use `functools.wraps`.\n",
    "\n",
    "**Task 2.** Write the decorator `limit(max_calls)`. The wrapper raises `RuntimeError(\"call limit reached\")` when the decorated function is called more than `max_calls` times.\n",
    "\n",
    "**Task 3.** State, without executing the code, what the following cell prints and in which order. Then execute it.\n",
    "\n",
    "```python\n",
    "@shout\n",
    "@logged\n",
    "def sub(a, b):\n",
    "    return a - b\n",
    "\n",
    "sub(5, 2)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19",
   "metadata": {},
   "source": [
    "### Solutions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20",
   "metadata": {},
   "outputs": [],
   "source": [
    "import time\n",
    "\n",
    "\n",
    "# Task 1\n",
    "def timed(fn):\n",
    "    @functools.wraps(fn)\n",
    "    def wrapper(*args, **kwargs):\n",
    "        start = time.perf_counter()\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"{fn.__name__}: {(time.perf_counter() - start) * 1000:.1f} ms\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@timed\n",
    "def slow_square(x):\n",
    "    time.sleep(0.05)\n",
    "    return x * x\n",
    "\n",
    "\n",
    "print(slow_square(7))\n",
    "\n",
    "\n",
    "# Task 2\n",
    "def limit(max_calls):\n",
    "    def decorator(fn):\n",
    "        calls = 0\n",
    "        @functools.wraps(fn)\n",
    "        def wrapper(*args, **kwargs):\n",
    "            nonlocal calls\n",
    "            if calls >= max_calls:\n",
    "                raise RuntimeError(\"call limit reached\")\n",
    "            calls += 1\n",
    "            return fn(*args, **kwargs)\n",
    "        return wrapper\n",
    "    return decorator\n",
    "\n",
    "\n",
    "@limit(2)\n",
    "def ping():\n",
    "    return \"pong\"\n",
    "\n",
    "\n",
    "print(ping(), ping())\n",
    "try:\n",
    "    ping()\n",
    "except RuntimeError as e:\n",
    "    print(\"RuntimeError:\", e)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "21",
   "metadata": {},
   "source": [
    "Task 3: decorators are applied from the bottom, `sub = shout(logged(sub))`. `logged` returns the wrapper, to which `functools.wraps` has given the name `sub`; `shout` then prints `decorating sub` at definition time. The call prints `call sub(5, 2) -> 3`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "22",
   "metadata": {},
   "outputs": [],
   "source": [
    "@shout\n",
    "@logged\n",
    "def sub(a, b):\n",
    "    return a - b\n",
    "\n",
    "\n",
    "sub(5, 2)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "23",
   "metadata": {},
   "source": [
    "## References\n",
    "\n",
    "- Pydantic. \"agent/__init__.py.\" pydantic-ai repository, pydantic-ai 2.54.0. 2026. <https://github.com/pydantic/pydantic-ai/blob/v2.54.0/pydantic_ai_slim/pydantic_ai/agent/__init__.py>\n",
    "- Pydantic. \"graph_builder.py.\" pydantic-ai repository, pydantic-graph 2.54.0. 2026. <https://github.com/pydantic/pydantic-ai/blob/v2.54.0/pydantic_graph/pydantic_graph/graph_builder.py>\n",
    "- Python Software Foundation. \"Glossary.\" Python 3.12 documentation. 2023. <https://docs.python.org/3.12/glossary.html>\n",
    "- Smith, Kevin D., et al. \"PEP 318 – Decorators for Functions and Methods.\" Python Enhancement Proposals. 2003. <https://peps.python.org/pep-0318/>\n",
    "- Talin. \"PEP 3102 – Keyword-Only Arguments.\" Python Enhancement Proposals. 2006. <https://peps.python.org/pep-3102/>"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "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.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}