diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst index d6f5055955e8cf..23ad085f6e0d58 100644 --- a/Doc/library/annotationlib.rst +++ b/Doc/library/annotationlib.rst @@ -368,6 +368,9 @@ Functions doesn't have its own annotations dict, returns an empty dict. * All accesses to object members and dict values are done using ``getattr()`` and ``dict.get()`` for safety. + * For :class:`functools.partial` and :class:`functools.partialmethod` objects, + only returns annotations for parameters that have not been bound by the + partial application, along with the return annotation if present. *eval_str* controls whether or not values of type :class:`!str` are replaced with the result of calling :func:`eval` on those values: @@ -391,7 +394,8 @@ Functions * If *obj* is a callable, *globals* defaults to :attr:`obj.__globals__ `, although if *obj* is a wrapped function (using - :func:`functools.update_wrapper`) or a :class:`functools.partial` object, + :func:`functools.update_wrapper`), a :class:`functools.partial` object, + or a :class:`functools.partialmethod` object, it is unwrapped until a non-wrapped function is found. Calling :func:`!get_annotations` is best practice for accessing the @@ -405,7 +409,20 @@ Functions >>> get_annotations(f) {'a': , 'b': , 'return': } - .. versionadded:: 3.14 + :func:`!get_annotations` also works with :class:`functools.partial` and + :class:`functools.partialmethod` objects, returning only the annotations + for parameters that have not been bound: + + .. doctest:: + + >>> from functools import partial + >>> def add(a: int, b: int, c: int) -> int: + ... return a + b + c + >>> add_10 = partial(add, 10) + >>> get_annotations(add_10) + {'b': , 'c': , 'return': } + + .. versionadded:: 3.15 .. function:: type_repr(value) @@ -422,6 +439,63 @@ Functions .. versionadded:: 3.14 +Using :func:`!get_annotations` with :mod:`functools` objects +-------------------------------------------------------------- + +:func:`get_annotations` has special support for :class:`functools.partial` +and :class:`functools.partialmethod` objects. When called on these objects, +it returns only the annotations for parameters that have not been bound by +the partial application, along with the return annotation if present. + +For :class:`functools.partial` objects, positional arguments bind to parameters +in order, and the annotations for those parameters are excluded from the result: + +.. doctest:: + + >>> from functools import partial + >>> def func(a: int, b: str, c: float) -> bool: + ... return True + >>> partial_func = partial(func, 1) # Binds 'a' + >>> get_annotations(partial_func) + {'b': , 'c': , 'return': } + +Keyword arguments in :class:`functools.partial` set default values but do not +remove parameters from the signature, so their annotations are retained: + +.. doctest:: + + >>> partial_func_kw = partial(func, b="hello") # Sets default for 'b' + >>> get_annotations(partial_func_kw) + {'a': , 'b': , 'c': , 'return': } + +For :class:`functools.partialmethod` objects accessed through a class (unbound), +the first parameter (usually ``self`` or ``cls``) is preserved, and subsequent +parameters are handled similarly to :class:`functools.partial`: + +.. doctest:: + + >>> from functools import partialmethod + >>> class MyClass: + ... def method(self, a: int, b: str) -> bool: + ... return True + ... partial_method = partialmethod(method, 1) # Binds 'a' + >>> get_annotations(MyClass.partial_method) + {'b': , 'return': } + +When a :class:`functools.partialmethod` is accessed through an instance (bound), +it becomes a :class:`functools.partial` object and is handled accordingly: + +.. doctest:: + + >>> obj = MyClass() + >>> get_annotations(obj.partial_method) # Same as above, 'self' is also bound + {'b': , 'return': } + +This behavior ensures that :func:`get_annotations` returns annotations that +accurately reflect the signature of the partial or partialmethod object, as +determined by :func:`inspect.signature`. + + Recipes ------- diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 43e1d51bc4b807..e012edeea0a40e 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -1062,11 +1062,141 @@ def annotations_to_string(annotations): } +def _get_annotations_for_partialmethod(partialmethod_obj, format): + """Get annotations for a functools.partialmethod object. + + Returns annotations for the wrapped function, but only for parameters + that haven't been bound by the partial application. The first parameter + (usually 'self' or 'cls') is kept since partialmethod is unbound. + """ + import inspect + + # Get the wrapped function + func = partialmethod_obj.func + + # Get annotations from the wrapped function + func_annotations = get_annotations(func, format=format) + + if not func_annotations: + return {} + + # For partialmethod, we need to simulate the signature calculation + # The first parameter (self/cls) should remain, but bound args should be removed + try: + # Get the function signature + func_sig = inspect.signature(func) + func_params = list(func_sig.parameters.keys()) + + if not func_params: + return func_annotations + + # Calculate which parameters are bound by the partialmethod + partial_args = partialmethod_obj.args or () + partial_keywords = partialmethod_obj.keywords or {} + + # Build new annotations dict in proper order + # (parameters first, then return) + new_annotations = {} + + # The first parameter (self/cls) is always kept for unbound partialmethod + first_param = func_params[0] + if first_param in func_annotations: + new_annotations[first_param] = func_annotations[first_param] + + # For partialmethod, positional args bind to parameters AFTER the first one + # So if func is (self, a, b, c) and partialmethod.args=(1,) + # Then 'self' stays, 'a' is bound, 'b' and 'c' remain + + remaining_params = func_params[1:] + num_positional_bound = len(partial_args) + + for i, param_name in enumerate(remaining_params): + # Skip if this param is bound positionally + if i < num_positional_bound: + continue + + # For keyword binding: keep the annotation (keyword sets default, doesn't remove param) + if param_name in partial_keywords: + if param_name in func_annotations: + new_annotations[param_name] = func_annotations[param_name] + continue + + # This parameter is not bound, keep its annotation + if param_name in func_annotations: + new_annotations[param_name] = func_annotations[param_name] + + # Add return annotation at the end + if 'return' in func_annotations: + new_annotations['return'] = func_annotations['return'] + + return new_annotations + + except (ValueError, TypeError): + # If we can't process, return the original annotations + return func_annotations + + +def _get_annotations_for_partial(partial_obj, format): + """Get annotations for a functools.partial object. + + Returns annotations for the wrapped function, but only for parameters + that haven't been bound by the partial application. + """ + import inspect + + # Get the wrapped function + func = partial_obj.func + + # Get annotations from the wrapped function + func_annotations = get_annotations(func, format=format) + + if not func_annotations: + return {} + + # Get the signature to determine which parameters are bound + try: + sig = inspect.signature(partial_obj) + except (ValueError, TypeError): + # If we can't get signature, return empty dict + return {} + + # Build new annotations dict with only unbound parameters + # (parameters first, then return) + new_annotations = {} + + # Only include annotations for parameters that still exist in partial's signature + for param_name in sig.parameters: + if param_name in func_annotations: + new_annotations[param_name] = func_annotations[param_name] + + # Add return annotation at the end + if 'return' in func_annotations: + new_annotations['return'] = func_annotations['return'] + + return new_annotations + + def _get_and_call_annotate(obj, format): """Get the __annotate__ function and call it. May not return a fresh dictionary. """ + import functools + + # Handle functools.partialmethod objects (unbound) + # Check for __partialmethod__ attribute first + try: + partialmethod = obj.__partialmethod__ + except AttributeError: + pass + else: + if isinstance(partialmethod, functools.partialmethod): + return _get_annotations_for_partialmethod(partialmethod, format) + + # Handle functools.partial objects + if isinstance(obj, functools.partial): + return _get_annotations_for_partial(obj, format) + annotate = getattr(obj, "__annotate__", None) if annotate is not None: ann = call_annotate_function(annotate, format, owner=obj) @@ -1084,6 +1214,21 @@ def _get_dunder_annotations(obj): Does not return a fresh dictionary. """ + # Check for functools.partialmethod - skip __annotations__ and use __annotate__ path + import functools + try: + partialmethod = obj.__partialmethod__ + if isinstance(partialmethod, functools.partialmethod): + # Return None to trigger _get_and_call_annotate + return None + except AttributeError: + pass + + # Check for functools.partial - skip __annotations__ and use __annotate__ path + if isinstance(obj, functools.partial): + # Return None to trigger _get_and_call_annotate + return None + # This special case is needed to support types defined under # from __future__ import annotations, where accessing the __annotations__ # attribute directly might return annotations for the wrong class. diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index a8a8bcec76a429..fde6e173f98248 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1704,6 +1704,186 @@ def test_fwdref_invalid_syntax(self): fr.evaluate() +class TestFunctoolsPartialMethod(unittest.TestCase): + """Tests for get_annotations() with functools.partialmethod objects.""" + + def test_partialmethod_unbound(self): + """Test unbound partialmethod.""" + class MyClass: + def method(self, a: int, b: str, c: float) -> bool: + return True + + partial_method = functools.partialmethod(method, 1) + + result = get_annotations(MyClass.partial_method) + + # 'a' is bound, but 'self' should remain (unbound method) + expected = {'self': type(None).__class__, 'b': str, 'c': float, 'return': bool} + # Note: 'self' might not have an annotation in the original function + # So we check what parameters remain + self.assertIn('b', result) + self.assertIn('c', result) + self.assertIn('return', result) + self.assertNotIn('a', result) + + def test_partialmethod_bound(self): + """Test bound partialmethod (which becomes a partial object).""" + class MyClass: + def method(self, a: int, b: str, c: float) -> bool: + return True + + partial_method = functools.partialmethod(method, 1) + + obj = MyClass() + result = get_annotations(obj.partial_method) + + # 'self' and 'a' are bound, only b, c remain + expected = {'b': str, 'c': float, 'return': bool} + self.assertEqual(result, expected) + + def test_partialmethod_with_keyword(self): + """Test partialmethod with keyword argument.""" + class MyClass: + def method(self, a: int, b: str, c: float) -> bool: + return True + + partial_method = functools.partialmethod(method, b="hello") + + result = get_annotations(MyClass.partial_method) + + # Keyword args don't remove params, but 'a' might be affected + self.assertIn('b', result) + self.assertIn('c', result) + self.assertIn('return', result) + + def test_partialmethod_classmethod(self): + """Test partialmethod with classmethod.""" + class MyClass: + @classmethod + def method(cls, a: int, b: str) -> bool: + return True + + partial_method = functools.partialmethod(method, 1) + + result = get_annotations(MyClass.partial_method) + + # 'a' is bound, 'cls' and 'b' should remain + self.assertIn('b', result) + self.assertIn('return', result) + self.assertNotIn('a', result) + + def test_partialmethod_no_annotations(self): + """Test partialmethod without annotations.""" + class MyClass: + def method(self, a, b, c): + return True + + partial_method = functools.partialmethod(method, 1) + + result = get_annotations(MyClass.partial_method) + self.assertEqual(result, {}) + + +class TestFunctoolsPartial(unittest.TestCase): + """Tests for get_annotations() with functools.partial objects.""" + + def test_partial_basic(self): + """Test basic partial with positional argument.""" + def foo(a: int, b: str, c: float) -> bool: + return True + + partial_foo = functools.partial(foo, 1) + result = get_annotations(partial_foo) + + # 'a' is bound, so only b, c, and return should remain + expected = {'b': str, 'c': float, 'return': bool} + self.assertEqual(result, expected) + + def test_partial_with_keyword(self): + """Test partial with keyword argument.""" + def foo(a: int, b: str, c: float) -> bool: + return True + + partial_foo = functools.partial(foo, b="hello") + result = get_annotations(partial_foo) + + # Keyword arguments don't remove parameters from signature + expected = {'a': int, 'b': str, 'c': float, 'return': bool} + self.assertEqual(result, expected) + + def test_partial_all_args_bound(self): + """Test partial with all arguments bound.""" + def foo(a: int, b: str) -> bool: + return True + + partial_foo = functools.partial(foo, 1, "hello") + result = get_annotations(partial_foo) + + # Only return annotation should remain + expected = {'return': bool} + self.assertEqual(result, expected) + + def test_partial_no_annotations(self): + """Test partial of function without annotations.""" + def foo(a, b, c): + return True + + partial_foo = functools.partial(foo, 1) + result = get_annotations(partial_foo) + + # Should return empty dict + self.assertEqual(result, {}) + + def test_nested_partial(self): + """Test nested partial applications.""" + def foo(a: int, b: str, c: float, d: list) -> bool: + return True + + partial1 = functools.partial(foo, 1) + partial2 = functools.partial(partial1, "hello") + result = get_annotations(partial2) + + # a and b are bound, c and d remain + expected = {'c': float, 'd': list, 'return': bool} + self.assertEqual(result, expected) + + def test_partial_no_return_annotation(self): + """Test partial without return annotation.""" + def foo(a: int, b: str): + pass + + partial_foo = functools.partial(foo, 1) + result = get_annotations(partial_foo) + + # Only b should remain + expected = {'b': str} + self.assertEqual(result, expected) + + def test_partial_format_string(self): + """Test partial with STRING format.""" + def foo(a: int, b: str) -> bool: + return True + + partial_foo = functools.partial(foo, 1) + result = get_annotations(partial_foo, format=Format.STRING) + + # Should return strings + expected = {'b': 'str', 'return': 'bool'} + self.assertEqual(result, expected) + + def test_partial_format_forwardref(self): + """Test partial with FORWARDREF format.""" + def foo(a: int, b: str) -> bool: + return True + + partial_foo = functools.partial(foo, 1) + result = get_annotations(partial_foo, format=Format.FORWARDREF) + + # Should resolve to actual types + expected = {'b': str, 'return': bool} + self.assertEqual(result, expected) + + class TestAnnotationLib(unittest.TestCase): def test__all__(self): support.check__all__(self, annotationlib) diff --git a/Misc/NEWS.d/next/Library/2025-10-08-15-37-59.gh-issue-91002.vNJdih.rst b/Misc/NEWS.d/next/Library/2025-10-08-15-37-59.gh-issue-91002.vNJdih.rst new file mode 100644 index 00000000000000..99966edd5899b7 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-08-15-37-59.gh-issue-91002.vNJdih.rst @@ -0,0 +1,2 @@ +Support :class:`functools.partial` and :class:`functools.partialmethod` +inspect in :func:`annotationlib.get_annotations`