From 0328ac17909a3ada06e52045b462ddb57dfc33ec Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 10:53:36 +1030 Subject: [PATCH 01/33] Add `_get_annotate_attr()` to access attrs on non-func annotates, and implement in fwdref format --- Lib/annotationlib.py | 53 ++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 2166dbff0ee70c..7e85b8628313ac 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -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, @@ -772,14 +779,17 @@ 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) + if isinstance(annotate.__call__, types.MethodType): + result = func(annotate.__call__.__self__, Format.VALUE_WITH_FAKE_GLOBALS) + else: + result = func(Format.VALUE_WITH_FAKE_GLOBALS) except NotImplementedError: # FORWARDREF and VALUE_WITH_FAKE_GLOBALS not supported, fall back to VALUE return annotate(Format.VALUE) @@ -793,7 +803,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, @@ -802,13 +812,16 @@ 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) + if isinstance(annotate.__call__, types.MethodType): + result = func(annotate.__call__.__self__, Format.VALUE_WITH_FAKE_GLOBALS) + else: + result = func(Format.VALUE_WITH_FAKE_GLOBALS) globals.transmogrify(cell_dict) if _is_evaluate: if isinstance(result, ForwardRef): @@ -833,12 +846,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 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: @@ -857,7 +871,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, ) @@ -879,6 +893,17 @@ 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 call_method := getattr(annotate, "__call__", None): + if call_func := getattr(call_method, "__func__", None): + return getattr(call_func, attr, default) + + return default + + def get_annotate_from_class_namespace(obj): """Retrieve the annotate function from a class namespace dictionary. From d98ef644a0657f0e5bb3f02b2c3bfb8d6e71cc25 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 10:58:07 +1030 Subject: [PATCH 02/33] Implement non-func callable annotate support for `call_annotate_function(format=Format.STRING)` --- Lib/annotationlib.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 7e85b8628313ac..ba81152869c6cb 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -728,13 +728,18 @@ 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) + if isinstance(annotate.__call__, types.MethodType): + annos = func( + annotate.__call__.__self__, Format.VALUE_WITH_FAKE_GLOBALS + ) + else: + annos = func(Format.VALUE_WITH_FAKE_GLOBALS) if _is_evaluate: return _stringify_single(annos) return { From b600f8c7bcc346edd4933f5792400b5dab33b4a0 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 11:00:38 +1030 Subject: [PATCH 03/33] Fix `__code__` access in `_build_closure` for non-func annotates --- Lib/annotationlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index ba81152869c6cb..a15c2d08948909 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -854,7 +854,7 @@ def _build_closure(annotate, owner, is_class, stringifier_dict, *, allow_evaluat 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(closure): From a9a7f8865f7c4c6fb4431f795030e5a9c0784fae Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 11:33:10 +1030 Subject: [PATCH 04/33] Add `_direct_call_annotate()` and support callable classes --- Lib/annotationlib.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index a15c2d08948909..4e9c761ce02126 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -734,12 +734,7 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False): argdefs=_get_annotate_attr(annotate, "__defaults__", None), kwdefaults=_get_annotate_attr(annotate, "__kwdefaults__", None), ) - if isinstance(annotate.__call__, types.MethodType): - annos = func( - annotate.__call__.__self__, Format.VALUE_WITH_FAKE_GLOBALS - ) - else: - 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 { @@ -791,10 +786,7 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False): kwdefaults=annotate_kwdefaults, ) try: - if isinstance(annotate.__call__, types.MethodType): - result = func(annotate.__call__.__self__, Format.VALUE_WITH_FAKE_GLOBALS) - else: - 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) @@ -823,10 +815,7 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False): argdefs=annotate_defaults, kwdefaults=annotate_kwdefaults, ) - if isinstance(annotate.__call__, types.MethodType): - result = func(annotate.__call__.__self__, Format.VALUE_WITH_FAKE_GLOBALS) - else: - 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): @@ -902,12 +891,29 @@ def _get_annotate_attr(annotate, attr, default): if (value := getattr(annotate, attr, None)) is not None: return value - if call_method := getattr(annotate, "__call__", None): - if call_func := getattr(call_method, "__func__", None): + 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) return default +def _direct_call_annotate(func, annotate, format): + # 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 isinstance(annotate, type): + inst = annotate.__new__(annotate) + func(inst, format) + return inst + + return func(format) + def get_annotate_from_class_namespace(obj): """Retrieve the annotate function from a class namespace dictionary. From 4703e8de0e1f3425e458c32c18ca20b966ba3077 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 11:51:29 +1030 Subject: [PATCH 05/33] Support `functools.partial` objects as annotate functions --- Lib/annotationlib.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 4e9c761ce02126..835f3c520bf1fd 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -896,6 +896,11 @@ def _get_annotate_attr(annotate, attr, default): return getattr(call_func, attr, default) elif isinstance(annotate, type): return getattr(annotate.__init__, attr, default) + elif ( + "functools" in sys.modules + and isinstance(annotate, sys.modules["functools"].partial) + ): + return getattr(annotate.func, attr, default) return default @@ -912,6 +917,12 @@ def _direct_call_annotate(func, annotate, format): func(inst, format) return inst + if ( + "functools" in sys.modules + and isinstance(annotate, sys.modules["functools"].partial) + ): + return func(*annotate.args, format, **annotate.keywords) + return func(format) From 6e310074ddd7584aa67750ea95a48463b581dff8 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 13:19:22 +1030 Subject: [PATCH 06/33] Support placeholders in `functools.partial` annotate functions --- Lib/annotationlib.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 835f3c520bf1fd..537aa81389dd9b 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -897,8 +897,8 @@ def _get_annotate_attr(annotate, attr, default): elif isinstance(annotate, type): return getattr(annotate.__init__, attr, default) elif ( - "functools" in sys.modules - and isinstance(annotate, sys.modules["functools"].partial) + (functools := sys.modules.get("functools", None)) + and isinstance(annotate, functools.partial) ): return getattr(annotate.func, attr, default) @@ -912,16 +912,19 @@ def _direct_call_annotate(func, annotate, format): ): 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 ( - "functools" in sys.modules - and isinstance(annotate, sys.modules["functools"].partial) - ): - return func(*annotate.args, format, **annotate.keywords) + # 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) From f752c481aa7863511071e070f4319add248980d7 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 13:21:03 +1030 Subject: [PATCH 07/33] Add tests for `call_annotate_function()` with classes, instances, and partials --- Lib/test/test_annotationlib.py | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 9f3275d5071484..48d6b9c5f153d9 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1561,6 +1561,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 @@ -1606,6 +1692,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): From 9e4faa4362477bb401553e18521de97e3296709e Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 17:46:03 +1030 Subject: [PATCH 08/33] Test `get_annotations()` on callable class instance --- Lib/test/test_annotationlib.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 48d6b9c5f153d9..7b9aa4043f3fe9 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -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): From c4d8d9df5c13f3c1e9c288da6b2b1127066b2e0b Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Wed, 12 Nov 2025 22:31:06 +1030 Subject: [PATCH 09/33] Support `functools.cache` callables in `call_annotate_function()` --- Lib/annotationlib.py | 24 +++++++++++++++++++----- Lib/test/test_annotationlib.py | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 537aa81389dd9b..6a4b97b6fcd9e6 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -894,9 +894,14 @@ def _get_annotate_attr(annotate, attr, default): if isinstance(annotate.__call__, types.MethodType): if call_func := getattr(annotate.__call__, "__func__", None): return getattr(call_func, attr, default) - elif isinstance(annotate, type): + + if isinstance(annotate, type): return getattr(annotate.__init__, attr, default) - elif ( + + if (wrapped := getattr(annotate, "__wrapped__", None)) is not None: + return getattr(wrapped, attr, default) + + if ( (functools := sys.modules.get("functools", None)) and isinstance(annotate, functools.partial) ): @@ -919,13 +924,22 @@ def _direct_call_annotate(func, annotate, format): 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 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 isinstance(annotate, functools.partial): return functools.partial(func, *annotate.args, **annotate.keywords)(format) + # If annotate is a cached function, re-create it with the new function object. + # We want a new, clean, cache, as we've updated the function data, so let's + # re-create with the new function and old cache parameters. + if isinstance(annotate, functools._lru_cache_wrapper): + return functools._lru_cache_wrapper( + func, **annotate.cache_parameters(), + cache_info_type=(0, 0, 0, annotate.cache_parameters()["maxsize"]) + )(format) + return func(format) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 7b9aa4043f3fe9..3cad106f907230 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1647,6 +1647,30 @@ def format(format, second, /, *, third, __Format=Format, self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6}) + def test_callable_cache_annotate_forwardref_value_fallback(self): + import random + # If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not + # supported fall back to Format.VALUE and convert to strings + @functools.cache + def format(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": random.random()} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + format, + Format.FORWARDREF, + ) + + self.assertIsInstance(annotations, dict) + self.assertIn("x", annotations) + self.assertIsInstance(annotations["x"], float) + + new_anns = annotationlib.call_annotate_function(format, Format.FORWARDREF) + self.assertEqual(annotations, new_anns) + 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 From a83ca8fb9e8e30da5177d3c41a40ca3eedd69ff4 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 07:19:53 +1030 Subject: [PATCH 10/33] Improve quality of test for cached annotate function --- Lib/test/test_annotationlib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 3cad106f907230..c653e39cb9c9ad 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1655,7 +1655,7 @@ def test_callable_cache_annotate_forwardref_value_fallback(self): def format(format, /, __Format=Format, __NotImplementedError=NotImplementedError): if format == __Format.VALUE: - return {"x": random.random()} + return {"x": random.random(), "y": str} else: raise __NotImplementedError(format) @@ -1667,6 +1667,7 @@ def format(format, /, __Format=Format, self.assertIsInstance(annotations, dict) self.assertIn("x", annotations) self.assertIsInstance(annotations["x"], float) + self.assertIs(annotations["y"], str) new_anns = annotationlib.call_annotate_function(format, Format.FORWARDREF) self.assertEqual(annotations, new_anns) From e6bc7a03a3ff3270e0696b422eb0c36cb369e957 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 07:29:00 +1030 Subject: [PATCH 11/33] Don't create an unused new cache wrapper for cached annotate functions --- Lib/annotationlib.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 6a4b97b6fcd9e6..4b7f628342d60f 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -931,15 +931,10 @@ def _direct_call_annotate(func, annotate, format): if isinstance(annotate, functools.partial): return functools.partial(func, *annotate.args, **annotate.keywords)(format) - # If annotate is a cached function, re-create it with the new function object. - # We want a new, clean, cache, as we've updated the function data, so let's - # re-create with the new function and old cache parameters. - if isinstance(annotate, functools._lru_cache_wrapper): - return functools._lru_cache_wrapper( - func, **annotate.cache_parameters(), - cache_info_type=(0, 0, 0, annotate.cache_parameters()["maxsize"]) - )(format) - + # If annotate is a cached function, we've now updated the function data, so + # let's not use the old cache. Furthermore, we're about to call the function + # and never use it again, so let's not bother trying to cache it. + # Or, if it's a normal function or unsupported callable, we should just call it. return func(format) From 2e4b9278604386c6fbbbf905af55576a14adfc61 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 07:44:00 +1030 Subject: [PATCH 12/33] Test `functools` wrapped as annotate function --- Lib/test/test_annotationlib.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index c653e39cb9c9ad..0adf5d29989f7d 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 random import typing import sys import unittest @@ -1648,7 +1649,6 @@ def format(format, second, /, *, third, __Format=Format, self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6}) def test_callable_cache_annotate_forwardref_value_fallback(self): - import random # If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not # supported fall back to Format.VALUE and convert to strings @functools.cache @@ -1672,6 +1672,28 @@ def format(format, /, __Format=Format, new_anns = annotationlib.call_annotate_function(format, Format.FORWARDREF) self.assertEqual(annotations, new_anns) + def test_callable_wrapped_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 multiple_format(fn): + inputs = {"x": int} + @functools.wraps(fn) + def format(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {**inputs, **fn()} + else: + raise __NotImplementedError(format) + + return format + + annotations = annotationlib.call_annotate_function( + multiple_format(lambda: {"y": str}), + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int, "y": str}) + 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 From 4b955a8dd327536f88a9b8dbfe2ee218f7747862 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 08:41:24 +1030 Subject: [PATCH 13/33] Support `functools.partialmethod` annotate functions --- Lib/annotationlib.py | 5 ++++- Lib/test/test_annotationlib.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 4b7f628342d60f..1407e6d762f136 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -905,7 +905,7 @@ def _get_annotate_attr(annotate, attr, default): (functools := sys.modules.get("functools", None)) and isinstance(annotate, functools.partial) ): - return getattr(annotate.func, attr, default) + return _get_annotate_attr(annotate.func, attr, default) return default @@ -929,6 +929,9 @@ def _direct_call_annotate(func, annotate, format): # 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 isinstance(annotate, functools.partial): + # Partial methods + if self := getattr(annotate, "__self__", None): + return functools.partial(func, self, *annotate.args, **annotate.keywords)(format) return functools.partial(func, *annotate.args, **annotate.keywords)(format) # If annotate is a cached function, we've now updated the function data, so diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 0adf5d29989f7d..5918852d36bef3 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1648,6 +1648,31 @@ def format(format, second, /, *, third, __Format=Format, self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6}) + def test_callable_partialmethod_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 _internal_format(self, format, second, /, *, third, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": format * second * third} + else: + raise __NotImplementedError(format) + + format = functools.partialmethod( + _internal_format, + functools.Placeholder, + 5, + third=6 + ) + + annotations = annotationlib.call_annotate_function( + Annotate().format, + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6}) + def test_callable_cache_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 From f5ea8a0198209c2ef3f3a219644a66ccd1dee447 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 09:07:42 +1030 Subject: [PATCH 14/33] Test `functools.singledispatch`/`functools.singledispatchmethod` annotate functions --- Lib/test/test_annotationlib.py | 69 ++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 5918852d36bef3..4b7c9723aa31ca 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1719,6 +1719,75 @@ def format(format, /, __Format=Format, self.assertEqual(annotations, {"x": int, "y": str}) + def test_callable_singledispatch_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 + @functools.singledispatch + def format(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": str} + else: + raise __NotImplementedError(format) + + @format.register(float) + def _(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": float} + else: + raise __NotImplementedError(format) + + @format.register(int) + def _(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": int} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + format, + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int}) + + def test_callable_singledispatchmethod_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: + @functools.singledispatchmethod + def format(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": str} + else: + raise __NotImplementedError(format) + + @format.register(float) + def _(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": float} + else: + raise __NotImplementedError(format) + + @format.register(int) + def _(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": int} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + Annotate().format, + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int}) + 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 From a43a873e126d4842e76727257c0e20bae10ed149 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 09:23:11 +1030 Subject: [PATCH 15/33] Support methods as annotate functions --- Lib/annotationlib.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 1407e6d762f136..8aa576be4c1737 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -891,6 +891,11 @@ def _get_annotate_attr(annotate, attr, default): if (value := getattr(annotate, attr, None)) is not None: return value + if isinstance(annotate, types.MethodType): + if call_func := getattr(annotate, "__func__", None): + return getattr(call_func, attr, default) + + # Class instances themselves aren't methods, their __call__ functions are. if isinstance(annotate.__call__, types.MethodType): if call_func := getattr(annotate.__call__, "__func__", None): return getattr(call_func, attr, default) @@ -910,7 +915,14 @@ def _get_annotate_attr(annotate, attr, default): return default def _direct_call_annotate(func, annotate, format): - # If annotate is a method, we need to pass its self as the first param + # If annotate is a method, we need to pass self as the first param. + if ( + hasattr(annotate, "__func__") and + (self := getattr(annotate, "__self__", None)) + ): + return func(self, format) + + # If annotate is a class instance, its __call__ function is the method. if ( hasattr(annotate.__call__, "__func__") and (self := getattr(annotate.__call__, "__self__", None)) From a3b68eec9adc87edd173bcca70bb95bbc6409ff7 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 09:23:35 +1030 Subject: [PATCH 16/33] Test classmethods and staticmethods as annotate functions --- Lib/test/test_annotationlib.py | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 4b7c9723aa31ca..da55ce23790aae 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1613,6 +1613,44 @@ def __call__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": str}) + def test_callable_classmethod_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: + @classmethod + def __call__(cls, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": str} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + Annotate.__call__, + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": str}) + + def test_callable_staticmethod_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: + @staticmethod + def __call__(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": str} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + Annotate.__call__, + 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 From ea6022395b6b71b1e4d076a27dbd90db6f319cc5 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 09:24:58 +1030 Subject: [PATCH 17/33] Update and simplify classmethod/staticmethod annotate function tests --- Lib/test/test_annotationlib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index da55ce23790aae..332f43a7cc811a 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1618,7 +1618,7 @@ def test_callable_classmethod_annotate_forwardref_value_fallback(self): # supported fall back to Format.VALUE and convert to strings class Annotate: @classmethod - def __call__(cls, format, /, __Format=Format, + def format(cls, format, /, __Format=Format, __NotImplementedError=NotImplementedError): if format == __Format.VALUE: return {"x": str} @@ -1626,7 +1626,7 @@ def __call__(cls, format, /, __Format=Format, raise __NotImplementedError(format) annotations = annotationlib.call_annotate_function( - Annotate.__call__, + Annotate.format, Format.FORWARDREF, ) @@ -1637,7 +1637,7 @@ def test_callable_staticmethod_annotate_forwardref_value_fallback(self): # supported fall back to Format.VALUE and convert to strings class Annotate: @staticmethod - def __call__(format, /, __Format=Format, + def format(format, /, __Format=Format, __NotImplementedError=NotImplementedError): if format == __Format.VALUE: return {"x": str} @@ -1645,7 +1645,7 @@ def __call__(format, /, __Format=Format, raise __NotImplementedError(format) annotations = annotationlib.call_annotate_function( - Annotate.__call__, + Annotate.format, Format.FORWARDREF, ) From c16083b2a7f5d0a907f8bc17b608ee7fabdb56d6 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 09:25:12 +1030 Subject: [PATCH 18/33] Add standard method annotate function test --- Lib/test/test_annotationlib.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 332f43a7cc811a..c179a3a65bf90b 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1613,6 +1613,24 @@ def __call__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": str}) + def test_callable_method_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 format(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": str} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + Annotate().format, + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": str}) + def test_callable_classmethod_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 From ba1927c0411cb4e31741e98e72deea0eb3a766b9 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 13 Nov 2025 10:56:07 +1030 Subject: [PATCH 19/33] Support and test generics as annotate callables --- Lib/annotationlib.py | 15 ++++++++++++++- Lib/test/test_annotationlib.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 8aa576be4c1737..52175f32996dc0 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -900,7 +900,9 @@ def _get_annotate_attr(annotate, attr, default): if call_func := getattr(annotate.__call__, "__func__", None): return getattr(call_func, attr, default) - if isinstance(annotate, type): + # Classes and generics are callable, usually the __init__ method sets attributes, + # so let's access this method for fake globals and the like. + if isinstance(annotate, type) or isinstance(annotate, types.GenericAlias): return getattr(annotate.__init__, attr, default) if (wrapped := getattr(annotate, "__wrapped__", None)) is not None: @@ -936,6 +938,17 @@ def _direct_call_annotate(func, annotate, format): func(inst, format) return inst + # Generic instantiation is slightly different. + if isinstance(annotate, types.GenericAlias): + inst = annotate.__new__(annotate.__origin__) + func(inst, format) + # Try to set the original class on the instance, if possible. + try: + inst.__orig_class__ = annotate + except Exception: + pass + return inst + if functools := sys.modules.get("functools", None): # 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, diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index c179a3a65bf90b..0fb774f62ef2a8 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1687,6 +1687,25 @@ def __init__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) + def test_callable_generic_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[T](dict[T]): + 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[int], + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int}) + self.assertEqual(annotations.__orig_class__, Annotate[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 From b96532f79860737be93aac019795aac606fb458b Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 10:42:42 +1030 Subject: [PATCH 20/33] Add secondary test for staticmethod as annotate function --- Lib/test/test_annotationlib.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 0fb774f62ef2a8..a7e32a3cbeb691 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1662,13 +1662,21 @@ def format(format, /, __Format=Format, else: raise __NotImplementedError(format) + # @staticmethod descriptor means that Annotate.format should be a function object. annotations = annotationlib.call_annotate_function( Annotate.format, Format.FORWARDREF, ) + self.assertEqual(annotations, {"x": str}) + # But if we access the __dict__, the underlying staticmethod object is returned. + annotations = annotationlib.call_annotate_function( + Annotate.__dict__["format"], + 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 From e70b489a2605c8b722a8302a2646ed05766fae90 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 11:38:06 +1030 Subject: [PATCH 21/33] Test `typing._BaseGenericAlias` callables as annotate functions --- Lib/test/test_annotationlib.py | 39 ++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index a7e32a3cbeb691..d36ba253026628 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -9,6 +9,7 @@ import pickle from string.templatelib import Template, Interpolation import random +import types import typing import sys import unittest @@ -1696,8 +1697,9 @@ def __init__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) def test_callable_generic_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 + # Generics that inherit from builtins become types.GenericAlias objects. + # This is special-case in annotationlib to ensure the constructor is handled + # as with standard classes and __orig_class__ is set correctly. class Annotate[T](dict[T]): def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): @@ -1714,6 +1716,39 @@ def __init__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) self.assertEqual(annotations.__orig_class__, Annotate[int]) + def test_callable_typing_generic_class_annotate_forwardref_value_fallback(self): + # Standard generics are 'typing._GenericAlias' objects. These are implemented + # in Python with a __call__ method (in _typing.BaseGenericAlias), so should work + # as with any callable class instance. + class Annotate[T]: + def __init__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + self.data = {"x": int} + else: + raise __NotImplementedError(format) + def __getitem__(self, item): + return self.data[item] + def __iter__(self): + return iter(self.data) + def __len__(self): + return len(self.data) + def __getattr__(self, attr): + val = getattr(collections.abc.Mapping, attr) + if isinstance(val, types.FunctionType): + return types.MethodType(val, self) + return val + def __eq__(self, other): + return dict(self.items()) == dict(other.items()) + + annotations = annotationlib.call_annotate_function( + Annotate[int], + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int}) + self.assertEqual(annotations.__orig_class__, Annotate[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 From 8df3d2418a5915713561cdc7b6b3e7b269ed15a8 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 14:12:04 +1030 Subject: [PATCH 22/33] Support recursive unwrapping of annotate functions --- Lib/annotationlib.py | 7 +++++-- Lib/test/test_annotationlib.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 52175f32996dc0..9ffaad54db89bc 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -905,8 +905,11 @@ def _get_annotate_attr(annotate, attr, default): if isinstance(annotate, type) or isinstance(annotate, types.GenericAlias): return getattr(annotate.__init__, attr, default) - if (wrapped := getattr(annotate, "__wrapped__", None)) is not None: - return getattr(wrapped, attr, default) + # Most 'wrapped' functions, including functools.cache and staticmethod, need us + # to manually, recursively unwrap. For partial.update_wrapper functions, the + # attribute is accessible on the function itself, so we never get this far. + if (unwrapped := getattr(annotate, "__wrapped__", None)) is not None: + return _get_annotate_attr(unwrapped, attr, default) if ( (functools := sys.modules.get("functools", None)) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index d36ba253026628..4d13c6c48f9282 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1812,9 +1812,39 @@ def format(format, /, __Format=Format, self.assertIsInstance(annotations["x"], float) self.assertIs(annotations["y"], str) + # Check annotations again to ensure that cache is working. new_anns = annotationlib.call_annotate_function(format, Format.FORWARDREF) self.assertEqual(annotations, new_anns) + def test_callable_double_wrapped_annotate_forwardref_value_fallback(self): + # The raw staticmethod object returns a 'wrapped' function, and so is + # @functools.cache. Here we test that functions unwrap recursively, + # allowing wrapping of wrapped functions. + class Annotate: + @staticmethod + @functools.cache + def format(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": random.random(), "y": str} + else: + raise __NotImplementedError(format) + + # Access the raw staticmethod object which wraps the cached function. + annotations = annotationlib.call_annotate_function( + Annotate.__dict__["format"], + Format.FORWARDREF, + ) + + self.assertIsInstance(annotations, dict) + self.assertIn("x", annotations) + self.assertIsInstance(annotations["x"], float) + self.assertIs(annotations["y"], str) + + # Check annotations again to ensure that cache is working. + new_anns = annotationlib.call_annotate_function(Annotate.format, Format.FORWARDREF) + self.assertEqual(annotations, new_anns) + def test_callable_wrapped_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 From 61b76a5c40bcc69ed7ec867ff400d5c973aa7ac3 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 14:58:38 +1030 Subject: [PATCH 23/33] Support recursive unwrapping and calling of methods as annotate functions --- Lib/annotationlib.py | 25 +++++++++++++++---------- Lib/test/test_annotationlib.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 9ffaad54db89bc..33adecb283094b 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -891,9 +891,11 @@ def _get_annotate_attr(annotate, attr, default): if (value := getattr(annotate, attr, None)) is not None: return value + # Redirect method attribute access to the underlying function. The C code + # verifies that the __func__ attribute is some kind of callable, so we need + # to look for attributes recursively. if isinstance(annotate, types.MethodType): - if call_func := getattr(annotate, "__func__", None): - return getattr(call_func, attr, default) + return _get_annotate_attr(annotate.__func__, attr, default) # Class instances themselves aren't methods, their __call__ functions are. if isinstance(annotate.__call__, types.MethodType): @@ -919,32 +921,35 @@ def _get_annotate_attr(annotate, attr, default): return default -def _direct_call_annotate(func, annotate, format): +def _direct_call_annotate(func, annotate, *args): # If annotate is a method, we need to pass self as the first param. if ( hasattr(annotate, "__func__") and (self := getattr(annotate, "__self__", None)) ): - return func(self, format) + # We don't know what type of callable will be in the __func__ attribute, + # so let's try again with knowledge of that type, including self as the first + # argument. + return _direct_call_annotate(func, annotate.__func__, self, *args) # If annotate is a class instance, its __call__ function is the method. if ( hasattr(annotate.__call__, "__func__") and (self := getattr(annotate.__call__, "__self__", None)) ): - return func(self, format) + return func(self, *args) # 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) + func(inst, *args) return inst # Generic instantiation is slightly different. if isinstance(annotate, types.GenericAlias): inst = annotate.__new__(annotate.__origin__) - func(inst, format) + func(inst, *args) # Try to set the original class on the instance, if possible. try: inst.__orig_class__ = annotate @@ -959,14 +964,14 @@ def _direct_call_annotate(func, annotate, format): if isinstance(annotate, functools.partial): # Partial methods if self := getattr(annotate, "__self__", None): - return functools.partial(func, self, *annotate.args, **annotate.keywords)(format) - return functools.partial(func, *annotate.args, **annotate.keywords)(format) + return functools.partial(func, self, *annotate.args, **annotate.keywords)(*args) + return functools.partial(func, *annotate.args, **annotate.keywords)(*args) # If annotate is a cached function, we've now updated the function data, so # let's not use the old cache. Furthermore, we're about to call the function # and never use it again, so let's not bother trying to cache it. # Or, if it's a normal function or unsupported callable, we should just call it. - return func(format) + return func(*args) def get_annotate_from_class_namespace(obj): diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 4d13c6c48f9282..488bd4f3352bea 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1632,6 +1632,27 @@ def format(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": str}) + def test_callable_custom_method_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__(inst, self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + super().__init__({"x": str}) + else: + raise __NotImplementedError(format) + + # This wouldn't happen on a normal class, but it's technically legal. + custom_method = types.MethodType(Annotate, Annotate(None, Format.VALUE)) + + annotations = annotationlib.call_annotate_function( + custom_method, + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": str}) + def test_callable_classmethod_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 From ac888cc9f157e5ab9bd75e57facc431898c1362c Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 15:09:49 +1030 Subject: [PATCH 24/33] Support (recursively unwrap/call) any type of callable as an annotation function class' __call__ attribute --- Lib/annotationlib.py | 24 ++++++++++++++++-------- Lib/test/test_annotationlib.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 33adecb283094b..3a1ab5218399b0 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -897,10 +897,15 @@ def _get_annotate_attr(annotate, attr, default): if isinstance(annotate, types.MethodType): return _get_annotate_attr(annotate.__func__, attr, default) - # Class instances themselves aren't methods, their __call__ functions are. - if isinstance(annotate.__call__, types.MethodType): - if call_func := getattr(annotate.__call__, "__func__", None): - return getattr(call_func, attr, default) + # If annotate is a class instance, its __call__ is the relevant function. + # However, __call__ Could be a method, a function descriptor, or any other callable. + # Normal functions have a __call__ property which is a useless method wrapper, + # ignore these. + if ( + (call := getattr(annotate, "__call__", None)) and + not isinstance(call, types.MethodWrapperType) + ): + return _get_annotate_attr(annotate.__call__, attr, default) # Classes and generics are callable, usually the __init__ method sets attributes, # so let's access this method for fake globals and the like. @@ -932,12 +937,15 @@ def _direct_call_annotate(func, annotate, *args): # argument. return _direct_call_annotate(func, annotate.__func__, self, *args) - # If annotate is a class instance, its __call__ function is the method. + # If annotate is a class instance, its __call__ is the function. + # __call__ Could be a method, a function descriptor, or any other callable. + # Normal functions have a __call__ property which is a useless method wrapper, + # ignore these. if ( - hasattr(annotate.__call__, "__func__") and - (self := getattr(annotate.__call__, "__self__", None)) + (call := getattr(annotate, "__call__", None)) and + not isinstance(call, types.MethodWrapperType) ): - return func(self, *args) + return _direct_call_annotate(func, annotate.__call__, *args) # If annotate is a class, `func` is the __init__ method, so we still need to call # __new__() to create the instance diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 488bd4f3352bea..e421e5ce320718 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1717,6 +1717,28 @@ def __init__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) + def test_callable_object_custom_call_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 AnnotateClass(dict): + def __init__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + super().__init__({"x": int}) + else: + raise __NotImplementedError(format) + + class Annotate: + __call__ = AnnotateClass + + annotations = annotationlib.call_annotate_function( + Annotate(), + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int}) + + def test_callable_generic_class_annotate_forwardref_value_fallback(self): # Generics that inherit from builtins become types.GenericAlias objects. # This is special-case in annotationlib to ensure the constructor is handled From 6a48bbe01ff3d5ed07d90880e5336a8fefbb3a62 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 15:16:36 +1030 Subject: [PATCH 25/33] Add test to actually instantiate class for callable class as an annotate function --- Lib/test/test_annotationlib.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index e421e5ce320718..6ea101d46dea8e 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1514,6 +1514,25 @@ def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedErr self.assertEqual(annotations, {"x": int}) + def test_callable_class_annotate_forwardref_fakeglobals(self): + # If Format.FORWARDREF is not supported, use Format.VALUE_WITH_FAKE_GLOBALS + # before falling back to Format.VALUE + class Annotate(dict): + def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + super().__init__({'x': str}) + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + super().__init__({'x': int}) + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + Annotate, + Format.FORWARDREF + ) + + self.assertEqual(annotations, {"x": int}) + def test_user_annotate_forwardref_value_fallback(self): # If Format.FORWARDREF and Format.VALUE_WITH_FAKE_GLOBALS are not supported # use Format.VALUE From 1d35db062c81b090e9158b3d984534465ed2764d Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Fri, 14 Nov 2025 15:31:14 +1030 Subject: [PATCH 26/33] Test that `GenericAlias` objects which cannot have `__orig_class__` set don't raise an error when used as annotate functions --- Lib/annotationlib.py | 1 + Lib/test/test_annotationlib.py | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 3a1ab5218399b0..6e7d59b9cae594 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -959,6 +959,7 @@ def _direct_call_annotate(func, annotate, *args): inst = annotate.__new__(annotate.__origin__) func(inst, *args) # Try to set the original class on the instance, if possible. + # This is the same logic used in typing for custom generics. try: inst.__orig_class__ = annotate except Exception: diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 6ea101d46dea8e..642dc17987c964 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1533,6 +1533,28 @@ def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplemen self.assertEqual(annotations, {"x": int}) + def test_callable_generic_class_annotate_forwardref_fakeglobals(self): + # If Format.FORWARDREF is not supported, use Format.VALUE_WITH_FAKE_GLOBALS + # before falling back to Format.VALUE + class Annotate[K, V](dict[K, V]): + def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + super().__init__({'x': str}) + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + super().__init__({'x': int}) + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + Annotate[str, type], + Format.FORWARDREF + ) + + self.assertEqual(annotations, {"x": int}) + + # We will have to manually set the __orig_class__, ensure it is correct. + self.assertEqual(annotations.__orig_class__, Annotate[str, type]) + def test_user_annotate_forwardref_value_fallback(self): # If Format.FORWARDREF and Format.VALUE_WITH_FAKE_GLOBALS are not supported # use Format.VALUE @@ -1586,6 +1608,48 @@ def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedErr self.assertEqual(annotations, {"x": "int"}) + def test_callable_generic_class_annotate_string_fakeglobals(self): + # If Format.STRING is not supported but Format.VALUE_WITH_FAKE_GLOBALS is + # prefer that over Format.VALUE + class Annotate[T]: + __slots__ = "data", + + def __init__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + self.data = {"x": str} + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + self.data = {"x": int} + else: + raise __NotImplementedError(format) + def __getitem__(self, item): + return self.data[item] + def __iter__(self): + return iter(self.data) + def __len__(self): + return len(self.data) + def __getattr__(self, attr): + val = getattr(collections.abc.Mapping, attr) + if isinstance(val, types.FunctionType): + return types.MethodType(val, self) + return val + def __eq__(self, other): + return dict(self.items()) == dict(other.items()) + + # Subscripting a user-created class will return a typing._GenericAlias. + # We want to check that types.GenericAlias objects are created properly, + # so manually create it with the documented constructor. + annotations = annotationlib.call_annotate_function( + types.GenericAlias(Annotate, (int,)), + Format.STRING, + ) + + self.assertEqual(annotations, {"x": "int"}) + + # A __slots__ class can't have __orig_class__ set unless already specified. + # Ensure that the error passes silently. + self.assertNotHasAttr(annotations, "__orig_class__") + def test_user_annotate_string_value_fallback(self): # If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not # supported fall back to Format.VALUE and convert to strings From c2cccffb9b9026513ce8ea8fff62f54a37172bb3 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 18 Nov 2025 14:18:24 +1030 Subject: [PATCH 27/33] Improve comments and cleanup new tests --- Lib/test/test_annotationlib.py | 174 ++++++++++++--------------------- 1 file changed, 60 insertions(+), 114 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 642dc17987c964..f5c5950e922aad 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1515,10 +1515,12 @@ def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedErr self.assertEqual(annotations, {"x": int}) def test_callable_class_annotate_forwardref_fakeglobals(self): - # If Format.FORWARDREF is not supported, use Format.VALUE_WITH_FAKE_GLOBALS - # before falling back to Format.VALUE + # Calling the class will construct a new instance and call its __init__ function + # as an annotate function, returning the instance. This is fine as long as + # the class inherits from dict. class Annotate(dict): - def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): + def __init__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): if format == __Format.VALUE: super().__init__({'x': str}) elif format == __Format.VALUE_WITH_FAKE_GLOBALS: @@ -1534,10 +1536,12 @@ def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplemen self.assertEqual(annotations, {"x": int}) def test_callable_generic_class_annotate_forwardref_fakeglobals(self): - # If Format.FORWARDREF is not supported, use Format.VALUE_WITH_FAKE_GLOBALS - # before falling back to Format.VALUE + # Subscripted generic classes are types.GenericAlias instances + # for dict subclasses. Check that they are still + # callable as annotate functions, just like regular classes. class Annotate[K, V](dict[K, V]): - def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): + def __init__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): if format == __Format.VALUE: super().__init__({'x': str}) elif format == __Format.VALUE_WITH_FAKE_GLOBALS: @@ -1552,7 +1556,7 @@ def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplemen self.assertEqual(annotations, {"x": int}) - # We will have to manually set the __orig_class__, ensure it is correct. + # We manually set the __orig_class__ for this special-case, check this too. self.assertEqual(annotations.__orig_class__, Annotate[str, type]) def test_user_annotate_forwardref_value_fallback(self): @@ -1609,11 +1613,13 @@ def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedErr self.assertEqual(annotations, {"x": "int"}) def test_callable_generic_class_annotate_string_fakeglobals(self): - # If Format.STRING is not supported but Format.VALUE_WITH_FAKE_GLOBALS is - # prefer that over Format.VALUE + # If a generic class uses slots, we may not be able to set + # its __orig_class__ attr. class Annotate[T]: __slots__ = "data", + # If Format.STRING is not supported but Format.VALUE_WITH_FAKE_GLOBALS is, + # prefer that over Format.VALUE def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): if format == __Format.VALUE: @@ -1636,8 +1642,8 @@ def __getattr__(self, attr): def __eq__(self, other): return dict(self.items()) == dict(other.items()) - # Subscripting a user-created class will return a typing._GenericAlias. - # We want to check that types.GenericAlias objects are created properly, + # Subscripting a user-created class will usually return a typing._GenericAlias. + # We want to check that types.GenericAlias objects are still interpreted properly, # so manually create it with the documented constructor. annotations = annotationlib.call_annotate_function( types.GenericAlias(Annotate, (int,)), @@ -1647,7 +1653,7 @@ def __eq__(self, other): self.assertEqual(annotations, {"x": "int"}) # A __slots__ class can't have __orig_class__ set unless already specified. - # Ensure that the error passes silently. + # Ensure that the error passes silently, as is the case in typing. self.assertNotHasAttr(annotations, "__orig_class__") def test_user_annotate_string_value_fallback(self): @@ -1667,41 +1673,40 @@ 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} + # Check that all formats work with a standard callable object as an + # annotate function. 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 + def test_callable_method_annotate_forwardref_value_fallback(self): + # Calling a method requires call_annotate_function() to add the self param. class Annotate: - def __call__(self, format, /, __Format=Format, - __NotImplementedError=NotImplementedError): + def format(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): if format == __Format.VALUE: return {"x": str} else: raise __NotImplementedError(format) annotations = annotationlib.call_annotate_function( - Annotate(), + Annotate().format, Format.FORWARDREF, ) self.assertEqual(annotations, {"x": str}) - def test_callable_method_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 test_callable_object_annotate_forwardref_value_fallback(self): + # Calling an object is special-cased in call_annotate_function() + # to call its __call__ method. class Annotate: - def format(self, format, /, __Format=Format, + def __call__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): if format == __Format.VALUE: return {"x": str} @@ -1709,15 +1714,13 @@ def format(self, format, /, __Format=Format, raise __NotImplementedError(format) annotations = annotationlib.call_annotate_function( - Annotate().format, + Annotate(), Format.FORWARDREF, ) self.assertEqual(annotations, {"x": str}) def test_callable_custom_method_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__(inst, self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): @@ -1727,6 +1730,8 @@ def __init__(inst, self, format, /, __Format=Format, raise __NotImplementedError(format) # This wouldn't happen on a normal class, but it's technically legal. + # Ensure that methods (which are special-cased) can wrap class construction + # (which is also special-cased). custom_method = types.MethodType(Annotate, Annotate(None, Format.VALUE)) annotations = annotationlib.call_annotate_function( @@ -1737,13 +1742,13 @@ def __init__(inst, self, format, /, __Format=Format, self.assertEqual(annotations, {"x": str}) def test_callable_classmethod_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 + # @classmethod returns a descriptor to a method. + # Ensure that the class itself is correctly bound to the cls param. class Annotate: @classmethod def format(cls, format, /, __Format=Format, __NotImplementedError=NotImplementedError): - if format == __Format.VALUE: + if format == __Format.VALUE and cls is Annotate: return {"x": str} else: raise __NotImplementedError(format) @@ -1756,8 +1761,8 @@ def format(cls, format, /, __Format=Format, self.assertEqual(annotations, {"x": str}) def test_callable_staticmethod_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 + # @staticmethod returns a descriptor which means that Annotate.format + # should be a normal function object. class Annotate: @staticmethod def format(format, /, __Format=Format, @@ -1767,14 +1772,14 @@ def format(format, /, __Format=Format, else: raise __NotImplementedError(format) - # @staticmethod descriptor means that Annotate.format should be a function object. annotations = annotationlib.call_annotate_function( Annotate.format, Format.FORWARDREF, ) self.assertEqual(annotations, {"x": str}) - # But if we access the __dict__, the underlying staticmethod object is returned. + # But if we access via __dict__, the underlying staticmethod object is returned. + # Ensure that call_annotate_function() can handle this special case. annotations = annotationlib.call_annotate_function( Annotate.__dict__["format"], Format.FORWARDREF, @@ -1782,27 +1787,7 @@ def format(format, /, __Format=Format, 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_object_custom_call_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 AnnotateClass(dict): def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): @@ -1812,6 +1797,8 @@ def __init__(self, format, /, __Format=Format, raise __NotImplementedError(format) class Annotate: + # In this case, calling the instance returns a callable class, instead of + # the usual method. __call__ = AnnotateClass annotations = annotationlib.call_annotate_function( @@ -1821,29 +1808,8 @@ class Annotate: self.assertEqual(annotations, {"x": int}) - - def test_callable_generic_class_annotate_forwardref_value_fallback(self): - # Generics that inherit from builtins become types.GenericAlias objects. - # This is special-case in annotationlib to ensure the constructor is handled - # as with standard classes and __orig_class__ is set correctly. - class Annotate[T](dict[T]): - 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[int], - Format.FORWARDREF, - ) - - self.assertEqual(annotations, {"x": int}) - self.assertEqual(annotations.__orig_class__, Annotate[int]) - def test_callable_typing_generic_class_annotate_forwardref_value_fallback(self): - # Standard generics are 'typing._GenericAlias' objects. These are implemented + # Normally, generics are 'typing._GenericAlias' objects. These are implemented # in Python with a __call__ method (in _typing.BaseGenericAlias), so should work # as with any callable class instance. class Annotate[T]: @@ -1876,8 +1842,8 @@ def __eq__(self, other): self.assertEqual(annotations.__orig_class__, Annotate[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 + # functools.partial is implemented in C. Ensure that the annotate function + # is extracted and called correctly, particularly with Placeholder args. def format(format, second, /, *, third, __Format=Format, __NotImplementedError=NotImplementedError): if format == __Format.VALUE: @@ -1893,8 +1859,8 @@ def format(format, second, /, *, third, __Format=Format, self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6}) def test_callable_partialmethod_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 + # partialmethod is a Python wrapper around functools.partial, + # ensure that self is passed in and partial works as usual. class Annotate: def _internal_format(self, format, second, /, *, third, __Format=Format, __NotImplementedError=NotImplementedError): @@ -1918,8 +1884,8 @@ def _internal_format(self, format, second, /, *, third, __Format=Format, self.assertEqual(annotations, {"x": Format.VALUE * 5 * 6}) def test_callable_cache_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 + # lru cache is a C wrapper around functions, ensure that the underlying + # function is accessed correctly. @functools.cache def format(format, /, __Format=Format, __NotImplementedError=NotImplementedError): @@ -1938,14 +1904,14 @@ def format(format, /, __Format=Format, self.assertIsInstance(annotations["x"], float) self.assertIs(annotations["y"], str) - # Check annotations again to ensure that cache is working. + # Check annotations again to ensure that the result is still cached. new_anns = annotationlib.call_annotate_function(format, Format.FORWARDREF) self.assertEqual(annotations, new_anns) def test_callable_double_wrapped_annotate_forwardref_value_fallback(self): - # The raw staticmethod object returns a 'wrapped' function, and so is + # The raw staticmethod object returns a 'wrapped' function, and so does # @functools.cache. Here we test that functions unwrap recursively, - # allowing wrapping of wrapped functions. + # allowing annotate functions which wrap already wrapped functions. class Annotate: @staticmethod @functools.cache @@ -1967,18 +1933,17 @@ def format(format, /, __Format=Format, self.assertIsInstance(annotations["x"], float) self.assertIs(annotations["y"], str) - # Check annotations again to ensure that cache is working. + # Check annotations again to ensure that the result is still cached. new_anns = annotationlib.call_annotate_function(Annotate.format, Format.FORWARDREF) self.assertEqual(annotations, new_anns) def test_callable_wrapped_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 + # Test unwrapping of @functools.wraps functions, similar to @functools.cache. def multiple_format(fn): inputs = {"x": int} @functools.wraps(fn) def format(format, /, __Format=Format, - __NotImplementedError=NotImplementedError): + __NotImplementedError=NotImplementedError): if format == __Format.VALUE: return {**inputs, **fn()} else: @@ -1994,8 +1959,8 @@ def format(format, /, __Format=Format, self.assertEqual(annotations, {"x": int, "y": str}) def test_callable_singledispatch_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 + # Ensure that the correct singledispatch function is used when calling + # a singledispatch annotate function. @functools.singledispatch def format(format, /, __Format=Format, __NotImplementedError=NotImplementedError): @@ -2006,7 +1971,7 @@ def format(format, /, __Format=Format, @format.register(float) def _(format, /, __Format=Format, - __NotImplementedError=NotImplementedError): + __NotImplementedError=NotImplementedError): if format == __Format.VALUE: return {"x": float} else: @@ -2020,6 +1985,7 @@ def _(format, /, __Format=Format, else: raise __NotImplementedError(format) + print("Single dispatch") annotations = annotationlib.call_annotate_function( format, Format.FORWARDREF, @@ -2028,8 +1994,8 @@ def _(format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) def test_callable_singledispatchmethod_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 + # Ensure that the correct singledispatch method is used, along with the self + # parameter when calling a singledispatchmethod annotate function. class Annotate: @functools.singledispatchmethod def format(self, format, /, __Format=Format, @@ -2062,26 +2028,6 @@ def _(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) - 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 From 2ca8f95525a04a487bd91ee890ea4603198797c6 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 18 Nov 2025 15:09:18 +1030 Subject: [PATCH 28/33] Improve test for annotation function as method of non-function callable --- Lib/test/test_annotationlib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index f5c5950e922aad..b13c85c22900d5 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1732,7 +1732,7 @@ def __init__(inst, self, format, /, __Format=Format, # This wouldn't happen on a normal class, but it's technically legal. # Ensure that methods (which are special-cased) can wrap class construction # (which is also special-cased). - custom_method = types.MethodType(Annotate, Annotate(None, Format.VALUE)) + custom_method = types.MethodType(Annotate, Annotate) annotations = annotationlib.call_annotate_function( custom_method, @@ -1815,7 +1815,7 @@ def test_callable_typing_generic_class_annotate_forwardref_value_fallback(self): class Annotate[T]: def __init__(self, format, /, __Format=Format, __NotImplementedError=NotImplementedError): - if format == __Format.VALUE: + if format == __Format.VALUE_WITH_FAKE_GLOBALS: self.data = {"x": int} else: raise __NotImplementedError(format) From 33c9d139bb099353ddfd67060e917c0abac157cd Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 18 Nov 2025 16:19:28 +1030 Subject: [PATCH 29/33] Support fake globals in Python generic classes' __init__ methods --- Lib/annotationlib.py | 30 +++++++++++++- Lib/test/test_annotationlib.py | 71 ++++++++++++++++++---------------- 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 6e7d59b9cae594..46b2e875405603 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -897,6 +897,17 @@ def _get_annotate_attr(annotate, attr, default): if isinstance(annotate, types.MethodType): return _get_annotate_attr(annotate.__func__, attr, default) + # Python generics are callable. Usually, the __init__ method sets attributes. + # However, typing._BaseGenericAlias overrides the __init__ method, so we need + # to use the original class method for fake globals and the like. + # _BaseGenericAlias also override __call__, so let's handle this earlier than + # other class construction. + if ( + (typing := sys.modules.get("typing", None)) + and isinstance(annotate, typing._BaseGenericAlias) + ): + return getattr(annotate.__origin__.__init__, attr, default) + # If annotate is a class instance, its __call__ is the relevant function. # However, __call__ Could be a method, a function descriptor, or any other callable. # Normal functions have a __call__ property which is a useless method wrapper, @@ -937,6 +948,22 @@ def _direct_call_annotate(func, annotate, *args): # argument. return _direct_call_annotate(func, annotate.__func__, self, *args) + # Python generics (typing._BaseGenericAlias) override __call__, so let's handle + # them earlier than other class construction. + if ( + (typing := sys.modules.get("typing", None)) + and isinstance(annotate, typing._BaseGenericAlias) + ): + inst = annotate.__new__(annotate.__origin__) + func(inst, *args) + # Try to set the original class on the instance, if possible. + # This is the same logic used in typing for custom generics. + try: + inst.__orig_class__ = annotate + except Exception: + pass + return inst + # If annotate is a class instance, its __call__ is the function. # __call__ Could be a method, a function descriptor, or any other callable. # Normal functions have a __call__ property which is a useless method wrapper, @@ -954,7 +981,8 @@ def _direct_call_annotate(func, annotate, *args): func(inst, *args) return inst - # Generic instantiation is slightly different. + # Generic instantiation is slightly different. Since we want to give + # __call__ priority, the custom logic for builtin generics is here. if isinstance(annotate, types.GenericAlias): inst = annotate.__new__(annotate.__origin__) func(inst, *args) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index b13c85c22900d5..d08dd023ed20b9 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1559,6 +1559,44 @@ def __init__(self, format, /, __Format=Format, # We manually set the __orig_class__ for this special-case, check this too. self.assertEqual(annotations.__orig_class__, Annotate[str, type]) + def test_callable_typing_generic_class_annotate_forwardref_fakeglobals(self): + # Normally, generics are 'typing._GenericAlias' objects. These are implemented + # in Python with a __call__ method (in _typing.BaseGenericAlias), but this + # needs to be bypassed so we can inject fake globals into the origin class' + # __init__ method. + class Annotate[T]: + def __init__(self, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + self.data = {'x': str} + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + self.data = {"x": int} + else: + raise __NotImplementedError(format) + def __getitem__(self, item): + return self.data[item] + def __iter__(self): + return iter(self.data) + def __len__(self): + return len(self.data) + def __getattr__(self, attr): + val = getattr(collections.abc.Mapping, attr) + if isinstance(val, types.FunctionType): + return types.MethodType(val, self) + return val + def __eq__(self, other): + return dict(self.items()) == dict(other.items()) + + annotations = annotationlib.call_annotate_function( + Annotate[int], + Format.FORWARDREF, + ) + + self.assertEqual(annotations, {"x": int}) + + # We manually set the __orig_class__ for this special-case, check this too. + self.assertEqual(annotations.__orig_class__, Annotate[int]) + def test_user_annotate_forwardref_value_fallback(self): # If Format.FORWARDREF and Format.VALUE_WITH_FAKE_GLOBALS are not supported # use Format.VALUE @@ -1808,39 +1846,6 @@ class Annotate: self.assertEqual(annotations, {"x": int}) - def test_callable_typing_generic_class_annotate_forwardref_value_fallback(self): - # Normally, generics are 'typing._GenericAlias' objects. These are implemented - # in Python with a __call__ method (in _typing.BaseGenericAlias), so should work - # as with any callable class instance. - class Annotate[T]: - def __init__(self, format, /, __Format=Format, - __NotImplementedError=NotImplementedError): - if format == __Format.VALUE_WITH_FAKE_GLOBALS: - self.data = {"x": int} - else: - raise __NotImplementedError(format) - def __getitem__(self, item): - return self.data[item] - def __iter__(self): - return iter(self.data) - def __len__(self): - return len(self.data) - def __getattr__(self, attr): - val = getattr(collections.abc.Mapping, attr) - if isinstance(val, types.FunctionType): - return types.MethodType(val, self) - return val - def __eq__(self, other): - return dict(self.items()) == dict(other.items()) - - annotations = annotationlib.call_annotate_function( - Annotate[int], - Format.FORWARDREF, - ) - - self.assertEqual(annotations, {"x": int}) - self.assertEqual(annotations.__orig_class__, Annotate[int]) - def test_callable_partial_annotate_forwardref_value_fallback(self): # functools.partial is implemented in C. Ensure that the annotate function # is extracted and called correctly, particularly with Placeholder args. From d2dc8a3d74048908c77fafd42831050189ce0745 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 18 Nov 2025 16:25:47 +1030 Subject: [PATCH 30/33] Improve comments for annotate callables in `annotationlib` --- Lib/annotationlib.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 46b2e875405603..ec71596d62d2ea 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -759,6 +759,9 @@ 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. + + # Grab and store all the annotate function attributes that we might need to access + # multiple times as variables, as this could be a bit expensive for non-functions. annotate_globals = _get_annotate_attr(annotate, "__globals__", {}) annotate_code = _get_annotate_attr(annotate, "__code__", None) annotate_defaults = _get_annotate_attr(annotate, "__defaults__", None) @@ -929,6 +932,8 @@ def _get_annotate_attr(annotate, attr, default): if (unwrapped := getattr(annotate, "__wrapped__", None)) is not None: return _get_annotate_attr(unwrapped, attr, default) + # Partial functions and methods both store their underlying function as a + # func attribute. They can wrap any callable, so we need to recursively unwrap. if ( (functools := sys.modules.get("functools", None)) and isinstance(annotate, functools.partial) @@ -1004,9 +1009,10 @@ def _direct_call_annotate(func, annotate, *args): return functools.partial(func, self, *annotate.args, **annotate.keywords)(*args) return functools.partial(func, *annotate.args, **annotate.keywords)(*args) - # If annotate is a cached function, we've now updated the function data, so - # let's not use the old cache. Furthermore, we're about to call the function - # and never use it again, so let's not bother trying to cache it. + # If annotate is a cached function, we've now updated the function data, so + # let's not use the old cache. Furthermore, we're about to call the function + # and never use it again, so let's not bother trying to cache it. + # Or, if it's a normal function or unsupported callable, we should just call it. return func(*args) From 4acb56b4a6850dff351ba5847ef26ca5f7b8e911 Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Tue, 18 Nov 2025 16:48:28 +1030 Subject: [PATCH 31/33] Add NEWS entry --- .../next/Library/2025-11-18-16-46-58.gh-issue-141388.V5UBkb.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-11-18-16-46-58.gh-issue-141388.V5UBkb.rst diff --git a/Misc/NEWS.d/next/Library/2025-11-18-16-46-58.gh-issue-141388.V5UBkb.rst b/Misc/NEWS.d/next/Library/2025-11-18-16-46-58.gh-issue-141388.V5UBkb.rst new file mode 100644 index 00000000000000..8f895f1facbe37 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-11-18-16-46-58.gh-issue-141388.V5UBkb.rst @@ -0,0 +1 @@ +Support arbitrary callables as annotate functions. From b749c4919408595549484cc9b47a8631f71c654b Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 20 Nov 2025 10:15:39 +1030 Subject: [PATCH 32/33] Support arbitrary callables as __init__ methods for class annotate functions --- Lib/annotationlib.py | 24 +++++++++++++++--------- Lib/test/test_annotationlib.py | 26 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index ec71596d62d2ea..28b82fbebae94a 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -891,8 +891,11 @@ def _stringify_single(anno): def _get_annotate_attr(annotate, attr, default): - if (value := getattr(annotate, attr, None)) is not None: - return value + # Try to get the attr on the annotate function. If it doesn't exist, we might + # need to look in other places on the object. If all of those fail, we can + # return the default at the end. + if hasattr(annotate, attr): + return getattr(annotate, attr) # Redirect method attribute access to the underlying function. The C code # verifies that the __func__ attribute is some kind of callable, so we need @@ -909,7 +912,7 @@ def _get_annotate_attr(annotate, attr, default): (typing := sys.modules.get("typing", None)) and isinstance(annotate, typing._BaseGenericAlias) ): - return getattr(annotate.__origin__.__init__, attr, default) + return _get_annotate_attr(annotate.__origin__.__init__, attr, default) # If annotate is a class instance, its __call__ is the relevant function. # However, __call__ Could be a method, a function descriptor, or any other callable. @@ -921,16 +924,17 @@ def _get_annotate_attr(annotate, attr, default): ): return _get_annotate_attr(annotate.__call__, attr, default) - # Classes and generics are callable, usually the __init__ method sets attributes, + # Classes and generics are callable. Usually the __init__ method sets attributes, # so let's access this method for fake globals and the like. + # Technically __init__ can be any callable object, so we recurse. if isinstance(annotate, type) or isinstance(annotate, types.GenericAlias): - return getattr(annotate.__init__, attr, default) + return _get_annotate_attr(annotate.__init__, attr, default) # Most 'wrapped' functions, including functools.cache and staticmethod, need us # to manually, recursively unwrap. For partial.update_wrapper functions, the # attribute is accessible on the function itself, so we never get this far. - if (unwrapped := getattr(annotate, "__wrapped__", None)) is not None: - return _get_annotate_attr(unwrapped, attr, default) + if hasattr(annotate, "__wrapped__"): + return _get_annotate_attr(annotate.__wrapped__, attr, default) # Partial functions and methods both store their underlying function as a # func attribute. They can wrap any callable, so we need to recursively unwrap. @@ -983,14 +987,16 @@ def _direct_call_annotate(func, annotate, *args): # __new__() to create the instance if isinstance(annotate, type): inst = annotate.__new__(annotate) - func(inst, *args) + # func might refer to some non-function object. + _direct_call_annotate(func, annotate.__init__, inst, *args) return inst # Generic instantiation is slightly different. Since we want to give # __call__ priority, the custom logic for builtin generics is here. if isinstance(annotate, types.GenericAlias): inst = annotate.__new__(annotate.__origin__) - func(inst, *args) + # func might refer to some non-function object. + _direct_call_annotate(func, annotate.__init__, inst, *args) # Try to set the original class on the instance, if possible. # This is the same logic used in typing for custom generics. try: diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index d08dd023ed20b9..60668cb74b9e65 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1535,6 +1535,31 @@ def __init__(self, format, /, __Format=Format, self.assertEqual(annotations, {"x": int}) + def test_callable_class_custom_init_annotate_forwardref_fakeglobals(self): + # Calling the class will construct a new instance and call its __init__ function + # as an annotate function, except this __init__ is not a method, + # but a partial function. + def custom_init(self, second, format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + super(type(self), self).__init__({"x": str}) + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + super(type(self), self).__init__({"x": second}) + else: + raise __NotImplementedError(format) + + class Annotate(dict): + pass + + Annotate.__init__ = functools.partial(custom_init, functools.Placeholder, int) + + annotations = annotationlib.call_annotate_function( + Annotate, + Format.FORWARDREF + ) + + self.assertEqual(annotations, {"x": int}) + def test_callable_generic_class_annotate_forwardref_fakeglobals(self): # Subscripted generic classes are types.GenericAlias instances # for dict subclasses. Check that they are still @@ -1990,7 +2015,6 @@ def _(format, /, __Format=Format, else: raise __NotImplementedError(format) - print("Single dispatch") annotations = annotationlib.call_annotate_function( format, Format.FORWARDREF, From a76d794b72b0e0cfa7e27b31fd43abe354027a3f Mon Sep 17 00:00:00 2001 From: dr-carlos Date: Thu, 20 Nov 2025 10:27:42 +1030 Subject: [PATCH 33/33] Improve error message when `__code__` attribute is not found on annotate function --- Lib/annotationlib.py | 8 +++++--- Lib/test/test_annotationlib.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 28b82fbebae94a..a2496f64ddb63f 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -728,7 +728,7 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False): annotate, owner, is_class, globals, allow_evaluation=False ) func = types.FunctionType( - _get_annotate_attr(annotate, "__code__", None), + _get_annotate_attr(annotate, "__code__"), globals, closure=closure, argdefs=_get_annotate_attr(annotate, "__defaults__", None), @@ -763,7 +763,7 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False): # Grab and store all the annotate function attributes that we might need to access # multiple times as variables, as this could be a bit expensive for non-functions. annotate_globals = _get_annotate_attr(annotate, "__globals__", {}) - annotate_code = _get_annotate_attr(annotate, "__code__", None) + annotate_code = _get_annotate_attr(annotate, "__code__") annotate_defaults = _get_annotate_attr(annotate, "__defaults__", None) annotate_kwdefaults = _get_annotate_attr(annotate, "__kwdefaults__", None) namespace = { @@ -890,7 +890,7 @@ def _stringify_single(anno): return repr(anno) -def _get_annotate_attr(annotate, attr, default): +def _get_annotate_attr(annotate, attr, default=_sentinel): # Try to get the attr on the annotate function. If it doesn't exist, we might # need to look in other places on the object. If all of those fail, we can # return the default at the end. @@ -944,6 +944,8 @@ def _get_annotate_attr(annotate, attr, default): ): return _get_annotate_attr(annotate.func, attr, default) + if default is _sentinel: + raise TypeError(f"annotate function missing {attr!r} attribute") return default def _direct_call_annotate(func, annotate, *args): diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 60668cb74b9e65..9d09374222350f 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2119,6 +2119,31 @@ def __call__(self, format, /): with self.assertRaises(DemoException): annotationlib.call_annotate_function(Annotate(), format=fmt) + def test_unsupported_callable_object_fakeglobals_error(self): + # Test that a readable error is raised when an unsupported callable + # type is used as an annotate function with fake globals. + + def annotate(format, /, __Format=Format, + __NotImplementedError=NotImplementedError): + if format == __Format.VALUE: + return {"x": int} + elif format == __Format.VALUE_WITH_FAKE_GLOBALS: + return {"x": str} + else: + raise __NotImplementedError(format) + + annotations = annotationlib.call_annotate_function( + annotate.__call__, + Format.VALUE + ) + self.assertEqual(annotations, {"x": int}) + + for fmt in (Format.FORWARDREF, Format.STRING): + with self.assertRaisesRegex( + TypeError, "annotate function missing '__code__' attribute" + ): + annotationlib.call_annotate_function(annotate.__call__, fmt) + class MetaclassTests(unittest.TestCase): def test_annotated_meta(self):