Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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.
Copy link
Member

Choose a reason for hiding this comment

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

With the new implementation, I’m not sure how much annotationlib docs should mention support for partial. Maybe mention the generic support and link to a new section in functools doc with the info here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes! I have updated the docs


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

Choose a reason for hiding this comment

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

Are these three examples the only special cases handled, or is this paragraph saying that __wrapped__ attributes are followed to unwrap wrappers?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have updated the docs, PTAL

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

.. 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
123 changes: 123 additions & 0 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)


Expand All @@ -513,6 +519,123 @@ 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)
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 _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)
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
return func_annotations

################################################################################
### LRU Cache function decorator
################################################################################
Expand Down
Loading
Loading