PEP 649: Deferred evaluation of annotations, tentatively accepted

The Python Steering Council is tentatively accepting @larry 's PEP-649: Deferred Evaluation Of Annotations Using Descriptors. (related GH PSC agenda item issue)

Why tentatively? What does that even mean?

It means we think this PEP should be Accepted and recommend that the next Steering Council do so in three months time (long before 3.12 beta 1) unless people can convince us with reasons not to.

While we could’ve just accepted it today, the hole we’d like filled in is what the current experience from projects making use of type annotations at runtime is in regards to the deferred evaluation solution as laid out in the PEP. Are there concerns we’ve overlooked or don’t know about?

-Greg, with a Python Steering Council :rescue_worker_helmet: on

15 Likes

Tentative yay :slight_smile:

What does that mean for string annotations with from __future__ import annotations? PEP 649 suggests that it gets deprecated and eventually removed. But future declarations are supposed to remain forever, so presumably it will just become a no-op.

(We still support the “nested scopes” future declaration even though it hasn’t been needed since, what was it, 2.2 or 2.3?)

During the deprecation period, how do we resolve the conflict if somebody enables both PEP 649 annotation descriptors and PEP 563 stringified annotations?

5 Likes

I think we shouldn’t emit DeprecationWarning for PEP 563, at least 3 releases.

Library authors can not use PEP 649 until they drop Python 3.11 support. PEP 563 is the only efficient and convenient way to use type annotations.

I think it should be syntax error.
Using PEP 649 means that source code doesn’t support Python 3.11. Allowing PEP 563 with PEP 649 doesn’t increase backward compatibility. Authors should chose one.

4 Likes

Or more. Our assumption was that 563’s future import would stay around for a vague long while, so I probably wouldn’t even make a deprecation warning until we find reason to actually plan its demise. If ever.

Will that be our first __future__ import who’s future never came to pass?

They’re parallel futures. Only one gets realized.

4 Likes

from __schrodinger__ import annotations

8 Likes

On an editorial note, shouldn’t this be moved to #peps with all the other PEP-related discussions and announcements (aside from #packaging , which has its own category and process?) per standard convention and the current PEP 1, etc. guidance? I went looking for this thread everywhere there but didn’t find it.

It’s not a PEP, it’s a discussion about a PEP. I think it’s fine here (though the actual PEP post should link to this one).

(FTR: I have different level of notifications set up for when new PEPs are announced based on the category. So it impacts my workflow to have non-PEP posts in that category.)

1 Like

Discussions on this one happened over years in many places so I wasn’t sure where to post this as it wasn’t all in one canonical spot. There didn’t seem to be one best place.

Please feel free to link to this discussion from anywhere else that had talked about PEP 649 to make sure everybody knows in case they haven’t seen this.

1 Like

How will this interact with dataclasses and the Field type attribute?

(I think dataclasses are the only thing in the standard library that used annotations at this point, yes?)

I’m pretty sure that it simply gets set to the annotation, so I think it would get evaluated before being set, so the type would be the type, as it is now.

On the other hand, it would get evaluated when the dataclass decorator was run, so it may not give you the advantages for circular references, so maybe we’d want to have it set to a deferred object?

Note that with PEP 563 enabled, dataclasses store the string in the type attribute, which very much breaks code like mine, which is expecting the Field.type attribute to be an actual type :slight_smile:

So I’m happy about this plan – but wanted to make sure.

Is there an easy way to try this out? Maybe wait for 3.12 beta? (I’d rather not have to go find Larry’s code and compile it …)

-Chris

1 Like

Are you talking about dataclass.field() which isn’t involved with this PEP, or dataclasses.fields() which should just work? Since dataclasses.Field isn’t supposed to be created directly I’m not sure which case you’re specifically referring to.

My assumption is dataclasses will use whatever inspect returns as the type annotation for an attriute.

I’m talking about dataclasses.Field, which, yes, is not supposed to be instantiated directly, but is what is returned by the fields() function, and is what is stored in the __dataclass_fields__ (private) attribute.

