Writing/Metaprogramming: An Adventure
Chapter 8 · 24 min read
A Metaprogramming Layer
Honestly, though, in most cases we're not really going to augment the Python
interpreter – definitely not for something as minute as adding a regex
comparison operator. In fact, I'd argue we didn't really add one anyway,
precisely because it's such a huge undertaking – instead, we resorted to
using ast to change one code into another. But that's a pretty cool idea in
and of itself, isn't it? One, I think, that warrants an exploration of its own.
Auto-Logging: with Tracers
Let's take logging as an example: adding prints to code can be useful, and we
can always just do it explicitly. But as we've seen, mixing such a cross-cutting
concern into the main flow tends to complicate it:
>>> def add(x, y):
... print(f"{x=}")
... print(f"{y=}")
... result = x + y
... print(f"{result=}")
... return result
That's 6 lines instead of 2 (or even 1) – for one of the simplest scenarios imaginable!
Of course, logging a function's invocation and return value can be achieved in many other ways, so let's go over the techniques we covered and see which ones can be of help. One thing we could do is use a tracer:
>>> def log(frame, event, arg):
... if event == "call":
... print(f"enter {format_call(frame)}")
... if event == "return":
... print(f"leave {format_call(frame)}: {arg!r}")
... return log
>>> def format_call(frame):
... name = frame.f_code.co_name
... params = frame.f_code.co_varnames[:frame.f_code.co_argcount]
... args = [frame.f_locals[param] for param in params]
... return f"{name}({', '.join(repr(arg) for arg in args)})"
>>> import sys
>>> sys.settrace(log)
This gives us the same logs without us having to add a single line:
>>> def add(x, y):
... return x + y
>>> add(1, 2)
enter add(1, 2)
leave add(1, 2): 3
3
But it's probably an overkill: tracers are rather slow, and it's not so easy to work with frame objects, either – supporting for keyword arguments, for example, or detecting when a function exits with an error, aren't trivial fixes to make.
Auto-Logging: with Decorators
Recall that when we discussed aspect-oriented programming, we saw that all it takes to address such common problems as logging is plugging our code into a framework, which can be implemented in a much more straightforward fashion – like, by way of a decorator:
>>> def log(function):
... def wrapper(*args, **kwargs):
... call = format_call(function, *args, **kwargs)
... try:
... print(f"enter {call}")
... result = function(*args, **kwargs)
... print(f"leave {call}: {result!r}")
... return result
... except Exception as error:
... print(f"leave {call} on error: {error}")
... raise
... return wrapper
>>> def format_call(function, *args, **kwargs):
... args = [repr(arg) for arg in args]
... args.extend(f"{key}={value!r}" for key, value in kwargs.items())
... return f"{function.__name__}({', '.join(args)})"
This achieves the same result with negligible overhead:
>>> @log
... def add(x, y):
... return x + y
>>> add(1, 2)
enter add(1, 2)
leave add(1, 2): 3
3
And incidentally, supports keyword arguments and errors just as well. Its main
drawback is the registration method: the fact that we have to add @log on top
of every single invocation we want to log means there's no quick and easy way to
"just apply" it to on an entire project – not unless we dive into its source
code and edit each function accordingly.
Auto-Logging: with Dynamic Execution
Or is there? We've also seen that one code can easily execute another, so we can
write a program like with_logs.py that will load a module and auto-decorate
all the callables in its namespace, effectively changing the pointcut from
"anything with @log on top" to "anything at all". There's just one problem –
suppose we put this code in example.py:
def add(x, y):
return x + y
add(1, 2)
If python with_logs.py example.py were to load the code and decorate it,
add(1, 2) would be invoked at load time, before we'd have a chance to apply
@log to the add function.
So, let's do a trick: instead of executing the code into a regular dictionary,
let's do it into one that decorates callables as soon as they're defined,
which we can intercept in __setitem__:
>>> class LoggedNamespace(dict):
... def __setitem__(self, name, value):
... if callable(value):
... value = log(value)
... super().__setitem__(name, value)
>>> def run_with_logs(path):
... code = open(path).read()
... namespace = LoggedNamespace()
... exec(code, namespace)
>>> run_with_logs("example.py")
enter add(1, 2)
leave add(1, 2): 3
Still, there are some drawbacks to this approach: for one, it won't catch nested
or dynamically defined functions, since LoggedNamespace only replaces the
global scope. Moreover, we've introduced what's effectively a level of
virtualization: it's no longer our program being executed – it's with_logs.py
executing our program. If anything goes wrong, this setup is considerably harder
to debug: the executed code doesn't include any logging, while the executor
doesn't include any of the original logic; it's only in their dynamic interplay
that the two form a coherent flow. In other words, we don't see the full picture
in either source file, or even in both – it only emerges at runtime. It doesn't
matter much in our case, since it's exceedingly simple, but in a real
application mixing logics this way has a cost: not only does it increase the
complexity, but it does so in a liminal space that's much more difficult to
observe and reason about.
Auto-Logging: with AST
In some sense, augmenting functionality dynamically is akin to an interpreter, which operates on our behalf; what we'd like instead, in this analogy, is more of a compiler: something that inspects our code, augments it, and spits out a result – a flat output, so to speak, that we can look and poke at without any invisible, virtual dimensions involved. Maybe it's not a compiler per say, since it doesn't lower our code into a more basic language; and not exactly a transpiler either, since it doesn't translate between languages; but however we call it, its should rewrite the original code to include log statements.
So how do we do it? Let's start with return statements, since it's easier: all
we want is to swap them with a version that prints the return value before
returning – so, rewrite return <expression> into something like:
_ = <expression>
print(_)
return _
Let's start by comparing their ASTs:
>>> import ast
>>> ast.dump(ast.parse("return 1 + 2"), indent=2)
Module(
body=[
Return(
value=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)))],
type_ignores=[])
>>> ast.dump(ast.parse("_ = 1 + 2; print(_); return _"), indent=2)
Module(
body=[
Assign(
targets=[
Name(id='_', ctx=Store())],
value=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2))),
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
Name(id='_', ctx=Load())],
keywords=[])),
Return(
value=Name(id='_', ctx=Load()))],
type_ignores=[])
What we need, then, is a transformer that visits Return nodes, and replaces
each with three statements: an assignment of the returned expression, followed
by a call to print it, followed by a return statement of the assigned name.
We've done things like this before – the only catch is that this time, we need
to call self.generic_visit(node), which recursively transforms "the rest of"
our node, so that we can use the fully transformed node.value in our
assignment:
>>> class ReturnLogger(ast.NodeTransformer):
... def visit_Return(self, node):
... self.generic_visit(node)
... assign = ast.Assign(
... targets=[ast.Name(id="_", ctx=ast.Store())],
... value=node.value,
... )
... print_ = ast.Expr(
... value=ast.Call(
... func=ast.Name(id="print", ctx=ast.Load()),
... args=[ast.Name(id="_", ctx=ast.Load())],
... keywords=[],
... ),
... )
... return_ = ast.Return(
... value=ast.Name(id="_", ctx=ast.Load()),
... )
... return [assign, print_, return_]
>>> def add_logs(source, target):
... code = open(source).read()
... tree = ast.parse(code)
... tree = ReturnLogger().visit(tree)
... ast.fix_missing_locations(tree)
... open(target, "w").write(ast.unparse(tree))
>>> add_logs("example.py", "logged_example.py")
I'm also using fix_missing_locations to fill line numbers and column offsets
automatically; this might introduce some discrepencies, but it's good enough for
our purposes. As for the generated logged_example.py:
def add(x, y):
_ = x + y
print(_)
return _
add(1, 2)
It's exactly what we wanted – and running it prints 3, which it didn't before.
A Metaprogramming Layer
Still, if we want to recreate the nice, informative messages we had before, we
need to figure out how to get the function name and arguments, as well as a way
to inject the enter message at the function's beginning. Since transformers
operate recursively, we know that visit_Return happens as part of a
FunctionDef – that is, if we implement the latter, we can set the name and
parameters list there and call self.generic_visit(node). As we recurse "into"
each node's return-statements, we can count on those values being available for
reconstruction:
>>> class ReturnLogger(ast.NodeTransformer):
... def visit_FunctionDef(self, node):
... self.function_name = node.name
... self.function_params = [arg.arg for arg in node.args.args]
... self.generic_visit(node)
... return node
... def visit_Return(self, node):
... # … same as before, except:
... print_ = ast.Expr(
... value=ast.Call(
... func=ast.Name(id="print", ctx=ast.Load()),
... args=[self.format_call("leave")],
... keywords=[],
... ),
... )
Only... how do we format the call? We've collected the function name and parameters at definition time, great; but what we want to print is their arguments, at invocation time, which is a whole other matter. Well, how would we do it if we were writing the code manually?
>>> def add(x, y):
... _ = x + y
... print(f"leave add({x!r}, {y!r}): {_!r}")
... return _
So in fact, we don't need the resolved values – since we're replacing code before it's being executed, we only need to regenerate it in such a way that once it is, those values will be resolved; in other words, we need an f-string. The way these are implemented in AST is a bit tedious, because each part has to be addressed separately – but it is what it is:
>>> class ReturnLogger(ast.NodeTransformer):
... # … same as before
... def format_call(self, action):
... parts = [
... ast.Constant(value=f"{action} {self.function_name}("),
... ]
... for n, param in enumerate(self.function_params, 1):
... parts.append(ast.FormattedValue(
... value=ast.Name(id=param, ctx=ast.Load()),
... conversion=114, # that's "!r"
... ))
... is_last = n == len(self.function_params)
... if not is_last:
... parts.append(ast.Constant(value=", "))
... parts.append(ast.Constant(value=")"))
... if action == "leave":
... parts.append(ast.Constant(value=": "))
... parts.append(ast.FormattedValue(
... value=ast.Name(id="_", ctx=ast.Load()),
... conversion=114,
... ))
... return ast.JoinedStr(parts) # that's an f-string
Now, when we run add_logs("example.py", "logged_example.py") we get:
def add(x, y):
_ = x + y
print(f"leave add({x!r}, {y!r}): {_!r}")
return _
add(1, 2)
And when we run that, we get leave add(1, 2): 3, just like we wanted. To add
the enter message, we can use the same format_call("enter") (that's why I
made it accept an action and put the : {_!r} bit in an if-statement, sneaky
me); we just have to act after the node (that is, the function definition)
has been visited, so we have access to its fully transformed body and can
insert our print statement at its beginning. Oh, and we should probably change
the class name to something more appropriate:
>>> class LogCalls(ast.NodeTransformer):
... def visit_FunctionDef(self, node):
... # … same as before
... self.generic_visit(node)
... print_ = ast.Expr(
... value=ast.Call(
... func=ast.Name(id="print", ctx=ast.Load()),
... args=[self.format_call("enter")],
... keywords=[],
... ),
... )
... node.body.insert(0, print_)
... return node
And now, assuming we wrap add_logs in a script, we can do:
$ python add_logs.py example.py logged_example.py
$ cat logged_example.py
def add(x, y):
print(f'enter add({x!r}, {y!r})')
_ = x + y
print(f'leave add({x!r}, {y!r}): {_!r}')
return _
add(1, 2)
$ python logged_example.py
enter add(1, 2)
leave add(1, 2): 3
Here I'd like to give a shout out to metap, a metaprogramming library (one of
very few, really) that enlightened me as to the potential of this approach,
which fits so snugly in our narrative. To borrow their terminology, what we've
developed is a metaprogramming layer: that word that I was missing for a
toolchain that rewrites code from a language unto itself.
Because that's what add_logs.py does, isn't it? It takes a source program
(example.py), much like a compiler does, and transmutes it into something else
(logged_example.py) – although here, like we said, it's not the lowered code
a compiler produces, nor the translated one a transpiler does; it's, well, a
metaprogrammed version thereof: analyzed and augmented by meta-code.
What else can we do, if we turn our add_logs.py into a more general-purpose
layer, like metap is? Again, borrowing from their abstraction, a
metaprogramming layer can:
-
Augment programs: essentially what we did, rewriting some code into other code;
-
Generate code: rewrite the program in such a way as to add additional functionality – and not one handling a common problem like logging, either, but custom logic defined by users;
-
Inspect structure: expose an API, based on our newfound AST manipulation powers, that would let users ask questions about the code's structure.
The third point lets us do static analysis, like linters or type checkers do, which can help us assert the code adheres to certain standards or patterns – but it's the second point that I find particularly intruiging.
The Standard of Elegance
What would it mean, to have user-defined code generation? I mean, when users
write code, we call it "programming"; when we talk of "code generation", it
implies code is written automatically, in which case the methods by which users
can customize it are not entirely clear. Sure, it can be context-aware, just
like our enter and leave messages accounted for each function's particular
arguments; and it can parameterizeable, letting users toggle options that
generate code one way or another. But how can it be user-defined?
Luckily, we've already seen this paradigm in action, where constructs created by users are hoisted and integrated into a bigger whole: we called them plugins, and saw how they can accommodate all sorts of aspects, from web apps to tests to filtering and post-processing. In this case, we're talking about code rewriting, so let's consider what a plugin of this sort could mean. Before we do, though, I feel like a bit of history is in order. Consider this abstract code:
>>> def f(x, y):
... do_something(x)
... do_something(y)
... return x + y
Depending on what sort of something we want to do, programming languages
already come equipped with ways of code composition: first and foremost the
ability to call functions, of course. So, if we want to do something with no
side effects, like printing x and y, we'd have no problem acheiving it with:
>>> def f(x, y):
... print(x)
... print(y)
... return x + y
If, however, we want to do something that modifies our values, we'd have to rely
on another "trivial" feature: return values. To increment x and y, for
example, we'd write:
>>> def f(x, y):
... x = inc(x)
... y = inc(y)
... return x + y
Obvious though it may seem, this wasn't always possible. While all programming languages always had the ability to jump – it's sort of a prerequisite for being Turing-complete – and could thus create reusable blocks of code, the interface that mediates the process evolved over time. Initially, to modify a value, we'd have to rewrite it in memory; to simulate this in Python, we need to wrap each value in a reference objects, often called pointers:
>>> def f(x_obj, y_obj):
... inc(x_obj)
... inc(y_obj)
... return x_obj.value + y_obj.value
So that inc can dereference its value and modify it in place:
>>> def inc(obj):
... obj.value = obj.value + 1
Furthermore, when return values were introduced, they were still limited: a
function could return a single, simple value at most. This posed a challenge:
what if a function might fail (that is, "return" an error) as well as produce a
result? Suppose that instead of incrementing x and y, we'd want to get their
reciprocals, 1/x and 1/y – this code:
>>> def f(x, y):
... x = recip(x)
... y = recip(y)
... return x + y
Would not do, since if either value is zero, an error indication is in order. In languages like C, we have to revert back to pointers, perserving the return value's limited capacity for status reports:
>>> def f(x_obj, y_obj):
... err = recip(x_obj)
... if err:
... return
... err = recip(y_obj)
... if err:
... return
... return x_obj.value + y_obj.value
Whereby:
>>> def recip(obj):
... if obj.value == 0:
... return 1 # error
... obj.value = 1 / obj.value
... return 0 # success
In fact, if we're being pedantic, that's what our f should do as well: if
either of its recips fails, it should fail as well. But this means it should
return a status, too, which means its return value is occupied, which means that
to communicate its result, we need a third, so-called output parameter:
>>> def f(x_obj, y_obj, out_obj):
... err = recip(x_obj)
... if err:
... return err
... err = recip(y_obj)
... if err:
... return err
... out_obj.value = x_obj.value + y_obj.value
... return 0 # success
Pretty clumsy, no? And yet in C, such code is completely commonplace, even today. The point I'm trying to illustrate is just how profoundly the flow control capabilities of a language affect its clarity, flexbility, and overall design: as you can see, the inability to communicate errors by other means proliferates, shaping the entire codebase as a result. Of course, in Python this isn't the case – first of all, because we can return multiple values:
>>> def f(x, y):
... x, err = recip(x)
... if err:
... return None, err
... y, err = recip(y)
... if err:
... return None, err
... return x + y, 0
And second, because we have exceptions, so all recip needs to do is:
>>> def recip(value):
... if value == 0:
... raise ZeroDivisionError()
... return 1 / value
And we're back to:
>>> def f(x, y):
... x = recip(x)
... y = recip(y)
... return x + y
Because a ZeroDivisionError will tear through the call stack until it's
caught; exceptions, in other words, are a flow control construct, and a pretty
sophisticated one at that. My point, once again, is just how important such flow
control is – and to what a degree it shapes our perception of how code can (and
should) look. C programmers have no problem with error indications and output
parameters; until multiple return values and exceptions were introduced, we
didn't know what we were missing, so to speak – and even when they were, many
criticized the latter for being too slow and too complex. In terms of
performance, at least in realtime languages like C (well, C++), it's true that
exceptions are considerably more expensive than returning an integer (although
in high-level languages like Python and Javascript, it's completely immaterial);
in terms of complexity, well, I just don't agree. It's always the same stubborn
argument: "I am used to thinking about software a certain way, so a different
paradigm must be necessarily worse." Google's C++ coding standards are a case in
point: they initially forbade exception on these exact grounds, that raising and
catching them makes control flow more confusing (read: "I'm not used to it") and
less predictable (read: more flexible, if you learn to predict it). Over time,
the excuse shifted to the irrefutable "our codebase doesn't use it, so adding it
now would cause problems", but even they admit that exceptions are used by most
other modern languages, and that their benefits probably outweigh their costs.
Personally, I don't care for branding language features as good or bad; these
arguments always get very hypothetical ("what if someone does..."), and usually
assume people are stupid ("bad programmers will..."). My attitude, as you can
see from this book, is that people can learn and get smarter, and their creative
experiments, even when they don't work out, carry latent and potentially
explosive benefits. In terms of software design, I prefer to judge a codebase
based on the clarity and novelty of its thought, not language. Learning new
syntax is considerably easier and less exciting than learning new concepts,
as those can fundamentally alter how we percieve and reason about a subject –
just skim through the evolution of f again to see what I mean. That, I
believe, is where the prize is – and why I see metaprogramming as such a
treasure trove of untapped potential. The mere readability of code, its
aesthetics – our current standard of elegance, if you will – is constantly
changing as come up with new, more powerful paradigms; and the unspoken truth
about it is that it can be taught, especially to people who are curious to
learn.
User-Defined Code Generation
Which brings us back to user-defined code generation. With today's flow control,
one of the things we still can't do is make do_something quit f. It can
raise an error, sure, which then propagates – but if we want f to exit
gracefully, we have to either catch it, or go back to status reports:
>>> def f(x, y):
... try:
... do_something(x)
... except Exception:
... return
... # … same for y
>>> # or...
>>> def f(x, y):
... err = do_something(x)
... if err:
... return
... # … same for y
What if there was a way to embed code – not call it in a different scope
like we do with functions, but actually paste it in, so that if do_something
is:
>>> def do_something(value):
... if value is None:
... return
f would end up as:
>>> def f(x, y):
... if x is None:
... return
... if y is None:
... return
... return x + y
I guess you see where I'm going with this. Let's create a decorator, macro,
which will be used to mark functions as embeddable:
>>> @macro
... def return_if_none(value):
... if value is None:
... return
In practice, though, it will make them uncallable:
>>> def macro(function):
... def wrapper(*args, **kwargs):
... raise RuntimeError("metaprogramming layer must run first")
... return wrapper
Because, like the error states, for this to work we have to run it through a
metaprogramming layer first, giving it a chance to actually embed our macro.
Since it works on source code, let's put everything in appropriate files; in
example.py we have:
from meta import macro
@macro
def return_if_none(value):
if value is None:
return
def f(x, y):
return_if_none(x)
return_if_none(y)
return x + y
print(f(1, 2))
print(f(1, None))
That is, some code – f and a couple of its invocation – and the return_if_none
macro, which can be thought of as bona fide user-defined code-to-be-generated.
In meta.py we'll put our silly macro decorator, as well as the transformer
that will take care of embedding it and the API to run it:
import ast, sys
def macro(function):
# … same as before
class Embedder(ast.NodeTransformer):
# … to be continued
def embed(source, target):
code = open(source).read()
tree = ast.parse(code)
tree = Embedder().visit(tree)
ast.fix_missing_locations(tree)
open(target, "w").write(ast.unparse(tree))
if __name__ == "__main__":
embed(sys.argv[1], sys.argv[2])
Now for the transformer itself: the first thing we need to detect is all the
function definitions, so as to see if they're decorated with @macro – and if
so, keep their node for later, to be embedded on demand:
class Embedder(ast.NodeTransformer):
def __init__(self):
self.macros = {}
def visit_FunctionDef(self, node):
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id == "macro":
self.macros[node.name] = node
return None # remove definition from final code
self.generic_visit(node)
return node
Next, we need to find all the call sites, and if they're invoking a macro,
replace them with its actual content. Doing so in visit_Call will not do,
since those are expressions that can't be replaced with a statement; so instead,
we'll target the expression-statement (Expr) nodes that wrap them:
class Embedder(ast.NodeTransformer):
# … same as before
def visit_Expr(self, node):
if isinstance(node.value, ast.Call):
name = node.value.func.id
if name in self.macros:
return self._embed(self.macros[name], node)
return node
The way we do the actual embedding is a bit tricky. We can't simply return the macro body as-is, because then we'll end up with:
def f(x, y):
if value is None:
return None
# …
See the problem? What we need is a mapping between the macro's parameters and the arguments passed into the call – so we traverse the body and swap each such name with its corresponding value:
import copy
class Embedder(ast.NodeTransformer):
# … same as before
def _embed(self, macro, call):
args = {}
for param, arg in zip(macro.args.args, call.value.args):
args[param.arg] = arg
embed = Substituter(args).visit(copy.deepcopy(macro))
return embed.body
Admittedly, it's pretty confusing, but each macro.args.args's .arg refers to
a parameter name in the macro's signature, while each of call.value.args is
the argument that's been passed to the call. And since visitation changes nodes
in place, note that we need to create a deep copy of the macro each time we
embed it. As for the substitution itself, there's not much to it:
class Substituter(ast.NodeTransformer):
def __init__(self, args):
self.args = args
def visit_Name(self, node):
return self.args[node.id] if node.id in self.args else node
With that, we're done:
$ python meta.py example.py embedded_example.py
$ cat embedded_example.py
from meta import macro # should've probably removed this, too
def f(x, y):
if x is None:
return
if y is None:
return
return x + y
print(f(1, 2))
print(f(1, None))
$ python embedded_example.py
3
None
And I know, I know; it's quite a lot of work, where we could have simply written
if x is None: return by ourselves. But that's not the point – just an example
that might reasonably fit in a book. That's why I went on that weird tangent
about return values and exceptions earlier: what we've implemented here is a
new flow control construct, and that, as we've seen, is no small feat. Instead
of jumping into functions, we can embed them directly, unlocking
possibilities that simply did not exist in the language before. What design
patterns and programming styles it might lead to is hard to predict – but as far
as our adventure goes, it's a pretty cool item to cross off our bucket list.