Agent Rules / Dispatch and reflection
Why does my coding agent miss calls made through getattr?
Rule AR-07
Don't build method names as strings (getattr(obj, f\"handle_{x}\"), obj[name]()).
When code calls a method by a name it assembles at runtime, nothing in the source says which function runs. An agent that changes one of the handlers has no call site to find, so it finishes the edit and the dispatch breaks later. Make the set of targets visible to the type checker, with a literal-typed mapping or an explicit match (see AR-08), and every tool can follow the call again.
Why it happens
Python. The reference docs define getattr(object, name) as returning the named attribute, where name must be a string, and getattr(x, 'foobar') is equivalent to x.foobar. That equivalence holds only when the string is a constant. When the string is built (f"handle_{kind}") or read from data (a config file, a request field), the binding between the call and the function exists only while the program runs, and no static tool can list the targets without knowing every value the string can take.
Two related facts from the data model: when normal lookup fails and the class defines __getattr__, Python calls it with the name as a string. A proxy class built on it (see AR-22) adds a second layer of the same blindness.
A dict of functions (HANDLERS = {"refund": handle_refund}) is half visible. The functions are named where the dict is built, but the call HANDLERS[kind](order) does not say which one runs.
TypeScript and JavaScript. obj[name]() with name: string has the same shape. With an index signature or any, the compiler has no declaration to connect to the call. When the key's type is a union of literals and the table is typed Record<Kind, Handler>, the compiler checks the call against every entry. The TypeScript docs state that Record<Colors, unknown> with satisfies requires exactly the keys in the union and rejects extra ones, while keeping each property's own type.
Bad and good
Take an order service whose handlers are named after event kinds.
# bad: dispatch by a built name
class Orders:
def handle_refund(self, order: Order) -> None: ...
def handle_ship(self, order: Order) -> None: ...
def on_event(self, event: Event) -> None:
getattr(self, f"handle_{event.kind}")(event.order)// bad: dispatch by a built name
class Orders {
handleRefund(order: Order): void {}
handleShip(order: Order): void {}
onEvent(event: Event): void {
const fn = (this as any)[`handle${event.kind}`];
fn.call(this, event.order);
}
}An agent asked to give handle_refund a second parameter finds no caller for it. The edit compiles and type-checks, and on_event calls it with one argument on the first refund event.
# good: the names appear in the source
from typing import Callable, Literal
Kind = Literal["refund", "ship"]
class Orders:
def handle_refund(self, order: Order, reason: str) -> None: ...
def handle_ship(self, order: Order) -> None: ...
def on_event(self, event: Event) -> None:
match event.kind:
case "refund":
self.handle_refund(event.order, event.reason)
case "ship":
self.handle_ship(event.order)// good: the names appear in the source
type Kind = "refund" | "ship";
const handlers = {
refund: (o: Order, reason: string) => refund(o, reason),
ship: (o: Order) => ship(o),
} satisfies Record<Kind, (o: Order, reason: string) => void>;
function onEvent(event: Event): void {
handlers[event.kind](event.order, event.reason);
}Every handler is now referenced by name, and a search for handle_refund lands on the dispatch. AR-08 covers how to write this form so the type checker enforces it.
Trade-offs and when getattr is acceptable
- The dynamic form is shorter. One line covers every kind. The explicit form costs a line per case, which is the price of making each call findable.
- Reflection over attributes you don't own (serialisers, test helpers, adapters over third-party objects) is a legitimate use. Keep it out of code paths an agent may well edit, and leave a comment saying where the targets live.
- Plugin and command systems are open sets by design. Keep them dynamic and make the contract visible (AR-09).
- A constant string (
getattr(obj, "save")) is a different case. The target is knowable, but it reads worse thanobj.save, and ruff flags it (see below). - **
getattrwith a default** (getattr(obj, name, None)) adds a silentNonewhen the name is wrong. A table lookup that raises on an unknown key fails where the mistake is.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | B009 get-attr-with-constant | getattr(obj, "literal") that should be obj.literal (fix always offered, unsafe for some names). It does not flag built names. | 2026-10-30 |
| ruff | B010 set-attr-with-constant | setattr(obj, "literal", v) | 2026-10-30 |
| pyright | reportUnknownMemberType, reportUnknownVariableType, reportUnknownArgumentType (all off in basic and standard, error in strict) | values of unknown type, which is what string dispatch produces | 2026-10-30 |
| tsconfig | noUncheckedIndexedAccess | adds undefined to undeclared index-signature fields, forcing a check before the call | 2026-10-30 |
| tsconfig | noPropertyAccessFromIndexSignature | requires bracket access for index-signature fields, so a dynamic lookup shows in the syntax | 2026-10-30 |
| typescript-eslint | no-explicit-any (in recommended) | any annotations, the usual way around a typed table | 2026-10-30 |
| typescript-eslint | no-unsafe-call, no-unsafe-member-access (in recommended-type-checked, need type information) | calls on any, and member access through an any value or any-typed key | 2026-10-30 |
No rule in the pages checked flags a built name in getattr directly. Reaching the unknown type is the lever, so strict mode on the paths agents edit does most of the work (AR-17).
Rule block for AGENTS.md / CLAUDE.md
## Dynamic dispatch
- Call functions directly. Don't build method names as strings (getattr(obj, f"handle_{x}"), obj[name]()).
- For dispatch over a known set, use a typed mapping with literal keys or an explicit match.
- Before changing a handler's signature, find its table entry and the dispatch site, and update both.
- If a call must stay dynamic, keep the registry and the handler's typed contract next to the call site.Edge cases
- **
operator.methodcaller("name")andfunctools.partialover a looked-up attribute** hide the call the same way. The operator docs saymethodcaller(name)returns a callable that "calls the method name on its operand" and givegetattr(obj, name)(*args, **kwargs)as the equivalent, so the method name is a string at the call site (https://docs.python.org/3/library/operator.html#operator.methodcaller). Thefunctoolsdocs saypartial(func, /, *args, **keywords)returns an object that behaves likefunccalled with the frozen arguments, so the call site names the partial object, not the target (https://docs.python.org/3/library/functools.html#functools.partial). The target stays visible in the source only where the callable is passed topartial. - **
vars(obj)ortype(self).__dict__lookups** are the same category asgetattrwith a built name. - String enums as keys: a TypeScript enum used as the key type of a
Recordworks like a literal union. Checked with tsc 7.0.2 under--strict: a table typedRecord<Kind, () => void>that lacks theKind.Bentry is reported as TS2741 ("Property 'b' is missing in type ... but required in type 'Table'"), and a table with every entry passes. - Python dict dispatch does not make the type checker demand an entry for every
Literalmember. Amatchwith anassert_neverfallthrough does (AR-08). - **Tests that call
getattron mocks** are a fixture concern (AR-35), not production dispatch.
What LLMGRAPH shows
For a handler reached through getattr(self, f"handle_{kind}"), LLMGRAPH prints no calls to it found in this repo and plus 1 dynamic call nearby that could reach anything. After an explicit match, it prints certain with the call line.
References
- Python library reference, built-in functions (
getattr,eval): https://docs.python.org/3/library/functions.html - Python data model,
__getattr__: https://docs.python.org/3/reference/datamodel.html - Python typing,
Literal: https://docs.python.org/3/library/typing.html - Python compound statements, the
matchstatement: https://docs.python.org/3/reference/compound_stmts.html - Ruff B009, B010: https://docs.astral.sh/ruff/rules/get-attr-with-constant/ · https://docs.astral.sh/ruff/rules/set-attr-with-constant/
- Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- TypeScript 4.9 release notes (
satisfies): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html - TypeScript utility types (
Record): https://www.typescriptlang.org/docs/handbook/utility-types.html - TSConfig reference: https://www.typescriptlang.org/tsconfig/
- typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-call/ · https://typescript-eslint.io/rules/no-unsafe-member-access/
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: verify items resolved (docs check)
- 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2)
- 2026-10-30: copy-gate wording fixes
Related
- AR-08 How do I write dispatch that type checkers can follow?
- AR-09 How should plugin registries be written for coding agents?
- AR-10 Why can't any tool see calls made through eval?
- AR-22 Why do __getattr__ proxies confuse coding agents?
- CH-01 Why does my Django signal break after a refactor?
Chimeras that use this rule
- CH-01 Why does my Django signal break after a refactor?
Last verified