diff --git a/Doc/glossary.rst b/Doc/glossary.rst index a4066d42927f64..11fbe06412a566 100644 --- a/Doc/glossary.rst +++ b/Doc/glossary.rst @@ -39,10 +39,11 @@ Glossary ABCs with the :mod:`abc` module. annotate function - A function that can be called to retrieve the :term:`annotations ` - of an object. This function is accessible as the :attr:`~object.__annotate__` - attribute of functions, classes, and modules. Annotate functions are a - subset of :term:`evaluate functions `. + A callable that can be called to retrieve the :term:`annotations ` of + an object. Annotate functions are usually :term:`functions `, + automatically generated as the :attr:`~object.__annotate__` attribute of functions, + classes, and modules. Annotate functions are a subset of + :term:`evaluate functions `. annotation A label associated with a variable, a class diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst index 40f2a6dc30460b..afb4a7de2f2099 100644 --- a/Doc/library/annotationlib.rst +++ b/Doc/library/annotationlib.rst @@ -510,6 +510,79 @@ annotations from the class and puts them in a separate attribute: return typ +Creating a custom callable annotate function +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Custom :term:`annotate functions ` may be literal functions like those +automatically generated for functions, classes, and modules. Or, they may wish to utilise +the encapsulation provided by classes, in which case any :term:`callable` can be used as +an :term:`annotate function`. + +To provide the :attr:`~Format.VALUE`, :attr:`~Format.STRING`, or +:attr:`~Format.FORWARDREF` formats directly, an :term:`annotate function` must provide +the following attribute: + +* A callable ``__call__`` with signature ``__call__(format, /) -> dict``, that does not + raise a :exc:`NotImplementedError` when called with a supported format. + +To provide the :attr:`~Format.VALUE_WITH_FAKE_GLOBALS` format, which is used to +automatically generate :attr:`~Format.STRING` or :attr:`~Format.FORWARDREF` if they are +not supported directly, :term:`annotate functions ` must provide the +following attributes: + +* A callable ``__call__`` with signature ``__call__(format, /) -> dict``, that does not + raise a :exc:`NotImplementedError` when called with + :attr:`~Format.VALUE_WITH_FAKE_GLOBALS`. +* A :ref:`code object ` ``__code__`` containing the compiled code for the + annotate function. +* Optional: A tuple of the function's positional defaults ``__kwdefaults__``, if the + function represented by ``__code__`` uses any positional defaults. +* Optional: A dict of the function's keyword defaults ``__defaults__``, if the function + represented by ``__code__`` uses any keyword defaults. +* Optional: All other :ref:`function attributes `. + +.. code-block:: python + + class Annotate: + called_formats = [] + + def __call__(self, format=None, /, *, _self=None): + # When called with fake globals, `_self` will be the + # actual self value, and `self` will be the format. + if _self is not None: + self, format = _self, self + + self.called_formats.append(format) + if format <= 2: # VALUE or VALUE_WITH_FAKE_GLOBALS + return {"x": MyType} + raise NotImplementedError + + __defaults__ = (None,) + + __kwdefaults__ = property(lambda self: dict(_self=self)) + + __code__ = property(lambda self: self.__call__.__code__) + +This can then be called with: + +.. code-block:: pycon + + >>> from annotationlib import call_annotate_function, Format + >>> call_annotate_function(Annotate(), format=Format.STRING) + {'x': 'MyType'} + +Or used as the annotate function for an object: + +.. code-block:: pycon + + >>> from annotationlib import get_annotations, Format + >>> class C: + ... pass + >>> C.__annotate__ = Annotate() + >>> get_annotations(Annotate(), format=Format.STRING) + {'x': 'MyType'} + + Limitations of the ``STRING`` format ------------------------------------ diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 8208d0e9c94819..1caf16b2602995 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -8,6 +8,7 @@ import itertools import pickle from string.templatelib import Template, Interpolation +import types import typing import sys import unittest @@ -1590,6 +1591,84 @@ def annotate(format, /): # Some non-Format value annotationlib.call_annotate_function(annotate, 7) + def test_basic_non_function_annotate(self): + class Annotate: + def __call__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {'x': str} + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + return {'x': int} + elif format == __Format.STRING: + return {'x': "float"} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function(Annotate(), Format.VALUE) + self.assertEqual(annotations, {"x": str}) + + annotations = annotationlib.call_annotate_function(Annotate(), Format.STRING) + self.assertEqual(annotations, {"x": "float"}) + + with self.assertRaises(AttributeError) as cm: + annotations = annotationlib.call_annotate_function( + Annotate(), Format.FORWARDREF + ) + + self.assertEqual(cm.exception.name, "__builtins__") + self.assertIsInstance(cm.exception.obj, Annotate) + + def test_full_non_function_annotate(self): + def outer(): + local = str + + class Annotate: + called_formats = [] + + def __call__(self, format=None, *, _self=None): + nonlocal local + if _self is not None: + self, format = _self, self + + self.called_formats.append(format) + if format == 1: # VALUE + return {"x": MyClass, "y": int, "z": local} + if format == 2: # VALUE_WITH_FAKE_GLOBALS + return {"w": unknown, "x": MyClass, "y": int, "z": local} + raise NotImplementedError + + __globals__ = {"MyClass": MyClass} + __builtins__ = {"int": int} + __closure__ = (types.CellType(str),) + __defaults__ = (None,) + + __kwdefaults__ = property(lambda self: dict(_self=self)) + __code__ = property(lambda self: self.__call__.__code__) + + return Annotate() + + annotate = outer() + + self.assertEqual( + annotationlib.call_annotate_function(annotate, Format.VALUE), + {"x": MyClass, "y": int, "z": str} + ) + self.assertEqual(annotate.called_formats[-1], Format.VALUE) + + self.assertEqual( + annotationlib.call_annotate_function(annotate, Format.STRING), + {"w": "unknown", "x": "MyClass", "y": "int", "z": "local"} + ) + self.assertIn(Format.STRING, annotate.called_formats) + self.assertEqual(annotate.called_formats[-1], Format.VALUE_WITH_FAKE_GLOBALS) + + self.assertEqual( + annotationlib.call_annotate_function(annotate, Format.FORWARDREF), + {"w": support.EqualToForwardRef("unknown"), "x": MyClass, "y": int, "z": str} + ) + self.assertIn(Format.FORWARDREF, annotate.called_formats) + self.assertEqual(annotate.called_formats[-1], Format.VALUE_WITH_FAKE_GLOBALS) + def test_error_from_value_raised(self): # Test that the error from format.VALUE is raised # if all formats fail diff --git a/Misc/NEWS.d/next/Library/2025-12-06-08-48-26.gh-issue-141449.hQvNW_.rst b/Misc/NEWS.d/next/Library/2025-12-06-08-48-26.gh-issue-141449.hQvNW_.rst new file mode 100644 index 00000000000000..8509e98253f57c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-06-08-48-26.gh-issue-141449.hQvNW_.rst @@ -0,0 +1,2 @@ +Improve support, error messages, and documentation for non-function callables as +:term:`annotate functions `.