sys and inspect - Runtime Introspection at Engineering Depth
Reading time: ~35 minutes | Level: Intermediate → Engineering
Before reading further, predict what this program prints:
import inspect
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
sig = inspect.signature(greet)
for name, param in sig.parameters.items():
print(f"{name}: default={param.default}, annotation={param.annotation}")
Show Answer
name: default=<class 'inspect._empty'>, annotation=<class 'str'>
greeting: default=Hello, annotation=<class 'str'>
Parameters without defaults report inspect.Parameter.empty - a sentinel class, not None. None is a valid default value (e.g., def f(x=None)), so inspect uses a distinct sentinel to mean "no default was specified at all."
This is exactly how FastAPI reads your function signature at startup. When you write:
@app.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
...
FastAPI calls inspect.signature(get_user), iterates over sig.parameters, and checks each parameter's annotation and default. If the default is Depends(...), FastAPI registers it as a dependency. If the annotation is a Pydantic model, FastAPI builds a request body validator. All of this happens at import time - before a single request arrives.
sys and inspect are the two modules that give Python programs access to their own runtime internals. sys exposes the interpreter state; inspect exposes the source code and call structure of live objects. Every serious framework you use - pytest, FastAPI, Django, click - relies on both. This lesson teaches you to use them as a framework author would.
What You Will Learn
sys.argv,sys.path,sys.modulesand how they control program executionsys.getrefcount(),sys.getsizeof(),sys.setrecursionlimit()sys.exc_info(): the current exception triple used by logging and error handlerssys._getframe()andsys.settrace()/sys.setprofile(): how debuggers and coverage tools worksys.stdin,sys.stdout,sys.stderrand redirecting theminspect.signature()and all five parameter kindsinspect.getmembers(),inspect.getsource(),inspect.getsourcelines()inspect.stack()andinspect.currentframe()- How FastAPI, pytest, and click use
inspectas their core mechanism
Prerequisites
- Lesson 05 (Decorators) - many
inspectpatterns involve decorated functions - Lesson 06 (Closures and Free Variables) -
sys._getframe()exposes frame locals/globals - Python type annotations (PEP 484) -
inspect.signature()reads__annotations__
Part 1 - The sys Module
sys.argv: Command-Line Arguments
import sys
# sys.argv[0] is always the script name
# sys.argv[1:] are the arguments passed on the command line
# script.py
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <name>")
sys.exit(1)
name = sys.argv[1]
print(f"Hello, {name}!")
$ python script.py Alice
Hello, Alice!
$ python script.py
Usage: script.py <name>
sys.argv is a plain list[str]. For anything beyond trivial argument parsing, use argparse (standard library) or click (third party) - both read sys.argv internally.
sys.path: The Module Search Path
import sys
# sys.path is the list of directories Python searches for modules
print(sys.path)
# ['', '/usr/lib/python312.zip', '/usr/lib/python3.12', ...]
# '' means the current working directory
# You can append to sys.path at runtime to import from non-standard locations
import os
sys.path.insert(0, "/opt/internal-packages") # highest priority - searched first
import internal_tool # now findable
# More robust: use site.addsitedir() or PYTHONPATH environment variable
Mutating sys.path at runtime is fragile - the modification applies only to the current process and is not visible to subprocesses or other workers. Prefer installing packages into a virtual environment, or setting PYTHONPATH before the interpreter starts.
sys.modules: The Module Cache
sys.modules is a dict mapping module names to their loaded module objects. Every successful import stores the result here. A second import of the same name hits this cache instead of executing the module again.
import sys
import json
# json is already in the cache
print("json" in sys.modules) # True
print(sys.modules["json"] is json) # True - same object
# You can inspect what is loaded
stdlib_modules = [name for name in sys.modules if "." not in name]
print(len(stdlib_modules)) # varies by environment - typically 50–100 at startup
sys.modules is the import cache. You can manually insert objects into it to make a module "importable" without a file on disk. Testing frameworks and mocking libraries use this trick: sys.modules["requests"] = mock_requests makes import requests return the mock. This is how unittest.mock.patch works at the module level.
sys.getrefcount(): Reference Count of an Object
import sys
x = []
print(sys.getrefcount(x)) # 2: one for 'x', one for the argument to getrefcount
y = x
print(sys.getrefcount(x)) # 3: 'x', 'y', and the argument
del y
print(sys.getrefcount(x)) # 2 again
The count is always at least 1 higher than you expect because the act of passing the object to getrefcount creates a temporary reference.
sys.setrecursionlimit() and sys.getrecursionlimit()
Python's default recursion limit is 1000. Deep recursive algorithms - tree traversals, parsers, certain graph algorithms - can exceed it.
import sys
print(sys.getrecursionlimit()) # 1000
# Increase for algorithms that require deeper recursion
sys.setrecursionlimit(10_000)
def depth_first(node, depth=0):
if node is None or depth > 9_000:
return
depth_first(node.left, depth + 1)
depth_first(node.right, depth + 1)
Setting sys.setrecursionlimit to a very high value (e.g., 1 000 000) can cause a C stack overflow - a segmentation fault, not a Python RecursionError - because each Python frame consumes C stack space. The safe upper limit depends on your OS stack size (typically 1–8 MB). Prefer iterative algorithms with an explicit stack for very deep recursion.
sys.exc_info(): The Current Exception Triple
Inside an except block, sys.exc_info() returns the currently-handled exception as (type, value, traceback).
import sys
import logging
def run_with_logging(func, *args, **kwargs):
try:
return func(*args, **kwargs)
except Exception:
exc_type, exc_value, exc_tb = sys.exc_info()
logging.error(
"Exception in %s: %s: %s",
func.__name__,
exc_type.__name__,
exc_value,
)
raise # re-raise the original exception
# This pattern is used in Django's middleware and Celery's task runner
# to log exceptions with full context before allowing them to propagate
Outside an except block, sys.exc_info() returns (None, None, None).
sys._getframe(): Accessing the Call Stack
sys._getframe(depth) returns the frame object at depth levels up the call stack. Frame 0 is the caller, frame 1 is the caller's caller, etc.
import sys
def who_called_me() -> str:
frame = sys._getframe(1) # 1 = my caller's frame
return f"{frame.f_code.co_filename}:{frame.f_lineno} in {frame.f_code.co_name}"
def application_function():
print(who_called_me())
application_function()
# example.py:10 in application_function
Frame attributes:
f_code.co_filename- source filef_code.co_name- function or method namef_lineno- current line numberf_locals- local variables at that frame (a snapshot dict)f_globals- global namespace of the frame's modulef_back- parent frame (one level up)
sys._getframe() is a CPython implementation detail - the leading underscore signals it is not part of the public API and may not exist on other Python implementations (PyPy, Jython). Use inspect.currentframe() for slightly more portable code, or inspect.stack() for full call stack information.
sys.settrace() and sys.setprofile(): How Debuggers Work
sys.settrace(func) installs a trace function called on every line execution, function call, and function return. sys.setprofile(func) is similar but fires only on calls and returns - lower overhead.
import sys
call_log = []
def trace_calls(frame, event, arg):
if event == "call":
call_log.append(frame.f_code.co_name)
return trace_calls # must return itself to trace nested calls
sys.settrace(trace_calls)
def add(x, y):
return x + y
def multiply(x, y):
return x * y
result = add(1, 2) + multiply(3, 4)
sys.settrace(None) # disable tracing
print(call_log) # ['add', 'multiply']
The trace function signature is (frame, event, arg) where event is one of:
"call"- entering a function (argisNone)"line"- about to execute a line (argisNone)"return"- about to return from a function (argis the return value)"exception"- an exception has been raised (argis(type, value, traceback))
sys.settrace fires on every line of Python execution. The overhead is severe - 10–100x slowdown is typical. Tools like pdb, coverage.py, and IDE debuggers use sys.settrace internally but always disable it when not actively debugging. Never leave sys.settrace active in production code.
sys.stdin, sys.stdout, sys.stderr: Redirecting I/O
import sys
import io
# Capture stdout for testing
captured = io.StringIO()
sys.stdout = captured
print("This goes to the StringIO buffer, not the terminal")
sys.stdout = sys.__stdout__ # restore original stdout
output = captured.getvalue()
print(f"Captured: {repr(output)}")
# Captured: 'This goes to the StringIO buffer, not the terminal\n'
sys.__stdout__ (double underscore on both sides) is always the original stdout, even after reassignment. This is how capsys in pytest captures print output during tests without modifying the test code.
Part 2 - The inspect Module
inspect.signature(): Full Parameter Introspection
inspect.signature() returns a Signature object containing all parameters, their kinds, defaults, and annotations.
Parameter Kinds
import inspect
def complex_func(
pos_only: int, # POSITIONAL_ONLY
/,
regular: str, # POSITIONAL_OR_KEYWORD
*args: float, # VAR_POSITIONAL
kw_only: bool = True, # KEYWORD_ONLY
**kwargs: str, # VAR_KEYWORD
) -> None:
pass
sig = inspect.signature(complex_func)
for name, param in sig.parameters.items():
print(f"{name:12} kind={param.kind.name:25} default={param.default}")
pos_only kind=POSITIONAL_ONLY default=<class 'inspect._empty'>
regular kind=POSITIONAL_OR_KEYWORD default=<class 'inspect._empty'>
args kind=VAR_POSITIONAL default=<class 'inspect._empty'>
kw_only kind=KEYWORD_ONLY default=True
kwargs kind=VAR_KEYWORD default=<class 'inspect._empty'>
Binding Arguments with signature.bind()
import inspect
def create_user(name: str, age: int, role: str = "viewer") -> dict:
return {"name": name, "age": age, "role": role}
sig = inspect.signature(create_user)
# Validate args before calling - raises TypeError with a clear message on failure
try:
bound = sig.bind("Alice", 30) # missing 'role' is fine - has default
bound.apply_defaults()
print(bound.arguments)
# OrderedDict([('name', 'Alice'), ('age', 30), ('role', 'viewer')])
except TypeError as e:
print(f"Bad arguments: {e}")
# Missing required argument
try:
sig.bind("Alice") # missing 'age'
except TypeError as e:
print(f"Bad arguments: {e}")
# Bad arguments: missing a required argument: 'age'
Use inspect.signature(func).bind(*args, **kwargs) to validate arguments before calling a function dynamically. This gives you the same error message Python would give - "missing a required argument: 'x'" - without actually calling the function. FastAPI uses this pattern to validate that all dependency parameters can be satisfied before registering a route.
How FastAPI Uses inspect.signature
inspect.getmembers(), isfunction(), isclass(), ismethod()
import inspect
class MyService:
version = "1.0"
def process(self, data: dict) -> dict:
return data
@classmethod
def from_config(cls, config: dict) -> "MyService":
return cls()
@staticmethod
def validate(data: dict) -> bool:
return bool(data)
# Get all members, filtered by type
methods = inspect.getmembers(MyService, predicate=inspect.isfunction)
print([name for name, _ in methods])
# ['from_config', 'process', 'validate']
# ismethod vs isfunction on instances
svc = MyService()
print(inspect.ismethod(svc.process)) # True - bound method
print(inspect.isfunction(svc.process)) # False - bound methods are not functions
print(inspect.isfunction(MyService.process)) # True - unbound, it is a function
# isclass
print(inspect.isclass(MyService)) # True
print(inspect.isclass(svc)) # False
inspect.getsource(), getsourcefile(), getsourcelines()
import inspect
def compute_tax(income: float, rate: float = 0.2) -> float:
"""Compute income tax at the given rate."""
if income <= 0:
return 0.0
return income * rate
# Get the full source as a string
src = inspect.getsource(compute_tax)
print(src)
# def compute_tax(income: float, rate: float = 0.2) -> float:
# """Compute income tax at the given rate."""
# ...
# Get the source file path
print(inspect.getsourcefile(compute_tax))
# /path/to/example.py
# Get source lines and starting line number
lines, start_line = inspect.getsourcelines(compute_tax)
print(f"Defined at line {start_line}")
print("".join(lines))
inspect.getsource() fails for built-in functions and functions defined in C extensions. Calling inspect.getsource(len) raises OSError: could not get source code. It also fails for functions defined in the interactive REPL. Always wrap in a try/except when the source is not guaranteed to be available.
inspect.stack() and inspect.currentframe()
inspect.stack() returns the full call stack as a list of FrameInfo named tuples, from the current frame outward.
import inspect
def level_three():
stack = inspect.stack()
for i, frame_info in enumerate(stack[:4]):
print(f"[{i}] {frame_info.filename}:{frame_info.lineno} in {frame_info.function}")
if frame_info.code_context:
print(f" {frame_info.code_context[0].strip()}")
def level_two():
level_three()
def level_one():
level_two()
level_one()
[0] example.py:4 in level_three
stack = inspect.stack()
[1] example.py:11 in level_two
level_three()
[2] example.py:14 in level_one
level_two()
[3] example.py:17 in <module>
level_one()
FrameInfo fields:
frame- the actual frame object (use with care - keeping frame references causes memory leaks)filename- source file pathlineno- current line numberfunction- function namecode_context- list of source lines around the current lineindex- index withincode_contextof the current line
inspect.getdoc() and inspect.cleandoc()
import inspect
class DataProcessor:
def transform(self, data):
"""
Transform raw data into the canonical format.
Strips whitespace, normalises unicode, and validates structure.
Raises ValueError if data does not match the expected schema.
Args:
data: Raw input dict from the API layer.
Returns:
Normalised dict ready for database insertion.
"""
pass
# inspect.getdoc normalises indentation automatically
print(inspect.getdoc(DataProcessor.transform))
# Transform raw data into the canonical format.
#
# Strips whitespace, normalises unicode, and validates structure.
# ...
# inspect.cleandoc does the same normalisation on a raw string
raw = """
First line.
Second line.
Third line.
"""
print(inspect.cleandoc(raw))
# First line.
# Second line.
# Third line.
Part 3 - Real-World Framework Usage
pytest: Fixture Injection via Parameter Names
pytest reads the names of your test function's parameters to decide which fixtures to inject:
# pytest reads inspect.signature(test_function)
# and matches parameter names to registered fixtures
def test_user_creation(db_session, mock_email_service, tmp_path):
# pytest sees: parameter 'db_session' → inject db_session fixture
# parameter 'mock_email_service' → inject that fixture
# parameter 'tmp_path' → inject built-in tmp_path fixture
user = User.create(session=db_session, name="Alice")
mock_email_service.assert_called_once_with(user.email)
Under the hood, pytest does roughly:
import inspect
def resolve_fixtures(test_func, fixture_registry: dict):
sig = inspect.signature(test_func)
kwargs = {}
for param_name, param in sig.parameters.items():
if param_name in fixture_registry:
kwargs[param_name] = fixture_registry[param_name]()
elif param.default is inspect.Parameter.empty:
raise pytest.FixtureError(f"No fixture named '{param_name}'")
return test_func(**kwargs)
click: CLI Arguments from Function Signatures
click uses inspect.signature() to map CLI arguments to function parameters:
import click
@click.command()
@click.option("--name", required=True)
@click.option("--count", default=1, type=int)
def hello(name: str, count: int):
for _ in range(count):
click.echo(f"Hello, {name}!")
# click reads inspect.signature(hello) to understand
# that 'name' and 'count' are the function's parameters,
# then maps CLI flags to them by name
dataclasses: Field Inspection
The dataclasses module uses inspect to read class annotations and build __init__, __repr__, and __eq__:
import inspect
import dataclasses
@dataclasses.dataclass
class Point:
x: float
y: float
label: str = ""
# What dataclasses does internally:
fields = {
name: annotation
for name, annotation in inspect.get_annotations(Point).items()
}
print(fields)
# {'x': <class 'float'>, 'y': <class 'float'>, 'label': <class 'str'>}
# inspect.fields() on a dataclass gives you the full Field objects
for field in dataclasses.fields(Point):
print(f"{field.name}: {field.type}, default={field.default}")
Graded Practice
Level 1 - Predict the Output
import inspect
def outer(x: int) -> int:
def inner(y: int = 10) -> int:
return x + y
return inner
sig_outer = inspect.signature(outer)
sig_inner = inspect.signature(outer(5))
print(len(sig_outer.parameters))
print(len(sig_inner.parameters))
param = sig_inner.parameters["y"]
print(param.default)
print(param.default is inspect.Parameter.empty)
Show Answer
1
1
10
False
outer has one parameter (x). outer(5) returns inner, which has one parameter (y with default 10). param.default is 10 (an integer, not inspect.Parameter.empty), so the last line prints False.
Level 2 - Debug This Code
This decorator is supposed to log the name and arguments of every function call. It works but breaks inspect.signature() on decorated functions. Fix it.
import functools
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} args={args} kwargs={kwargs}")
return func(*args, **kwargs)
return wrapper
@log_calls
def create_order(user_id: int, product: str, quantity: int = 1) -> dict:
return {"user": user_id, "product": product, "qty": quantity}
import inspect
sig = inspect.signature(create_order)
print(sig) # prints (*args, **kwargs) - wrong!
Show Answer
functools.wraps is missing. Without it, wrapper.__wrapped__ is not set and inspect.signature() sees the generic (*args, **kwargs) signature of wrapper instead of the original function's signature.
Fixed version:
import functools
def log_calls(func):
@functools.wraps(func) # <-- add this
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} args={args} kwargs={kwargs}")
return func(*args, **kwargs)
return wrapper
@log_calls
def create_order(user_id: int, product: str, quantity: int = 1) -> dict:
return {"user": user_id, "product": product, "qty": quantity}
import inspect
sig = inspect.signature(create_order)
print(sig)
# (user_id: int, product: str, quantity: int = 1) -> dict - correct
functools.wraps sets wrapper.__wrapped__ = func. inspect.signature() follows __wrapped__ chains to find the innermost original function's signature. This is also why FastAPI's dependency injection works correctly even when your route handler functions are decorated.
Level 3 - Design Challenge
Design a lightweight dependency injection container inspired by pytest's fixture system, using inspect.signature. It should:
- Allow registering "providers" - functions that create objects (similar to pytest fixtures)
- When a registered provider or any other function is called through the container, automatically resolve its parameter names by looking them up in the registry
- Support transitive resolution (a provider can itself depend on other registered providers)
- Raise a clear
KeyErrorwith the missing dependency name if a parameter cannot be resolved
Write the Container class with register(name, factory) and call(func) methods, then demonstrate it with a three-level dependency chain.
Show Answer
import inspect
from typing import Callable, Any
class Container:
def __init__(self):
self._registry: dict[str, Callable] = {}
self._cache: dict[str, Any] = {}
def register(self, name: str, factory: Callable) -> None:
"""Register a factory function under a name."""
self._registry[name] = factory
def resolve(self, name: str) -> Any:
"""Resolve a named dependency, caching the result (singleton behaviour)."""
if name not in self._cache:
if name not in self._registry:
raise KeyError(f"No dependency registered for '{name}'")
factory = self._registry[name]
self._cache[name] = self.call(factory)
return self._cache[name]
def call(self, func: Callable) -> Any:
"""Call func, injecting all its parameters from the registry."""
sig = inspect.signature(func)
kwargs = {}
for param_name, param in sig.parameters.items():
if param_name in self._registry:
kwargs[param_name] = self.resolve(param_name)
elif param.default is not inspect.Parameter.empty:
kwargs[param_name] = param.default
else:
raise KeyError(
f"Cannot inject parameter '{param_name}' into {func.__name__}: "
f"not registered and has no default"
)
return func(**kwargs)
# --- Demonstration ---
container = Container()
# Level 1: no dependencies
container.register("config", lambda: {"db_url": "sqlite:///app.db", "debug": True})
# Level 2: depends on config
def make_db_connection(config: dict):
return f"Connection to {config['db_url']}"
container.register("db", make_db_connection)
# Level 3: depends on db and config
def make_user_repository(db, config: dict):
return {"connection": db, "debug": config["debug"]}
container.register("user_repo", make_user_repository)
# Resolve the full chain
repo = container.resolve("user_repo")
print(repo)
# {'connection': 'Connection to sqlite:///app.db', 'debug': True}
# Call an ad-hoc function that uses the container
def run_report(user_repo, config: dict) -> str:
return f"Report for {user_repo['connection']} (debug={config['debug']})"
result = container.call(run_report)
print(result)
# Report for Connection to sqlite:///app.db (debug=True)
This is the core of how pytest fixtures, FastAPI Depends, and Python IoC frameworks like injector work. inspect.signature provides the parameter names; the framework maps names to registered providers.
Key Takeaways
sys.modulesis the module cache: every successfully imported module is stored here and the secondimportof the same name returns the cached object without re-executing the module file.sys.argvis a plainlist[str];sys.pathis the list of directories searched for imports; both are mutable at runtime.sys.exc_info()returns(type, value, traceback)for the currently-handled exception - the standard pattern for re-raising with logging in middleware and error handlers.sys._getframe(n)gives direct access to the call stack at depthn, exposing local variables, source file, and line number. Used by logging formatters to report "called from" information.sys.settracefires on every line and is howpdb,coverage.py, and IDE debuggers work. It is too slow for production - always disable when not actively debugging.inspect.signature()is the foundation of Python's modern framework ecosystem. pytest, FastAPI, click, and dataclasses all use it to map parameter names to injectable values.- The five
Parameter.kindvalues -POSITIONAL_ONLY,POSITIONAL_OR_KEYWORD,VAR_POSITIONAL,KEYWORD_ONLY,VAR_KEYWORD- correspond directly to the five syntactic forms in a function definition. inspect.signature(func).bind(*args, **kwargs)validates arguments without calling the function, producing the same error message Python would produce.inspect.getsource()fails on built-ins and C extensions - always guard withtry/except OSError.inspect.stack()returns the full call stack with source context - useful for debugging and for framework code that needs to know the call site.
