|
|
Log in / Subscribe / Register

PEP 649 revisited

By Jake Edge
August 18, 2021

Back in June, we looked at a change to Python annotations, which provide a way to associate metadata, such as type information, with functions. That change was planned for the upcoming Python 3.10 release, but was deferred due to questions about it and its impact on run-time uses of the feature. The Python steering council felt that more time was needed to consider all of the different aspects of the problem before deciding on the right approach; the feature freeze for Python 3.10 was only around two weeks off when the decision was announced on April 20. But now, there is most of a year before another feature freeze, which gives the council (and the greater Python development community) some time to discuss it at a more leisurely pace.

To that end, Eric V. Smith raised the issue on the python-dev mailing list on August 9. He did so in the context of PEP 649 ("Deferred Evaluation Of Annotations Using Descriptors"), which was the late-breaking proposal that caused the original plan to be put on hold. That plan was embodied in PEP 563 ("Postponed Evaluation of Annotations"), which was accepted back in 2017 and was set to become the default—and only—behavior for annotations starting in Python 3.10. The council decided to defer the change in the default until Python 3.11 at the earliest and there is the possibility of switching to the behavior described in PEP 649 instead. Smith wanted to see if the issue could be resolved at this point.

Backstory

The history was described at some length in our earlier article, but a capsule summary is probably in order. Annotations were added to Python as a general feature, long before the static-typing features that use annotations came along. As originally envisioned in 2006, annotations were simply meant as a way to attach metadata to a function's arguments and return value—and to make the information available at run time in the __annotations__ dictionary associated with the function. Syntax was added so that programmers could optionally add this information to function definitions; interpretation of the metadata was left up to whatever was processing it. Type information was certainly one of the possibilities for that metadata, but there was no standard on how to represent the types of arguments or return values.

When type hints came about, the type information was naturally stored using the existing annotations mechanism, but eventually ran into some snags, with forward references to types being particularly problematic. So PEP 563 was adopted to defer the evaluation of annotations until they were actually needed; that also removed the cost of computing the annotations every time a module was imported, which was a cost that provided almost no benefit. For static type checkers, which were the main driving force behind the type hints effort, it would not change anything; those checks are not done at run time, so the values that got stored in __annotations__ were not used.

On the other hand, there are users of the type annotations at run time and there may be users of other kinds of annotations at run time as well. It seemed to come as a bit of a surprise when developers of the pydantic data-validation tool explained the problems they had using PEP 563, but other examples came to light as well. It would not be a huge surprise to discover that annotations are being used at run time for some non-type-related purpose—somewhere out there in the enormous Python ecosystem. Annotations were added to the language as a generalized feature so it is not a huge stretch to imagine that developers have used them in unexpected ways.

For some, including Python creator Guido van Rossum, restricting annotations to type information is a reasonable way forward. But that still does not solve the problems for the run-time users of __annotations__, who ran aground on some of the scoping issues that are inherent in the PEP 563 approach. But switching to PEP 563 is a one-way door; if a project like pydantic wanted the old behavior, it would not have been able to get it. That meant that even those who were using the annotations for type information could not do so, at least reasonably easily, at run time.

PEP 649 took a different approach that was meant to resolve the forward-reference problems and to maintain the performance boost that came from not evaluating the annotations until they were actually needed. Instead of keeping that annotations around in string form, as PEP 563 does, it turns them into functions that get called the first time __annotations__ is consulted. Those functions can properly handle the different local and global scopes that bedeviled the earlier approach, which effectively used eval() (or typing.get_type_hints()) to turn the annotation strings into Python objects.

Resurrection

But PEP 649 came about rather late in the development cycle for Python 3.10 and seemingly only barely headed off the switch to PEP 563 by default for that release. Instead of waiting until April 2022, just before the Python 3.11 feature freeze, as PEP 563 author Łukasz Langa jokingly warned against, Smith raised the question. He would like to see PEP 649 adopted, which was a fairly popular option back in April when it was discussed. He wondered what the next steps would be:

My understanding is that PEP 649 is currently in front of the SC [steering council]. But do we need to have any additional discussion here? My recollection is that we backed out the PEP 563 change because we didn't feel we had enough time to come to a good decision one way or the other before 3.10.

Council member Barry Warsaw filled in some of the considerations that led the council to defer the question, beyond just the problem of running out of time. These are "decisions we’d have to live with essentially forever", he said. One of those considerations is the question of compile-time versus run-time use of the annotations; he thinks that the two groups need to cooperate:

[...] we have to be very careful that the folks who use type annotation at compile/static checking time (e.g. mypy and friends) explicitly consider the existing use cases and needs of the runtime type community. These two constituents have to work together to avoid backward incompatible changes.

Luciano Ramalho noted that the large companies that have invested a lot of effort into Python typing features tend to be focused on the static-typing case and are concerned by its costs, so it is up to the rest of the community to ensure that the other use cases are still well-supported:

[...] static checking only happens in developer workstations and CI servers, but *imports* happen all the time in code running in production, on countless servers. That became a real issue for those very same companies operating at Web scale.

So they have a strong incentive to focus on the use of annotations for static checking only, while many of us also want type hints to address use cases where Python is used as a *dynamic* language, which is its nature, and likely a strong reason for its popularity in the first place—despite the inherent runtime costs of being a dynamic language.

Another steering council member, Brett Cannon, confirmed that PEP 649 was currently under consideration, but he did think that additional discussion was needed: "I think the question is whether we have general consensus around PEP 649?" In addition, Inada Naoki said that there was a need to evaluate the memory and performance impact of PEP 649 before deciding on it.

But PEP 649 author Larry Hastings does not see things that way. As he pointed out, PEP 563 does not mention performance or memory consumption; it is focused on solving a problem in the language. PEP 649 should be treated similarly:

