Skip to content

Commit 107f016

Browse files
committed
PEP 849: Update to describe new reference implementation
1 parent 0718ecb commit 107f016

1 file changed

Lines changed: 186 additions & 108 deletions

File tree

‎peps/pep-0849.rst‎

Lines changed: 186 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,61 @@ usual annotation dict ``{"a": int, "b": list[str]}``. But when called as
117117
.. code-block:: python
118118
119119
{
120-
"a": AnnotationAST(ast.Name("int"), {"int": int}),
121-
"b": AnnotationAST(ast.Subscript(ast.Name("list"), ast.Name("str")), {"list": list, "str": str}),
120+
"a": AnnotationAST(
121+
ast.Expression(ast.Name("int")),
122+
AnnotationNamespace(...),
123+
),
124+
"b": AnnotationAST(
125+
ast.Expression(ast.Subscript(ast.Name("list"), ast.Name("str"))),
126+
AnnotationNamespace(...),
127+
),
122128
}
123129
124-
However, most users will never see these objects directly. Rather, we propose
125-
to add a new function ``get_type_annotations`` to the :py:mod:`typing`
126-
module, which will internally perform the above call and then return the
127-
familiar ``{"a": int, "b": list[str]}`` annotation dictionary.
130+
Each value in this dictionary contains the AST of the annotation expression
131+
as it was found in the source code and a namespace that can be used to evaluate
132+
the AST into the correct typing objects. Note that this AST is the direct result
133+
of parsing the annotation without applying any optimizations such as constant
134+
folding. The namespace references all relevant enclosing scopes and thus will
135+
always contain the correct bindings for any names used in the annotation.
136+
137+
This format and the AST objects are not intended to be used directly by
138+
consumers of annotations. Rather, they are intended as a primarily internal
139+
representation that allows a new set of utility functions to evaluate them into
140+
the usual typing objects. For example, we propose ``get_type_annotations`` to be
141+
added to the :py:mod:`typing` module, which will internally perform the above call
142+
and then return the familiar ``{"a": int, "b": list[str]}`` annotation dictionary.
143+
It will internally use a simple helper that implements the semantics the typing
144+
spec defines, similar to this snippet:
145+
146+
.. code-block:: python
147+
148+
def eval_type_AST(node, namespace):
149+
match node:
150+
case ast.Name:
151+
return namespace[node.id]
152+
case ast.Attribute:
153+
value = eval_type_AST(node.value, namespace)
154+
return getattr(value, node.attr)
155+
case ast.BinOp(left, ast.BitOr(), right):
156+
return Union[eval_type_AST(left, namespace), eval_type_AST(right, namespace)]
157+
...
128158
129159
The power of this approach is that it lets us implement new type expressions
130-
such as the ones mentioned above by simply extending the evaluation logic in
131-
``get_type_annotations``. This logic can then be completely decoupled from the
132-
usual Python expression semantics and can instead create typing objects that
133-
allow complete runtime introspection.
160+
such as the ones mentioned above by simply extending this evaluation logic.
161+
And since it is not implemented in the compiled bytecode itself, it can even be
162+
backported to older Python versions via the ``typing_extensions`` module.
163+
For example, if we wanted to implement the ``1 | 2 | 3`` literal type expression,
164+
it would be enough to add a new case like this:
165+
166+
.. code-block:: python
167+
168+
def eval_type_AST(node, namespace):
169+
match node:
170+
...
171+
172+
case ast.Constant(value) if isinstance(value, int):
173+
return Literal[value]
174+
...
134175
135176
We also propose to add some additional utility functionality related to
136177
annotate functions and these AST objects. In particular, a new
@@ -141,102 +182,37 @@ users will need this in order to create annotate functions that support
141182
this somewhat more complex format.
142183

143184

144-
Implementation
145-
==============
146-
147-
While the exact mechanism annotate functions use is an implementation detail,
148-
it may be useful to look at what that might look like. Consider, for example,
149-
the class from above:
150-
151-
.. code-block:: python
152-
153-
class MyClass:
154-
a: int
155-
b: list[str]
156-
157-
Under this proposal its annotate function may behave similarly to this Python code:
158-
159-
.. code-block:: python
160-
161-
def __annotate__(format):
162-
namespace = {
163-
"int": int,
164-
"list": list,
165-
"str": str,
166-
}
167-
asts = {
168-
"a": "\x1a\x03int",
169-
"b": "\x18\x1a\x04list\x1a\x03str",
170-
}
171-
return _make_annotate_asts(namespace, asts)
172-
173-
It functions in essentially three steps. It populates the namespace dictionary
174-
by performing ordinary variable name lookups. This is important to ensure that
175-
we can later evaluate the returned AST objects using the correct bindings for
176-
each variable. It also loads some string constants that contain binary data
177-
defining each annotation's AST. This is a compact format that is easily
178-
implemented with existing compiler functionality. It also is much faster to
179-
parse than storing the source code of the annotations directly. The annotate
180-
function then uses a new intrinsic to actually build the required annotation
181-
AST objects.
182-
183-
The proposed ``get_type_annotations`` function would then compute the type objects
184-
using a function like this:
185-
186-
.. code-block:: python
187-
188-
def eval_type_annotation_AST(node, namespace):
189-
match node:
190-
case ast.Name:
191-
return namespace[node.id]
192-
case ast.Attribute:
193-
value = eval_type_annotation_AST(node.value, namespace)
194-
return getattr(value, node.attr)
195-
...
196-
197-
198185
Performance Impact
199186
==================
200187

