Writing/Metaprogramming: An Adventure

Chapter 6 · 28 min read

Aspect-Oriented Programming

That last trick we did — dynamically loading an extension defined by a client and integrating it into our flow — is actually part of a wider, fascinating paradigm that I'd like to explore some more. We've seen it before, when we injected a custom hook after an arbitrary line of code, but we didn't dwell on it all that much. In real life, however, solving challenges in such an aspect-oriented way is by far one of the most useful metaprogramming techniques I know — much more so than the difficult and error-prone endeavor of rewriting bytecode.

I say "aspect-oriented" because, historically, that's how this paradigm was known — although we'll assume a somewhat different perspective on it soon enough. Still, let's pay tribute to the original terminology: much like object-oriented programming uses the object as its core abstraction to help us organize our ideas, aspect-oriented programming uses the aspect as its primary organizational concept. It's not a literal language construct, but rather a principle: by thinking about different aspects of our problem, we can separate our code accordingly, maintaining cohesion and composing different aspects only on demand. Of course, there are similar design patterns for objects, functions, and other modular components — the difference is in how it's done.

In object-oriented code, we mix in functionality either with inheritance or composition; the latter — passing all the necessary information as arguments — is how functional and procedural code works as well. With aspects, however, we invert the pattern: using what's known as pointcuts, we effectively signal our role or intent, so some later weaver can imbue our code with additional advice at specific join points; as in, weave specific aspect capabilities into specific places. This works particularly well for what's known as cross-cutting concerns: irrelevant (or rather, orthogonal) considerations that would otherwise become a part of our business logic, thus burdening it with their implementation details and compounding its complexity.

AOP: with Decorators

This is all very abstract, and frankly, the nomenclature is terrible — so let's look at some practical examples, and rephrase our concepts in saner terms as we go along. The usual suspects for cross-cutting concerns are things like logging, security or performance profiling: you know, tasks that are important in and of themselves, but don't actually have anything to do with the rest of our logic. Embedding them inside our code makes everything more complicated, so it would be nice if we could apply them from without. Take this endpoint:

from flask import Flask, request

app = Flask(__name__)

@app.post("/add")
def add_handler():
    x = int(request.form["x"])
    y = int(request.form["y"])
    return str(x + y)
    
if __name__ == "__main__":
    app.run(port=8000)

If we want to log it, secure it and profile its performance, we end up with:

import time

@app.post("/add")
def add_handler():
    print(f"request: {request.method} {request.path}")
    authorization = request.headers.get("Authorization")
    # validate authentication token
    

    started = time.monotonic()
    x = int(request.form["x"])
    print(f"x = {x}")
    y = int(request.form["y"])
    print(f"y = {y}")
    response = str(x + y)
    print(f"response: {response}")
    elapsed = time.monotonic() - started
    print(f"request took {elapsed:.2f} seconds")
    return response

You can see how messy it gets; and these things tend to proliferate, muddying up our entire codebase. One way to get rid of (or rather, contain) them would be with decorators. We might define:

def log(handler):
    def wrapper(*args, **kwargs):
        print(f"request: {request.method} {request.path}")
        for key, value in request.form.items():
            print(f"{key} = {value}")
        response = handler(*args, **kwargs)
        print(f"response: {response}")
        return response
    return wrapper

To handle logging; then:

def secure(handler):
    def wrapper(*args, **kwargs):
        authorization = request.headers.get("Authorization")
        # validate authentication token
        return handler(*args, **kwargs)
    return wrapper

To handle security; and finally:

def profile(handler):
    def wrapper(*args, **kwargs):
        started = time.monotonic()
        response = handler(*args, **kwargs)
        elapsed = time.monotonic() - started
        print(f"request took {elapsed:.2f} seconds")
        return response
    return wrapper

To profile performance. Those decorators are in fact our aspects, and the effects they have is their advice. The join point is the target, our handler; and the pointcuts — that is, the declaration of what should happen where — are the decorations themselves:

@app.post("/add")
@log
@secure
@profile
def add_handler():
    x = int(request.form["x"])
    y = int(request.form["y"])
    return str(x + y)

