PEP 849: More Expressive Type Expressions

I’m excited to present PEP 849, which adds some new background capabilities to the way type annotations are processed in order to let us use arbitrary expressions while maintaining runtime introspection.

Currently, you technically can use arbitrary Python expressions in an annotation, so e.g. even var: f(x) if Something else 1 + 17 wouldn’t raise a compiler error. But if we inspect the annotations dict at runtime, we would only ever see the result of calling f or 18, depending on what Something evaluates to. This means that the typing spec can’t really assign any meaning to an expression like that because it wants to make sure that runtime tools will always see some representation of the full annotation.

This PEP introduces a new format for annotations, Format.AST. When an annotate function is called with it, it will not return the result of evaluating the annotation in some way, but the annotation’s AST and namespace. E.g. for the above example, we’d get something like this:

AnnotationAST(
    ast=ast.IfExp(
        test=ast.Name("Something"),
        body=ast.Call(ast.Name("f"), [ast.Name("x")]),
        orelse=ast.BinOp(ast.Constant(1), ast.Add(), ast.Constant(17)),
    ),
    namespace={"f": ..., "Something": ..., "x", ...},
)

In the most common use case, runtime introspection tools will not work with this AST directly, but use it to construct the correct typing objects. To make that easier, we introduce helper functions similar to annotationlib.get_annotations, which perform those computations under the hood.

There are still some implementation details to iron out, most notably how exactly the annotate functions are stored and how they construct the AST objects. But even in the current implementation, import times and memory usage are reduced compared to the current behavior, but executing the annotate functions takes longer in some common cases. Note that this slow down is comparing just the time it takes to construct the relevant typing objects. In most use cases that is just a small step of whatever introspection machinery is using annotation data. So the actual performance impact will be positive for many users and only a marginal decrease in some situations.

The full PEP is published here, the reference implementation is being worked on in my CPython fork and previous discussion around this idea took place in this thread and this thread.

19 Likes

I rarely speak up on typing proposals, but I do really like the premise of this.

I’ve said before that I wished we’d made annotations stored as ASTs rather than values at any of the times where we redefined how they work (a.k.a. broke compatibility assumptions), as I think that leads to far more interesting uses of them than import-time evaluation.

All the best with this PEP!

6 Likes

I’ve been cautiously following the discourse around this PEP. I know I’m way out of my depth here but one of the comments from the previous thread picked my interest. It was something about the possibility of implementing an extension module capable of interacting with annotations for faster analysis (I think).

I’ve also seen the changes on the CPython side that touch a bit of the C-code. Would it be possible to also add a C-API that would provide introspecting annotations from an extension? Unless this is already possible today of course.

EDIT: for the record, any kind of work towards more expressive typing is always welcome on my end, so thank you for your work!

Thanks, this is an interesting PEP.

IMO, it needs to focus more on clarifying (and probably strenghtening) ast’s stability guarantees, both across CPython versions and across Python implementations.

Currently, the AST is not very stable – it might change with each Python release; Python 3.8, for example, unified the ast.Num,Str,Bytes etc. nodes into ast.Constant. CPython compiler maintainers might want to do something similar again.
Other implementations of Python might have slightly different ASTs – for example, they could do constant folding early, combining 1 | 2 into 3. MicroPython doesn’t have an ast module at all, which currently is not much of an inconvenience.

If the AST does stabilize and become an integral part of the language, it would be useful for much more than type annotations.
Take the numexpr package, which takes an expression string and evaluates it (often more efficiently than Python can):

>>> import numpy as np
>>> import numexpr as ne
>>> a = np.arange(10)
>>> b = np.arange(0, 20, 2)
>>> c = ne.evaluate('2*a + 3*b')    # (*)
>>> c
array([ 0,  8, 16, 24, 32, 40, 48, 56, 64, 72])

With this PEP, and some straighforward (AFAICS) glue code, the (*) line can turn into a proper expression, with syntax highlighting & IDE support:

>>> c: 2*a + 3*b
>>> c = evaluate_annotation('c', __annotate__)  # (more magic is possible here)

Or alternative compilers (like Numba) might want a decorator-like syntax that makes a function’s AST available.

The reason we don’t have such things now is that AST is currently not an integral part of Python-the-language. It’s more of a window into the internal workings of the compiler, meant for debuggers, code generators, and the like.

The main change the PEP asks for isn’t a Typing change; it’s closer to PEP 638 than to PEP 649. That doesn’t make it a bad change, but it’s certainly ambitious.
Best of luck in the PEP process!

5 Likes

How is Format.AST supposed to handle forward references?

In the example in the implementation you give a concrete flat namespace with resolved names. The PEP talks about adding annotationlib.create_annotate_function but if the AST format annotations aren’t actually capturing the relevant scopes I don’t see how this is going to work? The way dataclasses creates its __annotate__ function doesn’t generalise (and also fails in cases where it could succeed).

I am also concerned about the performance impact on constructing dataclasses, but would need to see actual numbers.

Big +1 from me! This will give much more flexibility for type annotation syntax in the future.

PEP 827 – Type Manipulation | peps.python.org more or less needs this; (the current draft tries to manage just based on carefully calling __annotate__, but that has severe drawbacks; I will update it now to depend on PEP 849).

PEP 835 – Shorthand syntax for Annotated type metadata | peps.python.org had also wanted to add a Format.TYPE operator, though there was some opposition to it and they eventually decided they could live without it. The ASTs from this should also be able to give them what they wanted from Format.TYPE. (Though maybe that’s a double-edged sword and some of the complaints about that would apply to this? I’m not sure.)

I might have some nitpicky technical followups, but I’m very happy to see this PEP!

3 Likes

Thanks for the detailed feedback!

Conceptually, the AST format is largely internal. Most users will call get_type_annotations and never see the ASTs themselves. It still is useful for the annotate functions to not construct the typing objects themselves because that allows us to still backport typing features through typing_extensions and it also lets us combine typing semantics with the other annotation formats easily, i.e. users can call get_type_annotations(something, format=Format.FORWARDREF) and get forwardrefs that still maintain the typing semantics. Libraries might also want to implement the AST evaluation logic via e.g. a C extension to save time. Though I don’t think that will be super common since that part of the evaluation isn’t super time consuming.

I’m honestly not super familiar with the exact policies around stability guarantees of the AST and related functionality. Maybe it would be better to explicitly document the AST format as being unstable and to recommend to only use the helper functions? Or if that is too weak of a warning to outside users, even define the returned objects as just being some opaque data that get_type_annotations uses to create the proper typing objects? I think it’s a good idea to keep the mechanism open for library optimization in principle, but if that collides with stability guarantees then that would have to go.


Regarding this PEP being closer to the introduction of macros rather than a typing change: While the underlying mechanics can be used to implement macro-like features, that’s very much not what this is trying to support. I would see it as similar to it being possible to make a function like in your example via disassembling the bytecode of a function.

1 Like

It’s already documented as such.

Right, I worded my question badly. What I meant was whether the AST itself being documented as unstable is enough to make it acceptable that the protocol that annotate functions follow would then also be similarly unstable? Or should the PEP take further steps like making the data in the returned objects be completely opaque to the outside (i.e. stored in private variables, etc) to make sure that the AST’s instability doesn’t leak out into the annotation handling code.

1 Like

I don’t think that making the returned objects completely opaque is necessary; I think documentation about AST stability is probably enough.

3 Likes