PEP 649: Deferred evaluation of annotations, tentatively accepted

Did you see Thomas’ announcement about an extra 2 weeks due to all the changes in flight? Does that change anything?

2 Likes

I knew he was talking about it, but so far the 3.12 release schedule hasn’t changed.

The extra two weeks would make it pretty reasonable to get 649 in. But to be clear: the Steering Council have yet to render their decision on 649, much less ask that it be merged into 3.12.

2 Likes

The steering council has accepted PEP 649 for 3.13. So no need to worry about the time between now and 3.12beta1 in a two weeks, but we would like it to happen reasonably soon after that to give everybody using the main branch more experience with it.

9 Likes

I’m starting to look into implementing the PEP for Python 3.14 (since it didn’t make it into 3.13), and I’d like some clarity on what the __future__ will look like. The current PEP says very little about __future__; I believe a previous version proposed from __future__ import co_annotations, but the current version does not.

I’m happy to implement whatever the Steering Council and the broader community think best, but here’s a concrete proposal so we have something concrete to think about:

  • The default semantics in Python 3.14 will be PEP 649 semantics (lazily evaluated annotations).
  • If from __future__ import annotations is added, Python 3.14 will also use PEP 649 semantics, instead of the current PEP 563 semantics (stringified annotations).
  • from __future__ import annotations therefore will now do nothing. We will keep it around indefinitely, just like any other future that has arrived.

This approach has the advantage that we get into the end state we want (PEP 649 semantics) immediately and we don’t have to deal with any more future changes. The flip side, of course, is that any migration pain will be felt at once.

For the most part, code currently using annotations, either with or without from __future__ import annotations, should continue to work the same with PEP 649 semantics. That is certainly true for code that uses annotations purely statically, and it should also mostly be true for code that introspects annotations at runtime. There will certainly be exceptions where introspection code needs to change. Speaking as the maintainer of one tool that relies heavily on introspecting annotations (pyanalyze), I’d rather have to deal with the semantic change once, instead of having to support multiple variations of annotation semantics for several more years.

However, we can consider alternatives:

  • We could put PEP 649 semantics behind a future import and gain more insight into how it works in practice.
  • We could maintain PEP 563 (stringification) semantics for from __future__ import annotations, and eventually deprecate the future. However, this means we’d have to maintain this path until the last release without PEP 649 semantics (3.13) reaches its end of life, which would be in around 2029.
9 Likes

This is what the current table says:

feature optional in mandatory in effect
annotations 3.7.0b1 TBD PEP 563:
Postponed evaluation of annotations

How should this be updated?

I like changing immediately in theory, but in practice I suspect it will cause too many breakages in 3rd party code that relies on specific behavior in corner cases.

At the very least we should test this early and often with pydantic (@samuelcolvin, @davidhewitt). I know pydantic doesn’t like from __future__ import annotations much (though IIRC they mostly make it work), so there will be little love lost there. But I suspect that the different behavior in corner cases due to delayed evaluation might at least break some of their tests and possibly some of their core code, or corner cases in apps using pydantic.

Not to speak of the host of other 3rd party systems that do runtime inspection of type annotations for various purposes.

In the long term almost everyone will be happier with the new system, but I have a feeling that we should at least have a way to globally turn PEP 649 on or off, and possibly also per module (not sure about that – another __future__ import feels like a last resort).

2 Likes

Thanks for pinging me.

As soon as there’s a branch of cpython we can test against, we’ll definitely try it and let you know how we get on.

We could even run pydantic CI with CPython main so we find out quickly if something starts breaking.

9 Likes

I would like to bring up a use case that I think should be supported if the proposal by @Jelle is not adopted, and apologies if this is obvious.

That is:

  1. Use PEP 649 if on versions of Python that supports it
  2. Use PEP 563 on versions of Python that don’t support PEP 649

If, for example, both stringification and delayed evaluation are both supported in Python 3.14 by different __future__ flags, my question would be what is the behavior of (on 3.14 and earlier):

from __future__ import annotations, co_annotations

Future imports aren’t backported, so that wouldn’t work in earlier versions. You also can’t put them inside try-except or if/else. So I don’t think we can make that work unless we implement Jelle’s plan.

I like what @Jelle’s proposing here. If on Python 3.14 then the only semantics are PEP 649 then it’s much easier for Pydantic to reason about implementation given we can detect running on 3.14 easily.

Similar to what Samuel says, I think it’s best if we put effort into testing against the PEP 649 semantics as soon as you think it’s reasonable for us to do so. The sooner we put early support into Pydantic then the smoother the experience for users updating to 3.14 will hopefully be.

Great that it can work for Pydantic. Are there other tools or libraries or user code that depends on either the original or the PEP 563 semantics? Can we require them to do early testing as well? Are we allowed to break PEP 387 in this case?

