Skip to content

Commit 0ca6df4

Browse files
committed
gh-91002: Support functools.partial and functools.partialmethod inspect in annotationlib.get_annotations
Signed-off-by: Manjusaka <[email protected]>
1 parent a15aeec commit 0ca6df4

File tree

3 files changed

+398
-1
lines changed

3 files changed

+398
-1
lines changed

Doc/library/annotationlib.rst

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,9 @@ Functions
368368
doesn't have its own annotations dict, returns an empty dict.
369369
* All accesses to object members and dict values are done
370370
using ``getattr()`` and ``dict.get()`` for safety.
371+
* For :class:`functools.partial` and :class:`functools.partialmethod` objects,
372+
only returns annotations for parameters that have not been bound by the
373+
partial application, along with the return annotation if present.
371374

372375
*eval_str* controls whether or not values of type :class:`!str` are
373376
replaced with the result of calling :func:`eval` on those values:
@@ -391,7 +394,8 @@ Functions
391394
* If *obj* is a callable, *globals* defaults to
392395
:attr:`obj.__globals__ <function.__globals__>`,
393396
although if *obj* is a wrapped function (using
394-
:func:`functools.update_wrapper`) or a :class:`functools.partial` object,
397+
:func:`functools.update_wrapper`), a :class:`functools.partial` object,
398+
or a :class:`functools.partialmethod` object,
395399
it is unwrapped until a non-wrapped function is found.
396400

397401
Calling :func:`!get_annotations` is best practice for accessing the
@@ -405,6 +409,19 @@ Functions
405409
>>> get_annotations(f)
406410
{'a': <class 'int'>, 'b': <class 'str'>, 'return': <class 'float'>}
407411

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+
408425
.. versionadded:: 3.14
409426

410427
.. function:: type_repr(value)
@@ -422,6 +439,63 @@ Functions
422439
.. versionadded:: 3.14
423440

424441

442+
Using :func:`!get_annotations` with :mod:`functools` objects
443+
--------------------------------------------------------------
444+
445+
:func:`get_annotations` has special support for :class:`functools.partial`
446+
and :class:`functools.partialmethod` objects. When called on these objects,
447+
it returns only the annotations for parameters that have not been bound by
448+
the partial application, along with the return annotation if present.
449+
450+
For :class:`functools.partial` objects, positional arguments bind to parameters
451+
in order, and the annotations for those parameters are excluded from the result:
452+
453+
.. doctest::
454+
455+
>>> from functools import partial
456+
>>> def func(a: int, b: str, c: float) -> bool:
457+
... return True
458+
>>> partial_func = partial(func, 1) # Binds 'a'
459+
>>> get_annotations(partial_func)
460+
{'b': <class 'str'>, 'c': <class 'float'>, 'return': <class 'bool'>}
461+
462+
Keyword arguments in :class:`functools.partial` set default values but do not
463+
remove parameters from the signature, so their annotations are retained:
464+
465+
.. doctest::
466+
467+
>>> partial_func_kw = partial(func, b="hello") # Sets default for 'b'
468+
>>> get_annotations(partial_func_kw)
469+
{'a': <class 'int'>, 'b': <class 'str'>, 'c': <class 'float'>, 'return': <class 'bool'>}
470+
471+
For :class:`functools.partialmethod` objects accessed through a class (unbound),
472+
the first parameter (usually ``self`` or ``cls``) is preserved, and subsequent
473+
parameters are handled similarly to :class:`functools.partial`:
474+
475+
.. doctest::
476+
477+
>>> from functools import partialmethod
478+
>>> class MyClass:
479+
... def method(self, a: int, b: str) -> bool:
480+
... return True
481+
... partial_method = partialmethod(method, 1) # Binds 'a'
482+
>>> get_annotations(MyClass.partial_method)
483+
{'b': <class 'str'>, 'return': <class 'bool'>}
484+
485+
When a :class:`functools.partialmethod` is accessed through an instance (bound),
486+
it becomes a :class:`functools.partial` object and is handled accordingly:
487+
488+
.. doctest::
489+
490+
>>> obj = MyClass()
491+
>>> get_annotations(obj.partial_method) # Same as above, 'self' is also bound
492+
{'b': <class 'str'>, 'return': <class 'bool'>}
493+
494+
This behavior ensures that :func:`get_annotations` returns annotations that
495+
accurately reflect the signature of the partial or partialmethod object, as
496+
determined by :func:`inspect.signature`.
497+
498+
425499
Recipes
426500
-------
427501

Lib/annotationlib.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,11 +1062,139 @@ def annotations_to_string(annotations):
10621062
}
10631063

10641064

