Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
91036ac
Use default values for annotate functions' __globals__, __builtins__,…
dr-carlos Dec 5, 2025
414251b
Improve error messages for annotate functions missing __code__ attribute
dr-carlos Dec 5, 2025
fd6125d
Add non-function annotate tests
dr-carlos Dec 5, 2025
c008676
Don't require __closure__ and __globals__ on annotate functions
dr-carlos Dec 5, 2025
1ab0139
Clarify type of annotate function in glossary
dr-carlos Dec 5, 2025
e177621
Improve backup paths for annotate functions without __builtins__
dr-carlos Dec 5, 2025
fe84920
Add recipe to docs clarifying how non-function annotate functions work
dr-carlos Dec 5, 2025
d42d8ad
Add NEWS entry
dr-carlos Dec 5, 2025
45cb956
Clarify wording in annotate function documentation
dr-carlos Dec 5, 2025
eef70c4
Wrap `_build_closure` definition line to < 90 chars
dr-carlos Dec 5, 2025
44f2a45
Clarify NEWS entry
dr-carlos Dec 5, 2025
095cfb5
Change doctest to python codeblock in `Annotate` class recipe
dr-carlos Dec 5, 2025
d9bf2e8
Remove wrapping of `annotate.__code__` `AttributeError`s
dr-carlos Dec 6, 2025
943181c
Wrap line in `annotationlib`
dr-carlos Dec 6, 2025
a1daa6e
Improve documentation for custom callable annotate functions
dr-carlos Dec 6, 2025
028e0f9
Use pycon instead of python in output blocks
dr-carlos Dec 6, 2025
9cefbaa
Actually use dot points where intended in custom callable doc
dr-carlos Dec 6, 2025
8ec86ee
Fix bullet list indentation
dr-carlos Dec 6, 2025
a70a647
Start code block at right indentation
dr-carlos Dec 6, 2025
1737973
Various formatting changes in `library/annotationlib.rst` docs
dr-carlos Dec 7, 2025
cbc8466
Fix non-function annotate test for non-wrapped AttributeError
dr-carlos Dec 7, 2025
3bfd2e0
Remove test_non_function_annotate() and add improve AttributeError test
dr-carlos Dec 7, 2025
5440128
Simplify/reduce verbosity of class in `test_full_non_function_annotat…
dr-carlos Dec 7, 2025
46ef8b1
Remove defaults fro non-function annotates in `call_annotate_function()`
dr-carlos Dec 7, 2025
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
9 changes: 5 additions & 4 deletions Doc/glossary.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ Glossary
ABCs with the :mod:`abc` module.

annotate function
A function that can be called to retrieve the :term:`annotations <annotation>`
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 <evaluate function>`.
A callable that can be called to retrieve the :term:`annotations <annotation>` of
an object. Annotate functions are usually :term:`functions <function>`,
automatically generated as the :attr:`~object.__annotate__` attribute of functions,
classes, and modules. Annotate functions are a subset of
:term:`evaluate functions <evaluate function>`.

annotation
A label associated with a variable, a class
Expand Down
66 changes: 66 additions & 0 deletions Doc/library/annotationlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,72 @@ annotations from the class and puts them in a separate attribute:
return typ