Notice how we've managed to clean the code up, all the way back to its original form, while still reaping all the benefits. We didn't inherit from any base class and didn't pass any additional arguments. Thanks to Python's native support for decorators, what composition this entails (add = log(secure(profile(add)))) happens implicitly, behind the scenes, so everything looks tidy and declarative.

Weaving

So far, so good; nothing earth-shattering, really, except some strange terminology to describe otherwise familiar concepts. It's not even entirely fair to say that we did any clever weaving, since the decorators are applied to our handler one after the other at the moment of its definition, simply enriching it into an ever more sophisticated function.

There is, however, a fourth pointcut hiding in that example, which I glibly skipped; and it also takes the shape of a decorator: @app.post("/add"). The aspect it addresses is that of a web application: if you think about it, we didn't bind any sockets or parse any HTTP, and yet when we do app.run(), our code is magically integrated into a full-fledged server that does all of this for us. In other words, Flask itself is the weaver, applying the various aspects of web applications — connection handling, request parsing, response formatting, etc. — to our uninterested business logic, expressed in decorated handlers.

This is also known as inversion of control: instead of our code calling into a third-party library to get something done, which would qualify as regular control flow, we actually define pieces of code to be called by a third-party library. When we do app.run(), we effectively run a Flask program, not our program; it calls us, not the other way around. Admittedly, this is exactly what we want: to run a webserver, not a custom script, and just "fill in the gaps", defining what exactly this webserver does when – so a simple way to define handlers is precisely what we expect from a web framework.

The key word here, I think, is just that: framework – and the reason metaprogramming lies at its heart is that frameworks are, in essence, code that takes other code, written by someone else, and does something with it. As such, it can't know in advance what this other code is going to be like — so it needs to establish conventions and interfaces for future integrations to be possible (and ideally, smooth).

A different web framework, like the standard http.server module, might provide similar functionality through inheritance. Want to see what it looks like? Take a deep breath:

from http.server import BaseHTTPRequestHandler, HTTPServer
import urllib.parse

class WebApp(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers["Content-Length"])
        post_data = self.rfile.read(content_length).decode()
        params = urllib.parse.parse_qs(post_data)
        x = int(params["x"][0])
        y = int(params["y"][0])
        result = x + y
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(str(result).encode())
        
if __name__ == "__main__":
    server = HTTPServer(("", 8000), WebApp)
    server.serve_forever()

This gets significantly worse when we want to handle errors or support multiple paths, but let's not go there. Flask's genius was that it managed to drastically reduce such clutter; it distilled the aspects so well that a single decoration and a friendly request object became all we need. What's more, Python's native decorators allowed it to do so in an exceedingly elegant fashion, with not much magic behind the scenes. As other web frameworks (Starlette, Sanic, FastAPI) adopted this syntax, we came to take it so much for granted that recasting it into the somewhat arcane idea of aspect-oriented programming might seem a bit forced — but bear with me.

AOP: with Code Analysis

Let's look at a different but no less popular example that doesn't use decorators, but reveals the exact same paradigm upon closer examination. The de-facto standard for testing in Python is the Pytest library; and since it employs much heavier metaprogramming than mere decorators, I hope it will elucidate the connection I'm trying to draw. To give an example of how it works, let's stick to our simple function, albeit no longer in handler form. In example.py, we write:

def add(x, y):
    return x + y

And to test it, we write test.py:

from example import add

def test_add():
    assert add(1, 2) == 3
    assert add(1, 0) == 1
    assert add(1, -1) == 0
    assert add(1, 0.5) == 1.5

Then we can run pytest test.py, which triggers our test and prints out whether it passed or failed. Once again, compare it to the standard unittest module:

import unittest
from example import add

class TestAdd(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(1, 2), 3)
        self.assertEqual(add(1, 0), 1)
        self.assertEqual(add(1, -1), 0)
        self.assertEqual(add(1, 0.5), 1.5)
        
if __name__ == "__main__":
    unittest.main()

No doubt Pytest's version is cleaner — and when it comes to more complex scenarios, it gets exponentially more so. But before we discuss it, have you noticed something odd about our test file? If you look it over again, you'll see it doesn't have any invocation equivalent to unittest.main() — so how exactly did it run, if all we did was define a function?

