Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0328ac1
Add `_get_annotate_attr()` to access attrs on non-func annotates, and…
dr-carlos Nov 12, 2025
d98ef64
Implement non-func callable annotate support for `call_annotate_funct…
dr-carlos Nov 12, 2025
b600f8c
Fix `__code__` access in `_build_closure` for non-func annotates
dr-carlos Nov 12, 2025
a9a7f88
Add `_direct_call_annotate()` and support callable classes
dr-carlos Nov 12, 2025
4703e8d
Support `functools.partial` objects as annotate functions
dr-carlos Nov 12, 2025
6e31007
Support placeholders in `functools.partial` annotate functions
dr-carlos Nov 12, 2025
f752c48
Add tests for `call_annotate_function()` with classes, instances, and…
dr-carlos Nov 12, 2025
9e4faa4
Test `get_annotations()` on callable class instance
dr-carlos Nov 12, 2025
c4d8d9d
Support `functools.cache` callables in `call_annotate_function()`
dr-carlos Nov 12, 2025
a83ca8f
Improve quality of test for cached annotate function
dr-carlos Nov 12, 2025
e6bc7a0
Don't create an unused new cache wrapper for cached annotate functions
dr-carlos Nov 12, 2025
2e4b927
Test `functools` wrapped as annotate function
dr-carlos Nov 12, 2025
4b955a8
Support `functools.partialmethod` annotate functions
dr-carlos Nov 12, 2025
f5ea8a0
Test `functools.singledispatch`/`functools.singledispatchmethod` anno…
dr-carlos Nov 12, 2025
a43a873
Support methods as annotate functions
dr-carlos Nov 12, 2025
a3b68ee
Test classmethods and staticmethods as annotate functions
dr-carlos Nov 12, 2025
ea60223
Update and simplify classmethod/staticmethod annotate function tests
dr-carlos Nov 12, 2025
c16083b
Add standard method annotate function test
dr-carlos Nov 12, 2025
ba1927c
Support and test generics as annotate callables
dr-carlos Nov 13, 2025
b96532f
Add secondary test for staticmethod as annotate function
dr-carlos Nov 14, 2025
e70b489
Test `typing._BaseGenericAlias` callables as annotate functions
dr-carlos Nov 14, 2025
8df3d24
Support recursive unwrapping of annotate functions
dr-carlos Nov 14, 2025
61b76a5
Support recursive unwrapping and calling of methods as annotate funct…
dr-carlos Nov 14, 2025
ac888cc
Support (recursively unwrap/call) any type of callable as an annotati…
dr-carlos Nov 14, 2025
6a48bbe
Add test to actually instantiate class for callable class as an annot…
dr-carlos Nov 14, 2025
1d35db0
Test that `GenericAlias` objects which cannot have `__orig_class__` s…
dr-carlos Nov 14, 2025
c2cccff
Improve comments and cleanup new tests
dr-carlos Nov 18, 2025
2ca8f95
Improve test for annotation function as method of non-function callable
dr-carlos Nov 18, 2025
33c9d13
Support fake globals in Python generic classes' __init__ methods
dr-carlos Nov 18, 2025
d2dc8a3
Improve comments for annotate callables in `annotationlib`
dr-carlos Nov 18, 2025
4acb56b
Add NEWS entry
dr-carlos Nov 18, 2025
b749c49
Support arbitrary callables as __init__ methods for class annotate fu…
dr-carlos Nov 19, 2025
a76d794
Improve error message when `__code__` attribute is not found on annot…
dr-carlos Nov 19, 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
88 changes: 69 additions & 19 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,13 +728,13 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
annotate, owner, is_class, globals, allow_evaluation=False
)
func = types.FunctionType(
annotate.__code__,
_get_annotate_attr(annotate, "__code__", None),
globals,
closure=closure,
argdefs=annotate.__defaults__,
kwdefaults=annotate.__kwdefaults__,
argdefs=_get_annotate_attr(annotate, "__defaults__", None),
kwdefaults=_get_annotate_attr(annotate, "__kwdefaults__", None),
)
annos = func(Format.VALUE_WITH_FAKE_GLOBALS)
annos = _direct_call_annotate(func, annotate, Format.VALUE_WITH_FAKE_GLOBALS)
if _is_evaluate:
return _stringify_single(annos)
return {
Expand All @@ -759,11 +759,18 @@ 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 = _get_annotate_attr(annotate, "__globals__", {})
annotate_code = _get_annotate_attr(annotate, "__code__", None)
annotate_defaults = _get_annotate_attr(annotate, "__defaults__", None)
annotate_kwdefaults = _get_annotate_attr(annotate, "__kwdefaults__", None)
namespace = {
**_get_annotate_attr(annotate, "__builtins__", {}),
**annotate_globals
}
is_class = isinstance(owner, type)
globals = _StringifierDict(
namespace,
globals=annotate.__globals__,
globals=annotate_globals,
owner=owner,
is_class=is_class,
format=format,
Expand All @@ -772,14 +779,14 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
annotate, owner, is_class, globals, allow_evaluation=True
)
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)
result = _direct_call_annotate(func, annotate, Format.VALUE_WITH_FAKE_GLOBALS)
except NotImplementedError:
# FORWARDREF and VALUE_WITH_FAKE_GLOBALS not supported, fall back to VALUE
return annotate(Format.VALUE)
Expand All @@ -793,7 +800,7 @@ 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,
Expand All @@ -802,13 +809,13 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
annotate, owner, is_class, 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)
result = _direct_call_annotate(func, annotate, Format.VALUE_WITH_FAKE_GLOBALS)
globals.transmogrify(cell_dict)
if _is_evaluate:
if isinstance(result, ForwardRef):
Expand All @@ -833,12 +840,13 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):


def _build_closure(annotate, owner, is_class, stringifier_dict, *, allow_evaluation):
if not annotate.__closure__:
closure = _get_annotate_attr(annotate, "__closure__", None)
if not closure:
return None, None
freevars = annotate.__code__.co_freevars
freevars = _get_annotate_attr(annotate, "__code__", None).co_freevars
new_closure = []
cell_dict = {}
for i, cell in enumerate(annotate.__closure__):
for i, cell in enumerate(closure):
if i < len(freevars):
name = freevars[i]
else:
Expand All @@ -857,7 +865,7 @@ def _build_closure(annotate, owner, is_class, stringifier_dict, *, allow_evaluat
name,
cell=cell,
owner=owner,
globals=annotate.__globals__,
globals=_get_annotate_attr(annotate, "__globals__", {}),
is_class=is_class,
stringifier_dict=stringifier_dict,
)
Expand All @@ -879,6 +887,48 @@ def _stringify_single(anno):
return repr(anno)


def _get_annotate_attr(annotate, attr, default):
if (value := getattr(annotate, attr, None)) is not None:
return value

if isinstance(annotate.__call__, types.MethodType):
if call_func := getattr(annotate.__call__, "__func__", None):
return getattr(call_func, attr, default)
elif isinstance(annotate, type):
return getattr(annotate.__init__, attr, default)
Copy link
Contributor Author

@dr-carlos dr-carlos Nov 12, 2025

Choose a reason for hiding this comment

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

This means that only the __init__ method is used for fake global namespaces when a class is used as an annotate function. Maybe the __new__ method should also count? Just adds a bit more complexity.

elif (
(functools := sys.modules.get("functools", None))
and isinstance(annotate, functools.partial)
):
return getattr(annotate.func, attr, default)

return default

def _direct_call_annotate(func, annotate, format):
Copy link
Contributor Author

@dr-carlos dr-carlos Nov 12, 2025

Choose a reason for hiding this comment

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

This feels like a poorly named function. Need some way tof describing that we're calling the full annotate function (annotate) based on the part of it that we compiled previously (func) with the given args (usually the format, unless we're recursively calling).

# If annotate is a method, we need to pass its self as the first param
if (
hasattr(annotate.__call__, "__func__") and
(self := getattr(annotate.__call__, "__self__", None))
):
return func(self, format)

# If annotate is a class, `func` is the __init__ method, so we still need to call
# __new__() to create the instance
if isinstance(annotate, type):
inst = annotate.__new__(annotate)
func(inst, format)
return inst

# If annotate is a partial function, re-create it with the new function object.
# We could call the function directly, but then we'd have to handle placeholders,
# and this way should be more robust for future changes.
if functools := sys.modules.get("functools", None):
if isinstance(annotate, functools.partial):
return functools.partial(func, *annotate.args, **annotate.keywords)(format)

return func(format)


def get_annotate_from_class_namespace(obj):
"""Retrieve the annotate function from a class namespace dictionary.

Expand Down
123 changes: 123 additions & 0 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,26 @@ def __annotate__(self):
{"x": "int"},
)

def test_non_function_annotate(self):
class AnnotateCallable:
def __call__(self, format, /):
if format > 2:
raise NotImplementedError
return {"x": int}

class OnlyAnnotate:
@property
def __annotate__(self):
return AnnotateCallable()

oa = OnlyAnnotate()
self.assertEqual(get_annotations(oa, format=Format.VALUE), {"x": int})
self.assertEqual(get_annotations(oa, format=Format.FORWARDREF), {"x": int})
self.assertEqual(
get_annotations(oa, format=Format.STRING),
{"x": "int"},
)

def test_non_dict_annotate(self):
class WeirdAnnotate:
def __annotate__(self, *args, **kwargs):
Expand Down Expand Up @@ -1561,6 +1581,92 @@ def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedErr

self.assertEqual(annotations, {"x": "str"})

def test_callable_object_annotate(self):
# If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not
# supported fall back to Format.VALUE and convert to strings
class Annotate:
def __call__(self, format, /):
return {"x": str}

for fmt in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
self.assertEqual(
annotationlib.call_annotate_function(Annotate(), format=fmt),
{"x": str}
)

def test_callable_object_annotate_forwardref_value_fallback(self):
# If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not
# supported fall back to Format.VALUE and convert to strings
class Annotate:
def __call__(self, format, /, __Format=Format,
__NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {"x": str}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
Annotate(),
Format.FORWARDREF,
)

self.assertEqual(annotations, {"x": str})

def test_callable_class_annotate_forwardref_value_fallback(self):
# If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not
# supported fall back to Format.VALUE and convert to strings
class Annotate(dict):
def __init__(self, format, /, __Format=Format,
__NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
super().__init__({"x": int})
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
Annotate,
Format.FORWARDREF,
)

self.assertEqual(annotations, {"x": int})

def test_callable_partial_annotate_forwardref_value_fallback(self):
# If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not
# supported fall back to Format.VALUE and convert to strings
def format(format, second, /, *, third, __Format=Format,
__NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {"x": format * second * third}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
functools.partial(format, functools.Placeholder, 5, third=6),
Format.FORWARDREF,
)

self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6})

def test_callable_object_annotate_string_fakeglobals(self):
# If Format.STRING is not supported but Format.VALUE_WITH_FAKE_GLOBALS is
# prefer that over Format.VALUE
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}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
Annotate(),
Format.STRING,
)

self.assertEqual(annotations, {"x": "int"})

def test_condition_not_stringified(self):
# Make sure the first condition isn't evaluated as True by being converted
# to a _Stringifier
Expand Down Expand Up @@ -1606,6 +1712,23 @@ def annotate(format, /):
with self.assertRaises(DemoException):
annotationlib.call_annotate_function(annotate, format=fmt)

def test_callable_object_error_from_value_raised(self):
# Test that the error from format.VALUE is raised
# if all formats fail

class DemoException(Exception): ...

class Annotate:
def __call__(self, format, /):
if format == Format.VALUE:
raise DemoException()
else:
raise NotImplementedError(format)

for fmt in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
with self.assertRaises(DemoException):
annotationlib.call_annotate_function(Annotate(), format=fmt)


class MetaclassTests(unittest.TestCase):
def test_annotated_meta(self):
Expand Down
Loading