Creating a custom callable annotate function
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Custom :term:`annotate functions <annotate function>` 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`.

However, :term:`methods <method>`, class instances that implement
:meth:`object.__call__`, and most other callables, do not provide the same attributes as
true functions, which are needed for the :attr:`~Format.VALUE_WITH_FAKE_GLOBALS`
machinery to work. :func:`call_annotate_function` and other :mod:`annotationlib`
functions will attempt to infer those attributes where possible, but some of them must
always be present for :attr:`~Format.VALUE_WITH_FAKE_GLOBALS` to work.

Below is an example of a callable class that provides the necessary attributes to be
used with all formats, and takes advantage of class encapsulation:

.. code-block:: python

class Annotate:
called_formats = []

def __call__(self, format=None, *, _self=None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the signature we want to force?

Copy link
Contributor Author

@dr-carlos dr-carlos Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we're not really forcing it, but you're right that it is a somewhat awkward suggestion. I'll have a think about alternatives.

We could also move the logic in __call__ to a separate method and then do the self and _self handling within __call__ before passing to the method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, at this point, I prefer that we force users to use functions and not arbitrary callable. This additional cost doesn't seem right, it complicates the docs, and makes the code more complex as well.

# 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

@property
def __defaults__(self):
return (None,)

@property
def __kwdefaults__(self):
return {"_self": self}

@property
def __code__(self):
return self.__call__.__code__

This can then be called with:

.. code-block:: python

>>> 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:: python

>>> 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
------------------------------------

Expand Down
65 changes: 46 additions & 19 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,9 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
return annotate(format)
except NotImplementedError:
pass

annotate_defaults = getattr(annotate, "__defaults__", None)
annotate_kwdefaults = getattr(annotate, "__kwdefaults__", None)
if format == Format.STRING:
# STRING is implemented by calling the annotate function in a special
# environment where every name lookup results in an instance of _Stringifier.
Expand All @@ -734,14 +737,23 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
globals = _StringifierDict({}, format=format)
is_class = isinstance(owner, type)
closure, _ = _build_closure(
annotate, owner, is_class, globals, allow_evaluation=False
annotate, owner, is_class, globals,
getattr(annotate, "__globals__", {}), allow_evaluation=False
)
try:
annotate_code = annotate.__code__
except AttributeError:
raise AttributeError(
"annotate function requires __code__ attribute",
name="__code__",
obj=annotate
)
func = types.FunctionType(
annotate.__code__,
annotate_code,
globals,
closure=closure,
argdefs=annotate.__defaults__,
kwdefaults=annotate.__kwdefaults__,
argdefs=annotate_defaults,
kwdefaults=annotate_kwdefaults,
)
annos = func(Format.VALUE_WITH_FAKE_GLOBALS)
if _is_evaluate:
Expand All @@ -768,24 +780,38 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
# reconstruct the source. But in the dictionary that we eventually return, we
# want to return objects with more user-friendly behavior, such as an __eq__
# that returns a bool and an defined set of attributes.
namespace = {**annotate.__builtins__, **annotate.__globals__}
annotate_globals = getattr(annotate, "__globals__", {})
if annotate_builtins := getattr(annotate, "__builtins__", None):
namespace = {**annotate_builtins, **annotate_globals}
elif annotate_builtins := annotate_globals.get("__builtins__"):
namespace = {**annotate_builtins, **annotate_globals}
else:
namespace = {**builtins.__dict__, **annotate_globals}
is_class = isinstance(owner, type)
globals = _StringifierDict(
namespace,
globals=annotate.__globals__,
globals=annotate_globals,
owner=owner,
is_class=is_class,
format=format,
)
closure, cell_dict = _build_closure(
annotate, owner, is_class, globals, allow_evaluation=True
annotate, owner, is_class, globals, annotate_globals, allow_evaluation=True
)
try:
annotate_code = annotate.__code__
except AttributeError:
raise AttributeError(
"annotate function requires __code__ attribute",
name="__code__",
obj=annotate
)
func = types.FunctionType(
annotate.__code__,
annotate_code,
globals,
closure=closure,
argdefs=annotate.__defaults__,
kwdefaults=annotate.__kwdefaults__,
argdefs=annotate_defaults,
kwdefaults=annotate_kwdefaults,
)
try:
result = func(Format.VALUE_WITH_FAKE_GLOBALS)
Expand All @@ -802,20 +828,20 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
# a value in certain cases where an exception gets raised during evaluation.
globals = _StringifierDict(
{},
globals=annotate.__globals__,
globals=annotate_globals,
owner=owner,
is_class=is_class,
format=format,
)
closure, cell_dict = _build_closure(
annotate, owner, is_class, globals, allow_evaluation=False
annotate, owner, is_class, globals, annotate_globals, allow_evaluation=False
)
func = types.FunctionType(
annotate.__code__,
annotate_code,
globals,
closure=closure,
argdefs=annotate.__defaults__,
kwdefaults=annotate.__kwdefaults__,
argdefs=annotate_defaults,
kwdefaults=annotate_kwdefaults,
)
result = func(Format.VALUE_WITH_FAKE_GLOBALS)
globals.transmogrify(cell_dict)
Expand All @@ -841,12 +867,13 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
raise ValueError(f"Invalid format: {format!r}")


def _build_closure(annotate, owner, is_class, stringifier_dict, *, allow_evaluation):
if not annotate.__closure__:
def _build_closure(annotate, owner, is_class, stringifier_dict,
annotate_globals, *, allow_evaluation):
if not (annotate_closure := getattr(annotate, "__closure__", None)):
return None, None
new_closure = []
cell_dict = {}
for name, cell in zip(annotate.__code__.co_freevars, annotate.__closure__, strict=True):
for name, cell in zip(annotate.__code__.co_freevars, annotate_closure, strict=True):
cell_dict[name] = cell
new_cell = None
if allow_evaluation:
Expand All @@ -861,7 +888,7 @@ def _build_closure(annotate, owner, is_class, stringifier_dict, *, allow_evaluat
name,
cell=cell,
owner=owner,
globals=annotate.__globals__,
globals=annotate_globals,
is_class=is_class,
stringifier_dict=stringifier_dict,
)
Expand Down
136 changes: 136 additions & 0 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import itertools
import pickle
from string.templatelib import Template, Interpolation
import types
import typing
import sys
import unittest
Expand Down Expand Up @@ -1590,6 +1591,141 @@ 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.assertRaisesRegex(
AttributeError,
"annotate function requires __code__ attribute"
):
annotations = annotationlib.call_annotate_function(
Annotate(), Format.FORWARDREF
)

def test_non_function_annotate(self):
class Annotate:
called_formats = []

def __call__(self, format=None, *, _self=None):
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

@property
def __defaults__(self):
return (None,)

@property
def __kwdefaults__(self):
return {"_self": self}

@property
def __code__(self):
return self.__call__.__code__

annotate = Annotate()

with self.assertRaises(NameError):
annotationlib.call_annotate_function(annotate, Format.VALUE)
self.assertEqual(annotate.called_formats[-1], Format.VALUE)

annotations = annotationlib.call_annotate_function(annotate, Format.STRING)
self.assertEqual(annotations["x"], "MyType")
self.assertIn(Format.STRING, annotate.called_formats)
self.assertEqual(annotate.called_formats[-1], Format.VALUE_WITH_FAKE_GLOBALS)

annotations = annotationlib.call_annotate_function(annotate, Format.FORWARDREF)
self.assertEqual(annotations["x"], support.EqualToForwardRef("MyType"))
self.assertIn(Format.FORWARDREF, annotate.called_formats)
self.assertEqual(annotate.called_formats[-1], Format.VALUE_WITH_FAKE_GLOBALS)

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

@property
def __globals__(self):
return {"MyClass": MyClass}

@property
def __builtins__(self):
return {"int": int}

@property
def __closure__(self):
return (types.CellType(str),)

@property
def __defaults__(self):
return (None,)

@property
def __kwdefaults__(self):
return {"_self": self}

@property
def __code__(self):
return 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Improve support, error messages, and documentation for non-function callables as
:term:`annotate functions <annotate function>`.
Loading