Well, it didn't; it's Pytest that ran; and its main convention is that it collects and executes all the functions starting with test (from all the files starting with test); try renaming the function check_add and see it disappear from the tests. In other words, its pointcuts — the signals that indicate we expect some advice to be woven in — are different from those in Flask; instead of a decorator, they are detected by inspecting function (and file) names. Of course, it could have used decorators:

from example import add
from pytest import test

@test
def add_test():
    # … same as before

But seeing as real test files contain dozens of such functions, all of which would have to be decorated, why add this hassle? Better rely on a naming convention, especially since most of these functions would be called test_something anyway; and that's just the tip of the iceberg when it comes to Pytest's innovations.

Dependency Injection

One of the key challenges of framework design, when we use inversion of control, is parameterization: when it's us calling other code, we can pass whatever arguments we want — but when other code calls us, how does it know what parameters we might need to perform our task? In Flask, we've seen it encapsulated by the request object:

@app.post("/add")
def post_add():
    x = int(request.form["x"])
    y = int(request.form["y"])
    return str(x + y)

Although it's somewhat irregular that it's a global variable (from flask import request); a rather dubious design decision on Flask's part, if you ask me, and one that only works because different handlers run in different threads, so thread-local storage lets it present as different requests at once. A more standard approach would be to require each handler to have a fixed signature, an agreed-upon interface — like, accepting a request:

@app.post("/add")
def post_add(request):
    # … same as before

Incidentally, this is exactly what happens in newer web frameworks (Starlette, Sanic), so we can write it off to Flask being a pioneer in its domain. Anyway, this technique is known as dependency injection: since we've inverted control, whatever dependencies we might need (like the request's body or headers) are injected into our code by the framework, and we should account for that when filling in our snippets.

Still, the request object is cheating a bit, isn't it? We actually depend on x and y, but since it can't know that in advance, it provides a generic object with soft mappings, which might or might not contain our keys; a more responsible implementation would do:

@app.post("/add")
def post_add():
    try:
        x = int(request.form["x"])
        y = int(request.form["y"])
    except (KeyError, ValueError): # key is missing or not a number
        return "expected x and y as numbers", 400
    return str(x + y)

FastAPI solves this more elegantly: it lets us use type annotations to define what we expect to receive, and takes care of the parsing aspect for us:

from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn

app = FastAPI()

class AddRequest(BaseModel):
    x: int
    y: int
    
@app.post("/add")
async def post_add(request: AddRequest) -> str:
    return str(request.x + request.y)
    
if __name__ == "__main__":
    uvicorn.run(app, port=8000)

This can be achieved by inspecting the handler's __annotations__, pre-parsing the request body according to the structures defined therein, and passing them in instead of the "raw" request object. If we'd want to implement something similar in Flask, all we'd have to do is:

def post(app, url):
    def decorator(handler):
        @app.post(url)
        def parser_handler():
            args = []
            for name, annotation in handler.__annotations__.items():
                if name == "return":
                    continue
                arg = annotation(request.form[name])
                args.append(arg)
            return handler(*args)
        return parser_handler
    return decorator
    
# use @post(app, url) with annotations instead of @app.post(url) with request
@post(app, "/add")
def post_add(x: int, y: int) -> str:
    return str(x + y)

It's pretty neat; and what's neater still is that we can add informative errors if some values are missing or invalid without complicating our handler one bit – validation, if you will, is another cross-cutting concern. Pytest, however, does something infinitely cooler.

Dependency Request

Suppose we have a function that writes something to a file:

def hello(path):
    open(path, "w").write("Hello, world!")

And we'd like to test it. To do so properly, we'd have to create a temporary file, and make sure to clean it up whether the test succeeds or fails:

import os, tempfile
from example import hello

def test_hello():
    _, path = tempfile.mkstemp()
    try:
        hello(path)
        assert open(path).read() == "Hello, world!"
    finally:
        os.remove(path)