201-
While every new feature has to be weighed against its impact on performance and
202-
complexity, typing related features deserve additional scrutiny because type
203-
annotations are an entirely optional part of the Python language. We thus need
204-
to consider three separate groups of users and its impact on them: users who
205-
do not use type annotations at all, users who annotate their code for type
206-
checkers and/or linters but don't use runtime introspection and finally, users
207-
who also evaluate their type annotations at runtime.
208-
209-
For this, we considered three metrics: the time it takes to import modules both
210-
without and with type annotations, the annotate functions' size in memory and
211-
the time it takes to actually evaluate the annotate functions. The
212-
import time is most important for the first group of users; the second group is
213-
also affected by the annotate functions' memory footprint and the time to
214-
evaluate the annotate functions only impacts the last group of users.
215-
216-
Using our reference implementation, we have found no significant difference in
217-
import times of modules that do not use type annotations. For modules that do
218-
use annotations, import was moderately faster and the memory footprint slightly
219-
smaller using the proposed annotate functions, both by a few percent. But
220-
unfortunately, evaluation time can increase significantly. In the worst case,
221-
when annotations are requested in the value format and every used name is
222-
defined, the increase is about sevenfold. However, when some names are not
223-
defined and the ``STRING`` or ``FORWARDREF`` formats have to be used, the
224-
current approach is also significantly slower and results in comparable times
225-
to the proposed annotate functions.
226-
227-
A common situation where type annotations are evaluated is when tools like
228-
:py:mod:`dataclasses` or similar ORM packages analyze class or function
229-
definitions to synthesize additional behavior. For these tools, the time to,
230-
e.g., create a dataclass will be impacted by this proposal. But as mentioned
231-
above, there already are many situations where inspecting annotations takes
232-
a similar amount of time.
233-
234-
In total, since the negative performance impacts only affect the smallest group
235-
of users, who also benefit from the newly possible type annotations, we consider
236-
this a worthwhile trade-off. The specification of the proposed format is also
237-
open enough that many optimizations are possible should they be deemed necessary
238-
in the future. For many users, this proposal will even be a slight performance
239-
improvement since their code never evaluates any annotate functions.
188+
We measured the performance of the reference implementation across three
189+
groups of metrics: the time it takes to import modules, the memory footprint of
190+
annotate functions, and the time it takes to evaluate them. The first two of
191+
these are the most important since they impact every Python user, while the
192+
last only affects users that evaluate type annotations at runtime.
193+
194+
Relative to the current CPython implementation, import times for modules both
195+
with and without type annotations remained unchanged. Memory consumption
196+
decreased slightly by up to 5%, depending on the testing setup. The size of
197+
generated .pyc files decreased by about 6%. Note that these numbers are
198+
comparing the size of the entire modules, not just the annotate functions
199+
themselves.
200+
201+
Unfortunately, the annotate functions' evaluation time increased significantly,
202+
by about a factor of 10 in real-world scenarios. However, even code that
203+
directly uses annotations only spends a small fraction of its time in these
204+
function calls. For example, when using the :py:mod:`dataclasses` module, each
205+
call slowed down only by between 10% and 45%. The exact slowdown depends
206+
heavily on the structure of the annotations that are evaluated and on whether
207+
forward references are used. We suspect that most real-world code will be on the
208+
lower end of this range.
209+
210+
In total, this proposal improves performance for many users that do not
211+
evaluate annotations at runtime. The negative impact on users that do evaluate
212+
annotations is only moderate, and these users are likely to also benefit the
213+
most from the new expressiveness that is being enabled. The implementation also
214+
leaves room for future optimizations that can be tailored to observed
215+
real-world usage.
240216

241217
.. _outside-annos:
242218

@@ -289,11 +265,11 @@ The ``AST`` Format
289265
A new value called ``AST`` is added to the ``annotationlib.Format`` enum with
290266
value 5. Annotate functions do not have to support this format. If an
291267
annotate function is called with this format, it must return a
292-
dict with str keys and ``AnnotationAST`` objects as values (or a single ``AnnotationAST`` object
293-
for evaluate functions, like ``evaluate_value`` on ``TypeAliasType``).
294-
These are instances of a proposed new class that
295-
hold the annotation's AST as an ``ast.expr`` object and a namespace that is
296-
used to evaluate them.
268+
dict with str keys and ``AnnotationAST`` objects as values (or a single
269+
``AnnotationAST`` object for evaluate functions, like ``evaluate_value`` on
270+
``TypeAliasType``).
271+
These are instances of a proposed new class that hold the annotation's AST as
272+
an ``ast.Expression`` object and a namespace that is used to evaluate them.
297273

