Skip to content

Commit 2d606cf

Browse files
committed
fix review idea
Signed-off-by: Manjusaka <me@manjusaka.me>
1 parent ed9b31b commit 2d606cf

File tree

4 files changed

+177
-28
lines changed

4 files changed

+177
-28
lines changed

Doc/library/annotationlib.rst

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ Functions
371371
* For :class:`functools.partial` and :class:`functools.partialmethod` objects,
372372
only returns annotations for parameters that have not been bound by the
373373
partial application, along with the return annotation if present.
374+
See :ref:`below <functools-objects-annotations>` for details.
374375

375376
*eval_str* controls whether or not values of type :class:`!str` are
376377
replaced with the result of calling :func:`eval` on those values:
@@ -409,20 +410,7 @@ Functions
409410
>>> get_annotations(f)
410411
{'a': <class 'int'>, 'b': <class 'str'>, 'return': <class 'float'>}
411412

412-
:func:`!get_annotations` also works with :class:`functools.partial` and
413-
:class:`functools.partialmethod` objects, returning only the annotations
414-
for parameters that have not been bound:
415-
416-
.. doctest::
417-
418-
>>> from functools import partial
419-
>>> def add(a: int, b: int, c: int) -> int:
420-
... return a + b + c
421-
>>> add_10 = partial(add, 10)
422-
>>> get_annotations(add_10)
423-
{'b': <class 'int'>, 'c': <class 'int'>, 'return': <class 'int'>}
424-
425-
.. versionadded:: 3.15
413+
.. versionadded:: next
426414

427415
.. function:: type_repr(value)
428416

@@ -438,6 +426,7 @@ Functions
438426

439427
.. versionadded:: 3.14
440428

429+
.. _functools-objects-annotations:
441430

442431
Using :func:`!get_annotations` with :mod:`functools` objects
443432
--------------------------------------------------------------
@@ -495,6 +484,36 @@ This behavior ensures that :func:`get_annotations` returns annotations that
495484
accurately reflect the signature of the partial or partialmethod object, as
496485
determined by :func:`inspect.signature`.
497486

487+
If :func:`!get_annotations` cannot reliably determine which parameters are bound
488+
(for example, if :func:`inspect.signature` raises an error), it will raise a
489+
:exc:`TypeError` rather than returning incorrect annotations. This ensures that
490+
you either get correct annotations or a clear error, never incorrect annotations:
491+
492+
.. doctest::
493+
494+
>>> from functools import partial
495+
>>> import inspect
496+
>>> def func(a: int, b: str) -> bool:
497+
... return True
498+
>>> partial_func = partial(func, 1)
499+
>>> # Simulate a case where signature inspection fails
500+
>>> original_sig = inspect.signature
501+
>>> def broken_signature(obj):
502+
... if isinstance(obj, partial):
503+
... raise ValueError("Cannot inspect signature")
504+
... return original_sig(obj)
505+
>>> inspect.signature = broken_signature
506+
>>> try:
507+
... get_annotations(partial_func)
508+
... except TypeError as e:
509+
... print(f"Got expected error: {e}")
510+
... finally:
511+
... inspect.signature = original_sig
512+
Got expected error: Cannot compute annotations for ...: unable to determine signature
513+
514+
This design prevents the common error of returning annotations that include
515+
parameters which have already been bound by the partial application.
516+
498517

499518
Recipes
500519
-------

Doc/library/functools.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,3 +825,16 @@ have three read-only attributes:
825825
callable, weak referenceable, and can have attributes. There are some important
826826
differences. For instance, the :attr:`~definition.__name__` and :attr:`~definition.__doc__` attributes
827827
are not created automatically.
828+
829+
However, :class:`partial` objects do support the :attr:`~object.__annotate__` protocol for
830+
annotation introspection. When accessed, :attr:`!__annotate__` returns only the annotations
831+
for parameters that have not been bound by the partial application, along with the return
832+
annotation. This behavior is consistent with :func:`inspect.signature` and allows tools like
833+
:func:`annotationlib.get_annotations` to work correctly with partial objects. See the
834+
:mod:`annotationlib` module documentation for more information on working with annotations
835+
on partial objects.
836+
837+
:class:`partialmethod` objects similarly support :attr:`~object.__annotate__` for unbound methods.
838+
839+
.. versionadded:: next
840+
Added :attr:`~object.__annotate__` support to :class:`partial` and :class:`partialmethod`.

