This commit is contained in:
Nadeshiko Manju 2025-12-23 14:12:33 +05:30 committed by GitHub
commit 8e4d109281
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 586 additions and 4 deletions

View file

@ -383,6 +383,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__` descriptor,
such as :class:`functools.partial` and :class:`functools.partialmethod`.
See the :mod:`functools` module documentation for details on how these
objects support annotations.
*eval_str* controls whether or not values of type :class:`!str` are
replaced with the result of calling :func:`eval` on those values:
@ -404,10 +408,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__ <function.__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__ <function.__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
@ -436,6 +442,36 @@ Functions
.. versionadded:: 3.14
.. _support-annotations-custom-objects:
Supporting annotations in custom objects
-------------------------------------------
Objects can support annotation introspection by implementing the :attr:`~object.__annotate__`
protocol. When an object's class provides an :attr:`!__annotate__` descriptor, :func:`get_annotations`
will call it to retrieve the annotations for that object. The :attr:`!__annotate__` function
should accept a single argument, a member of the :class:`Format` enum, and return a dictionary
mapping annotation names to their values in the requested format.
This mechanism allows objects to dynamically compute their annotations based on their state.
For example, :class:`functools.partial` and :class:`functools.partialmethod` objects use
:attr:`!__annotate__` to provide annotations that reflect only the unbound parameters,
excluding parameters that have been filled by the partial application. See the
:mod:`functools` module documentation for details on how these specific objects handle
annotations.
Other examples of objects that implement :attr:`!__annotate__` include:
* :class:`typing.TypedDict` classes created through the functional syntax
* Generic classes and functions with type parameters
When implementing :attr:`!__annotate__` for custom objects, the function should handle
all three primary formats (:attr:`~Format.VALUE`, :attr:`~Format.FORWARDREF`, and
:attr:`~Format.STRING`) by either returning appropriate values or raising
:exc:`NotImplementedError` to fall back to default behavior. Helper functions like
:func:`annotations_to_string` and :func:`call_annotate_function` can assist with
implementing format support.
Recipes
-------

View file

@ -832,3 +832,94 @@ 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.
Annotation support
^^^^^^^^^^^^^^^^^^
:class:`partial` and :class:`partialmethod` objects support the :attr:`~object.__annotate__` protocol for
annotation introspection. This allows tools like :func:`annotationlib.get_annotations` to retrieve
annotations that accurately reflect the signature of the partial or partialmethod object.
For :class:`partial` objects, :func:`annotationlib.get_annotations` returns only the annotations
for parameters that have not been bound by the partial application, along with the return annotation
if present. Positional arguments bind to parameters in order, and the annotations for those parameters
are excluded from the result:
.. doctest::
>>> from functools import partial
>>> from annotationlib import get_annotations
>>> def func(a: int, b: str, c: float) -> bool:
... return True
>>> partial_func = partial(func, 1) # Binds 'a'
>>> get_annotations(partial_func)
{'b': <class 'str'>, 'c': <class 'float'>, 'return': <class 'bool'>}
Keyword arguments in :class:`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': <class 'int'>, 'b': <class 'str'>, 'c': <class 'float'>, 'return': <class 'bool'>}
For :class:`partialmethod` objects accessed through a class (unbound), the first parameter
(usually ``self`` or ``cls``) is preserved, and subsequent parameters are handled similarly
to :class:`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': <class 'str'>, 'return': <class 'bool'>}
When a :class:`partialmethod` is accessed through an instance (bound), it becomes a
:class:`partial` object and is handled accordingly:
.. doctest::
>>> obj = MyClass()
>>> get_annotations(obj.partial_method) # Same as above, 'self' is also bound
{'b': <class 'str'>, 'return': <class 'bool'>}
This behavior ensures that :func:`annotationlib.get_annotations` returns annotations that
accurately reflect the signature of the partial or partialmethod object, as determined by
:func:`inspect.signature`.
If :func:`annotationlib.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.
.. versionadded:: next
Added :attr:`~object.__annotate__` support to :class:`partial` and :class:`partialmethod`.

View file

@ -365,6 +365,152 @@ def _partial_repr(self):
args.extend(f"{k}={v!r}" for k, v in self.keywords.items())
return f"{module}.{qualname}({', '.join(args)})"
################################################################################
### _partial_annotate() - compute annotations for partial objects
################################################################################
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 annotations from the wrapped function
func_annotations = get_annotations(partial_obj.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
################################################################################
### _partialmethod_annotate() - compute annotations for partialmethod objects
################################################################################
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 annotations from the wrapped function
func_annotations = get_annotations(partialmethod_obj.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(partialmethod_obj.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
# Purely functional, no descriptor behaviour
class partial:
"""New function with partial application of the given arguments
@ -467,6 +613,8 @@ class partialmethod:
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 +640,8 @@ class partialmethod:
def __isabstractmethod__(self):
return getattr(self.func, "__isabstractmethod__", False)
__annotate__ = _partialmethod_annotate
__class_getitem__ = classmethod(GenericAlias)

View file

@ -2200,6 +2200,282 @@ class TestForwardRefClass(unittest.TestCase):
pass
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)

View file

@ -0,0 +1,2 @@
Support ``__annotate__`` for :class:`functools.partial`
and :class:`functools.partialmethod`

View file

@ -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)
@ -833,6 +859,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 */