Writing/Metaprogramming: An Adventure
Chapter 13 · Draft · 40 min read
Code Generation
Directory Structures
In the last chapter, we built ourselves a little metaprogramming engine. In this one, we're going to put it to work.
We'll tackle a different problem in every section, pointing our shiny new tool at it and seeing what we can do. We'll scaffold projects, write scripts, generate web pages and components and even React; we'll cache our generated code, figure out a way to ship it without the engine, and render this very book, all with the same basic approach — and without any new theory, really, just different plugins.
One of the most common tasks a programmer has to do is to create a directory and
put some files in it, which themselves have to contain some boilerplate. Whether
it's django-admin startproject or npm create, cookiecutter or copying the
last project and renaming a few things — it's always the same, really: a tree of
directories and files with some text at its leaves. And a tree of directories
and files with text at its leaves is pretty much an indentation-based template
waiting to happen.
Auryn actually ships with a filesystem plugin for this purpose, but we'll
write our own, because it's not that difficult. Once it's loaded, all we need to
do is change what plain lines mean: any line ending with / should be
considered a directory, and any other line should be considered a file; lines
nested beneath a file should be considered its content. Code lines and
interpolation should work as usual, even in paths. You might wonder why we don't
just write a Python script with os.makedirs calls; but when we use a script to
build a tree, we are effectively turning the tree inside out, aren't we? In this
case, the template looks like the result — so we can see what the project will
look like before we even create it.
Here's what such a project template might look like, and the code it generates:
>>> import auryn
>>> template = """
... %load filesystem
... {name}/
... README.md
... {name}/
... __init__.py
... tests/
... !for i in range(n):
... test_{i}.py
... """
>>> print(auryn.generate(template))
with directory(s(name, '/')):
with file(s('README.md')):
pass
with directory(s(name, '/')):
with file(s('__init__.py')):
pass
with directory(s('tests/')):
for i in range(n):
with file(s('test_', i, '.py')):
pass
>>> auryn.execute(template, name="proj", n=2)
''
Let's walk it through: the nesting structure in the template is translated to
nesting structure in the code, which is translated to a nesting structure on the
filesystem. The s(...) is a small core hook that concatenates the interpolated
pieces into a string, which is useful for macros that need an interpolated value
rather than an emitted line (gx.interpolated is what builds such expressions);
and to keep Python happy, empty files have a pass. This should result in:
$ find proj | sort
proj
proj/proj
proj/proj/__init__.py
proj/README.md
proj/tests
proj/tests/test_0.py
proj/tests/test_1.py
Here's what the plugin's on_load, path transform and directory hook look like:
# plugins/filesystem.py:
def on_load(gx):
gx.state["text_transforms"] = gx.transforms.copy()
gx.transforms[""] = transform_path
gx.transforms["$"] = transform_shell
def transform_path(gx, content):
path, _, source = content.partition(" ")
if path.endswith("/"):
add_directory(gx, path, source, gx.line.children)
else:
add_file(gx, path, source, gx.line.children)
def add_directory(gx, path, source, children):
gx.add_code(f"with directory({gx.interpolated(path)}):")
with gx.increased_code_indent():
before = len(gx.code)
if source:
for entry in sorted((gx.root / source).iterdir()):
if entry.is_dir():
add_directory(gx, f"{entry.name}/", entry, [])
else:
add_file(gx, entry.name, entry, [])
if children:
gx.transform(children.snap(0))
if len(gx.code) == before:
gx.add_code("pass")
@contextlib.contextmanager
def x_directory(gx, path):
cwd = os.getcwd()
pathlib.Path(path).mkdir(parents=True, exist_ok=True)
os.chdir(path)
try:
yield
finally:
os.chdir(cwd)
Let's walk it through again: on_load stashes the original transforms in the
state, because we'll need them when we're generating file contents, and then
installs transform_path as the default, and transform_shell for any lines
starting with $. A path line should be either a directory or a file.
add_directory generates a with-statement on the directory hook, and then
transforms the children inside it, snapped to 0, since their nesting is now
carried by the code's indentation (that source part is for the next section).
x_directory is a context manager that creates the directory, changes into it,
and changes back in a finally clause — that setup-and-teardown shape we've
seen before in Pytest's yield fixtures. Paths are relative to the current
directory at the time of execution, so the tree should appear wherever we run
this.
Compare this to the naive alternative suggested in Auryn's README: a macro that
generates os.mkdir and os.chdir calls inline. It works, but it doesn't have
the same vibe, does it? The hook lets us keep the generated code declarative,
while putting all the necessary logic in one place, in plain Python. And as for
the plugin itself, it hijacks the default line transform with the same few lines
we used in the paragraphs plugin to close out the previous chapter — except
here, we use it for something else: to turn plain lines into paths.
Files and Shells
Of course, directories alone are not enough: we need some files, too. Those are
where our content actually goes — and the content, we said, is ordinary template
text; so inside a file, our original text transforms are back. We can patch
gx.transforms with the ones we stashed away, so they are available as the
file's children are transformed; and let's also add the shell transform and hook
while we're at it:
# plugins/filesystem.py (continued):
def add_file(gx, path, source, children):
gx.add_code(f"with file({gx.interpolated(path)}):")
with gx.increased_code_indent():
before = len(gx.code)
with gx.patch(transforms=gx.state["text_transforms"]):
if source:
gx.extend(gx.derive(str(source)).generate())
if children:
gx.transform(children.snap(0))
if len(gx.code) == before:
gx.add_code("pass")
@contextlib.contextmanager
def x_file(gx, path):
output = []
with gx.patch(output=output, output_indent=0):
yield
pathlib.Path(path).write_text("".join(output))
def transform_shell(gx, content):
command, _, options = content.partition(" # ")
into = eval(f"dict({options})").get("into", "_")
gx.add_code(f"{into} = shell({gx.interpolated(command)})")
def x_shell(gx, command):
return subprocess.run(command, shell=True, capture_output=True, text=True).stdout
Let's walk through it. x_file is exactly like %assign — it replaces the
output with a fresh list (and output_indent with 0), lets the block run, and
writes the result to file. The text inside it doesn't even know it's going into
a file: it's just a block, like any other.
And while we're here, we can add a source as well: another template, generated
in place with derive and extend. We can similarly add a source to
add_directory: given a real directory, we walk it and add its entries — every
file as a template. That's a cookiecutter in a dozen lines.
But we can do better. Consider shell lines: $ command will run a command at
execution time, and $ command # into="name" will store its standard output in
an execution variable. This is just a thin wrapper around subprocess.run, as
you can see in x_shell. And we can use all of this to generate a project:
# project.aur:
%load filesystem
{name}/ skeleton
README.md
# {name.title()}
Generated with a mini-Auryn.
{name}/
__init__.py
tests/
!for i in range(n):
test_{i}.py
def test_{i}():
assert {i} + 1 == {i + 1}
run_tests.sh
python -m pytest tests
$ chmod +x run_tests.sh
And here are the skeleton directory's templates:
# skeleton/pyproject.toml:
[project]
name = "{name}"
version = "0.1.0"
requires-python = ">=3.13"
# skeleton/docs/index.md:
# {name.title()}
!for i in range(n):
- [Test {i}](../tests/test_{i}.py)
And this is why I like it — because it's so simple, but so powerful. The fact that the source is a template makes all the difference: now, it's not just a directory of files we can copy over, but a scaffolding tool. The skeleton can be edited, versioned and reviewed just like any other code — and the language used to describe it is exactly the same as the one we've already been using. All we need to do is execute it:
>>> import auryn
>>> os.chdir("out")
>>> auryn.execute("../project.aur", name="proj", n=2)
''
And we get (including the MANIFEST.txt we'll generate in a moment):
$ find proj -not -path "*/.git*" | sort
proj
proj/docs
proj/docs/index.md
proj/MANIFEST.txt
proj/proj
proj/proj/__init__.py
proj/pyproject.toml
proj/README.md
proj/run_tests.sh
proj/tests
proj/tests/test_0.py
proj/tests/test_1.py
$ cat proj/tests/test_1.py
def test_1():
assert 1 + 1 == 2
$ cat proj/docs/index.md
# Proj
- [Test 0](../tests/test_0.py)
- [Test 1](../tests/test_1.py)
$ ls -l proj/run_tests.sh | cut -c1-10
-rwxr-xr-x
The pyproject.toml and the docs/index.md came from the skeleton,
interpolated with the project's name; in fact, the latter even has a loop,
because skeleton files are full templates. And run_tests.sh is executable,
because of the $ chmod line. (Empty lines are removed by our text transform,
by the way; in the actual Auryn, they are only emitted when asked, by an empty
% line.)
This is already pretty cool, but it gets cooler still when you consider what
shell lines, combined with code lines, allow us to do: I'd even call it
generative scripting. The shell is good for what it's good for — running
programs, finding files — and the Python code is good for what the shell is bad
for, namely loops, data and string manipulations. The template, then, describes
a result — so we can use this to generate, say, a MANIFEST.txt based on the
output of find:
>>> auryn.execute("""
... %load filesystem
... {name}/
... $ find . -name "*.py" | sort # into="paths"
... MANIFEST.txt
... !for path in paths.split():
... {path.removeprefix("./")}
... """, name="proj")
''
Which results in:
$ cat proj/MANIFEST.txt
proj/__init__.py
tests/test_0.py
tests/test_1.py
That is, find is run inside the project directory, because that's where its
shell line is nested; its output is stored in paths; and then a code line
iterates over it to generate the desired file. As a bonus of having two phases,
print(generate(template)) will show us exactly what our script is going to do
before doing anything, a dry run for free — something most deployment scripts
can't do.
Web Pages
We've come full circle: two chapters ago, we started with Jinja2 rendering a
list of HTML elements, the kind of thing that web servers do all day. So let's
put our engine where templating engines go: behind a web framework. To keep
everything ours, I'll even write this framework, and it won't take more than 50
lines. I know what you're thinking: "why bother, when we have Flask?" — but hear
me out. First of all, I'm not trying to compete with Flask. Second, every piece
of this framework is a trick we've already seen before, so it's a nice way to
review everything we've learned — and third, in two sections, we'll actually
want to introspect this framework's routes. For now, though, behold web.py:
# web.py: a tiny web framework
import http.server
import inspect
import json
import re
class App:
def __init__(self):
self.routes = {}
def get(self, path):
def decorator(handler):
self.routes[path] = handler
return handler
return decorator
def dispatch(self, url):
for path, handler in self.routes.items():
pattern = re.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", path)
if match := re.fullmatch(pattern, url):
kwargs = {}
for name, parameter in inspect.signature(handler).parameters.items():
kwargs[name] = parameter.annotation(match[name])
return handler(**kwargs)
raise LookupError(url)
def serve(self, port):
app = self
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
try:
result = app.dispatch(self.path)
except LookupError:
return self.send_error(404)
if isinstance(result, str):
body, content_type = result, "text/html"
else:
body, content_type = json.dumps(result), "application/json"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(body.encode())
http.server.HTTPServer(("localhost", port), Handler).serve_forever()
So let's go over it: @app.get(path) is used to register handlers, and we're
using the decorator-as-registration-method trick from the aspect-oriented
programming chapter. dispatch is used to parse paths (turning {post_id}
placeholders into named regular expression groups) and inject path parameters by
name (converting them with the handler's annotation, post_id: int), which is
the FastAPI-style dependency injection we implemented in that chapter; and
serve is used to wrap everything up in one BaseHTTPRequestHandler, that
verbose standard-library class we took a deep breath before, and which we only
have to write once — returning strings as HTML, and everything else as JSON.
So far, so good; but let's try it on something more substantial, like a small
blog. In app.py, let's have:
# app.py
import dataclasses
import auryn
from web import App
@dataclasses.dataclass
class Post:
id: int
title: str
content: str
posts = [
Post(1, "Hello, world", "My first post."),
Post(2, "Metaprogramming", "It's an adventure."),
]
app = App()
def page(template, **context):
return auryn.execute(f"pages/{template}", **context)
@app.get("/")
def index():
return page("index.aur", posts=posts)
@app.get("/posts/{post_id}")
def post(post_id: int):
return page("post.aur", post=posts[post_id - 1])
if __name__ == "__main__":
app.serve(8000)
page is actually our entire view layer, distilled down to one call to
auryn.execute — and note how the template paths are relative to app.py,
which the engine figures out by walking the stack, so this server can run from
any directory. Here are our base layout, our index page and our post page:
# pages/base.aur:
<!DOCTYPE html>
<html>
<head>
<title>
%insert title
My Blog
</title>
</head>
<body>
%insert body
</body>
</html>
# pages/index.aur:
%extend base.aur
%define body
<h1>Posts</h1>
<ul>
!for post in posts:
<li><a href="/posts/{post.id}">{post.title}</a></li>
</ul>
# pages/post.aur:
%extend base.aur
%define title
{post.title}
%define body
<h1>{post.title}</h1>
<p>{post.content}</p>
Note how %extend, %define and %insert are used to compose the pages, and
how interpolation reaches into attributes like {post.title}; because really,
it's just Python code evaluated in a namespace we control, which means there's
no separate expression language to learn, like we complained about templating
languages before. At this point, we're ready to serve our blog on localhost:
$ curl -s localhost:8000/
<!DOCTYPE html>
<html>
<head>
<title>
My Blog
</title>
</head>
<body>
<h1>Posts</h1>
<ul>
<li><a href="/posts/1">Hello, world</a></li>
<li><a href="/posts/2">Metaprogramming</a></li>
</ul>
</body>
</html>
$ curl -s localhost:8000/posts/2
<!DOCTYPE html>
<html>
<head>
<title>
Metaprogramming
</title>
</head>
<body>
<h1>Metaprogramming</h1>
<p>It's an adventure.</p>
</body>
</html>
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/nope
404
Notice the 404 we get for a path that doesn't match any of our handlers — and notice, too, that every request does the following: parses the template, loads the plugins, runs the macros, compiles the code, and only then executes it. We'll come back to that point in a moment.
For now, I want to draw a lesson about frameworks: when you have your own
templating engine, the templating part of a web framework is actually quite
tiny; Flask's integration with Jinja, for example, is basically our page
function, and everything else (layouts, includes, custom delimiters for
script-heavy pages) is part of the engine. What you can't do with server-side
templates, though, is react to the user after the page has been sent: and that's
what today's web is all about, with its components and single-page applications
(usually written in React).
Components
Of course, modern frontends don't think in pages, but in components — reusable pieces of user interface that can be customized by passing in properties (or props) and nesting content inside them. Templating engines have includes and blocks; a component is their more structured cousin, a template with parameters and a slot.
To add support for such components, we'll make a new plugin. Every file in a
components/ directory will be turned into a macro with that name, so using a
component will look like using a tag: %Card title=post.title, with the nested
lines as its children; and in the component, %content will be used to put them
in place. That's how vynil, the renderer that typesets this book, does it:
# plugins/components.py
import contextlib
def on_load(gx):
for path in sorted((gx.root / "components").resolve().glob("*.aur")):
gx.g_globals[path.stem] = component(path)
def component(path):
def g_component(gx, props=""):
gx.state.setdefault("content", []).append(gx.line.children)
rendered = gx.derive(str(path))
rendered.lines.snap(gx.line.indent)
rendered.generate()
gx.add_code(f"with props({props}):")
with gx.increased_code_indent():
gx.extend(rendered)
gx.state["content"].pop()
return g_component
def g_content(gx):
content = gx.state["content"][-1]
gx.transform(content.snap(gx.line.indent))
@contextlib.contextmanager
def x_props(gx, **props):
previous = {name: gx.x_globals[name] for name in props if name in gx.x_globals}
gx.x_globals.update(props)
try:
yield
finally:
for name in props:
del gx.x_globals[name]
gx.x_globals.update(previous)
And that's how it works: on_load globs the directory and registers a macro for
each file by calling component, which returns a function that closes over the
path — so we're making macros at load time, because functions are values. The
macro itself pushes the caller's children onto a stack in the state (so that
components can nest), derives a GX for the component's file, snaps it to the
caller's position, generates it, and wraps its code in a with props(...):.
Since props are passed in as raw text that goes into this with-statement, they
are evaluated at execution time — so post.title is not going to be available
until then. To make them available, x_props makes them global names in the
execution namespace for the duration of the block, restoring whatever they might
have shadowed after it's over — dynamic scoping, like in Lisp.
As for the stack, it's a bit more subtle: a component's content might very well
contain other components — for example, a Link inside a Card, or a card
inside a card; so for every call, its children are pushed, and then popped when
it's done, so %content always refers to the innermost component's caller. For
example:
# pages/components/Card.aur:
<div class="card">
<h2>{title}</h2>
<div class="card-body">
%content
</div>
</div>
# pages/components/Link.aur:
<a href="{href}">{text}</a>
And an index page can use them like so:
# pages/index.aur:
%load components
%extend base.aur
%define body
<h1>Posts</h1>
!for post in posts:
%Card title=post.title
<p>{post.content}</p>
%Link href=f"/posts/{post.id}", text="Read more"
Note the %Link props: href=f"/posts/{post.id}", text="Read more"; since the
entire argument string is going to end up in a Python call, they are actually
keyword arguments, f-strings included. Rendering it, we get:
>>> from app import page, posts
>>> print(page("index.aur", posts=posts))
<!DOCTYPE html>
<html>
<head>
<title>
My Blog
</title>
</head>
<body>
<h1>Posts</h1>
<div class="card">
<h2>Hello, world</h2>
<div class="card-body">
<p>My first post.</p>
<a href="/posts/1">Read more</a>
</div>
</div>
<div class="card">
<h2>Metaprogramming</h2>
<div class="card-body">
<p>It's an adventure.</p>
<a href="/posts/2">Read more</a>
</div>
</div>
</body>
</html>
And the code generated for a card is:
>>> import auryn, pathlib
>>> gx = auryn.GX("""
... %load components
... !for post in posts:
... %Card title=post.title
... <p>{post.content}</p>
... """, root=pathlib.Path("pages"))
>>> print(gx.generate().to_string())
for post in posts:
with props(title=post.title):
emit(0, '<div class="card">')
emit(4, '<h2>', title, '</h2>')
emit(4, '<div class="card-body">')
emit(8, '<p>', post.content, '</p>')
emit(4, '</div>')
emit(0, '</div>')
Like you can see, the component's lines are inlined under
with props(title=post.title):, and the content <p> is emitted right where
%content was, at its indentation. This is quite an interesting transformation,
if you think about it, since there are no component objects at execution time:
components are compiled away, in a way that's reminiscent of how Svelte compiles
its components into plain code, rather than managing a virtual DOM.
These are, of course, server-side components, rendered once per request; the real thing happens in the browser, and it's coming up next.
A React Plugin
Now, say our posts have comments: those should load after the page does — so it's really a job for React. But there are two chores that make working with it together with Python somewhat awkward: creating a JavaScript client for our Python API (which is why Swagger Codegen and OpenAPI generators exist — people got tired of writing fetch wrappers by hand); and preparing a build step for our JSX. Luckily, both these problems are really code generation problems. Let's see if we can do it differently.
# app.py:
comments = {
1: [{"id": 1, "text": "Welcome!"}],
2: [{"id": 2, "text": "So meta."}, {"id": 3, "text": "Wait, what?"}],
}
# … same as before
def page(template, **context):
return auryn.execute(f"pages/{template}", app=app, **context)
# … same as before
@app.get("/api/posts/{post_id}/comments")
def get_comments(post_id: int):
return comments[post_id]
That's the new endpoint, and the updated page function. Let's go over it:
get_comments returns a list, so the framework will serve it as JSON; and
page now passes in the app itself to the template context. The client, then,
is auto-generated from the routes themselves — their handler names, their
parameters (using inspect.signature, like in dependency injection), and their
paths. It's a template, but it's full of JavaScript braces and ${...} template
literals, so it's exactly what our custom delimiters are for:
# pages/api.aur:
%interpolate <% %>
const api = {
!for name, params, path in routes(app):
<% name %>: async (<% ", ".join(params) %>) => {
const response = await fetch(`<% path %>`);
return await response.json();
},
};
And then, a new hook:
# plugins/react.py:
def x_routes(gx, app):
for path, handler in app.routes.items():
if path.startswith("/api/"):
params = [camel_case(name) for name in inspect.signature(handler).parameters]
path = re.sub(r"\{(\w+)\}", lambda match: f"${{{camel_case(match[1])}}}", path)
yield camel_case(handler.__name__), params, path
def camel_case(name):
first, *rest = name.split("_")
return first + "".join(word.title() for word in rest)
Notice the phase: routes is a hook, so it's called while the page is
executing, and it only depends on the app passed to it (and the same api.aur
would describe any app built with our framework). Let's break it down: for every
/api/ route, we want to yield its handler name in camel case, its parameters,
and its path as a JavaScript template literal ({post_id} becomes ${postId}).
The result is:
>>> import auryn
>>> from app import app, page, posts
>>> print(auryn.execute("""
... %load react
... %include pages/api.aur
... """, app=app))
const api = {
getComments: async (postId) => {
const response = await fetch(`/api/posts/${postId}/comments`);
return await response.json();
},
};
And you know what — it can't drift from the server, because it's auto-generated
from it; and it works (I checked it under node, with a fake fetch).
And now, the second chore: compiling JSX. To do that in the browser, Babel
provides a standalone build that can transpile <script type="text/babel"> into
JavaScript (this is actually how the web notebook's React frontend was
implemented in the chapter on dynamic code execution). Consider:
// pages/components/Comments.jsx:
function Comments({ api, postId }) {
const [comments, setComments] = React.useState(null);
React.useEffect(() => {
api.getComments(postId).then(setComments);
}, [postId]);
if (comments === null) {
return <p>Loading comments...</p>;
}
return (
<ul>
{comments.map((comment) => <li key={comment.id}>{comment.text}</li>)}
</ul>
);
}
And then, the React macros:
# plugins/react.py (continued):
SCRIPTS = [
"https://unpkg.com/react@18.3.1/umd/react.development.js",
"https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js",
"https://unpkg.com/@babel/standalone@7.26.4/babel.min.js",
]
def g_react_setup(gx):
for url in SCRIPTS:
gx.add_text(gx.line.indent, f'<script src="{url}"></script>', interpolate=False)
def g_react(gx, arg):
name, _, props = arg.partition(" ")
source = (gx.root / "components" / f"{name}.jsx").read_text()
indent = gx.line.indent
gx.add_text(indent, f'<div id="{name}"></div>', interpolate=False)
gx.add_text(indent, '<script type="text/babel">', interpolate=False)
for line in source.strip().splitlines():
gx.add_text(indent + 4 if line else 0, line, interpolate=False)
gx.add_code(f"emit({indent + 4}, 'const props = ', to_json({props}), ';')")
root = f'ReactDOM.createRoot(document.getElementById("{name}"))'
gx.add_text(indent + 4, f"{root}.render(<{name} api={{api}} {{...props}} />);", interpolate=False)
gx.add_text(indent, "</script>", interpolate=False)
def x_to_json(gx, **props):
return json.dumps(props)
Let's go over it: %react_setup emits the necessary script tags to load React,
ReactDOM and Babel, with pinned versions. Then, %react Name props reads
components/Name.jsx during generation and inlines it, as raw as it gets (since
its braces are actually JSX, not interpolation). Next, we emit
const props = ...;, serialized into JSON during execution by a to_json hook,
so post.id is actually replaced with the value. And finally, we mount it with
ReactDOM.createRoot(...).render(<Name api={api} {...props} />). Our post
template is now:
# pages/post.aur:
%load react
%extend base.aur
%define head
%react_setup
<script>
%include api.aur
</script>
%define title
{post.title}
%define body
<h1>{post.title}</h1>
<p>{post.content}</p>
%react Comments postId=post.id
And the result:
>>> print(page("post.aur", post=posts[1]))
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"></script>
<script>
const api = {
getComments: async (postId) => {
const response = await fetch(`/api/posts/${postId}/comments`);
return await response.json();
},
};
</script>
<title>
Metaprogramming
</title>
</head>
<body>
<h1>Metaprogramming</h1>
<p>It's an adventure.</p>
<div id="Comments"></div>
<script type="text/babel">
function Comments({ api, postId }) {
const [comments, setComments] = React.useState(null);
React.useEffect(() => {
api.getComments(postId).then(setComments);
}, [postId]);
if (comments === null) {
return <p>Loading comments...</p>;
}
return (
<ul>
{comments.map((comment) => <li key={comment.id}>{comment.text}</li>)}
</ul>
);
}
const props = {"postId": 2};
ReactDOM.createRoot(document.getElementById("Comments")).render(<Comments api={api} {...props} />);
</script>
</body>
</html>
Take a moment to appreciate it: not only does Python data end up as JSON props
in React, but the client itself is loaded into the head, and once the page makes
it to the browser, the component actually fetches /api/posts/2/comments on its
own! Admittedly, this isn't a framework (there's no shared state between the
server and the client, no hydration) — but it's the shape of one: a Python
server whose templates know about its routes, about its components, and can
integrate the two together.
Pre-Generation
Let's get back to the problem we noticed before. Even with our nice plugin,
every request still parses the template, loads the plugins (by executing their
source code), runs the macros (which include reading Comments.jsx from disk),
and finally compiles the result, all before executing a single emit. That was
the trade-off we mentioned in our survey of templating engines: Django
interprets its template on every render, whereas Jinja and Mako compile it to
Python once, and render it multiple times. First, let's time it:
>>> import timeit
>>> from app import app, page, posts
>>> timeit.timeit(lambda: page("post.aur", post=posts[1]), number=1000)
1.2749013747088611
In our case, since we've already separated the phases into different methods, this is easy to remedy:
>>> import auryn
>>> gx = auryn.GX("pages/post.aur").generate()
>>> code = compile(gx.to_string(), "post.aur", "exec")
>>> def render(**context):
... gx.output = []
... exec(code, {**gx.x_globals, **context})
... return "".join(gx.output).rstrip("\n")
>>> render(app=app, post=posts[1]) == page("post.aur", post=posts[1])
True
>>> timeit.timeit(lambda: render(app=app, post=posts[1]), number=1000)
0.021650417242199183
Let's walk through it: first, we generate the code once; then, we compile it
into a code object once; and finally, for every request, we reset the output and
exec the same code object in a fresh namespace, which is made up of the
execution globals and the context of that request (so it's indeed fresh, and
doesn't bleed over from one request to another). The output is exactly the same
— but now, a thousand renders drop from about 1.3 seconds to about 0.02 seconds
on my machine, which is some 60 times faster (your numbers may vary).
All that's left is a straight-line Python calling emit, so the bulk of the
templating cost was really the template language, not its output. We might call
this pre-generation, in the spirit of ahead-of-time compilation; and if you
squint, the cache of generated code is our .pyc file, which is rather fitting
for a book that started out as an attempt to understand what Python does with
our code behind our backs.
But where do we actually put this cache? If we keep it in memory, a dictionary
from template to code object will work fine — at least, as long as we're dealing
with a single process. On disk, though, it gets interesting; you see, the
generated code is not self-sufficient, because it calls emit, indent,
props, to_json and routes — all of which are hooks bound to a live GX,
which won't exist in the process that loads the file. Hand it to a bare exec,
and it'll just die with a NameError; for generated code to stand on its own,
then, it has to take its dependencies with it.
Standalone Code
Our goal is to generate code on one machine today, and execute it tomorrow on a machine that has never even heard of Auryn. We've already seen, in the runtime serialization chapter, that serialization of behavior is possible — and in fact, concluded that replaying recorded steps is superior to pickling frames. Here, instead of shipping the entire engine, all its plugins and the template — we only need to ship plain Python text, that we'll be able to execute with nothing more than a standard Python installation.
What does generated code need in order to run? emit, indent and the output
list, which is the execution half of the GX, as well as whatever hooks it uses,
and whatever imports and helpers those hooks use. So let's start with a small
refactoring, splitting GX into a Runtime class (which holds the output,
emit, indent and patch) and GX(Runtime) for generation. Then, the engine
can ship the part of itself that's needed for execution, as source — simply by
using inspect.getsource on it. As for figuring out which names the generated
code uses — that's something we've been doing since the tracer and bytecode
chapters: simply compiling it and looking at co_names, recursively so, since
those might include nested code objects in co_consts (function bodies,
comprehensions) — and ignoring the builtins:
# auryn.py:
def global_names(code):
if isinstance(code, str):
code = compile(code, "<code>", "exec")
names = set(code.co_names)
for const in code.co_consts:
if isinstance(const, types.CodeType):
names |= global_names(const)
return names - set(vars(builtins))
def collect_definitions(paths):
definitions = {}
for path in paths:
for node in ast.parse(pathlib.Path(path).read_text()).body:
if isinstance(node, (ast.Import, ast.ImportFrom)):
for alias in node.names:
definitions[alias.asname or alias.name] = ast.unparse(node)
elif isinstance(node, (ast.FunctionDef, ast.ClassDef)):
if node.name.startswith("x_"):
node.name = node.name.removeprefix("x_")
node.args.args = node.args.args[1:] # gx becomes a global
definitions[node.name] = ast.unparse(node)
return definitions
Let's walk through it: collect_definitions parses each plugin module with
ast and maps every top-level import, function and class to its source, using
ast.unparse. On the way, it rewrites the hooks, turning x_file to file and
removing its first parameter, since in standalone code, gx is a module-level
global and not an argument. It's the same kind of AST rewriting we've seen in
the source code manipulation chapter. And then, to_standalone:
# auryn.py:
class GX:
def to_standalone(self):
code = self.to_string()
paths = set()
for name in global_names(code):
hook = self.x_globals.get(name)
if inspect.ismethod(hook) and hook.__self__ is self and name not in ("emit", "indent"):
paths.add(inspect.unwrap(hook.__func__).__code__.co_filename)
definitions = collect_definitions(paths)
needed, queue = [], list(global_names(code))
while queue:
name = queue.pop(0)
if name in definitions and definitions[name] not in needed:
needed.append(definitions[name])
queue.extend(global_names(definitions[name]))
imports = ["import contextlib", "import json", "import sys"]
imports += [source for source in needed if source.startswith(("import", "from"))]
others = [source for source in needed if source not in imports]
return "\n\n".join([
"\n".join(dict.fromkeys(imports)),
inspect.getsource(Runtime).strip(),
"gx = Runtime(globals())\nemit, indent = gx.emit, gx.indent",
*others,
"globals().update(json.loads(sys.argv[1]) if len(sys.argv) > 1 else {})",
code,
'print("".join(gx.output), end="")',
]) + "\n"
It finds the plugin files of the hooks used by the code (and note how
inspect.unwrap peels off contextmanager's wrapper, thanks to the
__wrapped__ attribute, to reveal the original function and its co_filename);
then, collects their definitions; and finally, uses a worklist: starting from
the names used by the code, it adds their definitions, and queues their names in
turn — pretty much like a bundler's tree shaking algorithm would do.
Then, the assembly: imports first, so we have decorators like
@contextlib.contextmanager when they're needed; then, the Runtime's source,
gx = Runtime(globals()), the hooks, the context (which in standalone code we
need to fill in any free names, like n, by updating the module's globals from
a JSON argument we pass on the command line — the equivalent of passing the
context to execute), and then the code and the printing of the output. Note
that Runtime also gets the module's globals() as its execution namespace, so
hooks like props continue to work. Here are the names used by our scaffold's
generated code:
>>> sorted(auryn.global_names(gx.to_string()))
['directory', 'emit', 'file', 'i', 'n', 'name', 's', 'title']
>>> definitions = auryn.collect_definitions([gx.x_globals["file"].__func__.__wrapped__.__code__.co_filename])
>>> print(definitions["file"])
@contextlib.contextmanager
def file(path):
output = []
with gx.patch(output=output, output_indent=0):
yield
pathlib.Path(path).write_text(''.join(output))
Notice the title: since co_names keeps attribute names as well
(name.title()), it's a bit of an over-approximation, but that's OK, since only
names that have definitions are going to be pulled in anyway. Here's how we'd
generate the <ul> template as a standalone file:
>>> import auryn
>>> gx = auryn.GX("""
... <ul>
... !for i in range(n):
... <li>{i}</li>
... </ul>
... """).generate()
>>> open("out/loop.py", "w").write(gx.to_standalone())
1177
And here's the standalone loop.py:
# loop.py:
import contextlib
import json
import sys
class Runtime:
def __init__(self, x_globals=None):
self.x_globals = {} if x_globals is None else x_globals
self.output = []
self.output_indent = 0
def emit(self, indent, *args):
self.output.append(" " * (self.output_indent + indent) + "".join(map(str, args)) + "\n")
@contextlib.contextmanager
def indent(self, indent):
self.output_indent += indent
try:
yield
finally:
self.output_indent -= indent
@contextlib.contextmanager
def patch(self, **attributes):
previous = {name: getattr(self, name) for name in attributes}
for name, value in attributes.items():
setattr(self, name, value)
try:
yield
finally:
for name, value in previous.items():
setattr(self, name, value)
gx = Runtime(globals())
emit, indent = gx.emit, gx.indent
globals().update(json.loads(sys.argv[1]) if len(sys.argv) > 1 else {})
emit(0, '<ul>')
with indent(4):
for i in range(n):
emit(0, '<li>', i, '</li>')
emit(0, '</ul>')
print("".join(gx.output), end="")
And we can run it with plain Python:
$ python loop.py '{"n": 3}'
<ul>
<li>0</li>
<li>1</li>
<li>2</li>
</ul>
Similarly, we can generate the project scaffold as standalone code:
>>> gx = auryn.GX("""
... %load filesystem
... {name}/
... README.md
... # {name.title()}
... tests/
... !for i in range(n):
... test_{i}.py
... def test_{i}():
... assert True
... """).generate()
>>> open("out/scaffold.py", "w").write(gx.to_standalone())
1814
And run it, then list the tree it generates:
$ python scaffold.py '{"name": "proj", "n": 2}'
$ find proj | sort
proj
proj/README.md
proj/tests
proj/tests/test_0.py
proj/tests/test_1.py
$ cat proj/tests/test_1.py
def test_1():
assert True
Notice how only directory, file and s came along, and not shell or the
path transform — nothing else was needed. We have actually generated a project
generator that requires nothing but Python itself! In the real Auryn, this
standalone mode does the same collection, but also stores comments that map
every generated line back to its template, so that errors can still be traced to
the right line (it's restored with execute_standalone).
Rendering This Book
Finally, I have a confession to make. You've been reading a bunch of templates
all along. Every chapter of this book is a .vyn file, rendered by vynil, a
book renderer built on top of Auryn. All the %title, %section and %code
are actually macros; the plain text lines are paragraphs; and any components,
like tables, columns or images, are templates with %content.
Let's write a small version of it, then: the paragraphs plugin from the end of the previous chapter, all grown up:
# plugins/book.py
import html
import re
STYLES = [
(re.compile(r"`(.+?)`"), r"<code>\1</code>"),
(re.compile(r"\*\*(.+?)\*\*"), r"<strong>\1</strong>"),
(re.compile(r"\*(.+?)\*"), r"<em>\1</em>"),
]
def on_load(gx):
gx.transforms[""] = transform_paragraph
gx.on_complete(close_paragraph)
def transform_paragraph(gx, content):
if not content:
return close_paragraph(gx)
if not gx.state.get("paragraph"):
gx.add_text(0, "<p>", interpolate=False)
gx.state["paragraph"] = True
for pattern, replacement in STYLES:
content = pattern.sub(replacement, content)
gx.add_text(4, content, interpolate=False)
def close_paragraph(gx):
if gx.state.get("paragraph"):
gx.add_text(0, "</p>", interpolate=False)
gx.state["paragraph"] = False
def g_title(gx, title):
close_paragraph(gx)
gx.add_text(0, f"<h1>{title}</h1>", interpolate=False)
def g_section(gx, title):
close_paragraph(gx)
slug = re.sub(r"\W+", "-", title.lower()).strip("-")
gx.add_text(0, f'<h2 id="{slug}">{title}</h2>', interpolate=False)
def g_code(gx, language="python"):
close_paragraph(gx)
code = html.escape(gx.line.children.snap(0).to_string().strip())
gx.add_text(0, f'<pre class="{language}"><code>{code}</code></pre>', interpolate=False)
Let's go over it together: the on_load function replaces the default transform
with our own transform_paragraph and registers close_paragraph to run after
generation is complete. The transform_paragraph function takes care of
consecutive text lines, gluing them into a single paragraph (which we track with
a flag in the state) and closing it with an empty line (which is why we kept
those in the parser). As for inline styles, those are nothing more than regular
expression substitutions: backticks for code, double asterisks for strong, and
single asterisks for emphasis.
All the macros start by closing any open paragraph (that's why we needed the
state flag), and then do something. %section creates an anchor out of its
title, and %code treats its children as raw text (snap(0) followed by
to_string), escaping its HTML and emitting it with no interpolation (since
code samples are full of braces). If we look at the beginning of the source code
of the chapter about the 20-line templating engine:
# 11-a-templating-engine-in-20-lines.vyn:
%title A Templating Engine in 20 Lines
At this point, we've covered aspect-oriented programming, domain-specific
languages and user-defined code generation; we've manipulated source code as
plain text, and serialized the very act of execution. We're ready, at last, to
embark on the final leg of our journey: a metaprogramming framework, combining
all of these ideas into a particularly fun abstraction. Before we do, though,
I'd like to tell you the story of its conception – because the idea behind it
is quite simple, and literally fits in just a few lines of code.
In the book *The Architecture of Open-Source Applications: 500 Lines or Less*,
experienced programmers solve interesting problems from real-life projects. It's
an excellent read, full of practical insights into software design – and in one
of its chapters, Ned Batchelder describes the motivation, challenges and
solutions of writing a templating engine.
Here's the gist: most programs contain a lot of code, and only a little text, so
programming languages are designed for code — text is simply enclosed in quotes
and managed in strings:
%code
>>> def hello():
... print("Hello, world!")
And if we render it:
>>> import auryn
>>> gx = auryn.GX("excerpt.vyn")
>>> gx.load("book")
>>> html = gx.generate().execute()
>>> print("\n".join(html.splitlines()[:16]))
<h1>A Templating Engine in 20 Lines</h1>
<p>
At this point, we've covered aspect-oriented programming, domain-specific
languages and user-defined code generation; we've manipulated source code as
plain text, and serialized the very act of execution. We're ready, at last, to
embark on the final leg of our journey: a metaprogramming framework, combining
all of these ideas into a particularly fun abstraction. Before we do, though,
I'd like to tell you the story of its conception – because the idea behind it
is quite simple, and literally fits in just a few lines of code.
</p>
<p>
In the book <em>The Architecture of Open-Source Applications: 500 Lines or Less</em>,
experienced programmers solve interesting problems from real-life projects. It's
an excellent read, full of practical insights into software design – and in one
of its chapters, Ned Batchelder describes the motivation, challenges and
solutions of writing a templating engine.
And the rendered code block:
>>> start = html.index("<pre")
>>> print(html[start : html.index("</pre>") + len("</pre>")])
<pre class="python"><code>>>> def hello():
... print("Hello, world!")</code></pre>
Notice the escaped >>> and the quotes; and how the text from an earlier
chapter was rendered into paragraphs. The same thing happens with vynil's real
chapter plugin, which also does a little trick to prevent inline styles from
being applied to code spans, and uses Pygments to highlight code and support
markers for highlighting regions and breaking pages.
As a writer, this is exactly what I was looking for: the old promise of templating, to keep the source close to plain text, readable in any editor and easy to diff, while still generating nice typeset HTML and PDF. So the tool I wrote to write about metaprogramming is a metaprogram itself — and the HTML and PDF versions of these chapters are actually generated code's output. Whenever I needed something new — a table, two columns, a page break — I was able to do it myself, adding a component or a macro, not a feature request.
This is what I meant in the chapter about the 20-line templating engine, when I talked about convivial tools: tools that can be extended by their users, from within and in the same language they're already using, so as to foster their autonomy and creativity, not just their efficiency.
The End of the Adventure
And that's a wrap — on this chapter, and on this book. In two chapters, we grew
a 20-line proof of concept to a mini-Auryn — trees of lines, a GX that defers
indentation to execution, our own interpolation, macros, composition, plugins
and hooks, errors that point at templates, and then, in a spectacular flourish,
pointed it at directories and shells, web pages, components and React, caching,
standalone code, and this very book. The real thing is just a
pip install auryn away (it's pure Python, with no dependencies), and it's all
there, with the same names — so it should feel pretty familiar:
>>> import auryn
>>> template = """
... <ul>
... !for i in range(n):
... <li>{i}</li>
... </ul>
... """
>>> print(auryn.generate(template))
emit(0, '<ul>')
with indent(4):
for i in range(n):
emit(0, '<li>', i, '</li>')
emit(0, '</ul>')
>>> print(auryn.execute(template, n=3))
<ul>
<li>0</li>
<li>1</li>
<li>2</li>
</ul>
Except, maybe, it looks a little too familiar; character for character, it's
exactly what our mini-engine produced. The real Auryn adds all the things that
make a library production-worthy: origins and full error reports,
generation-time code lines, %raw, %inline and %strip, %bookmark and
%append, %param, standalone code with source maps, and even a CLI. If you go
look at its README, you'll also find a guide for writing your own plugins, which
ends with an invitation that still stands: let me know what fun ideas you come
up with.
Looking back, we've seen almost every chapter in the last two: frames were
walked to find the caller's directory, as we did in dynamic code execution;
co_names were read to find hooks, as in tracers and bytecode; exec was used
to load plugins and run macros, as in dynamic extensions; naming conventions and
decorators were employed for registration, like in aspect-oriented programming;
tracebacks were enriched, like when we were parsing tracebacks; the ast module
was used to rewrite hooks, like in source code manipulation; and code was
shipped instead of state, like in runtime serialization.
If there's a moral to this adventure, I suppose it's that metaprogramming earns
its keep when it disappears behind an honest interface; the person writing
%Card title=post.title needn't know anything about emit or props. And
unlike black-box generators like ProtoBuf, or an AI tool that we can't question
— here, the code generated is short, and readable, and always a
print(generate(...)) away. This, I think, is what makes the difference between
convivial tools and opaque ones: that we can read the generated code, and reason
about the generator; it's what keeps us fluent in the craft, and fluency in code
is what lets us reason about what the job even is.
Anyway, this has been the adventure. I don't know about you, but I had fun; like I said in the introduction, it was never meant to be a theory of metaprogramming — just a hands-on exploration of uncharted territory, for the sake of exploration, to see what dragons be there; and when they could be tamed, all the better. And remember that Saint-Exupéry quote: "If you want to build a ship, don't drum up the men to gather wood, divide the work, and give orders. Instead, teach them to yearn for the vast and endless sea." I didn't want to build a ship; if any of that yearning stuck, if you feel that joy of making code do what it wasn't supposed to, just for the fun of it — then this book did its job.
So thanks for reading, and go play!