Lib/functools.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -541,9 +541,14 @@ def _partial_annotate(partial_obj, format):
541541
# Get the signature to determine which parameters are bound
542542
try:
543543
sig = inspect.signature(partial_obj)
544-
except (ValueError, TypeError):
545-
# If we can't get signature, return empty dict
546-
return {}
544+
except (ValueError, TypeError) as e:
545+
# If we can't get signature, we can't reliably determine which
546+
# parameters are bound. Raise an error rather than returning
547+
# incorrect annotations.
548+
raise TypeError(
549+
f"Cannot compute annotations for {partial_obj!r}: "
550+
f"unable to determine signature"
551+
) from e
547552

548553
# Build new annotations dict with only unbound parameters
549554
# (parameters first, then return)
@@ -608,33 +613,52 @@ def _partialmethod_annotate(partialmethod_obj, format):
608613
# So if func is (self, a, b, c) and partialmethod.args=(1,)
609614
# Then 'self' stays, 'a' is bound, 'b' and 'c' remain
610615

616+
# We need to account for Placeholders which create "holes"
617+
# For example: partialmethod(func, 1, Placeholder, 3) binds 'a' and 'c' but not 'b'
618+
611619
remaining_params = func_params[1:]
612-
num_positional_bound = len(partial_args)
620+
621+
# Track which positions are filled by Placeholder
622+
placeholder_positions = set()
623+
for i, arg in enumerate(partial_args):
624+
if arg is Placeholder:
625+
placeholder_positions.add(i)
626+
627+
# Number of non-Placeholder positional args
628+
# This doesn't directly tell us which params are bound due to Placeholders
613629

614630
for i, param_name in enumerate(remaining_params):
615-
# Skip if this param is bound positionally
616-
if i < num_positional_bound:
631+
# Check if this position has a Placeholder
632+
if i in placeholder_positions:
633+
# This parameter is deferred by Placeholder, keep it
634+
if param_name in func_annotations:
635+
new_annotations[param_name] = func_annotations[param_name]
617636
continue
618637

619-
# For keyword binding: keep the annotation (keyword sets default, doesn't remove param)
620-
if param_name in partial_keywords:
638+
# Check if this position is beyond the partial_args
639+
if i >= len(partial_args):
640+
# This parameter is not bound at all, keep it
621641
if param_name in func_annotations:
622642
new_annotations[param_name] = func_annotations[param_name]
623643
continue
624644

625-
# This parameter is not bound, keep its annotation
626-
if param_name in func_annotations:
627-
new_annotations[param_name] = func_annotations[param_name]
645+
# Otherwise, this position is bound (not a Placeholder and within bounds)
646+
# Skip it
628647

629648
# Add return annotation at the end
630649
if 'return' in func_annotations:
631650
new_annotations['return'] = func_annotations['return']
632651

633652
return new_annotations
634653

635-
except (ValueError, TypeError):
636-
# If we can't process, return the original annotations
637-
return func_annotations
654+
except (ValueError, TypeError) as e:
655+
# If we can't process the signature, we can't reliably determine
656+
# which parameters are bound. Raise an error rather than returning
657+
# incorrect annotations (which would include bound parameters).
658+
raise TypeError(
659+
f"Cannot compute annotations for {partialmethod_obj!r}: "
660+
f"unable to determine which parameters are bound"
661+
) from e
638662

639663
################################################################################
640664
### LRU Cache function decorator

Lib/test/test_annotationlib.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,6 +1783,43 @@ def method(self, a, b, c):
17831783
result = get_annotations(MyClass.partial_method)
17841784
self.assertEqual(result, {})
17851785

