Runtime Introspection and Reflection with inspect

Updated

September 7, 2026

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

Output:

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

Output:

Handler output:
[DB] Executed 'UPDATE carts SET status='PAID' WHERE id=9812'
[AUDIT] Recorded 'Cart 9812 paid in USD'

Case 3: Caller-Context Telemetry and Frame Navigation

Structured loggers and profiling tools frequently need to know who invoked a function without forcing developers to pass __file__ and __line__ manually. By inspecting the active execution frame with inspect.currentframe() or sys._getframe(), we can climb the call stack:

# frame_telemetry.py
import inspect
import sys
from typing import Any, Dict

def capture_caller_context(depth: int = 1) -> Dict[str, Any]:
    """Captures caller metadata by walking up the CPython call stack."""
    frame = sys._getframe(depth + 1)
    try:
        return {
            "function": frame.f_code.co_name,
            "filename": frame.f_code.co_filename.split("/")[-1],
            "line": frame.f_lineno,
            "module": frame.f_globals.get("__name__", "<unknown>"),
        }
    finally:
        # Crucial: Delete frame reference to prevent circular garbage collection cycles
        del frame

def emit_telemetry(action: str, metric_value: float) -> None:
    ctx = capture_caller_context(depth=1)
    file_name = ctx["filename"]
    line_no = ctx["line"]
    fn_name = ctx["function"]
    print(f"[{file_name}:{line_no} in {fn_name}()] ACTION='{action}' VALUE={metric_value}")

class PaymentProcessor:
    def charge_card(self, amount: float) -> None:
        emit_telemetry("charge_initiated", amount)

def main() -> None:
    processor = PaymentProcessor()
    processor.charge_card(149.95)

if __name__ == "__main__":
    main()

Run:

uv run python frame_telemetry.py

Output:

[frame_telemetry.py:31 in charge_card()] ACTION='charge_initiated' VALUE=149.95

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 immediately

Pitfall 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

  1. 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.
  2. Implement a validation decorator that inspects keyword-only parameters (Parameter.KEYWORD_ONLY) and ensures they are never omitted when calling the function.
  3. Write a dynamic JSON schema generator that converts a function’s type annotations and default parameters into an OpenAPI/JSON-Schema-compliant parameter dictionary.
  4. Using sys._getframe(), implement an @auto_logger decorator that logs the execution time and the name of the function that invoked the decorated function.
  5. 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: inspectInspect 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.