Runtime Code Generation — Deep Dive

AST-Based Code Generation

String-based exec() has a major drawback: generated code is opaque and hard to debug. AST-based generation creates structured code that can be inspected, validated, and transformed before compilation:

import ast
import types

def make_init(field_names):
    """Generate an __init__ method using AST nodes."""
    args = [ast.arg(arg=name) for name in field_names]

    body = [
        ast.Assign(
            targets=[ast.Attribute(
                value=ast.Name(id='self', ctx=ast.Load()),
                attr=name,
                ctx=ast.Store()
            )],
            value=ast.Name(id=name, ctx=ast.Load()),
            lineno=i + 2,
        )
        for i, name in enumerate(field_names)
    ]

    func = ast.FunctionDef(
        name='__init__',
        args=ast.arguments(
            posonlyargs=[],
            args=[ast.arg(arg='self')] + args,
            kwonlyargs=[],
            kw_defaults=[],
            defaults=[],
        ),
        body=body or [ast.Pass()],
        decorator_list=[],
        lineno=1,
    )

    module = ast.Module(body=[func], type_ignores=[])
    ast.fix_missing_locations(module)

    code = compile(module, '<generated __init__>', 'exec')
    namespace = {}
    exec(code, namespace)
    return namespace['__init__']

# Usage
init = make_init(['name', 'age', 'email'])

class User:
    __init__ = init

u = User('Alice', 30, 'alice@example.com')
print(u.name, u.age, u.email)

Benefits over string-based generation:

  • No injection attacks — AST nodes cannot contain arbitrary code
  • Structural validationast.fix_missing_locations() catches malformed trees
  • Composability — AST nodes can be built from reusable factories
  • Debuggabilityast.dump() shows the exact generated structure

How dataclasses Uses Code Generation

CPython’s dataclasses module is the canonical example of production code generation. Here is a simplified version of how it generates __init__:

def _create_fn(name, args, body, *, globals=None, locals=None):
    """Create a function via exec. This is what dataclasses does internally."""
    args_str = ', '.join(args)
    body_str = '\n'.join(f'  {line}' for line in body)

    txt = f'def {name}({args_str}):\n{body_str}'

    local_vars = {}
    exec(txt, globals or {}, local_vars)
    return local_vars[name]

def generate_init(fields):
    """Generate __init__ like @dataclass does."""
    # Build self parameter and field parameters
    args = ['self'] + [
        f'{f.name}: {f.type.__name__} = {f.default!r}'
        if f.default is not None
        else f'{f.name}: {f.type.__name__}'
        for f in fields
    ]

    # Build assignment body
    body = [f'self.{f.name} = {f.name}' for f in fields]
    if not body:
        body = ['pass']

    return _create_fn('__init__', args, body)

The real dataclasses implementation adds __post_init__ calls, InitVar handling, frozen field checks, and slot management — all generated dynamically.

Closure-Based Generation vs exec()

For many cases, closures avoid exec() entirely while still generating specialized behavior:

def make_validator(schema):
    """Generate a validator function from a schema dict."""
    # Pre-compute checks at generation time
    checks = []
    for field, rules in schema.items():
        if 'type' in rules:
            expected_type = rules['type']
            checks.append((field, lambda v, t=expected_type: isinstance(v, t), f'must be {expected_type.__name__}'))
        if 'min' in rules:
            minimum = rules['min']
            checks.append((field, lambda v, m=minimum: v >= m, f'must be >= {minimum}'))
        if 'max_length' in rules:
            max_len = rules['max_length']
            checks.append((field, lambda v, ml=max_len: len(v) <= ml, f'must be <= {max_len} chars'))

    def validate(data):
        errors = []
        for field, check, message in checks:
            value = data.get(field)
            if value is not None and not check(value):
                errors.append(f'{field}: {message}')
        return errors

    return validate

schema = {
    'name': {'type': str, 'max_length': 100},
    'age': {'type': int, 'min': 0},
}
validator = make_validator(schema)
errors = validator({'name': 'Alice', 'age': -1})
# ['age: must be >= 0']

The closure captures pre-computed checks, avoiding both runtime schema interpretation and exec().

Code Object Construction

For maximum control, you can construct code objects directly:

import types
import dis

def make_fast_getter(attr_name):
    """Create a fast attribute getter without exec()."""
    # Equivalent to: lambda self: self.attr_name
    code = types.CodeType(
        1,                          # argcount
        0,                          # posonlyargcount (3.8+)
        0,                          # kwonlyargcount
        1,                          # nlocals
        2,                          # stacksize
        67,                         # flags (CO_OPTIMIZED | CO_NEWLOCALS)
        bytes([                     # bytecode
            124, 0,                 # LOAD_FAST 0 (self)
            106, 0,                 # LOAD_ATTR 0 (attr_name)
            83, 0,                  # RETURN_VALUE
        ]),
        (None,),                    # consts
        (attr_name,),               # names
        ('self',),                  # varnames
        '<generated>',              # filename
        f'get_{attr_name}',         # name
        f'get_{attr_name}',         # qualname (3.11+)
        1,                          # firstlineno
        b'',                        # linetable
    )
    return types.FunctionType(code, {})

