gh-132426: Add get_annotate_from_class_namespace replacing get_annotate_function (#132490)

As noted on the issue, making get_annotate_function() support both types and
mappings is problematic because one object may be both. So let's add a new one
that works with any mapping.

This leaves get_annotate_function() not very useful, so remove it.
This commit is contained in:
Jelle Zijlstra 2025-05-04 07:26:42 -07:00 committed by GitHub
parent 5a57248b22
commit 7cb86c5def
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 136 additions and 65 deletions

View file

@ -40,7 +40,7 @@ The :func:`get_annotations` function is the main entry point for
retrieving annotations. Given a function, class, or module, it returns retrieving annotations. Given a function, class, or module, it returns
an annotations dictionary in the requested format. This module also provides an annotations dictionary in the requested format. This module also provides
functionality for working directly with the :term:`annotate function` functionality for working directly with the :term:`annotate function`
that is used to evaluate annotations, such as :func:`get_annotate_function` that is used to evaluate annotations, such as :func:`get_annotate_from_class_namespace`
and :func:`call_annotate_function`, as well as the and :func:`call_annotate_function`, as well as the
:func:`call_evaluate_function` function for working with :func:`call_evaluate_function` function for working with
:term:`evaluate functions <evaluate function>`. :term:`evaluate functions <evaluate function>`.
@ -300,15 +300,13 @@ Functions
.. versionadded:: 3.14 .. versionadded:: 3.14
.. function:: get_annotate_function(obj) .. function:: get_annotate_from_class_namespace(namespace)
Retrieve the :term:`annotate function` for *obj*. Return :const:`!None` Retrieve the :term:`annotate function` from a class namespace dictionary *namespace*.
if *obj* does not have an annotate function. *obj* may be a class, function, Return :const:`!None` if the namespace does not contain an annotate function.
module, or a namespace dictionary for a class. The last case is useful during This is primarily useful before the class has been fully created (e.g., in a metaclass);
class creation, e.g. in the ``__new__`` method of a metaclass. after the class exists, the annotate function can be retrieved with ``cls.__annotate__``.
See :ref:`below <annotationlib-metaclass>` for an example using this function in a metaclass.
This is usually equivalent to accessing the :attr:`~object.__annotate__`
attribute of *obj*, but access through this public function is preferred.
.. versionadded:: 3.14 .. versionadded:: 3.14
@ -407,3 +405,76 @@ Functions
.. versionadded:: 3.14 .. versionadded:: 3.14
Recipes
-------
.. _annotationlib-metaclass:
Using annotations in a metaclass
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
A :ref:`metaclass <metaclasses>` may want to inspect or even modify the annotations
in a class body during class creation. Doing so requires retrieving annotations
from the class namespace dictionary. For classes created with
``from __future__ import annotations``, the annotations will be in the ``__annotations__``
key of the dictionary. For other classes with annotations,
:func:`get_annotate_from_class_namespace` can be used to get the
annotate function, and :func:`call_annotate_function` can be used to call it and
retrieve the annotations. Using the :attr:`~Format.FORWARDREF` format will usually
be best, because this allows the annotations to refer to names that cannot yet be
resolved when the class is created.
To modify the annotations, it is best to create a wrapper annotate function
that calls the original annotate function, makes any necessary adjustments, and
returns the result.
Below is an example of a metaclass that filters out all :class:`typing.ClassVar`
annotations from the class and puts them in a separate attribute:
.. code-block:: python
import annotationlib
import typing
class ClassVarSeparator(type):
def __new__(mcls, name, bases, ns):
if "__annotations__" in ns: # from __future__ import annotations
annotations = ns["__annotations__"]
classvar_keys = {
key for key, value in annotations.items()
# Use string comparison for simplicity; a more robust solution
# could use annotationlib.ForwardRef.evaluate
if value.startswith("ClassVar")
}
classvars = {key: annotations[key] for key in classvar_keys}
ns["__annotations__"] = {
key: value for key, value in annotations.items()
if key not in classvar_keys
}
wrapped_annotate = None
elif annotate := annotationlib.get_annotate_from_class_namespace(ns):
annotations = annotationlib.call_annotate_function(
annotate, format=annotationlib.Format.FORWARDREF
)
classvar_keys = {
key for key, value in annotations.items()
if typing.get_origin(value) is typing.ClassVar
}
classvars = {key: annotations[key] for key in classvar_keys}
def wrapped_annotate(format):
annos = annotationlib.call_annotate_function(annotate, format, owner=typ)
return {key: value for key, value in annos.items() if key not in classvar_keys}
else: # no annotations
classvars = {}
wrapped_annotate = None
typ = super().__new__(mcls, name, bases, ns)
if wrapped_annotate is not None:
# Wrap the original __annotate__ with a wrapper that removes ClassVars
typ.__annotate__ = wrapped_annotate
typ.classvars = classvars # Store the ClassVars in a separate attribute
return typ

View file

@ -1228,15 +1228,9 @@ Special attributes
:attr:`__annotations__ attributes <object.__annotations__>`. :attr:`__annotations__ attributes <object.__annotations__>`.
For best practices on working with :attr:`~object.__annotations__`, For best practices on working with :attr:`~object.__annotations__`,
please see :mod:`annotationlib`. please see :mod:`annotationlib`. Where possible, use
:func:`annotationlib.get_annotations` instead of accessing this
.. caution:: attribute directly.
Accessing the :attr:`!__annotations__` attribute of a class
object directly may yield incorrect results in the presence of
metaclasses. In addition, the attribute may not exist for
some classes. Use :func:`annotationlib.get_annotations` to
retrieve class annotations safely.
.. versionchanged:: 3.14 .. versionchanged:: 3.14
Annotations are now :ref:`lazily evaluated <lazy-evaluation>`. Annotations are now :ref:`lazily evaluated <lazy-evaluation>`.
@ -1247,13 +1241,6 @@ Special attributes
if the class has no annotations. if the class has no annotations.
See also: :attr:`__annotate__ attributes <object.__annotate__>`. See also: :attr:`__annotate__ attributes <object.__annotate__>`.
.. caution::
Accessing the :attr:`!__annotate__` attribute of a class
object directly may yield incorrect results in the presence of
metaclasses. Use :func:`annotationlib.get_annotate_function` to
retrieve the annotate function safely.
.. versionadded:: 3.14 .. versionadded:: 3.14
* - .. attribute:: type.__type_params__ * - .. attribute:: type.__type_params__

View file

@ -12,7 +12,7 @@ __all__ = [
"ForwardRef", "ForwardRef",
"call_annotate_function", "call_annotate_function",
"call_evaluate_function", "call_evaluate_function",
"get_annotate_function", "get_annotate_from_class_namespace",
"get_annotations", "get_annotations",
"annotations_to_string", "annotations_to_string",
"type_repr", "type_repr",
@ -619,20 +619,16 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
raise ValueError(f"Invalid format: {format!r}") raise ValueError(f"Invalid format: {format!r}")
def get_annotate_function(obj): def get_annotate_from_class_namespace(obj):
"""Get the __annotate__ function for an object. """Retrieve the annotate function from a class namespace dictionary.
obj may be a function, class, or module, or a user-defined type with Return None if the namespace does not contain an annotate function.
an `__annotate__` attribute. This is useful in metaclass ``__new__`` methods to retrieve the annotate function.
Returns the __annotate__ function or None.
""" """
if isinstance(obj, dict): try:
try: return obj["__annotate__"]
return obj["__annotate__"] except KeyError:
except KeyError: return obj.get("__annotate_func__", None)
return obj.get("__annotate_func__", None)
return getattr(obj, "__annotate__", None)
def get_annotations( def get_annotations(
@ -832,7 +828,7 @@ def _get_and_call_annotate(obj, format):
May not return a fresh dictionary. May not return a fresh dictionary.
""" """
annotate = get_annotate_function(obj) annotate = getattr(obj, "__annotate__", None)
if annotate is not None: if annotate is not None:
ann = call_annotate_function(annotate, format, owner=obj) ann = call_annotate_function(annotate, format, owner=obj)
if not isinstance(ann, dict): if not isinstance(ann, dict):

View file

@ -1,5 +1,6 @@
"""Tests for the annotations module.""" """Tests for the annotations module."""
import textwrap
import annotationlib import annotationlib
import builtins import builtins
import collections import collections
@ -12,7 +13,6 @@ from annotationlib import (
Format, Format,
ForwardRef, ForwardRef,
get_annotations, get_annotations,
get_annotate_function,
annotations_to_string, annotations_to_string,
type_repr, type_repr,
) )
@ -933,13 +933,13 @@ class MetaclassTests(unittest.TestCase):
b: float b: float
self.assertEqual(get_annotations(Meta), {"a": int}) self.assertEqual(get_annotations(Meta), {"a": int})
self.assertEqual(get_annotate_function(Meta)(Format.VALUE), {"a": int}) self.assertEqual(Meta.__annotate__(Format.VALUE), {"a": int})
self.assertEqual(get_annotations(X), {}) self.assertEqual(get_annotations(X), {})
self.assertIs(get_annotate_function(X), None) self.assertIs(X.__annotate__, None)
self.assertEqual(get_annotations(Y), {"b": float}) self.assertEqual(get_annotations(Y), {"b": float})
self.assertEqual(get_annotate_function(Y)(Format.VALUE), {"b": float}) self.assertEqual(Y.__annotate__(Format.VALUE), {"b": float})
def test_unannotated_meta(self): def test_unannotated_meta(self):
class Meta(type): class Meta(type):
@ -952,13 +952,13 @@ class MetaclassTests(unittest.TestCase):
pass pass
self.assertEqual(get_annotations(Meta), {}) self.assertEqual(get_annotations(Meta), {})
self.assertIs(get_annotate_function(Meta), None) self.assertIs(Meta.__annotate__, None)
self.assertEqual(get_annotations(Y), {}) self.assertEqual(get_annotations(Y), {})
self.assertIs(get_annotate_function(Y), None) self.assertIs(Y.__annotate__, None)
self.assertEqual(get_annotations(X), {"a": str}) self.assertEqual(get_annotations(X), {"a": str})
self.assertEqual(get_annotate_function(X)(Format.VALUE), {"a": str}) self.assertEqual(X.__annotate__(Format.VALUE), {"a": str})
def test_ordering(self): def test_ordering(self):
# Based on a sample by David Ellis # Based on a sample by David Ellis
@ -996,7 +996,7 @@ class MetaclassTests(unittest.TestCase):
for c in classes: for c in classes:
with self.subTest(c=c): with self.subTest(c=c):
self.assertEqual(get_annotations(c), c.expected_annotations) self.assertEqual(get_annotations(c), c.expected_annotations)
annotate_func = get_annotate_function(c) annotate_func = getattr(c, "__annotate__", None)
if c.expected_annotations: if c.expected_annotations:
self.assertEqual( self.assertEqual(
annotate_func(Format.VALUE), c.expected_annotations annotate_func(Format.VALUE), c.expected_annotations
@ -1005,25 +1005,39 @@ class MetaclassTests(unittest.TestCase):
self.assertIs(annotate_func, None) self.assertIs(annotate_func, None)
class TestGetAnnotateFunction(unittest.TestCase): class TestGetAnnotateFromClassNamespace(unittest.TestCase):
def test_static_class(self): def test_with_metaclass(self):
self.assertIsNone(get_annotate_function(object)) class Meta(type):
self.assertIsNone(get_annotate_function(int)) def __new__(mcls, name, bases, ns):
annotate = annotationlib.get_annotate_from_class_namespace(ns)
expected = ns["expected_annotate"]
with self.subTest(name=name):
if expected:
self.assertIsNotNone(annotate)
else:
self.assertIsNone(annotate)
return super().__new__(mcls, name, bases, ns)
def test_unannotated_class(self): class HasAnnotations(metaclass=Meta):
class C: expected_annotate = True
pass
self.assertIsNone(get_annotate_function(C))
D = type("D", (), {})
self.assertIsNone(get_annotate_function(D))
def test_annotated_class(self):
class C:
a: int a: int
self.assertEqual(get_annotate_function(C)(Format.VALUE), {"a": int}) class NoAnnotations(metaclass=Meta):
expected_annotate = False
class CustomAnnotate(metaclass=Meta):
expected_annotate = True
def __annotate__(format):
return {}
code = """
from __future__ import annotations
class HasFutureAnnotations(metaclass=Meta):
expected_annotate = False
a: int
"""
exec(textwrap.dedent(code), {"Meta": Meta})
class TestTypeRepr(unittest.TestCase): class TestTypeRepr(unittest.TestCase):

View file

@ -2906,7 +2906,7 @@ class NamedTupleMeta(type):
types = ns["__annotations__"] types = ns["__annotations__"]
field_names = list(types) field_names = list(types)
annotate = _make_eager_annotate(types) annotate = _make_eager_annotate(types)
elif (original_annotate := _lazy_annotationlib.get_annotate_function(ns)) is not None: elif (original_annotate := _lazy_annotationlib.get_annotate_from_class_namespace(ns)) is not None:
types = _lazy_annotationlib.call_annotate_function( types = _lazy_annotationlib.call_annotate_function(
original_annotate, _lazy_annotationlib.Format.FORWARDREF) original_annotate, _lazy_annotationlib.Format.FORWARDREF)
field_names = list(types) field_names = list(types)
@ -3092,7 +3092,7 @@ class _TypedDictMeta(type):
if "__annotations__" in ns: if "__annotations__" in ns:
own_annotate = None own_annotate = None
own_annotations = ns["__annotations__"] own_annotations = ns["__annotations__"]
elif (own_annotate := _lazy_annotationlib.get_annotate_function(ns)) is not None: elif (own_annotate := _lazy_annotationlib.get_annotate_from_class_namespace(ns)) is not None:
own_annotations = _lazy_annotationlib.call_annotate_function( own_annotations = _lazy_annotationlib.call_annotate_function(
own_annotate, _lazy_annotationlib.Format.FORWARDREF, owner=tp_dict own_annotate, _lazy_annotationlib.Format.FORWARDREF, owner=tp_dict
) )

View file

@ -0,0 +1,3 @@
Add :func:`annotationlib.get_annotate_from_class_namespace` as a helper for
accessing annotations in metaclasses, and remove
``annotationlib.get_annotate_function``.