https://github.com/python/cpython/commit/7cb86c5defa17147c67b56c4227e74e4c5968686
commit: 7cb86c5defa17147c67b56c4227e74e4c5968686
branch: main
author: Jelle Zijlstra <[email protected]>
committer: JelleZijlstra <[email protected]>
date: 2025-05-04T07:26:42-07:00
summary:
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.
files:
A Misc/NEWS.d/next/Library/2025-04-13-21-11-11.gh-issue-132426.SZno1d.rst
M Doc/library/annotationlib.rst
M Doc/reference/datamodel.rst
M Lib/annotationlib.py
M Lib/test/test_annotationlib.py
M Lib/typing.py
diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst
index 7946cd3a3ced34..b9932a9e4cca1f 100644
--- a/Doc/library/annotationlib.rst
+++ b/Doc/library/annotationlib.rst
@@ -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
+
diff --git a/Doc/reference/datamodel.rst b/Doc/reference/datamodel.rst
index b3096a9f0c3820..ea5b84b00c008c 100644
--- a/Doc/reference/datamodel.rst
+++ b/Doc/reference/datamodel.rst
@@ -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__
diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py
index 971f636f9714d7..37f51e69f94127 100644
--- a/Lib/annotationlib.py
+++ b/Lib/annotationlib.py
@@ -12,7 +12,7 @@
"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):
diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py
index be55f044b15ccb..404a8ccc9d3741 100644
--- a/Lib/test/test_annotationlib.py
+++ b/Lib/test/test_annotationlib.py
@@ -1,5 +1,6 @@
"""Tests for the annotations module."""
+import textwrap
import annotationlib
import builtins
import collections
@@ -12,7 +13,6 @@
Format,
ForwardRef,
get_annotations,
- get_annotate_function,
annotations_to_string,
type_repr,
)
@@ -933,13 +933,13 @@ class Y(metaclass=Meta):
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 Y(X):
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 D(metaclass=Meta):
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 D(metaclass=Meta):
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))
-
- def test_unannotated_class(self):
- class C:
- pass
+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)
+
+ class HasAnnotations(metaclass=Meta):
+ expected_annotate = True
+ a: int
- self.assertIsNone(get_annotate_function(C))
+ class NoAnnotations(metaclass=Meta):
+ expected_annotate = False
- D = type("D", (), {})
- self.assertIsNone(get_annotate_function(D))
+ class CustomAnnotate(metaclass=Meta):
+ expected_annotate = True
+ def __annotate__(format):
+ return {}
- def test_annotated_class(self):
- class C:
- a: int
+ code = """
+ from __future__ import annotations
- self.assertEqual(get_annotate_function(C)(Format.VALUE), {"a": int})
+ class HasFutureAnnotations(metaclass=Meta):
+ expected_annotate = False
+ a: int
+ """
+ exec(textwrap.dedent(code), {"Meta": Meta})
class TestTypeRepr(unittest.TestCase):
diff --git a/Lib/typing.py b/Lib/typing.py
index f70dcd0b5b7b5c..e019c5975800a9 100644
--- a/Lib/typing.py
+++ b/Lib/typing.py
@@ -2906,7 +2906,7 @@ def __new__(cls, typename, bases, ns):
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 @@ def __new__(cls, name, bases, ns, total=True):
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
)
diff --git
a/Misc/NEWS.d/next/Library/2025-04-13-21-11-11.gh-issue-132426.SZno1d.rst
b/Misc/NEWS.d/next/Library/2025-04-13-21-11-11.gh-issue-132426.SZno1d.rst
new file mode 100644
index 00000000000000..1f2b9a2df936c6
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-04-13-21-11-11.gh-issue-132426.SZno1d.rst
@@ -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``.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: [email protected]