Overload AST objects in `ast.parse`

1. Motivation

This is to provide convenient Pythonic-DSL parsing.

I believe (hopefully) upcoming t-strings in conjunction with such extension could be efficiently used for Pythonic-DSLs with interpolation.

See my thought process how I arrived to this point at DSL Operator – A different approach to DSLs - #73 by dg-pb

2. Proposal

The idea is simple:

print(ast.dump(ast.parse('a + 1'), indent=4))

Expression(
    body=BinOp(
        left=Name(id='a', ctx=Load()),
        op=Add(),
        right=Constant(value=1)))


class Custom:
    def Expression(body, *args, **kwds):
        return body

    def BinOp(left, op, right):
        sop = {ast.Add: '+'}
        return [sop[type(op)], left, right]

    def Name(id, ctx):
        return id

    def Constant(value):
        return value


print(ast.parse('a + 1', overloads=Custom.__dict__))
# ['+', 'a', 1]

3. Performance

This does not offer anything new from perspective of functionality.

Same can be done by:

  1. ast.parse → AST
  2. Do everything with AST

The main point of this is to be able to avoid unnecessary steps allowing optimal performance.

E.g.:

def two_step(s):
    bin_op = ast.parse(s, mode='eval').body
    return bin_op.left.value + bin_op.right.value

%timeit two_step('1 + 1')                # 5.31 μs

# By how much could it be faster?
%timeit ast.parse('1 + 1', mode='eval')  # 4.86 μs
from ast import Expression, BinOp, Name, Add, Constant, Load

%%timeit
Expression(
    body=BinOp(
        left=Name(id='a', ctx=Load()),
        op=Add(),
        right=Constant(value=1)))
# 2.41 μs

4.86 - 2.41 = 2.45 µs
So constricting AST takes more than half of the time. Furthermore, a lot of things can be done straight away, which would otherwise need to be done using ast.Visitor, which is much more expensive.

From optimization point of view this is similar to: JSON parser - Tutorial — Lark documentation

4. Implementation

Either:

  1. ast.parse(..., overloads=None)
  2. Separate function
    a) ast.parse_dsl
    b) builtins.eval_dsl

I have no strong preference really. But would obviously take time to find the best place for it given this is something that is deemed to be useful.

5. To Sum Up

This would significantly speed up ast.parse functionality for variety of applications.

Do you have any evidence that performance is an issue here? How many projects are you aware of that manually construct and walk the AST in the way you can currently do? Do they tend to have performance issues?

Unless there’s already a performance issue that needs fixing, I suggest doing nothing for now.

1 Like

The structure of AST is defined at compiler time by Python.asdl.

Ok so it is a bit less straight forward than I initially thought. Will figure out what would be the simplest strategy to do this if it comes to it.

I agree. This proposal is kind of forward looking and would be useful for new applications. Currently, I don’t think I personally would make use of this, but I have some applications in mind that I would be very happy to replace with t-strings coupled with this suggestion.

This would definitely need some more research. However, I think the research would be targeted not at applications that already use ast.parse (and end up not using AST graph), but instead the ones that do custom Pythonic-DSL parsing, that could be replaced elegantly with this suggestion (coupled with t-strings or not) that would lead to improvement in many aspects.


All in all, just wanted to get this out to see initial sentiment and don’t think will do anything in this direction before t-strings are out.

I think that for t-strings specifically it would be far more useful to use a cache instead to completely skip the parsing - then the cost is only payed once and doesn’t matter too much.

Could you give an example how would that look like?

I am actually not completely sure what the most up-to-date state of the proposal is. I am going to base this on the current PEP text on python.org, but I don’t think anything should be too different.

def swap_add_mul(template: Template):
    key = template.strings
    # Get the values we actually want to work with - this currently ignored all other attributes of an interpolation object
    values = tuple(i.value for i in template.interpolations)
    # Check if already saw this string (or at least an equivalent one)
    if key in swap_add_mul.cache:
        # If yes, reuse the saved expression
        return swap_add_mul.cache[key](*values)

    # Create a unique variable for each interpolation place
    variable_names = tuple(f'v{n}' for n in range(len(template.interpolations)))
    # Create an expression with those variables
    temp_expression = ''.join(v for pair in zip_longest(template.strings, variable_names, fillvalue='') for v in pair)
    # Put that expression into a lambda
    temp_expression = f"lambda {','.join(variable_names)}: {temp_expression}"
    # Parse the combined string
    tree = ast.parse(temp_expression, mode='eval')
    # Modify the tree however you want (potentially skipping the outermost lambda)
    _SwapAddMulVisitor().visit(tree)
    # Get the lambda we defined
    function = eval(compile(tree, '<string>', 'eval'))
    # Save the lambda
    swap_add_mul.cache[key] = function
    # Run it
    return swap_add_mul.cache[key](*values)


swap_add_mul.cache = {}

class _SwapAddMulVisitor(ast.NodeVisitor):
    def visit_BinOp(self, node: ast.BinOp) -> None:
        if isinstance(node.op, ast.Add):
            node.op = ast.Mult()
        elif isinstance(node.op, ast.Mult):
            node.op = ast.Add()

        self.generic_visit(node)


t = Template(('3 + ', ' * 4'), (Interpolation(6),))  # t'3 + {6} * 4'
print(swap_add_mul(t))

(I had difficulty coming up with an easy to implement but non-trivial operation to perform here. Swapping + and * while keeping the original priorities is easy to implement at least).

Note that in this case I construct the callable saved in the cache via eval - I am sure there will be many different approaches, but almost all of them should boil down to the simple pattern of creating a callable that then actually works with the values and that accepts them as an ordered list.

Also note that I am currently using just template.strings as the key - depending on your usecase you might want to use more than that.

1 Like

That does seem to be a good strategy at least for some cases.

Specifically, I think this would work very well for maybe (None-aware stuff).

Not sure how many hits this could have for more complex applications. E.g. evaluation graph building. But if this ends up being overall faster for many applications, then this proposal is unlikely to be worth it.

I’m maintaining Griffe, which uses static analysis to extract API data from Python sources. It gets the AST with ast.parse, visits it to extract data, and transforms parts of it into custom nodes for further use (typically type annotations, to be able to resolve each name to a fully qualified name and allow cross-referencing when rendering docs).

It would be very interesting to immediately parse the AST into a tree of custom/enhanced nodes. I’m however not sure it would be a performance gain, as the performance of ast is already great and far from being the bottleneck in my set of tools. But it would be interesting nonetheless. I was initially patching objects in the ast module to enhance nodes. In the end I removed these hacks and re-declared custom nodes myself, see griffe/src/_griffe/expressions.py at 971773927cba79b6952db8bf32c998600805d16f · mkdocstrings/griffe · GitHub. Could be nice to just parse the AST once and get back an enhanced tree :slight_smile: