inspect and Reflection

Updated

September 8, 2026

inspect and Reflection

inspect and getattr are last resorts: useful for debugging, adapters, and tests that need a signature. They are not how the desk opens a ticket on a normal path. Call the function. Name the attribute.

Mental model

Reflection means asking a live object what it is: its name, its signature, its attributes. inspect.signature(fn) is the callable’s parameters. getattr(obj, "table") is obj.table with a string name.

When the name is a string you computed, you have lost the editor, the type checker, and most grep. That cost is acceptable in a test helper or a serializer. It is not acceptable in open_table.

Worked examples

Case 1: inspect.signature

Save as show_sig.py. This is what you print when a helper’s arguments drifted.

# show_sig.py
import inspect


def open_table(n: int, *, reason: str = "shift") -> str:
    return f"opened table {n} ({reason})"


def main() -> None:
    sig = inspect.signature(open_table)
    print(sig)
    print(list(sig.parameters))


if __name__ == "__main__":
    main()

Run:

uv run python show_sig.py

Output:

(n: int, *, reason: str = 'shift') -> str
['n', 'reason']

Case 2: getattr with a default

Save as ticket_getattr.py. The default argument is the only reason to prefer getattr over obj.table here.

# ticket_getattr.py
class Ticket:
    def __init__(self, ticket_id: int, table: int) -> None:
        self.id = ticket_id
        self.table = table


def main() -> None:
    ticket = Ticket(7, 12)
    print(getattr(ticket, "table"))
    print(getattr(ticket, "status", "open"))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_getattr.py

Output:

12
open

ticket.table would have been clearer for the first line. The second line is the case getattr is for: optional attribute, default if missing.

Case 3: inspect.getdoc

Save as show_doc.py. Same string you wrote under def.

# show_doc.py
import inspect


def label(ticket_id: int, table: int) -> str:
    """Return a one-line ticket label for the desk display."""
    return f"ticket {ticket_id} → table {table}"


def main() -> None:
    print(inspect.getdoc(label))


if __name__ == "__main__":
    main()

Run:

uv run python show_doc.py

Output:

Return a one-line ticket label for the desk display.

The trap

A tiny dispatcher that looks up functions by string. It runs. It also hides every call site.

Save as dispatch_trap.py:

# dispatch_trap.py
def open_ticket() -> str:
    return "opened ticket"


def close_ticket() -> str:
    return "closed ticket"


def main() -> None:
    name = "open_ticket"
    fn = globals().get(name)
    if fn is None:
        raise KeyError(name)
    print(fn())


if __name__ == "__main__":
    main()

Run:

uv run python dispatch_trap.py

Output:

opened ticket

The boring version is print(open_ticket()). If you have two commands, use if name == "open": or a dict you write as a dict, not globals(). getattr(module, user_input) is the same trap with extra extra steps.

Fix:

# dispatch_fix.py
def open_ticket() -> str:
    return "opened ticket"


def close_ticket() -> str:
    return "closed ticket"


def main() -> None:
    commands = {
        "open": open_ticket,
        "close": close_ticket,
    }
    print(commands["open"]())


if __name__ == "__main__":
    main()

Run:

uv run python dispatch_fix.py

Output:

opened ticket

The keys are the API. Unknown names raise KeyError on the map, not on the module guts.

The boring rule

  • Call functions by name. Read attributes by name.
  • inspect.signature / getdoc for tools, docs, and tests — not for the happy path.
  • getattr(obj, name, default) when the name is actually dynamic.
  • Never getattr on user input against a module. Use an explicit dict.
  • If a plugin protocol needs reflection, keep it in one adapter file.

Try this

  1. In show_sig.py, print sig.parameters["reason"].default.
  2. In ticket_getattr.py, use ticket.table instead of getattr for the first print.
  3. In dispatch_fix.py, look up "pay" and catch KeyError.