I recently discovered something that blew my mind: Python lets you examine itself while it's running. You can ask a function "what are your parameters?" or look at the call stack to see who called you. The inspect module is like giving your code a mirror.
What Is Introspection?
Introspection means a program examining its own structure at runtime. Python is particularly good at this because everything is an object—functions, classes, modules, even code blocks. The inspect module gives you tools to examine all of them.
I started exploring this after needing to build a decorator that logged function arguments. The standard approach felt clunky until I discovered inspect.signature().
Getting Function Signatures
This is probably what I use most often. You can extract the complete signature of any function:
import inspect
def process_order(
order_id: int,
items: list[str],
discount: float = 0.0,
*,
expedited: bool = False,
notes: str | None = None
) -> dict:
"""Process an order and return confirmation."""
return {"order_id": order_id, "status": "processed"}
# Get the signature object
sig = inspect.signature(process_order)
print(sig)
# (order_id: int, items: list[str], discount: float = 0.0, *, expedited: bool = False, notes: str | None = None) -> dict
# Examine each parameter
for name, param in sig.parameters.items():
print(f"{name}:")
print(f" kind: {param.kind.name}")
print(f" default: {param.default}")
print(f" annotation: {param.annotation}")The kind attribute tells you what type of parameter it is:
import inspect
def example(pos_only, /, pos_or_kw, *args, kw_only, **kwargs):
pass
sig = inspect.signature(example)
for name, param in sig.parameters.items():
print(f"{name}: {param.kind.name}")
# pos_only: POSITIONAL_ONLY
# pos_or_kw: POSITIONAL_OR_KEYWORD
# args: VAR_POSITIONAL
# kw_only: KEYWORD_ONLY
# kwargs: VAR_KEYWORDPython 3.8 introduced positional-only parameters (the / separator), and inspect understands them perfectly.
Binding Arguments
What really impressed me was discovering sig.bind(). It lets you see exactly how arguments would map to parameters:
import inspect
def send_email(to: str, subject: str, body: str, cc: str | None = None, bcc: str | None = None):
pass
sig = inspect.signature(send_email)
# Simulate calling with specific arguments
bound = sig.bind("alice@example.com", "Hello", body="Test message")
print(bound.arguments)
# {'to': 'alice@example.com', 'subject': 'Hello', 'body': 'Test message'}
# Fill in defaults
bound.apply_defaults()
print(bound.arguments)
# {'to': 'alice@example.com', 'subject': 'Hello', 'body': 'Test message', 'cc': None, 'bcc': None}This is incredibly useful for decorators and validation. You can check if arguments are valid before the function even runs.
Examining Stack Frames
This is where things get really powerful. You can inspect the call stack—who called the current function, and who called them:
import inspect
def innermost():
"""Examine the call stack from here."""
print("Current stack:")
for frame_info in inspect.stack():
print(f" {frame_info.function} at {frame_info.filename}:{frame_info.lineno}")
def middle():
innermost()
def outer():
middle()
outer()
# Current stack:
# innermost at example.py:4
# middle at example.py:10
# outer at example.py:13
# <module> at example.py:15Getting Caller Information
I use this pattern a lot for logging and debugging:
import inspect
def get_caller_info():
"""Get information about who called this function."""
# Go back two frames: one for this function, one for our caller
caller_frame = inspect.currentframe().f_back.f_back
info = inspect.getframeinfo(caller_frame)
return {
'function': info.function,
'filename': info.filename,
'lineno': info.lineno,
'code_context': info.code_context[0].strip() if info.code_context else None
}
def log_with_caller(message):
"""Log a message with caller information."""
caller = get_caller_info()
print(f"[{caller['function']}:{caller['lineno']}] {message}")
def process_data():
log_with_caller("Starting data processing")
# ... do work ...
log_with_caller("Finished processing")
process_data()
# [process_data:21] Starting data processing
# [process_data:23] Finished processingAccessing Local Variables
Frame objects give you access to local variables at each level of the stack:
import inspect
def debug_locals():
"""Show local variables of the caller."""
caller_frame = inspect.currentframe().f_back
return caller_frame.f_locals.copy()
def example_function():
name = "Alice"
age = 30
items = ["apple", "banana"]
# See what variables exist here
print(debug_locals())
# {'name': 'Alice', 'age': 30, 'items': ['apple', 'banana']}
example_function()Warning: Be careful with frame references. They can create reference cycles that prevent garbage collection. Always clean up or use
inspect.stack()which handles this for you.
Source Code Inspection
You can actually retrieve the source code of functions and classes at runtime:
import inspect
def calculate_tax(amount: float, rate: float = 0.08) -> float:
"""Calculate tax on an amount.
Args:
amount: The base amount
rate: Tax rate (default 8%)
Returns:
The tax amount
"""
return amount * rate
# Get the source code
source = inspect.getsource(calculate_tax)
print(source)
# def calculate_tax(amount: float, rate: float = 0.08) -> float:
# """Calculate tax on an amount.
# ...
# Get source lines with starting line number
lines, start_line = inspect.getsourcelines(calculate_tax)
print(f"Function starts at line {start_line}")
for i, line in enumerate(lines):
print(f"{start_line + i}: {line}", end="")
# Get the file where it's defined
print(inspect.getfile(calculate_tax))
# Get clean docstring
print(inspect.getdoc(calculate_tax))This is how documentation generators and IDEs get their information. When you hover over a function in VSCode, it's using introspection behind the scenes.
The Limitation
Source inspection only works for functions defined in actual .py files. Built-in functions and C extensions don't have accessible source:
import inspect
try:
print(inspect.getsource(len))
except OSError as e:
print(f"Can't get source: {e}")
# Can't get source: could not find class definitionGetting Class Members
You can inspect classes to see all their attributes, methods, and properties:
import inspect
class DataProcessor:
"""Process data with various transformations."""
version = "1.0.0"
def __init__(self, config: dict):
self.config = config
self._cache = {}
def process(self, data: list) -> list:
"""Process the data."""
return [self._transform(item) for item in data]
def _transform(self, item):
"""Internal transformation."""
return item
@classmethod
def from_file(cls, path: str) -> "DataProcessor":
"""Create processor from config file."""
return cls({})
@staticmethod
def validate(data: list) -> bool:
"""Validate data format."""
return isinstance(data, list)
@property
def cache_size(self) -> int:
"""Return current cache size."""
return len(self._cache)
# Get all members
print("All members:")
for name, value in inspect.getmembers(DataProcessor):
if not name.startswith('_'):
print(f" {name}: {type(value).__name__}")
# Get only methods (functions defined in the class)
print("\nMethods:")
for name, method in inspect.getmembers(DataProcessor, inspect.isfunction):
if not name.startswith('_'):
print(f" {name}")
# Get class methods
print("\nClass methods:")
for name, method in inspect.getmembers(DataProcessor, inspect.ismethod):
print(f" {name}")Type Checking Predicates
inspect provides predicates to check what kind of object something is:
import inspect
import asyncio
def sync_func():
pass
async def async_func():
pass
class MyClass:
def method(self):
pass
obj = MyClass()
print(f"sync_func is function: {inspect.isfunction(sync_func)}") # True
print(f"async_func is coroutine function: {inspect.iscoroutinefunction(async_func)}") # True
print(f"MyClass is class: {inspect.isclass(MyClass)}") # True
print(f"obj.method is method: {inspect.ismethod(obj.method)}") # True
print(f"inspect is module: {inspect.ismodule(inspect)}") # True
# Generator detection
gen = (x for x in range(10))
print(f"gen is generator: {inspect.isgenerator(gen)}") # TruePractical Uses
Here's where all of this comes together. These are real patterns I use in my projects.
Automatic Logging Decorator
import inspect
from functools import wraps
def log_calls(func):
"""Decorator that logs function calls with arguments."""
sig = inspect.signature(func)
@wraps(func)
def wrapper(*args, **kwargs):
# Bind arguments to parameters
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
# Format arguments nicely
arg_strs = [f"{k}={v!r}" for k, v in bound.arguments.items()]
print(f"→ {func.__name__}({', '.join(arg_strs)})")
result = func(*args, **kwargs)
print(f"← {func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def calculate_total(items: list[float], tax_rate: float = 0.08) -> float:
subtotal = sum(items)
return subtotal * (1 + tax_rate)
calculate_total([10.0, 20.0, 30.0])
# → calculate_total(items=[10.0, 20.0, 30.0], tax_rate=0.08)
# ← calculate_total returned 64.8Argument Validation
import inspect
from typing import get_type_hints
def validate_types(func):
"""Decorator that validates argument types at runtime."""
sig = inspect.signature(func)
hints = get_type_hints(func)
@wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
for param_name, value in bound.arguments.items():
if param_name in hints:
expected_type = hints[param_name]
# Simple type check (doesn't handle generics)
if hasattr(expected_type, '__origin__'):
continue # Skip complex types like list[str]
if not isinstance(value, expected_type):
raise TypeError(
f"Parameter '{param_name}' expected {expected_type.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
@validate_types
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
print(greet("Alice", 2)) # Works fine
print(greet("Bob", "three")) # Raises TypeErrorDocumentation Generator
import inspect
def generate_docs(cls) -> str:
"""Generate markdown documentation for a class."""
lines = [f"# {cls.__name__}"]
if cls.__doc__:
lines.append(f"\n{inspect.cleandoc(cls.__doc__)}\n")
# Document public methods
lines.append("\n## Methods\n")
for name, method in inspect.getmembers(cls, inspect.isfunction):
if name.startswith('_'):
continue
sig = inspect.signature(method)
lines.append(f"### `{name}{sig}`\n")
if method.__doc__:
lines.append(f"{inspect.cleandoc(method.__doc__)}\n")
return '\n'.join(lines)
class Calculator:
"""A simple calculator class."""
def add(self, a: float, b: float) -> float:
"""Add two numbers."""
return a + b
def multiply(self, a: float, b: float) -> float:
"""Multiply two numbers."""
return a * b
print(generate_docs(Calculator))Dependency Injection
This pattern is common in web frameworks:
import inspect
from typing import Type, TypeVar, get_type_hints
T = TypeVar('T')
class Container:
"""Simple dependency injection container."""
def __init__(self):
self._services: dict[type, type] = {}
self._instances: dict[type, object] = {}
def register(self, cls: type) -> None:
"""Register a class as a service."""
self._services[cls] = cls
def resolve(self, cls: Type[T]) -> T:
"""Resolve a service, creating it if needed."""
if cls in self._instances:
return self._instances[cls]
if cls not in self._services:
raise KeyError(f"Service {cls.__name__} not registered")
# Get constructor signature and type hints
sig = inspect.signature(cls.__init__)
hints = get_type_hints(cls.__init__)
# Resolve dependencies
kwargs = {}
for name, param in sig.parameters.items():
if name == 'self':
continue
if name in hints and hints[name] in self._services:
kwargs[name] = self.resolve(hints[name])
instance = cls(**kwargs)
self._instances[cls] = instance
return instance
# Example usage
class Database:
def query(self, sql: str) -> list:
return []
class UserRepository:
def __init__(self, db: Database):
self.db = db
def find_user(self, user_id: int):
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo
def get_user(self, user_id: int):
return self.repo.find_user(user_id)
container = Container()
container.register(Database)
container.register(UserRepository)
container.register(UserService)
# Automatically resolves the dependency chain
service = container.resolve(UserService)
print(f"Service has repo: {type(service.repo).__name__}")
print(f"Repo has db: {type(service.repo.db).__name__}")Debug Context Manager
import inspect
from contextlib import contextmanager
@contextmanager
def debug_context(label: str = ""):
"""Context manager that shows entry/exit points."""
caller_frame = inspect.currentframe().f_back.f_back
info = inspect.getframeinfo(caller_frame)
location = f"{info.function}:{info.lineno}"
print(f"→ Entering {label or 'block'} at {location}")
try:
yield
finally:
print(f"← Exiting {label or 'block'}")
def process_data():
with debug_context("data loading"):
data = [1, 2, 3]
with debug_context("transformation"):
data = [x * 2 for x in data]
return data
process_data()
# → Entering data loading at process_data:3
# ← Exiting data loading
# → Entering transformation at process_data:6
# ← Exiting transformationKey Takeaways
After spending time with inspect, here's what I've learned:
-
Signatures are powerful —
inspect.signature()is the foundation for argument validation, documentation, and smart decorators. -
Stack frames reveal context — You can see exactly where code is executing and access local variables at any level.
-
Source inspection enables tooling — Documentation generators, linters, and IDEs all rely on this.
-
Use predicates for type checking —
isfunction,isclass,ismethodare cleaner thanisinstancefor callable types. -
Be careful with frames — Frame objects can create reference cycles. Use them carefully or rely on
inspect.stack().
The inspect module turned me from someone who wrote code to someone who could write code that understands code. It's essential knowledge for building decorators, frameworks, and debugging tools.