Skip to content

gh-85403: Make wraps retain type annotations #21392

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from

Conversation

david-caro
Copy link

@david-caro david-caro commented Jul 8, 2020

When decorating a function with wraps, retain the type annotations
of the wrapper if there's any, and use the wrapped function ones to
complete the annotations by default.

This changes the update_wrapper function to use the wrapper function
annotations if any, and fall back to the wrapped function annotations if the
wrapper one did not have them. For the return type annotations, and the
parameter types annotations independently.

The annotations overriding behavior was introduced in bpo-8814.

https://bugs.python.org/issue41231

When decorating a function with ``wraps``, retain the type annotations
of the wrapper if there's any, and use the wrapped function ones to
complete the annotations by default.

This changes the ``update_wrapper`` function to use the wrapper function
annotations if any, and fall back to the wrapped function annotations if the
wrapper one did not have them. For the return type annotations, and the
parameter types annotations independently.

The annotations overriding behavior was introduced in bpo-8814.
Signed-off-by: David Caro <david@dcaro.es>
@rhettinger rhettinger removed their request for review May 3, 2022 05:59
@python python deleted a comment from the-knights-who-say-ni Jul 11, 2022
@terryjreedy terryjreedy changed the title bpo-41231: Make wraps retain type annotations gh-85403: Make wraps retain type annotations Jul 11, 2022
@terryjreedy
Copy link
Member

Conflict was trivial. Do a simple update merge of main into the branch rather than a rebase.

@hmc-cs-mdrissi
Copy link
Contributor

hmc-cs-mdrissi commented Jul 12, 2022

Hmm, how does this interact with pep 612?

For toy example,

from functools import wraps

def log_info(func: Callable[P, R]) -> Callable[P, R]:
  @wraps
  def _log_func(*args: P.args, **kwargs: P.kwargs) -> R:
    print("stuff")
    return func(*args, **kwargs)

  return _log_func

@log_info
def foo(x: int) -> str:
  ...

The current behavior of wrap is very different here than new behavior. Paramspecs are intended to preserve original function signature and not overrwrite it. I know that code that uses decorators with beam library's runtime time introspection would be affected by this change in a way that can cause it to fail to work. beam inspects function signature types and doesn't handle paramspecs. If both original function (foo) and inner wrapper function (_log_func) have all type hints available still at runtime then I think this is possible to solve, but a very easy edge case bug for any runtime type library.

Copy link
Member

@gvanrossum gvanrossum left a comment

Choose a reason for hiding this comment

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

I was quite confused by the docs (which make things much more complicated than the code).

``assigned`` list parameter), it will be populated only if missing some
annotations as follows:

* If the wrapper function defines it's own return type and parameter
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* If the wrapper function defines it's own return type and parameter
* If the wrapper function defines its own return type and parameter

annotations, those will be preserved.

* If the wrapper function does not annotate the return type, it will be
copied from the wrapped if any.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
copied from the wrapped if any.
copied from the wrapped function, if it specifies a return type.

Comment on lines +641 to +642
When assigning the ``__annotations__`` attribute (when it's part of the
``assigned`` list parameter), it will be populated only if missing some
Copy link
Member

Choose a reason for hiding this comment

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

Is it not part of WRAPPER_ASSIGNMENTS?

copied from the wrapped if any.

* If the wrapped function does not annotate any parameters, the annotations
for them will be copied over from the wrapped if any.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
for them will be copied over from the wrapped if any.
for them will be copied over from the wrapped function, if it has any.

Comment on lines +645 to +646
* If the wrapper function defines it's own return type and parameter
annotations, those will be preserved.
Copy link
Member

Choose a reason for hiding this comment

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

It is not very clear from this paragraph what this actually does.

Suppose the wrapper has annotations for 'x' and 'y' and the wrapped function has annotations for 'z'. Does 'z' get added to the wrapper's annotations?

Suppose the wrapper has no annotations, so it receives annotations for 'x', 'y' and 'z' from the wrapped function. What happens if the wrapper doesn't have arguments by those names? That is going to be confusing in some cases of further introspection.

It almost sounds like __annotations__ should be listed under updated instead of assigned, there are so many special cases.

