Runtime Introspection and Reflection with inspect
Runtime Introspection and Reflection with inspect
After reading this chapter, you will master Python’s runtime reflection and introspection machinery using the standard library inspect module, dynamically parse function signatures and type annotations, bind and validate arguments programmatically before invocation, navigate live Python call frames, extract source code and docstrings dynamically, and engineer production-grade dependency injection containers.
Mental model
In compiled languages like C or Go, function signatures, parameter names, and local variable scopes are largely erased at compile time. In Python, code objects, functions, classes, and execution frames retain their full symbolic metadata in memory at runtime:
┌─────────────────────────────────────────────────────────────┐
│ Python Callable (Function / Method / Closure) │
├──────────────────────────────┬──────────────────────────────┤
│ __code__ (Bytecode & Names) │ __annotations__ (Type Hints) │
│ __defaults__ (Arg Defaults) │ __kwdefaults__ (KW Defaults) │
└──────────────────────────────┴──────────────────────────────┘
│
inspect.signature(fn)
▼
┌─────────────────────────────────────────────────────────────┐
│ inspect.Signature │
│ ├─ return_annotation: type │
│ └─ parameters: MappingProxyType │
│ ├─ 'req_id': Parameter(POSITIONAL_OR_KEYWORD, int) │
│ ├─ 'dry_run': Parameter(KEYWORD_ONLY, bool, False) │
│ └─ '*args': Parameter(VAR_POSITIONAL) │
└──────────────────────────────┬──────────────────────────────┘
│
sig.bind(*args, **kwargs)
▼
┌─────────────────────────────────────────────────────────────┐
│ inspect.BoundArguments │
│ ├─ arguments: {'req_id': 100, 'dry_run': False} │
│ └─ apply_defaults(): injects missing default parameters │
└─────────────────────────────────────────────────────────────┘
Furthermore, whenever a Python function executes, CPython pushes a frame object (PyFrameObject) onto the runtime call stack. The inspect module allows libraries to inspect the active call stack, locate the caller’s file and line number, and introspect the local namespace.
Minimal example
Save as inspect_overview.py:
# inspect_overview.py
import inspect
from typing import Any
def provision_cluster(
cluster_name: str,
node_count: int = 3,
*tags: str,
auto_repair: bool = True,
**metadata: Any,
) -> dict[str, Any]:
"""Provisions a virtual computing cluster with specified parameters."""
return {
"name": cluster_name,
"nodes": node_count,
"tags": tags,
"auto_repair": auto_repair,
"metadata": metadata,
}
def main() -> None:
# 1. Extract signature metadata
sig = inspect.signature(provision_cluster)
print(f"Callable signature:\n {sig}\n")
print("Parameter breakdown:")
for name, param in sig.parameters.items():
print(
f" Name: {name:<12} "
f"Kind: {param.kind.name:<22} "
f"Default: {str(param.default):<10} "
f"Annotation: {param.annotation}"
)
# 2. Programmatically bind runtime arguments
bound = sig.bind("prod-eu-west", 10, "k8s", "gpu", auto_repair=False, zone="eu-west-1a")
print(f"\nBound arguments dictionary:\n {bound.arguments}")
# 3. Invoke function with bound arguments
result = provision_cluster(*bound.args, **bound.kwargs)
print(f"\nFunction result:\n {result}")
if __name__ == "__main__":
main()Run via uv run python inspect_overview.py:
Callable signature:
(cluster_name: str, node_count: int = 3, *tags: str, auto_repair: bool = True, **metadata: Any) -> dict[str, typing.Any]
Parameter breakdown:
Name: cluster_name Kind: POSITIONAL_OR_KEYWORD Default: <class 'inspect._empty'> Annotation: <class 'str'>
Name: node_count Kind: POSITIONAL_OR_KEYWORD Default: 3 Annotation: <class 'int'>
Name: tags Kind: VAR_POSITIONAL Default: <class 'inspect._empty'> Annotation: <class 'str'>
Name: auto_repair Kind: KEYWORD_ONLY Default: True Annotation: <class 'bool'>
Name: metadata Kind: VAR_KEYWORD Default: <class 'inspect._empty'> Annotation: typing.Any
Bound arguments dictionary:
{'cluster_name': 'prod-eu-west', 'node_count': 10, 'tags': ('k8s', 'gpu'), 'auto_repair': False, 'metadata': {'zone': 'eu-west-1a'}}
Function result:
{'name': 'prod-eu-west', 'nodes': 10, 'tags': ('k8s', 'gpu'), 'auto_repair': False, 'metadata': {'zone': 'eu-west-1a'}}
Worked examples
Case 1: Runtime Type-Enforcing Decorator with Signature.bind()
While static type checkers like mypy and pyright catch errors before execution, boundaries such as HTTP endpoints, message queues, and user scripts receive raw data. inspect.signature enables writing a zero-boilerplate runtime type validator:
# runtime_type_guard.py
import functools
import inspect
from typing import Any, Callable
def enforce_contract(func: Callable[..., Any]) -> Callable[..., Any]:
"""Validates runtime arguments against function type annotations."""
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# Bind incoming arguments to parameter names, handling defaults
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for param_name, value in bound.arguments.items():
param = sig.parameters[param_name]
expected_type = param.annotation
# Validate basic types when annotations are explicit classes
if expected_type is not inspect.Parameter.empty and isinstance(expected_type, type):
if not isinstance(value, expected_type):
raise TypeError(
f"Contract violation on '{func.__name__}': "
f"argument '{param_name}' must be {expected_type.__name__}, "
f"received {type(value).__name__} ({value!r})"
)
result = func(*args, **kwargs)
# Validate return value
ret_type = sig.return_annotation
if ret_type is not inspect.Signature.empty and isinstance(ret_type, type):
if not isinstance(result, ret_type):
raise TypeError(
f"Contract violation on '{func.__name__}': "
f"expected return {ret_type.__name__}, got {type(result).__name__}"
)
return result
return wrapper
@enforce_contract
def scale_service(service_name: str, target_replicas: int, dry_run: bool = False) -> str:
return f"Service '{service_name}' scaling to {target_replicas} (dry_run={dry_run})"
def main() -> None:
# 1. Valid call succeeds
msg = scale_service("api-gateway", 8)
print(f"Success: {msg}")
# 2. Invalid parameter type raises immediate TypeError
try:
scale_service("api-gateway", "eight") # type: ignore
except TypeError as err:
print(f"\nCaught validation error:\n {err}")
if __name__ == "__main__":
main()Run:
uv run python runtime_type_guard.pyOutput:
Success: Service 'api-gateway' scaling to 8 (dry_run=False)
Caught validation error:
Contract violation on 'scale_service': argument 'target_replicas' must be int, received str ('eight')
Case 2: Zero-Magic Dependency Injection Container
Modern web frameworks like FastAPI and test frameworks like pytest automatically inject database sessions, configuration objects, and authentication credentials into endpoint handlers by introspecting parameter type annotations:
# dependency_injection.py
import inspect
from typing import Any, Callable, Dict, Type
class Container:
"""Type-based dependency injection registry."""
def __init__(self) -> None:
self._services: Dict[Type[Any], Any] = {}
def register(self, service_type: Type[Any], instance: Any) -> None:
"""Registers a singleton instance for a given type."""
self._services[service_type] = instance
def invoke(self, target: Callable[..., Any], **explicit_args: Any) -> Any:
"""Introspects target parameters and injects matching dependencies."""
sig = inspect.signature(target)
resolved_kwargs = dict(explicit_args)
for param_name, param in sig.parameters.items():
# If the argument was already explicitly supplied, skip auto-wiring
if param_name in resolved_kwargs:
continue
target_type = param.annotation
if target_type in self._services:
resolved_kwargs[param_name] = self._services[target_type]
elif param.default is inspect.Parameter.empty:
raise RuntimeError(
f"Unable to resolve required parameter '{param_name}: {target_type}' for {target.__name__}"
)
return target(**resolved_kwargs)
# Sample domain services
class DatabaseConnectionPool:
def execute(self, query: str) -> str:
return f"[DB] Executed '{query}'"
class AuditLogger:
def log(self, event: str) -> str:
return f"[AUDIT] Recorded '{event}'"
# Handler function declaring its dependencies via type hints
def checkout_handler(
cart_id: int,
db: DatabaseConnectionPool,
audit: AuditLogger,
currency: str = "USD",
) -> str:
db_res = db.execute(f"UPDATE carts SET status='PAID' WHERE id={cart_id}")
audit_res = audit.log(f"Cart {cart_id} paid in {currency}")
return f"{db_res}\n{audit_res}"
def main() -> None:
container = Container()
container.register(DatabaseConnectionPool, DatabaseConnectionPool())
container.register(AuditLogger, AuditLogger())
# Invoke without manually passing db or audit
output = container.invoke(checkout_handler, cart_id=9812)
print(f"Handler output:\n{output}")
if __name__ == "__main__":
main()Run:
uv run python dependency_injection.pyOutput:
Handler output:
[DB] Executed 'UPDATE carts SET status='PAID' WHERE id=9812'
[AUDIT] Recorded 'Cart 9812 paid in USD'
Pitfalls
Pitfall 1: Retaining Frame References and Creating GC Leaks
Frame objects contain references to their local dictionaries (f_locals), code objects, and enclosing execution scopes. Storing a frame in an exception trace or local variable without explicitly deleting it creates reference cycles that keep large local objects alive until the next garbage collection cycle:
# THE TRAP:
import inspect
def leaky_tracer():
frame = inspect.currentframe()
# If 'frame' is saved in a list or closure, all local variables in leaky_tracer
# remain anchored in RAM!
return frame.f_back
# THE FIX:
def safe_tracer():
frame = inspect.currentframe()
try:
return frame.f_back.f_lineno if frame and frame.f_back else None
finally:
del frame # Break reference cycle immediatelyPitfall 2: inspect.getsource() on Dynamically Evaluated or C Code
Functions loaded from compiled C-extensions (like math.sin or sys.exit) or created dynamically via exec() / eval() do not exist as source files on disk. Calling inspect.getsource() on them raises OSError or TypeError:
# THE TRAP:
import math
import inspect
try:
source = inspect.getsource(math.sin)
except TypeError as err:
print(f"Caught: {err}")Output:
Caught: <built-in function sin> is not a module, class, method, function, traceback, frame, or code object
The Fix: Always guard calls to inspect.getsource() with inspect.isbuiltin() or catch (OSError, TypeError).
Exercises
- Build a function
inspect_callable(fn)that prints whether the callable is a regular function, a coroutine function (async def), a generator function (yield), or a class constructor. - Implement a validation decorator that inspects keyword-only parameters (
Parameter.KEYWORD_ONLY) and ensures they are never omitted when calling the function. - Write a dynamic JSON schema generator that converts a function’s type annotations and default parameters into an OpenAPI/JSON-Schema-compliant parameter dictionary.
- Using
sys._getframe(), implement an@auto_loggerdecorator that logs the execution time and the name of the function that invoked the decorated function. - Create a command-line dispatcher that inspects an arbitrary function signature and automatically maps CLI strings from
sys.argv[1:]into typed function arguments (int,float,str,bool).
Further reading
- Python Documentation:
inspect— Inspect live objects. - Brett Slatkin: Effective Python (Item 48: Validate Subclasses with
__init_subclass__and Reflection). - Luciano Ramalho: Fluent Python (Chapter 7: Functions as First-Class Objects; Chapter 21: Class Metaprogramming).
- PEP 362: Function Signature Object.