Python for AI Engineers

◆ Chapter 05

Functions, arguments, and decorators

Positional vs keyword args, *args/**kwargs, closures, and decorators — the wrapper pattern behind most framework "magic".

~2,394 words · chapter 5 of 15

Functions in Python look familiar coming from JavaScript, but the calling conventions are richer and there's one genuinely alien construct — the decorator — that GenAI code is soaked in. Every LLM SDK, every agent framework, every FastAPI service leans on the features in this chapter. Learn to read them and half of an agent codebase stops looking like magic.

def, return, and docstrings

def greet(name):
    return f"Hello, {name}"

print(greet("Ada"))   # => Hello, Ada

def defines a function; return hands back a value. If a function ends without return (or does a bare return), it returns None — Python's null/undefined. There is no undefined; absence is always None.

def log_prompt(prompt):
    print(f"[prompt] {prompt}")

result = log_prompt("hi")
print(result)   # => None

The first statement in a function can be a docstring — a string literal, conventionally triple-quoted:

def summarize(text: str, max_tokens: int = 256) -> str:
    """Summarize `text` to at most `max_tokens` tokens.

    Returns the model's summary as a plain string.
    """
    ...

Docstrings are not comments — they're stored on the function as summarize.__doc__ and shown by help(summarize). This matters enormously in GenAI: agent frameworks read a tool function's docstring and type hints to build the JSON schema the LLM sees. A vague docstring literally makes the model worse at calling your tool. Treat docstrings as prompt engineering.

JS → Python: def name(args): replaces function name(args) {}. No braces — the indented block is the body. There's no function keyword and no hoisting; a function must be defined above where it's called at runtime. JSDoc is optional tooling metadata; a Python docstring is a real runtime attribute that frameworks introspect.

Parameters: positional and keyword

You can pass arguments by position or by name. Passing by name (keyword arguments) is idiomatic and everywhere in Python APIs:

def chat(model, prompt, temperature=0.7):
    return f"{model} @ temp={temperature}: {prompt}"

# positional
chat("gpt-4o", "hello")
# keyword — order no longer matters, intent is explicit
chat(prompt="hello", model="gpt-4o", temperature=0.2)

Any parameter can be filled either way unless the signature restricts it (see below). Real SDK calls almost always use keywords: client.chat.completions.create(model=..., messages=..., temperature=..., max_tokens=...). The keywords are self-documenting and survive parameter reordering.

JS → Python: JavaScript fakes keyword arguments with an options object: chat({ model, prompt, temperature }). Python has them as a first-class language feature — temperature=0.2 is real syntax, not a destructured object. Default values also work like JS defaults (temperature=0.7temperature = 0.7 in a JS param list).

The mutable default argument trap

This is the single most-flagged Python bug in code review. Default values are evaluated once, when the function is defined — not on each call. So a mutable default (a list, dict, set) is shared across every call:

def add_message(msg, history=[]):   # 🚨 BUG
    history.append(msg)
    return history

add_message("a")   # => ['a']
add_message("b")   # => ['a', 'b']   <- 'a' leaked across calls!

The [] was created once and lives on the function object forever. In an agent this manifests as conversation history bleeding between unrelated requests — a real, shipped-to-prod class of bug. The fix is the None sentinel:

def add_message(msg, history=None):
    if history is None:
        history = []           # fresh list every call
    history.append(msg)
    return history

add_message("a")   # => ['a']
add_message("b")   # => ['b']   ✅

Reading & judging: Any default that isn't an immutable primitive (None, a number, a string, a tuple) is suspect. def f(x, items=[]), def f(x, cfg={}), def f(x, seen=set()) are all bugs unless the author intended a shared cache — which they almost never did. This is a legitimate reason to send a PR back.

*args and **kwargs

*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. You'll read these constantly in SDK wrappers:

def call_llm(prompt, *args, **kwargs):
    print(args)      # extra positionals as a tuple
    print(kwargs)    # extra keywords as a dict
    return kwargs.get("temperature", 0.7)

call_llm("hi", "x", "y", temperature=0.2, top_p=0.9)
# => ('x', 'y')
# => {'temperature': 0.2, 'top_p': 0.9}

The star also spreads on the way out — mirroring JS spread:

def make_request(model, **opts):
    return {"model": model, **opts}

defaults = {"temperature": 0.7, "max_tokens": 512}
make_request("gpt-4o", **defaults)
# => {'model': 'gpt-4o', 'temperature': 0.7, 'max_tokens': 512}

A very common wrapper pattern in LLM libraries: accept **kwargs and forward them straight to the underlying client, so new provider parameters work without editing your code.

JS → Python: *args ≈ rest params (...args) and **kwargs has no JS twin — it's rest params for named arguments. f(*mylist)f(...myArray) (spread a list into positionals); f(**mydict) spreads a dict into keyword arguments, something JS can't do directly.

Keyword-only and positional-only parameters

A bare * in a signature means "everything after me must be passed by keyword". A / means "everything before me must be passed by position". You'll see both in well-designed SDKs:

def embed(text, /, *, model, normalize=True):
    ...

embed("hello", model="text-embedding-3-small")   # ✅
embed(text="hello", model=...)   # 💥 TypeError: text is positional-only
embed("hello", "text-embedding-3-small")         # 💥 TypeError: model is keyword-only

text before / can't be named; model after * can't be positional. Forcing keywords on model/normalize means calls stay readable and the author can reorder them later without breaking callers.

Type-annotated signatures (preview)

def chat(prompt: str, temperature: float = 0.7) -> str:
    return f"...{prompt}..."

The : str, : float, and -> str are type hints. Python does not enforce them at runtime — you can still pass a wrong type and it'll run. They exist for humans, editors, type checkers (mypy, pyright), and — critically for you — for frameworks that generate tool schemas from them. We cover the type system in depth later; for now, read them as documentation the tooling can act on.

Scope: LEGB, global, nonlocal

Python resolves names by the LEGB rule: Local → Enclosing → Global → Built-in. It walks outward until it finds the name.

MODEL = "gpt-4o"          # global

def outer():
    provider = "openai"    # enclosing (for inner)
    def inner():
        temp = 0.5         # local
        return f"{provider}/{MODEL} temp={temp}"
    return inner()

outer()   # => openai/gpt-4o temp=0.5

Assigning to a name inside a function makes it local by default. To rebind an outer name you must declare intent — global for module level, nonlocal for an enclosing function:

counter = 0
def bump():
    global counter
    counter += 1     # without `global`, this raises UnboundLocalError

def make_counter():
    n = 0
    def step():
        nonlocal n   # rebind the enclosing n, don't shadow it
        n += 1
        return n
    return step

JS → Python: LEGB ≈ JS scope chain, and Python closures behave like JS closures — inner functions capture surrounding variables. The difference: in JS you use let/const/var to declare; in Python assignment implicitly declares local, so you need global/nonlocal to say "no, reach outward and rebind." Avoid global in real code — it's a smell, same as leaning on JS globals.

The late-binding closure gotcha

Closures capture the variable, not its value at creation time — identical to JS var in a loop. This bites people building lists of callbacks:

funcs = [lambda: i for i in range(3)]
[f() for f in funcs]   # => [2, 2, 2]   <- all see the final i

All three lambdas close over the same i, which ends at 2. Bind per-iteration with a default argument (evaluated at definition time):

funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs]   # => [0, 1, 2]   ✅

Reading & judging: In async or retry code you'll see loops that build handlers or tasks; if each is supposed to capture a different value but there's no x=x default (or no factory function), suspect this bug. It's the Python equivalent of the classic for (var i...) setTimeout puzzle.

Lambdas

lambda is a one-expression anonymous function. No return, no statements — just an expression whose value is returned:

square = lambda x: x * x
square(5)   # => 25

You rarely name a lambda (just use def). Their real home is a key= argument to sorted/max/min:

runs = [
    {"model": "gpt-4o", "cost": 0.03},
    {"model": "haiku",  "cost": 0.001},
    {"model": "opus",   "cost": 0.09},
]
cheapest = min(runs, key=lambda r: r["cost"])
cheapest["model"]   # => haiku

by_cost = sorted(runs, key=lambda r: r["cost"])

JS → Python: lambda x: x*x ≈ the arrow x => x*x. But a Python lambda is expression-only — no braces, no multi-line body, no statements. The moment you want a line of logic, write a def. A lambda spanning half a screen is a code smell: it can't have a docstring, can't be tested by name, and hurts readability. Pull it out into a named function.

First-class and higher-order functions

Functions are values — pass them, return them, store them. That's the whole foundation of decorators:

def retry_call(fn, prompt):
    for _ in range(3):
        try:
            return fn(prompt)
        except Exception:
            continue

retry_call(str.upper, "hi")   # => HI

map, filter, and functools.reduce exist, but Pythonistas prefer comprehensions — they read better:

from functools import reduce

nums = [1, 2, 3, 4]
# functional style
list(map(lambda x: x * 2, nums))              # => [2, 4, 6, 8]
list(filter(lambda x: x % 2 == 0, nums))      # => [2, 4]
reduce(lambda a, b: a + b, nums, 0)           # => 10

# idiomatic Python
[x * 2 for x in nums]                         # => [2, 4, 6, 8]
[x for x in nums if x % 2 == 0]               # => [2, 4]
sum(nums)                                      # => 10

Reading & judging: map/filter with a lambda usually should have been a comprehension. Seeing list(map(lambda ...)) is a mild sign the author is writing JavaScript in Python. reduce in particular is discouraged for anything a plain loop or sum/math.prod expresses more clearly.

Decorators from first principles

A decorator is just a function that takes a function and returns a (usually wrapped) function. That's the entire idea. Build one by hand first:

def shout(fn):
    def wrapper(*args, **kwargs):
        result = fn(*args, **kwargs)
        return result.upper()
    return wrapper

def greet(name):
    return f"hi {name}"

greet = shout(greet)   # wrap it manually
greet("ada")           # => HI ADA

The @decorator syntax is pure sugar for that reassignment — @shout above def greet means exactly greet = shout(greet):

@shout
def greet(name):
    return f"hi {name}"

greet("ada")   # => HI ADA

*args, **kwargs in the wrapper is what lets one decorator wrap any function regardless of its signature.

functools.wraps — without it, the wrapper replaces your function's identity: greet.__name__ becomes "wrapper" and the docstring vanishes, which breaks the exact introspection agent frameworks rely on. Always copy the metadata across:

import functools

def shout(fn):
    @functools.wraps(fn)            # preserve __name__, __doc__, signature
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs).upper()
    return wrapper

Reading & judging: A hand-written decorator missing @functools.wraps is a real (if quiet) bug — it corrupts logging, docs, and any framework that reads __name__/__doc__. Flag it.

Decorators with arguments (a decorator factory)

To write @retry(times=3), you need one more layer: a function that takes the arguments and returns a decorator.

import functools, time

def retry(times=3, delay=0.5):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last = None
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last = e
                    time.sleep(delay)
            raise last          # exhausted retries
        return wrapper
    return decorator

@retry(times=3, delay=0.2)
def call_model(prompt):
    ...  # flaky network / rate-limited API

Three nested layers: retry(...) returns decorator, which receives call_model and returns wrapper. Retry decorators like this are everywhere in LLM code because provider APIs rate-limit and time out constantly.

A @timing decorator is the other canonical example — read how naturally it slots in:

def timing(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"{fn.__name__} took {time.perf_counter()-start:.3f}s")
    return wrapper

@timing
def embed(text): ...

The decorators a GenAI engineer must recognize

You'll rarely write exotic decorators, but you must read these fluently:

Decorator What it does
@property Turns a method into a computed attribute — obj.tokens calls a method but looks like a field.
@staticmethod / @classmethod A method with no self / one that takes the class as cls (used for alternate constructors like Model.from_config(...)).
@functools.cache / @lru_cache Memoize results by arguments — used to cache embeddings, tokenizer loads, config reads.
@dataclass Auto-generates __init__, __repr__, __eq__ for a data-holding class (covered in the classes chapter).
@app.get(...) / @app.post(...) FastAPI — registers the function as an HTTP route. The decorator is the routing table.
@tool / @agent Agent frameworks (LangChain, LlamaIndex, etc.) — registers a function as an LLM-callable tool, reading its docstring + type hints to build the schema.
@pytest.fixture / @pytest.mark.* Test setup and test tagging.
from functools import lru_cache

@lru_cache(maxsize=1)
def load_tokenizer():
    print("loading...")   # runs once; cached thereafter
    return {"vocab": 50000}

load_tokenizer()   # => loading...
load_tokenizer()   # (nothing printed — cached result returned)

JS → Python: Vanilla JavaScript has no decorators. The closest analog is a higher-order wrapper — const greet = shout(originalGreet) — which is literally what @shout desugars to. TypeScript's @decorator syntax (experimental / the newer TC39 proposal) is the direct cousin, and Angular/NestJS users will feel at home with @app.get(...)-style routing.

Reading & judging: Decorators hide control flow — a @retry silently loops, a @cache silently skips execution, an @app.post means the function is called by the framework, never directly. When a function "isn't being called but clearly runs," look up at its decorators. Watch for over-clever stacks (four decorators deep where order matters and isn't obvious), missing functools.wraps, and side effects at import time — a decorator factory whose arguments do real work (network calls, file reads) when the module loads, not when the function runs. Those turn a simple import into a landmine.

Try it: Write a @log_calls decorator (with functools.wraps) that prints the function name and arguments before calling, and the return value after. Stack it with @timing on a dummy def fake_llm(prompt): return prompt[::-1] — then swap the order of the two decorators and observe how the printed output nests differently. That nesting is exactly why decorator order matters in real code.