1786+
def test_partialmethod_with_placeholder(self):
1787+
"""Test partialmethod with Placeholder."""
1788+
class MyClass:
1789+
def method(self, a: int, b: str, c: float) -> bool:
1790+
return True
1791+
1792+
# Bind 'a', defer 'b', bind 'c'
1793+
partial_method = functools.partialmethod(method, 1, functools.Placeholder, 3.0)
1794+
1795+
result = get_annotations(MyClass.partial_method)
1796+
1797+
# 'self' stays, 'a' and 'c' are bound, 'b' remains
1798+
# For unbound partialmethod, we expect 'self' if annotated, plus remaining params
1799+
# Since 'self' isn't annotated, only 'b' and 'return' remain
1800+
self.assertIn('b', result)
1801+
self.assertIn('return', result)
1802+
self.assertNotIn('a', result)
1803+
self.assertNotIn('c', result)
1804+
1805+
def test_partialmethod_with_multiple_placeholders(self):
1806+
"""Test partialmethod with multiple Placeholders."""
1807+
class MyClass:
1808+
def method(self, a: int, b: str, c: float, d: list) -> bool:
1809+
return True
1810+
1811+
# Bind 'a', defer 'b', defer 'c', bind 'd'
1812+
partial_method = functools.partialmethod(method, 1, functools.Placeholder, functools.Placeholder, [])
1813+
1814+
result = get_annotations(MyClass.partial_method)
1815+
1816+
# 'b' and 'c' remain unbound, 'a' and 'd' are bound
1817+
self.assertIn('b', result)
1818+
self.assertIn('c', result)
1819+
self.assertIn('return', result)
1820+
self.assertNotIn('a', result)
1821+
self.assertNotIn('d', result)
1822+
17861823

17871824
class TestFunctoolsPartial(unittest.TestCase):
17881825
"""Tests for get_annotations() with functools.partial objects."""
@@ -1883,6 +1920,62 @@ def foo(a: int, b: str) -> bool:
18831920
expected = {'b': str, 'return': bool}
18841921
self.assertEqual(result, expected)
18851922

1923+
def test_partial_with_placeholder(self):
1924+
"""Test partial with Placeholder for deferred argument."""
1925+
def foo(a: int, b: str, c: float) -> bool:
1926+
return True
1927+
1928+
# Placeholder in the middle: bind 'a', defer 'b', bind 'c'
1929+
partial_foo = functools.partial(foo, 1, functools.Placeholder, 3.0)
1930+
result = get_annotations(partial_foo)
1931+
1932+
# Only 'b' remains unbound (Placeholder defers it), 'a' and 'c' are bound
1933+
# So we should have 'b' and 'return'
1934+
expected = {'b': str, 'return': bool}
1935+
self.assertEqual(result, expected)
1936+
1937+
def test_partial_with_multiple_placeholders(self):
1938+
"""Test partial with multiple Placeholders."""
1939+
def foo(a: int, b: str, c: float, d: list) -> bool:
1940+
return True
1941+
1942+
# Bind 'a', defer 'b', defer 'c', bind 'd'
1943+
partial_foo = functools.partial(foo, 1, functools.Placeholder, functools.Placeholder, [])
1944+
result = get_annotations(partial_foo)
1945+
1946+
# 'b' and 'c' remain unbound, 'a' and 'd' are bound
1947+
expected = {'b': str, 'c': float, 'return': bool}
1948+
self.assertEqual(result, expected)
1949+
1950+
def test_partial_placeholder_at_start(self):
1951+
"""Test partial with Placeholder at the start."""
1952+
def foo(a: int, b: str, c: float) -> bool:
1953+
return True
1954+
1955+
# Defer 'a', bind 'b' and 'c'
1956+
partial_foo = functools.partial(foo, functools.Placeholder, "hello", 3.0)
1957+
result = get_annotations(partial_foo)
1958+
1959+
# Only 'a' remains unbound
1960+
expected = {'a': int, 'return': bool}
1961+
self.assertEqual(result, expected)
1962+
1963+
def test_nested_partial_with_placeholder(self):
1964+
"""Test nested partial applications with Placeholder."""
1965+
def foo(a: int, b: str, c: float, d: list) -> bool:
1966+
return True
1967+
1968+
# First partial: bind 'a', defer 'b', bind 'c'
1969+
# (can't have trailing Placeholder)
1970+
partial1 = functools.partial(foo, 1, functools.Placeholder, 3.0)
1971+
# Second partial: provide 'b'
1972+
partial2 = functools.partial(partial1, "hello")
1973+
result = get_annotations(partial2)
1974+
1975+
# 'a', 'b', and 'c' are bound, only 'd' remains
1976+
expected = {'d': list, 'return': bool}
1977+
self.assertEqual(result, expected)
1978+
18861979

18871980
class TestAnnotationLib(unittest.TestCase):
18881981
def test__all__(self):

0 commit comments

Comments
 (0)