This is not terrible, but it's pretty annoying, especially since this pattern recurs a lot — it's really an aspect we'd expect a testing framework to take care of, wouldn't you agree? And indeed, Pytest does: all we have to do is add the tmp_path parameter to our function, and it will make sure to invoke it with an actual temporary directory as its argument, taking care of the allocation and cleanup for us:

def test_hello(tmp_path):
    path = tmp_path / "file.txt"
    hello(path)
    assert path.read_text() == "Hello, world!"

Now, this is just beautiful; and it's not simple dependency injection, either. A handler in a web framework always depends on the same request object, so it's reasonable to expect its signature to be handler(request) and simply pass it in. But this? This actually only applies to test functions with a tmp_path parameter; test functions, that is, that are explicitly asking for it. If we'd want to test a function that prints something to the standard output — another annoying scenario to orchestrate — Pytest similarly provides us with a utility, just by a different name:

def hello():
    print("Hello, world!")
    
def test_hello(capsys):
    hello()
    stdout, stderr = capsys.readouterr()
    assert stdout == "Hello, world!\n"

In other words, it's more of a dependency request: by the mere act of defining our signature, which we'd have to do anyway, we tell Pytest everything it needs to know – that is, what we expect to receive – simply by sticking to conventional names. And that's not all: we can create such utilities, known as fixtures, ourselves. Suppose we were testing a complex object that takes some effort to construct and configure; instead of repeating its preparation in each test, we could define it in a separate function once, and turn it into a reusable utility of our own:

@pytest.fixture
def o():
    o = Object()
    # prepare…
    return o

Then, simply adding the o parameter to a test's signature will prompt Pytest to call our o(), acquire a fresh copy and pass it in – matching the dependency to its injector by the fact that they are called by the same name. Pytest even supports dependency requests in fixtures, so if our tests expect a particular directory structure, we can do:

@pytest.fixture
def d(tmp_path):
    # create files and subdirectories in tmp_path…
    return tmp_path

Have Pytest allocate and clean up the temporary directory, but otherwise set it up ourselves. And the funny thing is — this utter magic is not even that difficult to implement; it's the idea that's so novel. To see the parameters of a function f, we can check f.__code__.co_varnames[:f.__code__.co_argcount]; or, if we're up to date on our standard library, use inspect.signature:

>>> import inspect

>>> def inject_dependencies(f, deps):
...     sig = inspect.signature(f)
...     kwargs = {}
...     for key, value in deps.items():
...         if key in sig.parameters:
...             kwargs[key] = value
...     return f(**kwargs)

So if we have the following functions:

>>> def f0():
...     print("f0")

>>> def f1x(x):
...     print("f1x", x)

>>> def f1y(y):
...     print("f1y", y)

>>> def f2(x, y):
...     print("f2", x, y)

And the following dependencies (which might as well have been callable fixtures):

>>> deps = {"x": 1, "y": 2}

What inject_dependencies does is filter out the values whose keys appear in the function's parameters, and pass them — and only them — on as arguments:

>>> inject_dependencies(f0, deps)
f0
>>> inject_dependencies(f1x, deps)
f1x 1
>>> inject_dependencies(f1y, deps)
f1y 2
>>> inject_dependencies(f2, deps)
f2 1 2

Setup and Teardown

Some fixtures, like tmp_path, need to run before and after the test — setting something up (like creating a temporary directory), and tearing it down when its over (removing that directory). It used to be that the way to configure such a fixture was:

import pathlib, pytest, tempfile, shutil

@pytest.fixture
def tmp_path(request):
    path = tempfile.mkdtemp()
    def remove_directory():
        shutil.rmtree(path)
    request.addfinalizer(remove_directory)
    return pathlib.Path(path)

That is, create a temporary directory, register a callback (using the special request dependency) to run at the end of the test and remove it, and return the actual dependency to inject. Later on this syntax was adopted instead:

@pytest.fixture
def tmp_path(request):
    path = tempfile.mkdtemp()
    yield pathlib.Path(path)
    shutil.rmtree(path)

Because a single yield-statement is the perfect syntax for a function that needs to run "before" and "after" — just like what contextlib.contextmanager does. If we'd want to revise inject_dependencies to work with fixtures and support this case, we'd have to tell apart regular callables, which we simply invoke to acquire the dependency they mean to inject, and such generators, which we'd have to start, run for a single step, and then resume after our target function has finished. In other words:

>>> def inject_dependencies(f, deps):
...     sig = inspect.signature(f)
...     kwargs = {}
...     finalizers = []
...     for key, value in deps.items():
...         if key not in sig.parameters:
...             continue 

...         if inspect.isgeneratorfunction(value):
...             generator = value()
...             dep = next(generator)
...             finalizers.append(generator)
...         else:
...             dep = value()
...         kwargs[key] = dep
...     try:
...         return f(**kwargs)
...     finally:
...         for generator in finalizers:
...             try:
...                 next(generator)
...             except StopIteration: # ignore generator's natural end
...                 pass

This way, if we have:

>>> def x():
...     return 1

>>> def y():
...     print("before")
...     yield 2
...     print("after")

>>> deps = {"x": x, "y": y}

We get:

>>> inject_dependencies(f2, deps)
before
f2 1 2
after

Just like we wanted.

Parsing Tracebacks

The reason I'm telling you all this is, well — it's just so delicious, and as we've already established, we are here first and foremost to have fun. But more than that, the point I'm trying to make in this chapter is that metaprogramming is not just a bunch of quirky, advanced topics; for aspect-oriented programming — that is, writing frameworks — it's a set of techniques that are quite indispensable, and I don't see it portrayed as such very often.

In fact, I don't know that even Pytest's core developers thought about it in these terms when they started. When the library just came out, its main feature, its core strength, was those simple assert statements we've seen in our very first example:

def test_add():
    assert add(1, 2) == 3

You see, there's a very good reason that tests before Pytest looked like this:

class TestAdd(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(1, 2), 3)

It's not that unittest's developers were stupid and forgot that Python has a builtin assert-statement; it's that this statement was deemed insufficient, at least on its own. If we try using it outside Pytest, and deliberately break our test:

>>> assert add(1, 0) == 3
AssertionError

That's what we get: an empty assertion error. The reason, by the way, has to do with the precedence of evaluation: first, add(1, 0) is evaluated as 1, then 1 == 3 is evaluated as False, and then assert False raises an exception. It doesn't know what went wrong, just that it did. To counteract this inherent limitation, assert-statements actually support a second argument, intended to describe what exactly was it that incited their ire:

>>> assert add(1, 0) == 3, "add(1, 0) != 3"
AssertionError: add(1, 0) != 3

But writing tests like this would be extremely tedious. The alternative, self.assertEqual, at least lets the framework come up with an intelligible error on its own — its implementation might be something like:

class TestCase:
    def assertEqual(self, arg1, arg2):
        if arg1 != arg2:
            raise AssertionError(f"{arg1} != {arg2}")

Here, add(1, 0) is an argument, so it's evaluated before assertEqual is called; the reason for the test failing ends up as 2 != 1. It's not ideal, but it's better than nothing, or having to spell everything out ourselves, which were the only two conceivable options at the time. Not so, thought Pytest's developers; the people who wrote unittest came from much less dynamic languages like Java and C++, and they replicated the same design pattern used in those languages, in frameworks such as jUnit or GoogleTest. Python, after all, can do much more — its AssertionError, despite being empty, has a traceback object attached, which can help reconstruct what went wrong. If we step out of the interpreter for a moment, put everything in a file and run it:

def add(x, y):
    return x + y
    
def test_add():
    assert add(1, 0) == 3
    
if __name__ == "__main__":
    test_add()
$ python example.py
Traceback (most recent call last):
File ".../example.py", line 8, in <module>
    test_add()
File ".../example.py", line 5, in test_add
    assert add(1, 0) == 3
           ^^^^^^^^^^^^^^
AssertionError

We see that somehow, Python knows which line is the culprit; and like we've seen when we played with the ast module, this information can be retrieved and analyzed in runtime. If we were to write our own Pytest, then, I'd suggest:

import sys

def main():
    code = open(sys.argv[1]).read()
    tests = collect_tests(code)
    for test in tests:
        try:
            test()
            print(f"{test.__name__} passed")
        except AssertionError as error:
            print(f"{test.__name__} failed:")
            analyze_error(code, error)
            