Warning: Direct code object construction is extremely version-dependent. The CodeType constructor signature changed in Python 3.8, 3.10, and 3.11. Use the bytecode library for portable code generation.

JIT-Like Patterns

While Python does not have a built-in JIT, you can implement JIT-like specialization:

import functools

def specialize_on_first_call(func):
    """Replace a generic function with a specialized version on first call."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Analyze argument types on first call
        arg_types = tuple(type(a).__name__ for a in args)
        specialized = generate_specialized(func, arg_types)

        # Replace the wrapper with the specialized version
        wrapper.__wrapped__ = specialized
        wrapper.__code__ = specialized.__code__

        return specialized(*args, **kwargs)
    return wrapper

def generate_specialized(func, arg_types):
    """Generate a type-specialized version of func."""
    # In practice, this would generate optimized code
    # based on the observed argument types
    source = inspect.getsource(func)
    # ... transform source based on arg_types ...
    namespace = {}
    exec(transformed_source, namespace)
    return namespace[func.__name__]

Numba takes this approach to its logical extreme — it reads function bytecode, infers types from the first call, generates LLVM IR, and compiles it to native machine code.

Production Patterns

Generated Code Caching

import hashlib
import pickle
from pathlib import Path

class CodeCache:
    def __init__(self, cache_dir='.codegen_cache'):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(exist_ok=True)

    def get_or_generate(self, key_data, generator):
        """Cache generated code objects by their input parameters."""
        key = hashlib.sha256(pickle.dumps(key_data)).hexdigest()[:16]
        cache_file = self.cache_dir / f'{key}.pyc'

        if cache_file.exists():
            return pickle.loads(cache_file.read_bytes())

        result = generator()
        cache_file.write_bytes(pickle.dumps(result))
        return result

Source Map for Debugging

Generated code is notoriously hard to debug. Preserve source information:

import linecache

def register_generated_source(filename, source_lines):
    """Make generated code debuggable by registering its source."""
    linecache.cache[filename] = (
        len(source_lines),
        None,
        source_lines,
        filename,
    )

# When generating code:
source = "def generated_func(x):\n    return x * 2\n"
filename = '<generated:my_module.func>'
register_generated_source(filename, source.splitlines(True))

code = compile(source, filename, 'exec')
# Now tracebacks will show the generated source

Testing Generated Code

def test_generated_init():
    init = make_init(['name', 'age'])

    class TestClass:
        __init__ = init

    obj = TestClass('Alice', 30)
    assert obj.name == 'Alice'
    assert obj.age == 30

def test_generated_init_type_annotations():
    """Verify generated code has proper signatures."""
    import inspect
    init = make_init(['name', 'age'])
    sig = inspect.signature(init)
    assert 'name' in sig.parameters
    assert 'age' in sig.parameters

def test_generated_code_is_debuggable():
    """Verify generated code has source info."""
    init = make_init(['x'])
    assert init.__code__.co_filename != '<string>'

Performance Considerations

ApproachGeneration CostExecution CostDebuggability
ClosureLowLowGood
exec() stringMediumLow (compiled)Poor
AST + compileHighLow (compiled)Medium
Code objectVery highLow (compiled)Poor
type()LowLowGood

The key insight: generation cost is paid once; execution cost is paid every call. For functions called millions of times, even expensive generation is worthwhile if it produces faster execution code. This is why dataclasses uses exec() — the generated __init__ runs at native Python speed with no interpretation overhead.

Security Hardening

import ast

def safe_eval(expression, allowed_names=None):
    """Evaluate an expression with restricted name access."""
    tree = ast.parse(expression, mode='eval')

    # Walk the AST and reject dangerous nodes
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            raise ValueError("Imports not allowed")
        if isinstance(node, ast.Call):
            if isinstance(node.func, ast.Name):
                if node.func.id not in (allowed_names or {}):
                    raise ValueError(f"Function {node.func.id} not allowed")
        if isinstance(node, ast.Attribute):
            if node.attr.startswith('_'):
                raise ValueError("Private attribute access not allowed")

    # Compile and evaluate with restricted globals
    code = compile(tree, '<safe_eval>', 'eval')
    safe_globals = {'__builtins__': {}}
    if allowed_names:
        safe_globals.update(allowed_names)
    return eval(code, safe_globals)

One thing to remember: Python’s runtime code generation spans a spectrum from simple closures to full AST construction — the standard library’s own dataclasses proves that exec()-based generation from controlled templates is both legitimate and performant, but always register source maps for debuggability, cache generated code objects, and use AST-based generation over string manipulation when security matters.

pythonmetaprogrammingcode-generation

See Also

  • Python Custom Import Hooks How Python's import system can be customized to load code from anywhere — databases, URLs, or even entirely new file formats.
  • Python Dsl Design Patterns How to create mini-languages inside Python that let people express complex ideas in simple, natural words.
  • Python Macro Systems How Python lets you build shortcuts that write code for you — like having magic stamps that expand into whole paragraphs.
  • Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
  • Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.