inspect and Reflection
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.pyOutput:
(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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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/getdocfor tools, docs, and tests — not for the happy path.getattr(obj, name, default)when the name is actually dynamic.- Never
getattron user input against a module. Use an explicit dict. - If a plugin protocol needs reflection, keep it in one adapter file.
Try this
- In
show_sig.py, printsig.parameters["reason"].default. - In
ticket_getattr.py, useticket.tableinstead ofgetattrfor the first print. - In
dispatch_fix.py, look up"pay"and catchKeyError.