Writing/Metaprogramming: An Adventure
Chapter 7 · 24 min read
Domain-Specific Languages
I really like Python; at this point it should be pretty obvious. For that reason, whenever the circumstances call for some sort of programmable format, I don't rush to invent my own mini-languages, but tend to start with solutions that, to use our newfound terminology, dynamically load a Python plugin – kind of like what we did with shell autocompletion. Sometimes it's enough, sometimes it's not; so in this chapter, I'd like to explore that tension.
Let's say we're building a search system, but don't want to commit to a particular database (today it's SQL, but tomorrow it might be MongoDB). So, we create a layer of abstraction where queries are represented as data, in which case YAML is my go-to format:
query:
collection: users
It's basically a more readable version of JSON, easily convertible to and from native Python types:
>>> import yaml
>>> text = open("query.yaml").read()
>>> data = yaml.safe_load(text)
>>> data
{'query': {'collection': 'users'}}
Suppose the results are returned as dictionaries, and we want to post-process
them somehow; like, change the username field to UserName, remove the
password, and combine first_name and last_name into FullName. We can
invent some sort of dialect to describe these modifications in our query:
collection: users
postprocess:
- action: rename
target: username
to: UserName
- action: remove
target: password
- action: combine
targets:
- first_name
- last_name
to: FullName
And while it looks alright, it's somewhat short-sighted: tomorrow we might need
more functionality, like extracting the year from a birth_date stored as
yyyy-mm-dd, or setting ResidesInUK to true if any address in addresses is
in the UK. Not only will this require further development, but over time we'll
end up with increasingly complicated and cumbersome syntax:
# … same as before
- action: split
target: birth_date
delimiter: "-"
index: 0
to: Year
- action: any
target: addresses.country
equals: UK
to: ResidesInUK
What's more, users of our systems will have to recall this every time they write a query – effectively learning a weird, declarative programming language we made up as we went along, which is exactly what we wanted to avoid.
Python Plugins
So what do we do instead? Depends on who our users are. If we're building an internal tool intended for semi-technical professionals — the kind of people who can use SQL or Excel, but aren't programmers per se — I'd recommend going with Python plugins. From my experience, even without extensive experience with the language, such people can figure out their way around it, especially given a few templates to copy-paste and edit; even if they don't, ChatGPT does, and they can always ask it for help. The result:
collection: users
postprocess: |
def postprocess(rows):
processed = []
for row in rows:
processed.append({
"UserName": row["username"],
"FullName": row["first_name"] + " " + row["last_name"],
"Year": int(row["birth_date"].split("-")[0]),
"ResidesInUK": any(address["country"] == "UK"
for address in row["addresses"]),
})
return processed
That is to say, there's no need to reinvent the wheel – Python is simple enough, powerful enough and well-known enough to fill in such gaps, especially if we're integrating it into another Python system, which can load and execute such plugins with ease:
def search(query):
rows = fetch(query["collection"])
postprocess = query.get("postprocess")
if postprocess:
namespace = {}
exec(postprocess, namespace)
rows = namespace["postprocess"](rows)
return rows
A different scenario is when our users are not technical enough to work with code. Although, before you make this call, I sincerely encourage you to try: I've found that people might surprise you, especially when they want to get something done. Still, our next step before developing our own mini-language would be to see if we can encode our requirements in regular strings, which we can then parse with:
Regular Expressions
Let's revise our query to be less scary:
collection: users
postprocess:
- rename username to UserName
- remove password
- combine first_name + last_name to FullName
- get year from birth_date to Year
- if any addresses.country is UK set ResidesInUK
This we can parse with regular expressions like:
rename_regex = r"rename (.*?) to (.*?)"
def rename(row, from_, to):
row[to] = row.pop(from_)
In fact, since we'll be doing a lot of this, we might want to build a small
framework around it — purely for ourselves, yeah? — so that adding new
functionality will take a minute. Consider a decorator that binds a regular
expression to a post-processing function, so that extending our pool of actions
is just a matter of implementing that function and sticking @action("<regex>")
on top. We can even simplify the pattern syntax and have the common (.*?)
abbreviated to ?:
import re
actions = []
def action(pattern):
regex = re.compile(pattern.replace("?", "(.*?)"))
def bind(postprocess):
def run_action(rows, instructions):
for row in rows:
for instruction in instructions:
if match := regex.fullmatch(instruction):
postprocess(row, *match.groups())
actions.append(run_action)
return postprocess
return bind
This way, all of our logics are short and sweet:
@action("rename ? to ?")
def rename(row, from_, to):
row[to] = row.pop(from_)
@action("remove ?")
def remove(row, field):
row.pop(field)
@action("combine ? to ?")
def combine(row, fields, to):
row[to] = ' '.join(row[f.strip()] for f in fields.split('+'))
@action("get year from ? to ?")
def get_year(row, field, to):
row[to] = int(row[field].split("-")[0])
@action("if any ? is ? set ?")
def if_any(row, field, value, flag):
row[flag] = any(v == value for v in resolve(row, field))
And note how there's no need to be overzealous in using regular expressions,
either: a simple (.*?) can be enough to extract what we need, and when it's
something like field+field+..., we don't have to come up with a clever way to
capture each element, but can simply fields.split('+') the lot of them later.
The one utility that we do need is a way to resolve "JSON paths" such as
addresses.country – but that's something that might come in handy elsewhere
as well, so I didn't want to bake it into if_any:
def resolve(row, json_path):
if not json_path:
return row
if "." in json_path:
field, json_path = json_path.split(".", 1)
else:
field, json_path = json_path, None
value = row[field]
if isinstance(value, list):
return [resolve(item, json_path) for item in value]
return resolve(value, json_path)
That's it; now that all our actions are primed and neatly tucked in a single list, all that's needed is to coordinate them with the actual instructions:
def search(query):
rows = fetch(query["collection"])
instructions = query.get("postprocess")
if instructions:
for action in actions:
action(rows, instructions)
return rows
As you can see, aspect-oriented thinking is not just for big projects; it can enhance our software design even in relatively mundane tasks.
Limited Evaluation
Sometimes, however, regular expressions are not enough. Imagine we'd want not only to post-process users, but also to filter them, and provide some sort of condition to that extent. Doing so in YAML is possible, but it ain't pretty — we have to split it into separate clauses, specifying the operation and operands for each:
collection: users
filter:
# (user_age > 18) and (username != "admin")
operation: and
operands:
- operation: greater_than
operand1: user_age
operand2: 18
- operation: not_equal
operand1: username
operand2: admin
This is easy to consume, but hard to produce, so it's not very considerate to
our users; let's think if we can do better. (user_age > 18) and (username != "admin")
would be ideal — it is, after all, pretty standard mathematical notation, if
only we could parse it. Unfortunately, regular expressions are not strong enough
to handle this: we can tailor one for a specific scenario, of course, but it's a
theoretical impossibility to support the general case, since regular expressions
can't deal with recursion, such as arbitrary nesting might entail:
(user_age > 18) and ((username != "admin") or (username != "root"))
You know what can, though? — I know you do; it's the same answer as always. If we change our query format to:
collection: users
filter: (user_age > 18) and (username != 'admin')
We can simply evaluate that expression for each result, using its fields as the
namespace, effectively having them play the role of variables in filter's
"code":
def search(query):
rows = fetch(query["collection"])
# … postprocessing …
filter_ = query.get("filter")
if filter_:
rows = [row for row in rows if eval(filter_, {}, row)]
return rows
Note how we call eval with row as its second argument: this makes it treat
the first one as globals and the second as locals. The reason we do this is so
that Python, which naturally injects the builtins into the global scope before
execution, doesn't pollute our actual rows.
Apropos builtins — in this case, we can make our evaluation reasonably secure by
removing them, since they aren't really necessary, and I don't know of a hack
that could hijack eval (that exception trick we saw earlier only works in
exec, since it requires a with-statement):
eval(filter_, {"__builtins__": {}}, row)
But honestly, we can do better still. Filtering, after all, is best done in the
database — rather than fetching everything and sieving through it afterwards, we
should fetch just the relevant stuff to begin with. At first sight, this might
seem impossible: eval only works on Python code and as part of a Python
program, whereas our database, whether it's SQL, MongoDB or something else,
probably won't run our Python as-is. What we need is a way of translating our
code into another dialect – and to achieve that, we'll use a nifty trick:
rather than evaluating the decision given a row, we'll construct an object that
describes what decision to make; a delayed evaluation, if you will, which can be
adjusted per database, generating an equivalent expression in whatever format it
does understand.
If we're working with SQL, this is particularly easy: SQLAlchemy's table
objects have a mapping of columns that already overload most operations, so
we can compose them into expressions that can be converted to SQL out of the
box:
>>> from sqlalchemy import create_engine, MetaData
>>> engine = create_engine("sqlite:///db.sqlite3")
>>> metadata = MetaData()
>>> metadata.reflect(bind=engine) # infer table structure from the database
>>> users = metadata.tables["users"]
>>> expression = (users.c.user_age > 18) & (users.c.username != "admin")
>>> expression # this is not a boolean, but an object:
<sqlalchemy.sql.elements.BooleanClauseList object at 0x…>
>>> # which can be converted to equivalent SQL:
>>> print(expression.compile(compile_kwargs={"literal_binds": True}))
users.user_age > 18 AND users.username != 'admin'
Again: since we want the database to do the filtering, we need to provide it
with the appropriate query; and since this query hasn't run yet, we don't have
its results on hand, and can't use the rows' fields to evaluate our decision.
Instead, we'll use the table's columns as the variables in our namespace, so
eval will construct an expression object like the one above. The only caveat
is that SQLAlchemy uses & and | instead of and and or (since those are
keywords, and as such, can't be overloaded), so we should account for that:
>>> code = "(user_age > 18) and (username != 'admin')"
>>> code = code.replace(" and ", " & ").replace(" or ", " | ")
>>> expression = eval(code, {"__builtins__": {}}, users.columns)
>>> print(expression.compile(compile_kwargs={"literal_binds": True}))
users.user_age > 18 AND users.username != 'admin'
This expression can be added as a WHERE clause in a SELECT statement, and
the database will do the rest. Of course, should we decide to switch to MongoDB,
we'll have to redo this — but if we want the filtering to happen in the backend,
there's no way around such backend-specific adaptation.
So, how might this look in MongoDB? Its library doesn't provide us with magical columns like SQLAlchemy does – but this doesn't mean we can't create them ourselves. It's simply a matter of implementing the necessary operator overloads (although, for the sake of brevity, I'll only do the ones used by our example):
>>> class Expression:
... def __gt__(self, other):
... return BinaryExpression("gt", self, other)
... def __ne__(self, other):
... return BinaryExpression("ne", self, other)
... def __and__(self, other):
... return BinaryExpression("and", self, other)
... def __or__(self, other):
... return BinaryExpression("or", self, other)
>>> class BinaryExpression(Expression):
... def __init__(self, op, arg1, arg2):
... self.op, self.arg1, self.arg2 = op, arg1, arg2
>>> class Name(Expression):
... def __init__(self, name):
... self.name = name
This works the same way that columns do: we start with bare Name objects per
field as our variables, and those get composed into binary expressions:
>>> fields = {"user_age": Name("user_age"), "username": Name("username")}
>>> expression = eval(code, {"__builtins__": {}}, fields)
>>> expression
<BinaryExpression object at 0x…>
Now we just need a way to convert this to MongoDB's query dialect, which would
be {$and: [{user_age: {$gt: 18}, username: {$ne: "admin"}}]}. Since our
expression objects are already properly nested, doing so recursively is pretty
straightforward:
>>> class BinaryExpression(Expression):
... # … same as before
... def to_mongo(self):
... arg1, arg2 = self.arg1, self.arg2
... if isinstance(arg1, Expression):
... arg1 = arg1.to_mongo()
... if isinstance(arg2, Expression):
... arg2 = arg2.to_mongo()
... if self.op in ["and", "or"]:
... return {f"${self.op}": [arg1, arg2]}
... return {self.arg1: {f"${self.op}": arg2}}
>>> class Name(Expression):
... # … same as before
... def to_mongo(self):
... return self.name
And we get:
>>> expression.to_mongo()
{'$and': [{'user_age': {'$gt': 18}}, {'username': {'$ne': 'admin'}}]}
That is, given a string with some condition, we found a way to evaluate it into intermediary representations that can be translated into SQL- and MongoDB-compatible queries both; adding new backends is equally feasible.
Context-Free Grammars
But what if Python's syntax itself is not enough – or rather, turns out to be
too cumbersome to support all the conditions we want to allow? Suppose we want
to filter all the users whose username matches the regular expression ^[aA]
(that is, starts with A), and we'd like the ~= operator to mean "matches"?
username ~= "^[aA]"
Needless to say, this is not valid Python, so eval is not going to like it;
but then, our users are not going to like the valid Python equivalent, either:
re.match(r"^[aA]", username)
We can, of course, content ourselves with what's possible and get creative —
like, use @ to mean "matches", relying on the seldom used __matmul__
operator:
>>> class Expression(Expression):
... # … same as before
... def __matmul__(self, pattern):
... return BinaryExpression("regex", self, pattern)
>>> expression = eval("username @ '^[aA]'", {"__builtins__": {}}, fields)
>>> expression.to_mongo()
{'username': {'$regex': '^[aA]'}}
But naturally, there's only so many operators we can overload.
At this point, it might be worth considering the next construct in the parsing hierarchy: context-free grammars, less known but more powerful than regular expressions. The PLY library I mentioned before is one way to define such grammars; another popular option is PyParsing, which was the de-facto standard for many years. Both, however, are pretty old, and provide a somewhat crude interface for the non-trivial task of articulating complex, recursive rules in Python. I prefer Lark: a newcomer that gave up on this altogether, realizing that the best way to define custom grammars is by using its own grammar-definition grammar instead; talk about meta.
The way it works is simple — at least, once we master Lark's meta-grammar. We're going to define a set of rules, outlining what potential forms our syntax might take. Starting from some root rule, we drill down into ever more specific configurations, until we arrive at mere tokens, which are simple enough to describe with plain old regular expressions. The core feature of CFGs that we were lacking before is that each rule can be defined in terms of other rules – even itself! – thus allowing nested, recursive structures.
A number with a balanced set of parentheses, for example, can be described with
x = /[0-9]+/ | "(" x ")" – essentially saying "I'm either a list of digits, or
myself between ( and )", with "myself" implying both a list of digits or,
again, itself between ( and ), ad infinitum, matching 1, (1), ((1))
and so on. In our case, the grammar might look like this:
>>> import lark
>>> grammar = lark.Lark("""
... %import common.WS
... %import common.CNAME -> NAME
... %import common.NUMBER
... %ignore WS
... ?start: expression
... ?expression: or_ | and_ | comparison | "(" expression ")"
... or_: expression ("or" expression)+
... and_: expression ("and" expression)+
... ?comparison: gt | ne | matches
... gt: NAME ">" NUMBER
... ne: NAME "!=" string
... matches: NAME "~=" string
... ?string: "'" /[^']+/ "'"
... """)
Briefly, the %import statements bring forth standard definitions like
whitespace (WS), identifiers (CNAME) and numbers. Then, %ignore WS tells
the grammar to discard whitespace, and the rest of the lines are the rules,
which have to include start as their root. We set it to be an expression,
which, in turn, can be or_, and_, comparison, or a parenthesized
expression (that is, itself) – CFG's recursive definition in action. or_ and
and_ are simply multiple expressions with or/and between them (note the
recursion again); and comparison is a name followed by > NUMBER,
!= string, or ~= string, where string is a sequence of
anything-except-quote between two quotes (i.e. a simple token, defined using a
regular expression). As for the occasional ? prefix, I'll address it in a bit.
All things considered, for defining something so nuanced and self-referential,
this isn't a lot of work – so reading Lark's documentation is well worth it.
At this point, we can parse strings into trees, just like we did with ast –
it's just that we get to do it according to our own custom rules rather than
Python's:
>>> tree1 = grammar.parse("(user_age > 18) and (username != 'admin')")
>>> print(tree1.pretty())
and_
gt
user_age
18
ne
username
"admin"
>>> tree2 = grammar.parse("username ~= '^[aA]'")
>>> print(tree2.pretty())
matches
username
^[aA]
And just like with ast, we can write transformers that use the visitor pattern
to call specific methods on specific nodes, effectively traversing the tree to
convert it into a more actionable object, like our MongoDB query:
>>> class MongoTransformer(lark.Transformer):
... def or_(self, nodes):
... return {"$or": list(nodes)}
... def and_(self, nodes):
... return {"$and": list(nodes)}
... def gt(self, nodes):
... name, number = nodes
... return {name.value: {"$gt": int(number.value)}}
... def ne(self, nodes):
... name, string = nodes
... return {name.value: {"$ne": string.value}}
... def matches(self, nodes):
... name, regex = nodes
... return {name.value: {"$regex": regex.value}}
>>> mongo = MongoTransformer()
>>> mongo.transform(tree1)
{'$and': [{'user_age': {'$gt': 18}}, {'username': {'$ne': '"admin"'}}]}
>>> mongo.transform(tree2)
{'username': {'$regex': '^[aA]'}}
And for SQL:
>>> import functools, operator
>>> class SQLTransformer(lark.Transformer):
... def __init__(self, table):
... self.table = table
... def or_(self, nodes):
... return functools.reduce(operator.or_, nodes)
... def and_(self, nodes):
... return functools.reduce(operator.and_, nodes)
... def gt(self, nodes):
... name, number = nodes
... return self.table.columns[name.value] > int(number.value)
... def ne(self, nodes):
... name, string = nodes
... return self.table.columns[name.value] != string.value
... def matches(self, nodes):
... name, regex = nodes
... return self.table.columns[name.value].regexp_match(regex.value)
>>> sql = SQLTransformer(users)
>>> sql.transform(tree1).compile(compile_kwargs={"literal_binds": True})
users.user_age > 18 AND users.username != '"admin"'
>>> sql.transform(tree2).compile(compile_kwargs={"literal_binds": True})
users.username REGEXP '^[aA]'
That is, we use the recursive traversal to construct the SQLAlchemy expression
object from before, utilizing regexp_match for the ~= operator and
functools.reduce to apply | and & operators on a list of expressions.
As for the ? prefix I skipped before – it's just a way to inline the results
of rules with a single value. If we'd have string: instead of ?string:, a
method like ne would get its string token wrapped in a (sub-)tree with that
"string" rule inside it, which would complicate parsing, or necessitate a
dedicated string visitor like string(self, nodes): return nodes[0]. The same
goes for start, expression and comparison – rules whose sole purpose is to
group other rules can be seamlessly inlined by Lark to construct simpler trees,
provided we add ? before their name.
Extending Python
Let's recap: when we find ourselves inventing our own special syntax for users
to code in, we're effectively creating what's known as a domain-specific language.
At this point, it's best to pause and ask ourselves whether Python itself can
be used, and only proceed if we're sure it can't. Then, the first thing to check
is whether regular expressions suffice, and move on only if we're sure they
don't. The final step, writing a context-free grammar, will definitely be
enough – in fact, Python itself is such a grammar, albeit with a lot of very
sophisticated machinery attached. For small cases like our condition language,
implementing the necessary semantics is not a big deal; but if we find ourselves
handling complex and stateful considerations like functions and classes, there
is a more radical alternative we can consider: extending Python itself – as in,
adding the ~= operator to its syntax.
This might sound mad, but Python is an open-source project, and there's nothing stopping us from making changes, compiling them, and getting an enhanced interpreter more to our liking. Once again, not necessarily something I'd use in production, but definitely an empowering exercise – so, let's go get the code:
$ git clone https://github.com/python/cpython.git
…
$ cd cpython/
$ git checkout 3.12
$ ./configure # prepare for compilation
…
Like I said, Python is a context-free grammar, which is defined across several
files. First, Grammar/Tokens, a list of all the available tokens, is where we
append our new symbol:
# …cropped…
AT '@'
ATEQUAL '@='
RARROW '->'
ELLIPSIS '...'
COLONEQUAL ':='
TILDEEQUAL '~='
# …cropped…
And then Grammar/python.gram, which outlines the grammar itself, is where we
specify how this symbol is used. We don't have to understand it in depth; one
way to navigate such complexity is to look for something similar to what we
need, copy it, and make slight modifications. Since we're adding a comparison
operator, let's imitate LTE (<=):
# …cropped…
lte_bitwise_or[CmpopExprPair*]: '<=' a=bitwise_or
{ _PyPegen_cmpop_expr_pair(p, LtE, a) }
rgx_bitwise_or[CmpopExprPair*]: '~=' a=bitwise_or
{ _PyPegen_cmpop_expr_pair(p, RGX, a) }
lt_bitwise_or[CmpopExprPair*]: '<' a=bitwise_or
{ _PyPegen_cmpop_expr_pair(p, Lt, a) }
# …cropped…
The syntax is somewhat similar to Lark: rule_name[type]: rule { callback }.
Nested rules can be bound to parameters (a=bitwise_or) so as to be passed to
the callback, and the nesting structure has to do with precedence: apparently,
comparison operators are parsed right before bitwise-or, which is why they
reference it inbetween their own tokens. The callback triggered is
_PyPegen_cmpop_expr_pair(p, LtE, a), so we invent our own comparator, RGX,
and all that's left is to include our new rule in the larger picture:
# …cropped…
compare_op_bitwise_or_pair[CmpopExprPair*]:
| eq_bitwise_or
| noteq_bitwise_or
| lte_bitwise_or
| rgx_bitwise_or
| lt_bitwise_or
# …cropped…
Having augmented the grammar, we also need to adjust the code that handles it — and this is tricky, because we don't know in advance which parts of the interpreter deal with what rules (not to mention, this changes with each Python version). So, what I do is:
$ grep -rlIE 'lte_bitwise_or|LtE' . | uniq
./Lib/ast.py
./Lib/test/test_ast/test_ast.py # test
./Include/internal/pycore_ast.h # auto-generated
./Include/internal/pycore_ast_state.h # auto-generated
./Grammar/python.gram # already edited
./Parser/Python.asdl
./Parser/parser.c # auto-generated
./Doc/data/python3.12.abi # documentation
./Doc/library/ast.rst # documentation
./Python/compile.c
./Python/ast_unparse.c
./Python/ast_opt.c
./Python/Python-ast.c
That is, search (grep) recursively (-r) for filenames only (-l), except
binary files (-I), for anything that contains the (extended; -E) regular
expression lte_bitwise_or|LtE, since those were the only special identifiers
we duplicated and modified in the grammar. If we similarly duplicate and modify
the code that handles them, there's no reason Python won't let us include this
new syntax, although for now it will have the same functionality as LTE. We
can ignore tests, documentation and auto-generated code (always check the top of
the file for an indication), which leaves us with 6 files to consider:
# somewhere in ./Lib/ast.py:
cmpops = {
"Eq": "==",
"NotEq": "!=",
"Lt": "<",
"LtE": "<=",
"RGX": "~=",
"Gt": ">",
# somewhere in ./Parser/Python.asdl:
cmpop = Eq | NotEq | Lt | LtE | RGX | Gt | GtE | Is | IsNot …
// somewhere in ./Python/ast_unparse.c:
case LtE:
op = " <= ";
break;
case RGX:
op = " ~= ";
break;
case Gt:
op = " > ";
break;
// somewhere in ./Python/ast_opt.c:
case LtE:
case RGX:
case Gt:
The last two are a bit more verbose, so I won't paste them here, but the idea is
the same: we find any reference to LtE or lte_bitwise_or, clone it, and swap
in RGX or rgx_bitwise_or (we can also "fix" any <= to ~=, but these are
just debug prints). In compile.c, we'll keep:
case RGX:
cmp = Py_LE;
break;
So as to retain the less-than functionality; if we'd venture to add actual
Py_RGX logic, it would take us ever deeper into the interpreter's code,
culminating in a new __regex__ magic method, which is a lot more work. And I
know, I know, it's all eclectic and haphazard — in my defense, this used to be
easier in earlier Python versions; and besides, we're done. At this point, we
can compile everything:
$ make regen-token # regenerate the tokens
…
$ make regen-pegen # regenerate the parser
…
$ make regen-ast # regenerate the ast module
…
$ make -j # compile
…
And if we run the resulting ./python, we can do:
>>> 1 ~= 2 # the same as <=, yeah?
True
Transpiling Python
Of course, we're not really done yet; we've added new syntax, but no new
functionality. Like I said, doing so in the interpreter is possible, but it's
not easy – so instead, let me show you a different approach: transpilation, the
conversion of one code into another. In our case, we want username ~= '^[aA]'
to become re.match(username, '^[aA]') – as in, convert our special syntax into
standard Python, which can be executed without any adjustments to achieve the
same goal that our syntax stands for. We already know how such transformations
can be achieved; all we need is for this:
>>> import ast
>>> print(ast.dump(ast.parse("username ~= '^[aA]'"), indent=2))
Module(
body=[
Expr(
value=Compare(
left=Name(id='username', ctx=Load()),
ops=[RGX()],
comparators=[Constant(value='^[aA]')]))],
type_ignores=[])
To become this:
>>> code = "__import__('re').match('^[aA]', username)"
>>> print(ast.dump(ast.parse(code), indent=2))
Module(
body=[
Expr(
value=Call(
func=Attribute(
value=Call(
func=Name(id='__import__', ctx=Load()),
args=[Constant(value='re')],
keywords=[]),
attr='match',
ctx=Load()),
args=[
Constant(value='^[aA]'),
Name(id='username', ctx=Load())],
keywords=[]))],
type_ignores=[])
And notice how I prefer __import__("re").match to re.match, because the
latter assumes the availability of a global re name, which might not have been
imported, while the former relies only on builtins to work. Well:
>>> class Transpiler(ast.NodeTransformer):
... def visit_Compare(self, node):
... if not isinstance(node.ops[0], ast.RGX):
... return node
... kw = {"lineno": node.lineno, "col_offset": node.col_offset}
... return ast.Call(
... func=ast.Attribute(
... value=ast.Call(
... func=ast.Name(id="__import__", ctx=ast.Load(), **kw),
... args=[ast.Constant(value="re", **kw)],
... **kw,
... ),
... attr="match",
... ctx=ast.Load(),
... **kw,
... ),
... args=[node.comparators[0], node.left],
... **kw,
... )
Once again, all this does — for our newly added RGX comparisons only — is
replace x ~= y with __import__('re').match(y, x), effectively implementing
the functionality of our new syntax using existing language constructs. This
way, we only ever need to modify the interpreter to accept new symbols; their
purpose, if you will, is merely to signal our intent, so we can intercept their
nodes later and infuse them with actual meaning:
>>> def eval_condition(text, **namespace):
... tree = Transpiler().visit(ast.parse(text))
... expr = ast.Expression(tree.body[0].value)
... code = compile(expr, "<string>", "eval")
... return bool(eval(code, namespace))
>>> eval_condition("username ~= '^[aA]'", username="alice")
True
>>> eval_condition("username ~= '^[aA]'", username="bob")
False
And there you have it: your very own Python++.