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 validation —
ast.fix_missing_locations()catches malformed trees - Composability — AST nodes can be built from reusable factories
- Debuggability —
ast.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
| Approach | Generation Cost | Execution Cost | Debuggability |
|---|---|---|---|
| Closure | Low | Low | Good |
exec() string | Medium | Low (compiled) | Poor |
| AST + compile | High | Low (compiled) | Medium |
| Code object | Very high | Low (compiled) | Poor |
type() | Low | Low | Good |
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.
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.