Introspection and Reflection

Medium35 min read

Inspecting Objects at Runtime

Why Introspection Matters

The Problem: Frameworks need to inspect your code: serializers, ORMs, dependency injection, plugin loaders, debuggers all rely on it.

The Solution: Python exposes its object model at runtime through inspect, getattr, type, and __dict__ — letting libraries do magic that would require code generation in other languages.

Real Impact: Use introspection sparingly in app code, generously in library code — it's the foundation of pytest fixtures, Django models, FastAPI dependency injection, and dataclasses.

Real-World Analogy

Think of introspection as the building inspector with x-ray vision:

  • type(x) = what kind of building is this?
  • dir(x) = the floor plan — what rooms (attributes) exist
  • getattr / setattr = knocking down or putting up internal walls dynamically
  • inspect.signature = reading the door's handle to know what it takes
  • Metaclass = the architect who decides how buildings of this type get constructed
class User:
    def __init__(self, name):
        self.name = name

    def greet(self):
        return f"hi {self.name}"

u = User("Alice")

type(u)                      # <class 'User'>
isinstance(u, User)         # True
issubclass(User, object)    # True

dir(u)                       # list of attributes + methods (incl. dunders)
vars(u)                      # {'name': 'Alice'} — instance __dict__

hasattr(u, "name")          # True
getattr(u, "name")          # 'Alice'
getattr(u, "email", None)    # None — safe access
setattr(u, "email", "[email protected]")
delattr(u, "email")

Dynamic attribute access

getattr/setattr let you treat attribute names as runtime data. Useful for plugin systems, config loaders, ORMs — but harder to follow than explicit attribute access. Use sparingly.

The inspect Module

Standard library's reflection toolkit — read function signatures, source code, class hierarchies.

import inspect

def divide(a: float, b: float = 1.0) -> float:
    """Divide a by b."""
    return a / b

# Inspect signature
sig = inspect.signature(divide)
print(sig)                       # (a: float, b: float = 1.0) -> float
for name, param in sig.parameters.items():
    print(name, param.annotation, param.default)

# Get source code and docstring
print(inspect.getsource(divide))
print(inspect.getdoc(divide))

# Class hierarchy
for base in inspect.getmro(User):
    print(base)

Inspecting Modules

import inspect
import math

for name, member in inspect.getmembers(math, inspect.isfunction):
    print(name)

__dict__, __slots__, and __class__

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Point(1, 2)
p.__dict__                    # {'x': 1, 'y': 2}
p.__class__                   # <class 'Point'>
p.__class__.__name__          # 'Point'

# With __slots__ — no per-instance __dict__
class Pt:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Pt(1, 2)
p.z = 3                       # AttributeError — no slot for z

__getattr__ and __getattribute__

Customize attribute access. __getattr__ is called only when normal lookup fails — perfect for proxies or lazy attributes.

class LazyConfig:
    def __init__(self, source):
        self._source = source
        self._cache = {}

    def __getattr__(self, name):
        if name not in self._cache:
            self._cache[name] = self._source.load(name)
        return self._cache[name]

cfg = LazyConfig(FileSource("settings.yml"))
print(cfg.database_url)         # lazily loaded

⚠️ __getattribute__ is invoked for EVERY attribute access

Including self.something inside the method itself — easy to cause infinite recursion. Use __getattr__ instead unless you really need to intercept all accesses.

Metaclasses (Brief)

A metaclass is the class of a class. type is the default metaclass — every class is an instance of type. You almost never need custom metaclasses; class decorators and __init_subclass__ cover 95% of cases.

# type() can create classes dynamically
User = type("User", (), {"name": "Alice", "greet": lambda self: "hi"})
u = User()
print(u.greet())                 # 'hi'

__init_subclass__ — the modern alternative

class Plugin:
    registry = []

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Plugin.registry.append(cls)

class EmailPlugin(Plugin): ...
class SmsPlugin(Plugin): ...

print(Plugin.registry)         # [EmailPlugin, SmsPlugin] — auto-registered

Tim Peters' rule

"Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don't." Use decorators, mixins, or __init_subclass__ first.

🎯 Practice Exercises

Exercise 1: Function inspector

Write describe(fn) that prints its name, signature, docstring, and source.

Exercise 2: Object diff

Given two instances, return a dict of attributes that differ. Use vars() and set difference.

Exercise 3: Plugin registry

Build a plugin base class using __init_subclass__ that registers concrete plugins keyed by name.

Exercise 4: Lazy proxy

Build a Lazy wrapper using __getattr__ that defers object creation until first attribute access.