Use cases¶
Four patterns cover most of what function-scoped rewriting is for. Each has a deployed exemplar in the wild, and each example below runs as shown against the current library.
1. Capture an expression the user wrote: embedded DSLs¶
You want users to write ordinary Python (and, in, comparisons) and your library to receive the expression, because you are compiling it to SQL, a constraint system, or another engine. Tracing objects cannot see and and in, while the source can. recover gives you the verified tree, and you never re-define anything:
import ast
from redef import recover
def to_sql(node):
match node:
case ast.BoolOp(op=ast.And(), values=vs):
return " AND ".join(to_sql(v) for v in vs)
case ast.Compare(left=l, ops=[ast.Gt()], comparators=[r]):
return f"{to_sql(l)} > {to_sql(r)}"
case ast.Compare(left=l, ops=[ast.In()], comparators=[r]):
return f"{to_sql(r)} LIKE '%' || {to_sql(l)} || '%'"
case ast.Attribute(attr=a):
return a
case ast.Constant(value=str() as s):
return f"'{s}'"
case ast.Constant(value=v):
return str(v)
raise ValueError(ast.dump(node))
def sql_where(fn):
tree, meta = recover(fn)
return to_sql(tree.body[0].body[0].value) # the return expression
@sql_where
def expensive_ipads(p):
return p.price > 500 and "iPad" in p.name
# expensive_ipads == "price > 500 AND name LIKE '%' || 'iPad' || '%'"
The user wrote real Python with a real and, and your compiler got the structure. In the wild: PonyORM captures generator expressions for SQL (via bytecode; source is the version-stable route), and choreographic-programming DSLs project one decorated body into per-role programs.
2. Enforce something inside the body: instrumentation¶
Argument and return checks need only a wrapper. Checks inside the body, on an annotated local, at a yield, around every statement, need the tree. This transform makes inner annotations enforceable:
import ast
from redef import rewrite
class CheckAnnotated(ast.NodeTransformer):
"""`x: int = expr` gains `assert isinstance(x, int)` right after it."""
def visit_AnnAssign(self, node):
if not (isinstance(node.target, ast.Name)
and isinstance(node.annotation, ast.Name)):
return node
check = ast.parse(
f"assert isinstance({node.target.id}, {node.annotation.id}), "
f"'{node.target.id} is not {node.annotation.id}'").body[0]
return [node, check]
@rewrite(CheckAnnotated())
def parse_port(raw):
port: int = int(raw)
return port
parse_port("8080") # fine
@rewrite(CheckAnnotated())
def broken(raw):
port: int = raw # forgot int()
return port
broken("8080")
# AssertionError: port is not int -- at the assignment rather than three calls later
In the wild: typeguard's instrumentation exists precisely because its earlier wrapper could check arguments and returns but never an inner x: int = ... or a yield.
3. Better failures: rewrite assert to explain itself¶
The pattern pytest applies to whole test modules, scoped to one function you choose. The transform replaces assert cond with a check that reports each operand's value on failure. With the transform written (an ast.NodeTransformer of about thirty lines), the user-facing part is one decorator:
@rewrite(ExplainAsserts())
def check(items, threshold):
total = sum(items)
assert total > threshold
return total
check([1, 2, 3], 10)
# AssertionError:
# total > threshold
# total = 6
# threshold = 10
Two details the kit handles that this transform would otherwise trip over: the temporaries it introduces must not collide with the user's names (gensym(avoid=tree)), and the failure must report the user's line, which survives because kept nodes carry their true locations through the rewrite. This exact example is the library's acceptance test, producing output byte-identical to four other mechanisms' implementations.
4. Ship the body elsewhere: relocation¶
Remote execution, database UDFs, worker processes: the function must exist as code somewhere else before anything runs here. recover hands you a clean artifact to serialize, dedented, decorator-stripped, verified against the live function, with true line numbers for the remote traceback:
import ast
from redef import recover
def ship(fn):
tree, meta = recover(fn) # raises loudly if source is stale or absent
return ast.unparse(tree) # send this text to the worker
def task(a, b):
total = a + b
return total
payload = ship(task)
# 'def task(a, b):\n total = a + b\n return total'
In the wild: execnet ships functions to remote interpreters, snowflake-snowpark extracts UDF bodies to run server-side. Both hand-roll acquisition today; both inherit the notebook staleness this route detects. Acquisition accepts closures (meta["freevars"] tells you what the body captures), so self-recursive nested functions relocate too; only in-place recompilation refuses them.
Choosing your entry point¶
| You need | Use |
|---|---|
| the tree, to compile or analyze | recover(fn) |
| the function, rewritten in place | @rewrite(transform) |
| a fresh name inside generated code | gensym(base, avoid=tree) |
| to show users what ran | rewrite.expansion(f) |
If the body's values are enough for your case, none of this is needed: use a wrapper. The Why? page has the full decision argument.