I think PEP 649 should be considered in the same way. In my opinion, the important thing is to figure out what semantics we want for the language. Once we figure out what semantics we want, we should implement them, and only then should we start worrying about performance. Fretting about performance at this point is premature and a distraction.

I assert PEP 649's performance and memory use is already acceptable, particularly for a prototype. And I'm confident that if PEP 649 is accepted, the core dev community will find endless ways to optimize the implementation.

Finding the right balance

There is, to a certain extent, a struggle going on between those who want to further enshrine type features into annotations and, by extension, the Python language itself, and those who are perfectly happy to see the typing features, but do not want them to preclude other uses of annotations. That echoes Ramalho's observations about the companies behind the static-typing feature somewhat. For example, PEP 646 ("Variadic Generics") proposes "syntax for type annotations that may or may not be useful or desired for regular Python", as Warsaw put it. But having the syntax of the language and that of its type annotations diverge is not something that he believes the council will allow.

There are aspects of PEP 649 (or some, as yet unwritten, successor) where typing proponents would like push annotation support in directions that might wall off other uses. Hastings is particularly concerned by that:

Annotations aren't special enough to break the rules.

I worry about Python-the-language enshrining design choices made by the typing module. Python is now on its fourth string interpolation technology, and it ships with three command-line argument parsing libraries; in each of these cases, we were adding a New Thing that was viewed at the time as an improvement over the existing thing(s). It'd be an act of hubris to assert that the current "typing" module is the ultimate, final library for expressing type information in Python. But if we tie the language too strongly to the typing module, I fear we could strangle its successors in their cribs.

Steve Holden agreed with that concern, noting that "optional" may be slowly getting elbowed aside. "Which would be unfortunate given the (explicit?) assurances that annotations would be optional; they are casting their shadow over the whole language." For some, it is about finding the balance between the needs of the new feature without precluding older uses; others, including Van Rossum, seem more willing to fully embrace annotations only for types and mostly only for static analysis.

One gets the feeling that this particular debate would have played out quite a bit differently if Van Rossum were still the benevolent dictator for the language. But he voluntarily relinquished that role and has fully embraced the steering council model that the community adopted. That model purposely provides for multiple voices that can try to find the balance in a disagreement of this sort. Van Rossum credited the council with "the wisdom of Solomon" in his reaction to the deferral decision back in April, but it may well be that the council in fact has a wisdom of a different sort entirely. Python seems likely to benefit from its multi-headed wisdom going forward.

While the fate of PEP 649 itself is somewhat unclear at this point, it does seem like there are efforts being made to enhance it to cover the problem areas that have been brought up. Given the known use cases for annotations at run time (e.g. pydantic), though, queuing up PEP 563 as the default for 3.11 seems highly unlikely. There is still lots of time to discuss, further prototype, and revise the idea well before hitting Langa's "deadline". We may see it all resolve before 2021 ends, in truth—stay tuned.

Index entries for this article
PythonAnnotations
PythonPython Enhancement Proposals (PEP)/PEP 649


to post comments

PEP 649 revisited

Posted Aug 18, 2021 21:12 UTC (Wed) by pj (subscriber, #4506) [Link] (1 responses)

@cached_property is now in the stdlib ; having it be used to implement PEP649 seems straightforward.

PEP 649 revisited

Posted Aug 18, 2021 23:26 UTC (Wed) by NYKevin (subscriber, #129325) [Link]

Well yes, but... we need to clear up a couple of things about how @property, @cached_property, and annotations work.

@property and @cached_property are, essentially, syntactic sugar for a language feature called "descriptors." A descriptor works like this:

0. You declare a class, let's call it Foo.
1. You override the methods __get__() (usually), __set__() (sometimes), and/or __delete__() (rarely).
2. You make an instance of Foo, and assign it to a class variable inside of another class (which I'm going to call Bar, and let's call the class variable baz).
3. You create an instance of Bar, which we can call qux.
4. You try to access qux.baz.
5. Subject to certain conditions, the language will then call __get__(), __set__(), or __delete__() on your Foo instance, as applicable to the type of access you attempted.

In this context, instances of Foo are called "descriptors." If either of __set__() or __delete__() are implemented, then Foo instances are called "data descriptors," and have slightly different semantics (in particular, they take priority over qux.__dict__['baz'], if it exists, while a non-data descriptor is only invoked if there's no instance variable and the access would otherwise fail). The full documentation may be found at https://docs.python.org/3/reference/datamodel.html#implem...

@property and @cached_property are basically just doing #0-2 for you. The functions which you declare inside of a class are considered class variables, and the decorator syntax reassigns them to the descriptor object created by property() or cached_property(). The underlying functionality has been around since Python 2.x, and so you don't actually need explicit language support to do this kind of chicanery (or rather, the language support which you need already existed). It's always been possible to write out the logic by hand, and that is what PEP 649 currently proposes doing (probably to make it easier for end users to inspect __co_annotations__ separately for debugging purposes). But at runtime, the difference is largely immaterial.

Incidentally, plain old functions are already descriptors (at the C level, naturally). That's how the self parameter gets set on a method call. The @staticmethod decorator basically wraps it in a descriptor-that-returns-the-original-function to defeat this mechanism (the descriptor protocol is not recursive), and @classmethod is also a descriptor. This is why you can sometimes run into problems if you try to stack those decorators or do anything "interesting" with them.

You can use this to build really interesting things, like Django's model/field syntax, which dynamically transpiles Python method calls into SQL.


Copyright © 2021, Eklektix, Inc.
This article may be redistributed under the terms of the Creative Commons CC BY-SA 4.0 license
Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds