Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions Doc/library/annotationlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -391,7 +394,8 @@ Functions
* 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,
: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
Expand All @@ -405,7 +409,20 @@ Functions
>>> get_annotations(f)
{'a': <class 'int'>, 'b': <class 'str'>, 'return': <class 'float'>}

.. 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': <class 'int'>, 'c': <class 'int'>, 'return': <class 'int'>}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could refer to the new section below rather that repeating the example, what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

.. versionadded:: 3.15

.. function:: type_repr(value)

Expand All @@ -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': <class 'str'>, 'c': <class 'float'>, 'return': <class 'bool'>}

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': <class 'int'>, 'b': <class 'str'>, 'c': <class 'float'>, 'return': <class 'bool'>}

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': <class 'str'>, 'return': <class 'bool'>}

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': <class 'str'>, 'return': <class 'bool'>}

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
-------

Expand Down
145 changes: 145 additions & 0 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will fail if the original function's signature has annotations that do not evaluate in the VALUE 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

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? If we can't get correct annotations, we ideally shouldn't return wrong ones.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import is in the crucial code path for annotationlib, it doesn't make sense to make it lazy.


# 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)
Expand All @@ -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.
Expand Down
Loading
Loading