◆ 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):replacesfunction name(args) {}. No braces — the indented block is the body. There's nofunctionkeyword 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.2is real syntax, not a destructured object. Default values also work like JS defaults (temperature=0.7≈temperature = 0.7in 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**kwargshas 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/varto declare; in Python assignment implicitly declares local, so you needglobal/nonlocalto say "no, reach outward and rebind." Avoidglobalin 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=xdefault (or no factory function), suspect this bug. It's the Python equivalent of the classicfor (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 arrowx => 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 adef. 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/filterwith a lambda usually should have been a comprehension. Seeinglist(map(lambda ...))is a mild sign the author is writing JavaScript in Python.reducein particular is discouraged for anything a plain loop orsum/math.prodexpresses 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.wrapsis 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@shoutdesugars to. TypeScript's@decoratorsyntax (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
@retrysilently loops, a@cachesilently skips execution, an@app.postmeans 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), missingfunctools.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 simpleimportinto a landmine.
Try it: Write a
@log_callsdecorator (withfunctools.wraps) that prints the function name and arguments before calling, and the return value after. Stack it with@timingon a dummydef 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.