298274
Compiler-generated annotate functions will always support this format.
299275
They will store the necessary AST data as string constants and then
@@ -334,6 +310,79 @@ an annotate function that supports all annotation formats by converting the
334310
passed-in values appropriately.
335311

336312

313+
**************
314+
Implementation
315+
**************
316+
317+
How each annotate function can implement the AST format is an implementation
318+
detail. It will differ greatly not only between different Python interpreters
319+
but also between, e.g., compiler-generated annotate functions and user-defined or
320+
synthesized ones. We will now explain several changes in the implementation of
321+
compiler-generated annotate functions that are necessary to support the new
322+
format with the performance characteristics described above.
323+
324+
Currently, when a class like this is defined:
325+
326+
.. code-block:: python
327+
328+
class MyClass:
329+
a: int
330+
b: list[str]
331+
332+
The compiler generates a ``__annotate__`` method that behaves like this:
333+
334+
.. code-block:: python
335+
336+
def __annotate__(format):
337+
if format > Format.VALUE_WITH_FAKE_GLOBALS:
338+
raise NotImplementedError
339+
return {"a": int, "b": list[str]}
340+
341+
That is, the annotations are compiled into each annotate function's bytecode.
342+
In the reference implementation, we instead store the annotations' ASTs and some
343+
additional name and scoping data in constants and then construct the objects in
344+
the requested format on demand. Since this process is identical for all annotate
345+
functions, we no longer need a new function object for each annotate and instead
346+
construct a simple ``AnnotateObject`` object that holds the necessary data
347+
and implements the ``__call__`` method to return the annotation in the requested
348+
format. A simplified Python version of these looks like this:
349+
350+
.. code-block:: python
351+
352+
class AnnotateObject:
353+
def __init__(self, asts, globals, ...):
354+
self.asts = asts
355+
self.globals = globals
356+
...
357+
358+
def __call__(self, format):
359+
namespace = AnnotationNamespace(globals=self.globals, ...)
360+
asts = {
361+
name: AnnotationAST(_build_ast(ast), namespace)
362+
for name, ast in self.asts.items()
363+
}
364+
if format == Format.AST:
365+
return asts
366+
elif format == Format.VALUE:
367+
return {
368+
name: ast.evaluate()
369+
for name, ast in asts.items()
370+
}
371+
elif format == Format.STRING:
372+
return {
373+
name: ast.unparse()
374+
for name, ast in asts.items()
375+
}
376+
...
377+
378+
These objects do not store the ASTs as :py:class:`ast.AST` objects directly, but
379+
rather strings containing a serialization of them, saving a significant amount
380+
of memory. Many of the performance characteristics are highly dependent on this
381+
storage representation and the implementation of the ``_build_ast`` method.
382+
If, for example, it is decided in the future that a different tradeoff between
383+
use cases and their performance is more desirable, the implementation of these
384+
objects can be changed easily without affecting the external interface.
385+
337386
***********************
338387
Backwards Compatibility
339388
***********************
@@ -368,6 +417,14 @@ valid type annotations will not be changed to mean something different.
368417
Potential future changes can only define new semantics for syntactic constructs that
369418
currently are not valid type annotations and thus do not occur in user code.
370419

420+
It also is important to note that the Python AST itself is unstable and may
421+
change from version to version. This means that the internal structure of the
422+
values returned by annotate functions called with the AST format are similarly
423+
unstable. Because of this, we encourage users to not use this format directly
424+
but to rely on the helper functions that return the usual typing objects. These
425+
functions and their return values are part of the typing spec and thus are
426+
stable; a change in the AST would only change what their implementation looks
427+
like.
371428

372429
*********************
373430
Security Implications
@@ -457,6 +514,27 @@ the annotate function's object since they contain the globals and cellvars used.
457514
However, this is not sufficient since namespaces can use more complex lookup
458515
logic when ``global`` statements or name mangling are used.
459516

517+
Lossy AST Serialization
518+
=======================
519+
520+
There are some attributes of AST nodes that are not strictly necessary to
521+
evaluate them. Most importantly, many nodes store the position in source code
522+
where they were found. These are used in error messages and debugging tools.
523+
Storing these attributes in the internal AST serialization results in about
524+
3% larger .pyc files. Since we already achieved a significant reduction in
525+
bytecode size, we decided to keep all attributes in the AST serialization and
526+
maintain full compatibility with existing tracebacks and debugging tools.
527+
528+
529+
****************
530+
Acknowledgements
531+
****************
532+
533+
Thanks to Michael J. Sullivan for a lot of helpful feedback on this PEP and the
534+
reference implementation. Particularly, the idea to replace annotate functions
535+
with ``AnnotateObject``, which lead to a significant performance improvement.
536+
537+
460538
*********
461539
Copyright
462540
*********

0 commit comments

Comments
 (0)