Skip to content

gh-132426: Add get_annotate_from_class_namespace replacing get_annotate_function #132490

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 80 additions & 9 deletions Doc/library/annotationlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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>`.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

19 changes: 3 additions & 16 deletions Doc/reference/datamodel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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>`.
Expand All @@ -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__
Expand Down
24 changes: 10 additions & 14 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
60 changes: 37 additions & 23 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the annotations module."""

import textwrap
import annotationlib
import builtins
import collections
Expand All @@ -12,7 +13,6 @@
Format,
ForwardRef,
get_annotations,
get_annotate_function,
annotations_to_string,
type_repr,
)
Expand Down Expand Up @@ -1085,13 +1085,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):
Expand All @@ -1104,13 +1104,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
Expand Down Expand Up @@ -1148,7 +1148,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
Expand All @@ -1157,25 +1157,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 TestToSource(unittest.TestCase):
Expand Down
4 changes: 2 additions & 2 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2896,7 +2896,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)
Expand Down Expand Up @@ -3082,7 +3082,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
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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``.
Loading