Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PEP 749: Add conditional annotations and partially executed modules #4316

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions peps/pep-0749.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ specification:
(which were added by :pep:`695` and :pep:`696`) using PEP 649-like semantics.
* The ``SOURCE`` format is renamed to ``STRING`` to improve clarity and reduce the risk of
user confusion.
* Conditionally defined class and module annotations are handled correctly.
* Accessing annotations on a partially executed module will raise :py:exc:`RuntimeError`.

Motivation
==========
Expand Down Expand Up @@ -752,6 +754,126 @@ PEP, the four supported formats are now:
- ``FORWARDREF``: replaces undefined names with ``ForwardRef`` objects.
- ``STRING``: returns strings, attempts to recreate code close to the original source.

Conditionally defined annotations
=================================

:pep:`649` does not support annotations that are conditionally defined
in the body of a class or module:

It's currently possible to set module and class attributes with
annotations inside an ``if`` or ``try`` statement, and it works
as one would expect. It's untenable to support this behavior
when this PEP is active.

However, the maintainer of the widely used SQLAlchemy library
`reported <https://github.com/python/cpython/issues/130881>`__
that this pattern is actually common and important:

.. code:: python

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from some_module import SpecialType

class MyClass:
somevalue: str
if TYPE_CHECKING:
someothervalue: SpecialType

Under the behavior envisioned in :pep:`649`, the ``__annotations__`` for
``MyClass`` would contain keys for both ``somevalue`` and ``someothervalue``.

Fortunately, there is a tractable implementation strategy for making
this code behave as expected again. This strategy relies on a few fortuitous
circumstances:

* This behavior change is only relevant to module and class annotations,
because annotations in local scopes are ignored.
* Module and class bodies are only executed once.
* The annotations of a class are not externally visible until execution of the
class body is complete. For modules, this is not quite true, because a partially
executed module can be visible to other imported modules, but this is an
unusual case that is problematic for other reasons (see the next section).

This allows the following implementation strategy:

* Each annotated assignment is assigned a unique identifier (e.g., an integer).
* During execution of a class or module body, a set, initially empty, is created
to hold the identifiers of the annotations that have been defined.
* When an annotated assignment is executed, its identifier is added to the set.
* The generated ``__annotate__`` function uses the set to determine
which annotations were defined in the class or module body, and return only those.

This was implemented in `python/cpython#130935
<https://github.com/python/cpython/pull/130935>`__.

Specification
-------------

For classes and modules, the ``__annotate__`` function will return only
annotations for those assignments that were executed when the class or module body
was executed.

Caching of annotations on partially executed modules
====================================================

:pep:`649` specifies that the value of the ``__annotations__`` attribute
on classes and modules is determined on first access by calling the
``__annotate__`` function, and then it is cached for later access.
This is correct in most cases and preserves compatibility, but there is
one edge case where it can lead to surprising behavior: partially executed
modules.

Consider this example:

.. code:: python

# recmod/__main__.py
from . import a
print("in __main__:", a.__annotations__)

# recmod/a.py
v1: int
from . import b
v2: int

# recmod/b.py
from . import a
print("in b:", a.__annotations__)

Note that while ``.py`` executes, the ``recmod.a`` module is defined,
but has not yet finished execution.

On 3.13, this produces:

.. code:: shell

$ python3.13 -m recmod
in b: {'v1': <class 'int'>}
in __main__: {'v1': <class 'int'>, 'v2': <class 'int'>}

But with :pep:`649` implemented as originally proposed, this would
print an empty dictionary twice, because the ``__annotate__`` function
is set only when module execution is complete. This is obviously
unintuitive.

See :gh:issue:`130907` for implementation.

Specification
-------------

Accessing ``__annotations__`` on a partially executed module will
raise :py:exc:`RuntimeError`. After module execution is complete,
accessing ``__annotations__`` will execute and cache the annotations as
normal.

This is technically a compatibility break for code that introspects
annotations on partially executed modules, but that should be a rare
case. It is better to couple this compatibility break with the other
changes in annotations behavior introduced by this PEP and :pep:`649`.


Miscellaneous implementation details
====================================

Expand Down
Loading