Pretty much every library that supports multiple python versions and inspects annotations correctly implicitly relies on both of the original and pep 563 semantics, as the tools that exist in typing (ie. get_type_hints et al) have significant holes in their capabilities (see: gh-111353: GenericAlias support and TypeVarLike resolution for `typing.get_type_hints` by NCPlayz · Pull Request #111515 · python/cpython · GitHub for an example, but it’s far from the only case) and correctness here really does require DIY. get_type_hints is also explicitly documented as not working in cases involving forward references, and prior to 3.11, also incorrectly changed annotations involving a None default, which some libraries do handle properly: discord.py/discord/utils.py at f77ba711ba21bc2f70b252134880ebac3856acf8 · Rapptz/discord.py · GitHub

I imagine that this is likely to need a lot of testing to ensure uses here aren’t broken because of how much libraries really needed to do on their own, there isn’t a pre-existing standard library function that existed through all supported python versions in a state that “just worked” for these cases where we can continue ensuring that it “just works” which would hide the differences from a larger portion of developers.

I also don’t think every library that uses annotations at runtime is constantly watching for breakage here or going to be heavily watching discourse/mailing lists for this when they are relying on an accepted pep with a future import.

I don’t think we can change the behavior of the future import without breaking someone in a very unexpected manner, and I don’t know that it would be easy to determine all of the libraries that do this which are public code, let alone cases involving private code.

1 Like

I see, the motivation for my use case is to allow a library to quickly transition from PEP 563 to PEP 649.

If PEP 649 was initially hidden behind a future flag, and then @Jelle’s proposal was implemented, it would allow the same migration, just some number of Python versions later.

I think PEP 649 has a lot of non-trivial moving parts. It is both likely to change the behavior of some existing code, and we are likely to find things in it that we want to change based on real-world experience. I would feel more comfortable with PEP 649 initially being behind its own __future__ flag (mutually exclusive with from __future__ import annotations) in Python 3.14, and make it default in a later version.

But I also realize that this might actually increase the overall transition pain, just spreading it out over a longer transition period, so if others feel comfortable with making it the default behavior immediately, I wouldn’t stand in the way.

From the perspective of trying to minimize disruption, I think that the very fact that we need to revisit a future import to change the desired future should be a strong indicator that a longer transition period is warranted. I understand people want to just be done with it and have this behavior going forward, but clearly, it’s less trivial and there are things which could have been gotten wrong along the way.

I’d also be pretty disappointed in the precedent this would set for __future__ imports, which are meant to be a tool that provides a transition plan.

I’m not a user who would be strongly directly affected by either outcome, so I feel slightly out of place arguing for this, but I think just changing the behavior of the import has the potential to further damage people’s perception of typing if it breaks them, and undermines what I see as the point of __future__ imports to begin with.

5 Likes

With 3.7 as the minimum supported version last year, Flask, Jinja, and Click were finally able to use the annotations future and add type annotations without incurring import time cost. If we now had to use a different __future__ import to use type annnotations without incurring import time cost, we would not be able to transition until the minimum supported version was 3.14, 6+ years from now, 15 years after deferred annotations was first available. On the other hand, if 3.14 just started using the new implementation, and we found we needed to fix a few annotations during the beta, that would be absolutely fine by me.

13 Likes

I see Carl said what I did (extending this transition to 2029 seems undesirable) in much more detail over here:

1 Like

I think it’s a little bit premature to decide whether a separate __future__ flag is warranted. I’d instead like to see an organized effort by maintainers and users of runtime type libraries to use the implementation of PEP-649 as soon as it is available in one of the alpha builds, so we can get a feel for how much breakage there actually will be in real world code.

I’d much rather see an informed decision than being too careful based solely on intuition and later on regretting that choice, because that intuition turned out to be incorrect. This may also lead us to some small tweaks in the internal workings of PEP-649 that help with the majority of the breakages we’ve seen in the wild sooner rather than later.

I realize that this is a tall ask, considering the relatively slim time window we’ll likely be working with, but I’d like us to at least try. If we then decide we don’t really have enough data points to make an informed decision and that the risks are too great, we can always pull the breaks and add the new flag then.

6 Likes

Wouldn’t this option be a breaking change with no warning in a prior version?

Same with this one.

I now have a working implementation of much of the C part of PEP 649 here: gh-119180: PEP 649 compiler changes by JelleZijlstra · Pull Request #119361 · python/cpython · GitHub. There are many issues still to work out and I haven’t run it on any third-party code yet.

However, CPython’s own test suite found a lot of interesting issues already. Here are some of my findings:

  • Contrary to my earlier post, I don’t think we can apply PEP 649 semantics if from __future__ import annotations is active. This would break too much working code, in cases where a library inspects the annotations eagerly (e.g., in a decorator) and the user is relying on the future import to use forward references. Therefore, I kept the from __future__ import annotations behavior in place and applied PEP 649 semantics only in cases where the future import is off.
  • Code that does cls.__dict__.get("__annotations__") to get to a class’s annotations dict will break. There is no longer going to be an annotations key in the class dict unless the user has already accessed the cls.__annotations__ descriptor. This affected several parts of typing.py. It is fixable by using cls.__annotations__ directly.
  • Code that interacts directly with the __annotations__ name in the local namespace stops working. I don’t know why people would do this, but there are test cases in CPython that rely on it (e.g., the ones removed in this commit: gh-119180: PEP 649 compiler changes by JelleZijlstra · Pull Request #119361 · python/cpython · GitHub).

Both of these cases are also called out in the PEP: PEP 649 – Deferred Evaluation Of Annotations Using Descriptors | peps.python.org. The PEP was accepted with the understanding that some unusual code patterns would break; we’ll have to see how problematic this is in practice.

8 Likes