-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Support callback protocols #5463
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
I really like the new syntax, and think we should go ahead with your proposal. |
One snag to replacing extended callables is that protocols don't seem to enforce argument name equivalence. |
@msullivan Could you please clarify what do you mean? I think they behave exactly the same way as "normal" callables (including the dunder argument name convention), see the test I added. |
I mean that mypy will reject the following:
while the protocol version won't. |
Oh which maybe that is a bug in protocols more generally? |
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.
My concerns about whether this as-is can be a drop in replacement for extended callables aside, this seems good to me.
@msullivan maybe I am missing something, but isn't |
(the error message is cryptic in this case, because arg names are not shown, but we already have a high priority issue about this) |
Oh, oops, nevermind. |
Ah, my confusion was caused by testing that I did using regular protocols, where it doesn't seem to check argument names:
|
Yes, and this is intentional, see this code in # Nominal check currently ignores arg names
is_compat = is_subtype(subtype, supertype, ignore_pos_arg_names=True) the motivation for this is probably very old, I just wanted structural to match nominal as much as possible. |
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 liked this approach for representing extended callables! This also needs to be documented (it can happen in a follow-up PR). If some of the suggestions below are non-trivial to implement, they can also be implemented separately.
pass | ||
|
||
func(call) | ||
func(bad) # E: Argument 1 to "func" has incompatible type "Callable[[int, VarArg(str)], None]"; expected "Caller" |
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.
Once we deprecate the old syntax, we should come up with a different syntax for "rich" callables in error messages as well.
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.
The format is already there, MessageBuilder.pretty_callable
, but we need to update loads of test cases.
def func(caller: Caller[T, S]) -> Tuple[T, S]: | ||
pass | ||
|
||
reveal_type(func(call)) # E: Revealed type is 'Tuple[builtins.int*, builtins.str*]' |
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.
Test also passing a generic function when a function accepts a non-generic protocol-based callable (where the generic function is valid).
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.
OK, will add this test.
func(call) | ||
func(bad) # E: Argument 1 to "func" has incompatible type "Callable[[str], None]"; expected "Caller" | ||
anon(bad) | ||
[out] |
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.
Idea for test cases:
- Pass protocol callable
X
when function accepts protocol callableY
(both compatible and incompatible cases). Should these match argument names precisely or not? - Pass protocol callable when function accepts a normal callable. Should this be okay?
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.
The second is already supported and has test cases, it was part of the original protocols PR. For the first however, the argument names are not checked, but I think they should be (even for nominal), because __call__
is special, unlike for __add__
arg names are "visible".
mypy/subtypes.py
Outdated
@@ -214,6 +214,13 @@ def visit_callable_type(self, left: CallableType) -> bool: | |||
ignore_pos_arg_names=self.ignore_pos_arg_names) | |||
for item in right.items()) | |||
elif isinstance(right, Instance): | |||
if right.type.is_protocol and right.type.protocol_members == ['__call__']: |
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.
Maybe we should also support mixing protocol callables and normal callables in joins and meets?
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.
Hm, I will try this.
Also, there is a merge conflict. |
@JukkaL Thanks for review! Could you please take a look again at this? |
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 updates! Left various minor comments.
mypy/checker.py
Outdated
# __call__ is special among dunders, because arguments can be passed | ||
# as keyword args (unlike other dunders). | ||
ignore_names = name != '__call__' | ||
if not is_subtype(override, original, ignore_pos_arg_names=ignore_names): |
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.
Hmm, this could break some existing code which assumes that __call__
won't be called with keyword arguments. Maybe there is a way to restrict this to only protocol classes, where __call__
is special among dunder methods?
test-data/unit/check-classes.test
Outdated
class Base: | ||
def __call__(self, arg: int) -> None: ... | ||
class Sub(Base): | ||
def __call__(self, zzz: int) -> None: ... # E: Signature of "__call__" incompatible with supertype "Base" |
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.
As discussed above, this might break existing code, and the override doesn't seem special since non-protocols aren't compatible with callable types.
a: Call | ||
b: Normal | ||
|
||
reveal_type([a, b]) # E: Revealed type is 'builtins.list[def (__main__.B) -> __main__.B]' |
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.
Try also [b, a]
?
def a(x: Call) -> None: ... | ||
def b(x: Normal) -> None: ... | ||
|
||
reveal_type([a, b]) # E: Revealed type is 'builtins.list[def (x: def (__main__.B) -> __main__.B)]' |
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.
Try also [b, a]
?
docs/source/protocols.rst
Outdated
Callback protocols | ||
****************** | ||
|
||
Protocols can be used to define flexible callback types that are impossible |
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.
Nit: they are currently possible with the legacy extended callable syntax.
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.
Some of them are really impossible, like overloaded callbacks. I will try to clarify here.
docs/source/protocols.rst
Outdated
****************** | ||
|
||
Protocols can be used to define flexible callback types that are impossible | ||
to express using the ``Callable[...]`` syntax, for example variadic, overloaded, |
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.
Style nit: ", for example" -> ", such as" would perhaps read a bit better
docs/source/protocols.rst
Outdated
# different name and kind in the callback | ||
|
||
Callback protocols and ``Callable[...]`` types can be used interchangeably. | ||
To indicate an "anonymous" positional-only argument use double underscore, |
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.
Maybe explicitly mention that keyword argument names in __call__
methods must be identical, unless a double underscore prefix is used.
Just not to wait for @JukkaL to return from vacation I am merging this now. All major points have been addressed, if anything minor left, we can fix it later. |
Fixes #5453
The issue proposed an interesting idea. Allow callables as subtypes of protocols with
__call__
. IMO this is not just reasonable and type safe, but is also more clear that the extended callable syntax inmypy_extensions
. For example:The callback protocols:
Callable[[VarArg(bytes), DefaultNamedArg(Optional[int], 'maxlen')], List[bytes]]
(compare to above)If this will get some traction, I would actually propose to deprecate extended callable syntax in favor of callback protocols.