That ensures that any custom annotations from the wrapper will not be
overridden by the wrapped, allowing to change the annotated types of
the wrapped function.

.. versionadded:: 3.2
Copy link
Member

Choose a reason for hiding this comment

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

Clearly you need a versionadded:: 3.12 block for the new treatment of __annotations__.


if '__annotations__' in assigned:
try:
# Issue #41231: copy the annotations from wrapped, only if they are
Copy link
Member

Choose a reason for hiding this comment

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

Update the bug number using the GH-NNN or bpo-NNN format.

Comment on lines +80 to +84
# if the wrapper does not annotate the return type, copy the
# annotation from the wrapped
elif ('return' not in wrapper.__annotations__
and 'return' in wrapped.__annotations__):
wrapper.__annotations__['return'] = wrapped.__annotations__['return']
Copy link
Member

Choose a reason for hiding this comment

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

Oh this is quite different from what I had understood from the docs (which I read before reading the code). I recommend changing the docs to more clearly describe what this does.

@gvanrossum
Copy link
Member

Hmm, how does this interact with pep 612?

That's a really good point. I'll leave it to the patch author to respond, but without a good answer (and backwards compatible behavior, given that ParamSpec is in 3.10 and 3.11) this PR can't go through.

@JelleZijlstra JelleZijlstra removed their request for review October 3, 2022 20:20
Copy link
Contributor

@hauntsaninja hauntsaninja left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! Perhaps you could address the feedback / chime in on the PEP 612 thoughts?

Pros:

  1. It seems to me that the behaviour of this PR for PEP 612 functions might actually be desirable. PEP 612 has Concatenate, which can change the signature of the function:
from typing import *
from functools import wraps

P = ParamSpec("P")
R = TypeVar("R")

def log_info(func: Callable[P, R]) -> Callable[Concatenate[str, P], R]:
  @wraps(func)
  def _log_func(x: str, /, *args: P.args, **kwargs: P.kwargs) -> R:
    print(f"stuff {x}")
    return func(*args, **kwargs)

  return _log_func

@log_info
def foo(x: int) -> str:
  ...

print(foo.__annotations__)
print(foo.__wrapped__.__annotations__)

import inspect
print(inspect.signature(foo, follow_wrapped=False))
print(inspect.signature(foo, follow_wrapped=True))

Output on main:

{'x': <class 'int'>, 'return': <class 'str'>}
{'x': <class 'int'>, 'return': <class 'str'>}
(x: int, /, *args, **kwargs) -> str
(x: int) -> str

Output with this PR:

{'x': <class 'str'>, 'args': P.args, 'kwargs': P.kwargs, 'return': ~R}
{'x': <class 'int'>, 'return': <class 'str'>}
(x: str, /, *args: P.args, **kwargs: P.kwargs) -> ~R
(x: int) -> str
  1. I like that this PR makes runtime annotations less lossy (users still have access to the original annotations via __wrapped__).
  2. This fixes the behaviour of inspect.signature(foo, follow_wrapped=False) in the above example

Cons:

  1. It may be hard for runtime type checkers to make sense of this, since they can't see where type variables in the resulting signature are coming from
  2. (weak) The default behaviour of inspect.signature is follow_wrapped=True, which will no longer match up with __annotations__
  3. This is a change in behaviour, so there are some backward compatibility concerns

I'll note that there's some confusion in the original issue from OP regarding static vs dynamic typing. This PR will not change mypy's behaviour and the example of asynccontextmanager will not change in behaviour because asynccontextmanager does not have annotations at runtime.

Overall I lean in favour of this PR.

@bedevere-bot
Copy link

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

@encukou
Copy link
Member

encukou commented Apr 10, 2024

@david-caro, do you want to continue working on this PR?

@david-caro
Copy link
Author

I might yes, absolutely missed the comment and forgot the pr existed, sorry

Happy to hand over if you are interested though

@python-cla-bot
Copy link

python-cla-bot bot commented Apr 18, 2025

All commit authors signed the Contributor License Agreement.

CLA signed

@JelleZijlstra
Copy link
Member

With PEP-649 implemented, it is much harder to partially copy the annotations as this PR does. If we want to make a change here, we'll have to start over, so I'm going to close this for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants