diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst index d6f5055955e8cf..6ed0fcdbf0d3f0 100644 --- a/Doc/library/annotationlib.rst +++ b/Doc/library/annotationlib.rst @@ -368,6 +368,10 @@ 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. + * Supports objects that provide their own :attr:`~object.__annotate__` method, + such as :class:`functools.partial` and :class:`functools.partialmethod`. + See :ref:`below ` for details on using + :func:`!get_annotations` with :mod:`functools` objects. *eval_str* controls whether or not values of type :class:`!str` are replaced with the result of calling :func:`eval` on those values: @@ -389,10 +393,12 @@ Functions ``sys.modules[obj.__module__].__dict__`` and *locals* defaults to the *obj* class namespace. * 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, - it is unwrapped until a non-wrapped function is found. + :attr:`obj.__globals__ `. + If *obj* has a ``__wrapped__`` attribute (such as functions + decorated with :func:`functools.update_wrapper`), or if it is a + :class:`functools.partial` object, it is unwrapped by following the + ``__wrapped__`` attribute or :attr:`~functools.partial.func` attribute + repeatedly to find the underlying wrapped function's globals. Calling :func:`!get_annotations` is best practice for accessing the annotations dict of any object. See :ref:`annotations-howto` for @@ -421,6 +427,94 @@ Functions .. versionadded:: 3.14 +.. _functools-objects-annotations: + +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`. + +If :func:`!get_annotations` cannot reliably determine which parameters are bound +(for example, if :func:`inspect.signature` raises an error), it will raise a +:exc:`TypeError` rather than returning incorrect annotations. This ensures that +you either get correct annotations or a clear error, never incorrect annotations: + +.. doctest:: + + >>> from functools import partial + >>> import inspect + >>> def func(a: int, b: str) -> bool: + ... return True + >>> partial_func = partial(func, 1) + >>> # Simulate a case where signature inspection fails + >>> original_sig = inspect.signature + >>> def broken_signature(obj): + ... if isinstance(obj, partial): + ... raise ValueError("Cannot inspect signature") + ... return original_sig(obj) + >>> inspect.signature = broken_signature + >>> try: + ... get_annotations(partial_func) + ... except TypeError as e: + ... print(f"Got expected error: {e}") + ... finally: + ... inspect.signature = original_sig + Got expected error: Cannot compute annotations for ...: unable to determine signature + +This design prevents the common error of returning annotations that include +parameters which have already been bound by the partial application. + Recipes ------- diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index f8ffb3f41d1210..5f7b144964645e 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -825,3 +825,16 @@ have three read-only attributes: callable, weak referenceable, and can have attributes. There are some important differences. For instance, the :attr:`~definition.__name__` and :attr:`~definition.__doc__` attributes are not created automatically. + +However, :class:`partial` objects do support the :attr:`~object.__annotate__` protocol for +annotation introspection. When accessed, :attr:`!__annotate__` returns only the annotations +for parameters that have not been bound by the partial application, along with the return +annotation. This behavior is consistent with :func:`inspect.signature` and allows tools like +:func:`annotationlib.get_annotations` to work correctly with partial objects. See the +:mod:`annotationlib` module documentation for more information on working with annotations +on partial objects. + +:class:`partialmethod` objects similarly support :attr:`~object.__annotate__` for unbound methods. + +.. versionadded:: next + Added :attr:`~object.__annotate__` support to :class:`partial` and :class:`partialmethod`. diff --git a/Lib/functools.py b/Lib/functools.py index a92844ba7227b0..ebb4ee542f661f 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -467,6 +467,8 @@ def _method(cls_or_self, /, *args, **keywords): return self.func(cls_or_self, *pto_args, *args, **keywords) _method.__isabstractmethod__ = self.__isabstractmethod__ _method.__partialmethod__ = self + # Set __annotate__ to delegate to the partialmethod's __annotate__ + _method.__annotate__ = self.__annotate__ return _method def __get__(self, obj, cls=None): @@ -492,6 +494,10 @@ def __get__(self, obj, cls=None): def __isabstractmethod__(self): return getattr(self.func, "__isabstractmethod__", False) + def __annotate__(self, format): + """Return annotations for the partial method.""" + return _partialmethod_annotate(self, format) + __class_getitem__ = classmethod(GenericAlias) @@ -513,6 +519,147 @@ def _unwrap_partialmethod(func): func = _unwrap_partial(func) return func +def _partial_annotate(partial_obj, format): + """Helper function to compute annotations for a partial object. + + This is called by the __annotate__ descriptor defined in C. + Returns annotations for the wrapped function, but only for parameters + that haven't been bound by the partial application. + """ + import inspect + from annotationlib import get_annotations + + # 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, annotation_format=format) + except (ValueError, TypeError) as e: + # If we can't get signature, we can't reliably determine which + # parameters are bound. Raise an error rather than returning + # incorrect annotations. + raise TypeError( + f"Cannot compute annotations for {partial_obj!r}: " + f"unable to determine signature" + ) from e + + # 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 _partialmethod_annotate(partialmethod_obj, format): + """Helper function to compute annotations for a partialmethod object. + + This is called when accessing annotations on an unbound partialmethod + (via the __partialmethod__ attribute). + 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 + from annotationlib import get_annotations + + # 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, annotation_format=format) + 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 + + # We need to account for Placeholders which create "holes" + # For example: partialmethod(func, 1, Placeholder, 3) binds 'a' and 'c' but not 'b' + + remaining_params = func_params[1:] + + # Track which positions are filled by Placeholder + placeholder_positions = set() + for i, arg in enumerate(partial_args): + if arg is Placeholder: + placeholder_positions.add(i) + + # Number of non-Placeholder positional args + # This doesn't directly tell us which params are bound due to Placeholders + + for i, param_name in enumerate(remaining_params): + # Check if this position has a Placeholder + if i in placeholder_positions: + # This parameter is deferred by Placeholder, keep it + if param_name in func_annotations: + new_annotations[param_name] = func_annotations[param_name] + continue + + # Check if this position is beyond the partial_args + if i >= len(partial_args): + # This parameter is not bound at all, keep it + if param_name in func_annotations: + new_annotations[param_name] = func_annotations[param_name] + continue + + # Otherwise, this position is bound (not a Placeholder and within bounds) + # Skip it + + # Add return annotation at the end + if 'return' in func_annotations: + new_annotations['return'] = func_annotations['return'] + + return new_annotations + + except (ValueError, TypeError) as e: + # If we can't process the signature, we can't reliably determine + # which parameters are bound. Raise an error rather than returning + # incorrect annotations (which would include bound parameters). + raise TypeError( + f"Cannot compute annotations for {partialmethod_obj!r}: " + f"unable to determine which parameters are bound" + ) from e + ################################################################################ ### LRU Cache function decorator ################################################################################ diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index a8a8bcec76a429..3f9b0315d32f0c 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1704,6 +1704,282 @@ 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, {}) + + def test_partialmethod_with_placeholder(self): + """Test partialmethod with Placeholder.""" + class MyClass: + def method(self, a: int, b: str, c: float) -> bool: + return True + + # Bind 'a', defer 'b', bind 'c' + partial_method = functools.partialmethod(method, 1, functools.Placeholder, 3.0) + + result = get_annotations(MyClass.partial_method) + + # 'self' stays, 'a' and 'c' are bound, 'b' remains + # For unbound partialmethod, we expect 'self' if annotated, plus remaining params + # Since 'self' isn't annotated, only 'b' and 'return' remain + self.assertIn('b', result) + self.assertIn('return', result) + self.assertNotIn('a', result) + self.assertNotIn('c', result) + + def test_partialmethod_with_multiple_placeholders(self): + """Test partialmethod with multiple Placeholders.""" + class MyClass: + def method(self, a: int, b: str, c: float, d: list) -> bool: + return True + + # Bind 'a', defer 'b', defer 'c', bind 'd' + partial_method = functools.partialmethod(method, 1, functools.Placeholder, functools.Placeholder, []) + + result = get_annotations(MyClass.partial_method) + + # 'b' and 'c' remain unbound, 'a' and 'd' are bound + self.assertIn('b', result) + self.assertIn('c', result) + self.assertIn('return', result) + self.assertNotIn('a', result) + self.assertNotIn('d', 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: UndefinedType1, b: UndefinedType2) -> UndefinedReturnType: + return True + + partial_foo = functools.partial(foo, 1) + result = get_annotations(partial_foo, format=Format.FORWARDREF) + + # Should return forward references for undefined types + expected = { + 'b': support.EqualToForwardRef('UndefinedType2', owner=foo), + 'return': support.EqualToForwardRef('UndefinedReturnType', owner=foo) + } + self.assertEqual(result, expected) + + def test_partial_with_placeholder(self): + """Test partial with Placeholder for deferred argument.""" + def foo(a: int, b: str, c: float) -> bool: + return True + + # Placeholder in the middle: bind 'a', defer 'b', bind 'c' + partial_foo = functools.partial(foo, 1, functools.Placeholder, 3.0) + result = get_annotations(partial_foo) + + # Only 'b' remains unbound (Placeholder defers it), 'a' and 'c' are bound + # So we should have 'b' and 'return' + expected = {'b': str, 'return': bool} + self.assertEqual(result, expected) + + def test_partial_with_multiple_placeholders(self): + """Test partial with multiple Placeholders.""" + def foo(a: int, b: str, c: float, d: list) -> bool: + return True + + # Bind 'a', defer 'b', defer 'c', bind 'd' + partial_foo = functools.partial(foo, 1, functools.Placeholder, functools.Placeholder, []) + result = get_annotations(partial_foo) + + # 'b' and 'c' remain unbound, 'a' and 'd' are bound + expected = {'b': str, 'c': float, 'return': bool} + self.assertEqual(result, expected) + + def test_partial_placeholder_at_start(self): + """Test partial with Placeholder at the start.""" + def foo(a: int, b: str, c: float) -> bool: + return True + + # Defer 'a', bind 'b' and 'c' + partial_foo = functools.partial(foo, functools.Placeholder, "hello", 3.0) + result = get_annotations(partial_foo) + + # Only 'a' remains unbound + expected = {'a': int, 'return': bool} + self.assertEqual(result, expected) + + def test_nested_partial_with_placeholder(self): + """Test nested partial applications with Placeholder.""" + def foo(a: int, b: str, c: float, d: list) -> bool: + return True + + # First partial: bind 'a', defer 'b', bind 'c' + # (can't have trailing Placeholder) + partial1 = functools.partial(foo, 1, functools.Placeholder, 3.0) + # Second partial: provide 'b' + partial2 = functools.partial(partial1, "hello") + result = get_annotations(partial2) + + # 'a', 'b', and 'c' are bound, only 'd' remains + expected = {'d': list, '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..b807f2a709aa81 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-08-15-37-59.gh-issue-91002.vNJdih.rst @@ -0,0 +1,2 @@ +Support ``__annotate__`` for :class:`functools.partial` +and :class:`functools.partialmethod` diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c index 257d5c6d53611c..42b57547423afe 100644 --- a/Modules/_functoolsmodule.c +++ b/Modules/_functoolsmodule.c @@ -360,6 +360,32 @@ partial_descr_get(PyObject *self, PyObject *obj, PyObject *type) return PyMethod_New(self, obj); } +static PyObject * +partial_annotate(PyObject *self, PyObject *format_obj) +{ + /* Delegate to Python functools._partial_annotate helper */ + PyObject *functools = NULL, *helper = NULL, *result = NULL; + + /* Import functools module */ + functools = PyImport_ImportModule("functools"); + if (functools == NULL) { + return NULL; + } + + /* Get the _partial_annotate function */ + helper = PyObject_GetAttrString(functools, "_partial_annotate"); + Py_DECREF(functools); + if (helper == NULL) { + return NULL; + } + + /* Call _partial_annotate(self, format) */ + result = PyObject_CallFunctionObjArgs(helper, self, format_obj, NULL); + Py_DECREF(helper); + + return result; +} + static PyObject * partial_vectorcall(PyObject *self, PyObject *const *args, size_t nargsf, PyObject *kwnames) @@ -832,6 +858,7 @@ partial_setstate(PyObject *self, PyObject *state) static PyMethodDef partial_methods[] = { {"__reduce__", partial_reduce, METH_NOARGS}, {"__setstate__", partial_setstate, METH_O}, + {"__annotate__", partial_annotate, METH_O}, {"__class_getitem__", Py_GenericAlias, METH_O|METH_CLASS, PyDoc_STR("See PEP 585")}, {NULL, NULL} /* sentinel */