if __name__ == "__main__":
    main()

That is, collect all the functions starting with test in a given file, run them, and if any of them fails, investigate further. Collecting the functions should be quite trivial by now:

def collect_tests(code):
    namespace = {}
    exec(code, namespace)
    return [test for name, test in namespace.items()
            if name.startswith("test")]

As for parsing their traceback, a few words are in order. Much like its first printed line suggests, it's organized with the most recent call (which we want) last. It's effectively an inverse of frame objects: where each frame has an f_back attribute pointing "outwards" to the frame that called it, tracebacks start from the outermost scope, where the exception was caught, and have a tb_next attribute pointing "inwards". So if we do:

def analyze_error(code, error):
    traceback = error.__traceback__
    while traceback.tb_next: # drill down to the innermost frame
        traceback = traceback.tb_next
    line_number = traceback.tb_lineno
    line = code.splitlines()[line_number - 1].strip()
    print(f"  on line {line_number}: {line}")

We'll get a message that's already considerably nicer than what was thought possible:

$ python test.py example.py
test_add failed:
  on line 5: assert add(1, 0) == 3

But Pytest did better — and we can, too. Tracebacks don't only hold a line number; they actually have a live frame object in tb_frame, which can give us its globals and locals, which lets us re-evaluate add(1, 0) and see why it didn't equal 3. Admittedly, I tailored the code to our particular use case – but you can easily imagine how it might be extended to accommodate other scenarios:

import ast

def analyze_error(code, error):
    # … same as before
    frame = traceback.tb_frame
    ErrorTransformer(frame.f_globals, frame.f_locals).visit(ast.parse(line))

class ErrorTransformer(ast.NodeTransformer):

    def __init__(self, globals_, locals_):
        self.globals = globals_
        self.locals = locals_
        
    def visit_Call(self, node):
        call = ast.unparse(node) # python 3.9+
        result = eval(call, self.globals, self.locals)
        print(f"    where {call} = {result}")
        return node

And we have:

$ python test.py example.py
test_add failed:
on line 5: assert add(1, 0) == 3
    where add(1, 0) = 1

Framework Design

Like I said, I don't think the Pytest developers were particularly preoccupied with aspect-oriented theory when they were writing their library: they just figured they can use Python's crazy dynamicity to do some really cool stuff, and seem to have had a knack for it, because they kept coming up with one creative solution after the other. The result is the most beautiful and versatile testing framework I've seen — and my feeling is that it has to do with the degrees of metaprogramming involved, so let's see if we can generalize our conclusions into a broader lesson in software design (and finally revise that awful terminology, like I promised).

