# 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.

```python tab
# 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)
```

```ts tab
// 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.

```python tab
# 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)
```

```ts tab
// 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 than `obj.save`, and ruff flags it (see below).
- **`getattr` with a default** (`getattr(obj, name, None)`) adds a silent `None` when 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
```markdown
## 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")` and `functools.partial` over a looked-up attribute** hide the call the same way. The operator docs say `methodcaller(name)` returns a callable that "calls the method name on its operand" and give `getattr(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). The `functools` docs say `partial(func, /, *args, **keywords)` returns an object that behaves like `func` called 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 to `partial`.
- **`vars(obj)` or `type(self).__dict__` lookups** are the same category as `getattr` with a built name.
- **String enums as keys:** a TypeScript enum used as the key type of a `Record` works like a literal union. Checked with tsc 7.0.2 under `--strict`: a table typed `Record<Kind, () => void>` that lacks the `Kind.B` entry 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 `Literal` member. A `match` with an `assert_never` fallthrough does (AR-08).
- **Tests that call `getattr` on 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 `match` statement: 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?](https://beta.llmgraph.in/agent-readable/ar-08.md)
- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-10: Why can't any tool see calls made through eval?](https://beta.llmgraph.in/agent-readable/ar-10.md)
- [AR-22: Why do __getattr__ proxies confuse coding agents?](https://beta.llmgraph.in/agent-readable/ar-22.md)
- [CH-01: Why does my Django signal break after a refactor?](https://beta.llmgraph.in/agent-readable/ch-01.md)

## Chimeras that use this rule

- [CH-01: Why does my Django signal break after a refactor?](https://beta.llmgraph.in/agent-readable/ch-01.md)

Last verified 2026-10-30