They are presumably created by the dataclass decorator.

and Field has a type attribute, which is currently populated by the value of the annotation by the decorator – I haven’t looked at the code in a while, so I’m not sure exactly how or when it is set, and even if I did, I would not be sure how this PEP might affect that, 'cause i don’t quite understand it well enough to know for sure.

NOTE: dataclasses.field() returns a Field instance, but it does not accept a parameter to set the type attribute – it is being set by the decorator.

I think it’s relevant to this PEP because dataclasses is in the standard library, and its behavior may be affected. So there may need to be changes made to dataclasses, and if so, they should be made at the same time as the PEP is implemented.

  • Perhaps no changes are needed – great!
  • Perhaps the only change needed is a note in the docs – fine.
  • Perhaps there needs to be a modest change in the code, in which case, it would be best if that change were made in the same release.

I’m trying to understand which of those it is.

-CHB

NOTE: If PEP 563 had been finalized as planned, I was going to put in a PR to call typing.get_type_hints() in the dataclass decorator – I’m trying to understand if that’s necessary with this PEP.

I started a committers survey regarding when we should have the default behavior change if accepted over in Survey: Should PEP-649 implementation use another future import? as burying a poll in the middle of this comment thread sounded less visible.

I think the arguments for making the behavior change independently, as listed in the poll, are sound, but it’s also a backward compatibility break. Would it make sense to add a way to turn on the __future__ globally, maybe with an -X command-line option?

2 Likes

A command line flag environment variable pair to turn on the new default is possible with poll option #2 or on its own as new option without a future import. But that might fall into a trap: are people who we want to test things that way going to do so? And does the presence of such an option mean all code needs to be ready for it no matter what as it can be turned on by users rather than the authors or maintainers of specific code?

IIRC we have had major behavior changing flags like that before such as -U (?), I’m not sure how often they got used in practice.

What’s the behavior of that flag/envvar vs files that contain a 563 future annotations import? Overrule it? Honor it? Generate per file warnings? Error?

@dataclass inspects the annotations, so it will cause all annotations to be immediately evaluated.

There’s at least typing.NamedTuple.

In the absence of from __future__ import annotations, yes.

I’d suggest a string. @larry and I tried to sell forward declarations of classes to avoid this, but it didn’t go over well.

@dataclass doesn’t alter the annotations, so you get whatever it gets.

1 Like

Thanks Eric.

OK, then things will “just work”.

Do you mean to set the annotation to a string when you write the dataclass, e.g.:

@dataclass
class Node:
    parent: "NotYetDeclared" = None

As one has to do now without either of the PEPs enabled?

That would work fine.

I think we can do better than requiring manually stringified annotations for all forward/self/cyclic reference cases. We can evaluate the PEP 649 thunk using a custom globals dict that allows references to not-yet-defined names to be replaced by some kind of forward reference object.

Doing this in a way that faithfully reproduces every theoretically possible syntactic construct in an annotation is hard, but we don’t need that level of perfection for it to be a massive improvement in user experience for the common case of someone who just wants their self-referencing dataclass to work at runtime without having to quote their annotations. (And of course for those who do care what ends up in the Field object, it’s not hard to make the forward-reference object carry sufficient information to allow later reifying the actual type, better than a string would.) So I would hope that we go the extra mile to do this in dataclasses (and I’m happy to write the code for it if that’s useful.)

Carl

7 Likes

I don’t think the current pep as designed has really been vetted against metaprogramming or other class side effects. First problem that shows up quickly is that while previously if you wanted to change the namespace dict of a class during evaluation you would use a custom prepare method, now any modification of the class dict that is passed to __new__ and to __init__ may result in modification.

class Bug:...
class Correct:...

class CustomType(type):

    def __new__(mcls, name, bases, namespace, /, **kwargs):
        namespace["Correct"] = Bug
        cls = super().__new__(mcls, name, bases, namespace, **kwargs)
        return cls


