PEP649 means that PEP563 will see *more* usage, not less, and will break runtime typecheckers

It seems PEP649 has been accepted! Hurrah, it’s great that string annotations might finally go the way of the dodo.

Unfortunately, until Python 3.12 is EOL, then approximately everyone is still going to writing code that is multi-version. And because PEP649 makes it possible to use bare forward references, then practically speaking that still means writing from __future__ import annotations everywhere, since on Python<=3.12, this is how you use bare forward references. Now we’re going to have stringified annotations everywhere!

So it seems that whilst the intention of PEP649 was to make runtime access to annotations better, it is in fact making things worse. At least for the time being.

…and this is making those of us who write runtime type checkers very sad. @leycec spotted this issue first, so we’ve recently started a discussion on the topic here. (It also touches on some other points, like speed, but I’ve highlighted above what I think is the most difficult concern for us to handle.)

I think us runtime-typing folks aren’t yet sure what might be an appropriate fix, but I wanted to make contact with the rest of you non-runtime-typing folks and start a parallel discussion over here.

1 Like

Apologies, I struggle to read text that leycec writes. Thanks for opening a discussion — if I understand correctly, the concern is something like:

  1. The PEP 649 behaviour is enabled by default in 3.13
  2. Someone writes a forward ref. When they run tests against 3.12, they get a NameError, so they add from __future__ import annotations to fix (or quote their annotations)
  3. If a user of that code is doing runtime type checking, and it’s one of the cases where eval works badly, that user is unhappy

Maybe I’m missing something obvious, but I’m confused how this is much worse than today. Step 2 plays out exactly the same as it would today. from __future__ import annotations is widely used enough that every runtime type checker needs to have basic support for it.

Anyway, I think it’s fairly likely that PEP 649 behaviour does not end up being enabled by default in 3.13. The SC at the time a) appeared unsure about that point and ended up polling, b) wanted it to land early in the lifecycle of 3.13. If it doesn’t land relatively soon, I wouldn’t be surprised if PEP 649 remains __future__ gated.

9 Likes

Interesting… thanks @hauntsaninja :slight_smile:

I’d just like to add that I maintain a (somewhat) runtime type checker, pyanalyze, and I am very excited about PEP 649, because it mostly makes the situation far better for runtime typing. Right now, when people use a forward reference in an annotation, they put it in a string (or they write from __future__ import annotation and automatically put everything in a string, which isn’t really any different). And when pyanalyze needs to resolve that annotation, it has to sort of guess at the right namespace in which to look up the name. Usually it’s easy (the function’s __module__), but sometimes it’s impossible (e.g., a type alias imported from another module). With PEP 649, people no longer have to stringify their annotations when writing forward references, and when pyanalyze accesses __annotations__, things will still work.

5 Likes

Actually, that’s not been my experience – in all my work I tend to take the opposite approach: given a stringified annotation (or even worse, a partially-stringified annotation), then I’ll tend to try typing.get_type_hints as a best-effort, and then either fail loudly or silently convert to Any, depending on what is most practical.

If so, would it be possible to backport that as a __future__ for earlier versions of Python as well?

Then we can at least tell everyone to just unconditionally add “always use from __future__ import pep649” and leave it at that, rather than the more-complicated
“”"
(a) if you’re on Python <=3.12 then don’t use from __future__ import annotations or everything will break, and please don’t use forward references either, and (b) if you’re on Python >=3.13 then do anything you like, and (c) if you want to write cross-version code then I’m afraid you’re stuck without forward references until Python 3.12 is EOL.
“”"

(Note that part (a) is what we already tell users, and it already comes up all the time.)


Jelle – I agree, I’m exciting about PEP649 as well! I’m aware of everything you’ve touched on. :slight_smile:


Whilst I’m here, I’ll copy-paste one of the more important messages from the runtime discussion thread I’ve linked:

  • To preserve compatibility, the new optional format parameter accepted by the inspect.get_annotations() and typing.get_type_hints() functions must default to FORWARDREF rather than VALUE. The current default of VALUE breaks compatibility by unpredictably raising unreadable NameError exceptions on accessing the __annotations__ dictionary, which prevents __annotations__ from being safely accessed, which (…waitforit) breaks compatibility.
  • It’s unclear how to safely modify type hints – or whether type hints even can be safely modified anymore. Pain points include:
    • How can two or more third-party packages safely modify __annotations__ and/or __annotate__() without destroying each other’s work?
    • The fact that __annotations__ and __annotate__() silently destroy each other is concerning. Ideally, one of these dunder attributes should be the definitive source of truth concerning annotations and not destroyable; the other dunder attribute should just be a cache that is destroyed and then silently recreated whenever the first dunder attribute is modified.
    • None of these dunder attributes should be destroyable, really. None values do not make sense for either __annotations__ and __annotate__(). Am I missing something? I’m probably missing something.

As a quick example of the kinds of things we don’t know the answer to right now, would something like this continue to be acceptable? Right now we’re not sure.

class MyClass:
    pass

foo = fn.__annotations__['foo']
if isinstance(foo, MyClass):
    foo.bar = baz

(note that in this case I own MyClass but not fn, so I’m being careful only to mutate the object that I own)


I’m sure all of these things can be understood, but right now those of us doing runtime stuff aren’t too sure how any of this is going to work, so it’s probably about time we reached out and changed that :slight_smile:

If so, would it be possible to backport that as a __future__ for earlier versions of Python as well?

Unless you have a time machine or the ability to control all computers, it is unfortunately impossible to backport PEP 649 to currently released versions of Python.

would something like this continue to be acceptable

By my read of the PEP this is mostly acceptable! The one quite marginal case is that if __annotate__ returns a dicts that refer to different objects, then mutations of objects within __annotations__ might not be visible if you try to inspect annotations with one of the other formats (because they will refer to different objects).

To preserve compatibility, the new optional format parameter accepted by the inspect.get_annotations() and typing.get_type_hints() functions must default to FORWARDREF rather than VALUE.

For the other quoted text there’s a lot that doesn’t line up with my reading of the PEP, e.g. it addresses the interactions between __annotations__ and __annotate__ pretty clearly; the thing the quoted text is complaining about is explicit design to keep basic semantics of __annotations__ backwards compatible. Most of the point of PEP 649 is that you won’t get NameError’s in many of the cases you formerly would, typing.get_type_hints is perfectly happy to give you NameError today, etc. But…

…I think that the best way to answer most of these questions is to have an implementation to play with. Unfortunately, we currently do not. This is presumably why the old SC wanted it to land early in the 3.13 lifecycle.

I suspect what was meant is “all bugfix-maintained major release series’ of Python,” (so that the next 3.11 and 3.12 release would contain the feature) which is the usual understanding of how backports would work, in the absence of a time machine :slight_smile:

On the purely technical level, I think this is somewhat plausible for 3.12, since the PEP 695 implementation already contained the key underlying “annotation scope” feature. I think it’s out of the question for 3.11, given the invasiveness of that feature.

But in terms of backport policy, it seems out of the question for either 3.11 or 3.12, because we backport bugfixes, we don’t backport new features. And there are good reasons for this, both because people rely on the stability of bugfix releases, and because specifying “Python 3.12” should be enough to clarify the feature-set; code written for 3.12.4 should not use features that don’t exist on 3.12.3.

But I also agree that all of this discussion is somewhat putting the cart before the horse, in the absence of a Python 3.13 implementation of PEP 649 proposed for merge.

11 Likes