-
-
Notifications
You must be signed in to change notification settings - Fork 32.2k
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
Conversation
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.
c5c1acd
to
48a2155
Compare
Signed-off-by: David Caro <david@dcaro.es>
48a2155
to
a020a97
Compare
Conflict was trivial. Do a simple update merge of main into the branch rather than a rebase. |
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. |
There was a problem hiding this 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
copied from the wrapped if any. | |
copied from the wrapped function, if it specifies a return type. |
When assigning the ``__annotations__`` attribute (when it's part of the | ||
``assigned`` list parameter), it will be populated only if missing some |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. |
* If the wrapper function defines it's own return type and parameter | ||
annotations, those will be preserved. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
# 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'] |
There was a problem hiding this comment.
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.
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. |
There was a problem hiding this 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:
- 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
- I like that this PR makes runtime annotations less lossy (users still have access to the original annotations via
__wrapped__
). - This fixes the behaviour of
inspect.signature(foo, follow_wrapped=False)
in the above example
Cons:
- 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
- (weak) The default behaviour of
inspect.signature
isfollow_wrapped=True
, which will no longer match up with__annotations__
- 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.
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 |
@david-caro, do you want to continue working on this PR? |
I might yes, absolutely missed the comment and forgot the pr existed, sorry Happy to hand over if you are interested though |
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. |
When decorating a function with
wraps
, retain the type annotationsof 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 functionannotations 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