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
an annotations dictionary in the requested format. This module also provides
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
:func:`call_evaluate_function` function for working with
:term:`evaluate functions <evaluate function>`.
@ -300,15 +300,13 @@ Functions
.. 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`
if *obj* does not have an annotate function. *obj* may be a class, function,
module, or a namespace dictionary for a class. The last case is useful during
class creation, e.g. in the ``__new__`` method of a metaclass.
This is usually equivalent to accessing the :attr:`~object.__annotate__`
attribute of *obj*, but access through this public function is preferred.
Retrieve the :term:`annotate function` from a class namespace dictionary *namespace*.
Return :const:`!None` if the namespace does not contain an annotate function.
This is primarily useful before the class has been fully created (e.g., in 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.
.. versionadded:: 3.14
@ -407,3 +405,76 @@ Functions
.. 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__>`.
For best practices on working with :attr:`~object.__annotations__`,
please see :mod:`annotationlib`.
.. caution::
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.
please see :mod:`annotationlib`. Where possible, use
:func:`annotationlib.get_annotations` instead of accessing this
attribute directly.
.. versionchanged:: 3.14
Annotations are now :ref:`lazily evaluated <lazy-evaluation>`.
@ -1247,13 +1241,6 @@ Special attributes
if the class has no annotations.
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
* - .. attribute:: type.__type_params__

View file

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

View file

@ -1,5 +1,6 @@
"""Tests for the annotations module."""
import textwrap
import annotationlib
import builtins
import collections
@ -12,7 +13,6 @@ from annotationlib import (
Format,
ForwardRef,
get_annotations,
get_annotate_function,
annotations_to_string,
type_repr,
)
@ -933,13 +933,13 @@ class MetaclassTests(unittest.TestCase):
b: float
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.assertIs(get_annotate_function(X), None)
self.assertIs(X.__annotate__, None)
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):
class Meta(type):
@ -952,13 +952,13 @@ class MetaclassTests(unittest.TestCase):
pass
self.assertEqual(get_annotations(Meta), {})
self.assertIs(get_annotate_function(Meta), None)
self.assertIs(Meta.__annotate__, None)
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_annotate_function(X)(Format.VALUE), {"a": str})
self.assertEqual(X.__annotate__(Format.VALUE), {"a": str})
def test_ordering(self):
# Based on a sample by David Ellis
@ -996,7 +996,7 @@ class MetaclassTests(unittest.TestCase):
for c in classes:
with self.subTest(c=c):
self.assertEqual(get_annotations(c), c.expected_annotations)
annotate_func = get_annotate_function(c)
annotate_func = getattr(c, "__annotate__", None)
if c.expected_annotations:
self.assertEqual(
annotate_func(Format.VALUE), c.expected_annotations
@ -1005,25 +1005,39 @@ class MetaclassTests(unittest.TestCase):
self.assertIs(annotate_func, None)
class TestGetAnnotateFunction(unittest.TestCase):
def test_static_class(self):
self.assertIsNone(get_annotate_function(object))
self.assertIsNone(get_annotate_function(int))
class TestGetAnnotateFromClassNamespace(unittest.TestCase):
def test_with_metaclass(self):
class Meta(type):
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 C:
pass
self.assertIsNone(get_annotate_function(C))
D = type("D", (), {})
self.assertIsNone(get_annotate_function(D))
def test_annotated_class(self):
class C:
class HasAnnotations(metaclass=Meta):
expected_annotate = True
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):

View file

@ -2906,7 +2906,7 @@ class NamedTupleMeta(type):
types = ns["__annotations__"]
field_names = list(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(
original_annotate, _lazy_annotationlib.Format.FORWARDREF)
field_names = list(types)
@ -3092,7 +3092,7 @@ class _TypedDictMeta(type):
if "__annotations__" in ns:
own_annotate = None
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_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``.