My claim is this: when developing a framework (that is, "weaver"), we are primarily concerned with handling certain aspects (good term, let's keep it) of a common problem ("cross-cutting concern") and letting users fill in their business logic by defining what I like to call plugins ("join points") — because, well, it's pieces of code that we plug into the framework. In Flask, it's the decorated handlers; in Pytest, the test_ functions; in other cases, it might be classes (SQLAlchemy) or entire modules (Django) that are hoisted and integrated into some bigger whole.

However, because frameworks necessarily invert control, we have to come up with conventions that will handle plugin integration — first and foremost, some registration method ("pointcuts"): how we communicate to the framework which plugin should go where. In Flask, we use a decorator to mark them; in Pytest, we match them by name; in other cases, we might inherit from a base class (Twisted), or pass our plugins into a particular function (Starlette).

In any case, what makes a framework great is not just its featureset — that's what makes any third-party library great, so it's irrelevant to its quality as a framework. To assess the latter, we should ask how easy it is to develop plugins for it, and how seamlessly those plugins are integrated. After all, the framework is developed once, for some express purpose, by people who are well-versed in its problem domain; the plugins, on the other hand, are developed by a myriad people, for a myriad reasons, over and over again – for every line of code in Pytest, there are thousands of lines of tests that use it. So even if a framework solves a burning need, the degree to which it manages to abstract it away and provide friendly interfaces that translate what users might want into what has to happen — that, I think, is its mark of excellence.

And this is where metaprogramming comes in. Honestly, all of the above can be done without it — but this might lead to what's known as boilerplate code: tedious incantations we have to recite in our codebase, not to solve a particular problem, but to appease whatever framework we're using. And since Python is so delightfully introspective, we can use its reflective abilities to reduce such noise — effectively inferring what to do from subtle, indirect, and generally declarative signals, whether it's names, annotations, signatures, even docstrings.

This reminds me of a cute little framework I once used, called PLY: Python Lex-Yacc, intended to provide the lexical parsing functionality of the popular lex and yacc tools; essentially, a means for creating our own domain-specific language. In it, we'd use regular expressions to specify which tokens our language consists of (e.g. [0-9]+), and possibly define some post-processing for each (like converting a string of digits into an actual number). So in effect, we needed to pair regular expressions with functions – something a different framework might have done with a tedious mapping:

>>> number_pattern = r"[0-9]+"

>>> def parse_number(token):
...     return int(token.value)

>>> # and later:
>>> framework.register_tokens({
...     number_pattern: parse_number,
...     # … other tokens …
... })

Or, more elegantly, with a decorator:

>>> @framework.token(r"[0-9]+")
... def parse_number(token):
...     return int(token.value)

PLY, however, came up with a brilliant idea: since these small parsing functions don't really make use of their documentation string, why not put the regular expression there?

>>> def t_NUMBER(token):
...     r"[0-9]+"
...     return int(token.value)

>>> t_NUMBER.__doc__ # easy enough to access
'[0-9]+'

It also paid attention to names (functions starting with t_ would be used to parse tokens; whatever came next, like NUMBER, would be used later on to articulate the semantics in which they're to be used, etc.); and generally made use of Python's capabilities to their fullest extent, resulting in a neat little framework we'll revisit later.

A Note on Decorators

To be fair, if I had to choose just one of Python's features that is most invaluable for framework development, it would be decorators. At the end of the day, names and signatures have some constraints, whereas a decorator, being a function call, can pretty much accommodate any and all needs simply by receiving this or that argument. Of course, it's nicer when we don't have to add any extra marks — but it's also important not to overstretch the syntax of plugin definition into something too bizarre (regex-docstrings are about as far as I would go), and admit it when a clear mark is called for. Flask, for example, could have put the URL paths in the docstring:

>>> def add():
...     "/add"
...     # … same as before

But actually, the @app.post decoration also indicated which HTTP method is acceptable (as opposed to e.g. @app.get). We could account for that by requiring a specific prefix:

>>> def POST_add():
...     "/add"
...     # … same as before

But what about a handler that can serve both GET and POST requests? In Flask, we simply use @app.route("/add", methods=["GET", "POST"]); without it, we'd have to come up with some contrived convention:

>>> def GET_POST__add():
...     "/add"
...     # … same as before

Like expecting a handler name with two underscores, just so we can split the prefix itself by single ones. That's already getting ugly, which is what I meant by "overstretching the syntax".

But where do decorators fit within the aspect-oriented paradigm? Do they even qualify as metaprogramming, and if not, how does it sit with our conclusion on framework design? In my opinion, because of their native support and elegant syntax, we sort of take them for granted — but actually they're very much in line with everything we discussed. For starters, a decorator is literally code that works on other code: it's a function that receives a function and returns a function. But I would say something even stronger; when we do:

>>> def double(f):
...     def wrapper(*args, **kwargs):
...         return 2 * f(*args, **kwargs)
...     return wrapper

>>> @double
... def add(x, y):
...     return x + y

We actually invert control:

>>> add
<function double.<locals>.wrapper at 0x…>

That is, add(1, 2) no longer calls our original function, but something else, which calls our function at its discretion. It's also the reason wrappers have (*args, **kwargs) for a signature — effectively, a lazy way of coordinating interfaces, making sure that the wrapper can pass the original function whatever parameters it might expect.

So in a sense, a decorator is a miniature framework: it takes care of some aspect of a common problem by accepting a function as its plugin. Its registration method — adding a decoration line on top — just happens to be a native enough part of the language so as to make it feel like it isn't a big deal. The big deal in this case, though, is having a language so geared towards framework development that it makes it so seamless we don't even notice.