Skip to Content
Course content

247: The inspect Module for Introspection

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Every now and then, you'll find yourself building something that needs to be "smart" about the code it's running. Maybe you're building a plugin system, a custom test runner, or a sophisticated decorator that needs to validate its target. This is where introspection comes in—the ability of a Python program to examine its own objects at runtime.

When I first started doing this, I took the path of least resistance. I knew that functions in Python were objects, and those objects had attributes. I figured I could just poke around the internals to find what I needed.

The temptation of the dunder attributes

Let's say we're building a simple task registry. We want to ensure that any function registered as a "task" accepts exactly two arguments: data and context. If it doesn't, we want to throw an error immediately upon registration rather than waiting for the task to fail mid-execution.

The naive approach is to dive straight into the __code__ object. It feels efficient because you're touching the raw data:

def register_task(func):
    # Naive check: looking at the variable names in the code object
    args = func.__code__.co_varnames
    if args != ('data', 'context'):
        raise TypeError(f"Task {func.__name__} must accept exactly 'data' and 'context'")
    print(f"Registered {func.__name__} successfully!")

def my_task(data, context):
    pass

register_task(my_task) # This works... for now.

This looks fine until you realize how brittle this is. The co_varnames tuple doesn't just contain the arguments; it contains all local variables defined within the function body. If you add a single temporary variable inside my_task, your registration logic suddenly breaks. Even worse, you're relying on CPython implementation details. If you ever ran this on PyPy or another implementation, you might find that these attributes behave differently or don't exist at all.

The elegance of the inspect module

Instead of digging through the plumbing, you should use the inspect module. It provides a high-level, standardized API for introspection that handles the edge cases for you. The real star of the show here is inspect.signature().

If we rewrite our registry using inspect, the code becomes not only more robust but actually more readable:

import inspect

def register_task(func):
    sig = inspect.signature(func)
    params = list(sig.parameters.keys())
    
    if params != ['data', 'context']:
        raise TypeError(f"Task {func.__name__} must accept exactly 'data' and 'context'")
    print(f"Registered {func.__name__} successfully!")

def my_task(data, context):
    temp_var = 10  # This no longer breaks the registration!
    return f"Processed {data}"

register_task(my_task)

By using inspect.signature, we get a Signature object. This object doesn't care about local variables; it only cares about the function's interface. It understands the difference between positional arguments, keyword-only arguments, and variable arguments (like *args or **kwargs).

When the internals lie to you

You might wonder why we don't just use dir() or type(). While those are great for quick debugging in a REPL, they give you a flat list of attributes. They don't tell you the intent of the code. For example, if you wanted to know if a function has a default value for a specific argument, trying to find that in __defaults__ is a nightmare of indexing and offset calculations.

With inspect, you can simply iterate over the parameters and check the default attribute of each Parameter object. It turns a complex piece of reverse-engineering into a simple property check. I'll admit, the inspect module is slightly slower than hitting __code__ directly, but in the context of registration or setup logic, that overhead is negligible compared to the cost of a production bug caused by a local variable change.




📋 Practical Task

Build an Automated Dependency Injector

Your task is to create a function called inject_dependencies(func, deps). This function should act as a basic dependency injector.

Requirements:

  • The inject_dependencies function should take a target function func and a dictionary deps containing available dependencies (e.g., {'db': database_connection, 'logger': logger_instance}).
  • Use the inspect module to determine which arguments the target function func actually requires.
  • The injector should only pass arguments from the deps dictionary that match the parameter names of the function.
  • If the function requires an argument that is not present in the deps dictionary, it should raise a RuntimeError explaining which dependency is missing.

Example Scenario:

def save_user(user_id, db, logger):
    logger.log(f"Saving user {user_id}")
    db.save(user_id)

dependencies = {'db': 'DB_CONNECTION_OBJ', 'logger': 'LOGGER_OBJ'}
# This should work if you provide the 'user_id' separately or handle it
# For the exercise, ensure your injector can call the function 
# by merging the provided 'deps' with any other necessary arguments.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.