Skip to content
Open
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
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):
# 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