1065+
def _get_annotations_for_partialmethod(partialmethod_obj, format):
1066+
"""Get annotations for a functools.partialmethod object.
1067+
1068+
Returns annotations for the wrapped function, but only for parameters
1069+
that haven't been bound by the partial application. The first parameter
1070+
(usually 'self' or 'cls') is kept since partialmethod is unbound.
1071+
"""
1072+
import inspect
1073+
1074+
# Get the wrapped function
1075+
func = partialmethod_obj.func
1076+
1077+
# Get annotations from the wrapped function
1078+
func_annotations = get_annotations(func, format=format)
1079+
1080+
if not func_annotations:
1081+
return {}
1082+
1083+
# For partialmethod, we need to simulate the signature calculation
1084+
# The first parameter (self/cls) should remain, but bound args should be removed
1085+
try:
1086+
# Get the function signature
1087+
func_sig = inspect.signature(func)
1088+
func_params = list(func_sig.parameters.keys())
1089+
1090+
if not func_params:
1091+
return func_annotations
1092+
1093+
# Calculate which parameters are bound by the partialmethod
1094+
partial_args = partialmethod_obj.args or ()
1095+
partial_keywords = partialmethod_obj.keywords or {}
1096+
1097+
# Build new annotations dict
1098+
new_annotations = {}
1099+
1100+
# Keep return annotation if present
1101+
if 'return' in func_annotations:
1102+
new_annotations['return'] = func_annotations['return']
1103+
1104+
# The first parameter (self/cls) is always kept for unbound partialmethod
1105+
first_param = func_params[0]
1106+
if first_param in func_annotations:
1107+
new_annotations[first_param] = func_annotations[first_param]
1108+
1109+
# For partialmethod, positional args bind to parameters AFTER the first one
1110+
# So if func is (self, a, b, c) and partialmethod.args=(1,)
1111+
# Then 'self' stays, 'a' is bound, 'b' and 'c' remain
1112+
1113+
remaining_params = func_params[1:]
1114+
num_positional_bound = len(partial_args)
1115+
1116+
for i, param_name in enumerate(remaining_params):
1117+
# Skip if this param is bound positionally
1118+
if i < num_positional_bound:
1119+
continue
1120+
1121+
# For keyword binding: keep the annotation (keyword sets default, doesn't remove param)
1122+
if param_name in partial_keywords:
1123+
if param_name in func_annotations:
1124+
new_annotations[param_name] = func_annotations[param_name]
1125+
continue
1126+
1127+
# This parameter is not bound, keep its annotation
1128+
if param_name in func_annotations:
1129+
new_annotations[param_name] = func_annotations[param_name]
1130+
1131+
return new_annotations
1132+
1133+
except (ValueError, TypeError):
1134+
# If we can't process, return the original annotations
1135+
return func_annotations
1136+
1137+
1138+
def _get_annotations_for_partial(partial_obj, format):
1139+
"""Get annotations for a functools.partial object.
1140+
1141+
Returns annotations for the wrapped function, but only for parameters
1142+
that haven't been bound by the partial application.
1143+
"""
1144+
import inspect
1145+
1146+
# Get the wrapped function
1147+
func = partial_obj.func
1148+
1149+
# Get annotations from the wrapped function
1150+
func_annotations = get_annotations(func, format=format)
1151+
1152+
if not func_annotations:
1153+
return {}
1154+
1155+
# Get the signature to determine which parameters are bound
1156+
try:
1157+
sig = inspect.signature(partial_obj)
1158+
except (ValueError, TypeError):
1159+
# If we can't get signature, return empty dict
1160+
return {}
1161+
1162+
# Build new annotations dict with only unbound parameters
1163+
new_annotations = {}
1164+
1165+
# Keep return annotation if present
1166+
if 'return' in func_annotations:
1167+
new_annotations['return'] = func_annotations['return']
1168+
1169+
# Only include annotations for parameters that still exist in partial's signature
1170+
for param_name in sig.parameters:
1171+
if param_name in func_annotations:
1172+
new_annotations[param_name] = func_annotations[param_name]
1173+
1174+
return new_annotations
1175+
1176+
10651177
def _get_and_call_annotate(obj, format):
10661178
"""Get the __annotate__ function and call it.
10671179
10681180
May not return a fresh dictionary.
10691181
"""
1182+
import functools
1183+
1184+
# Handle functools.partialmethod objects (unbound)
1185+
# Check for __partialmethod__ attribute first
1186+
try:
1187+
partialmethod = obj.__partialmethod__
1188+
except AttributeError:
1189+
pass
1190+
else:
1191+
if isinstance(partialmethod, functools.partialmethod):
1192+
return _get_annotations_for_partialmethod(partialmethod, format)
1193+
1194+
# Handle functools.partial objects
1195+
if isinstance(obj, functools.partial):
1196+
return _get_annotations_for_partial(obj, format)
1197+
10701198
annotate = getattr(obj, "__annotate__", None)
10711199
if annotate is not None:
10721200
ann = call_annotate_function(annotate, format, owner=obj)
@@ -1084,6 +1212,21 @@ def _get_dunder_annotations(obj):
10841212
10851213
Does not return a fresh dictionary.
10861214
"""
1215+
# Check for functools.partialmethod - skip __annotations__ and use __annotate__ path
1216+
import functools
1217+
try:
1218+
partialmethod = obj.__partialmethod__
1219+
if isinstance(partialmethod, functools.partialmethod):
1220+
# Return None to trigger _get_and_call_annotate
1221+
return None
1222+
except AttributeError:
1223+
pass
1224+
1225+
# Check for functools.partial - skip __annotations__ and use __annotate__ path
1226+
if isinstance(obj, functools.partial):
1227+
# Return None to trigger _get_and_call_annotate
1228+
return None
1229+
10871230
# This special case is needed to support types defined under
10881231
# from __future__ import annotations, where accessing the __annotations__
10891232
# attribute directly might return annotations for the wrong class.

0 commit comments

Comments
 (0)