class OtherClass(metaclass=CustomType):
    val:Correct

OtherClass.__annotations__
#{'val': <class 'Bug'>}
#should be {'val': <class 'Correct'>}

This means this change effects any class that modifies the namespace dict. While there has been a reduction of libraries that need custom metaclasses of a small sample of 4 libraries that I know use them: django, pydantic, sqlalchemy, and pythons enum lib. Everyone, but sqlalchemy, modifies the namespace object. In Django all but 1 of their metaclasses does so, the only one that did not was a testing related metaclass.

This also would mean that the class namespace object that would normally be discarded after the class obj is returned in now kept alive. The current behavior to drop the object is documented.

Changing this to form a closure with the actual class.__dict__ object instead of the namespace dict seems like it would create even more chance for namespace collision.

There is also some strange side effects related to class function names bashing type annotations.

Take the following code

class A:
    clsvar:dict[int,str]
 
    def dict(self):...

The annotation is now referring to the function dict instead of the built in dict. This is going to artificially restrict function names.

It isn’t exactly rare for classes to shadow built in names, the UUID class for example shadows multiple builtins. That means we cannot reference these type in the annotations.

class A:
    clsvar:dict[int,str]

    def dict(self,a:dict) -> dict: ...

That is something that actually works perfectly fine right now.

I know this has been discussed multiple times already but I am really having trouble understanding what about the runtime cost of annotations is so high that it makes sense to create a feature that fundamentally behaves nothing like the rest of the language.

Considering that stringified annotations are already at an acceptable performance level (whatever that means). What exactly about annotations is causing enough overhead to warrant lazily capturing the names involved in the annotation?

Is this

def a(b:'str'):...

really that much cheaper then this?

def a(b:str):...

Or is the main issue that deeply nested annotations are expensive to compute? I am not really clear why the string version would be so much cheaper then the single object lookup version. This really feels like trading deterministic behavior for performance.

4 Likes

The issue with string literals as annotations are that writing code in quotes is both ugly and not checked by Python for syntax. When you get implied strings via PEP-563, the visual hackiness and parse error typo issues are gone, but they still carry no context about how they should be evaluated. We’d rather people working in annotated code bases not need to reason about when they need a stringified annotation or not at all in their typical daily flow of writing type annotated code.

Your method names mirroring builtin type names example is a good one and is basically taking one of the examples in the PEP a little further by using the builtin name dict and pointing out where such a name can get reused for valid reasons. Thanks! This is a place where PEP-649 deferred evaluation can “go wrong”. But code like that is far less common than situations where people need to manually think about stringifying annotations due to forward references. The workaround for such code are to use _dict = dict or from typing import Dict other name aliases. We don’t anticipate this being a common need.

Continuing down the strings as annotations path also doesn’t solve the problem that annotations being strings introduced in the first place: Runtime use of annotations. This prevented PEP-563 from ever becoming our default behavior. (pydantic et. al.)

The main issue PEP-649 aims to improve? Both PEP-563’s now-alternate-unrealized future and manually used string annotations had two motivating reasons: One is to allow forward references. The other is module import time performance and thus whole program startup time. PEP 649 aims to resolve those conflicting goals into a more natural - in most cases - implementation.

This really feels like trading deterministic behavior for performance.

We don’t have deterministic behavior today given string annotations and PEP-563. There is no guarantee which state of program context said strings will be evaluated within. PEP-649 deferred evaluation trades that non-deterministic behavior for an alternate one to get rid of the use of strings moving us to what appears to be a happier place.

1 Like

@larry The Python Steering Council is ready to officially accept PEP-649. How’s the implementation looking? Do you think an implementation could land in time for 3.12beta1?

Based on the poll results in Survey: Should PEP-649 implementation use another future import? we’d prefer to go without a new from __future__ import co_annotations as the PEP lays out and just change the default behavior.

(Understanding that feedback during people’s beta period testing could change our minds on the __future__ topic.)

4 Likes