Skip to content

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

Merged
merged 10 commits into from
Aug 16, 2018
Merged

Conversation

ilevkivskyi
Copy link
Member

@ilevkivskyi ilevkivskyi commented Aug 12, 2018

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 in mypy_extensions. For example:

class Combiner(Protocol):
    def __call__(self, *vals: bytes, maxlen: Optional[int] = None) -> List[bytes]: ...

def batch_proc(data: Iterable[bytes], cb_results: Combiner) -> bytes:
    ...

The callback protocols:

  • Have cleaner familiar syntax in contrast to Callable[[VarArg(bytes), DefaultNamedArg(Optional[int], 'maxlen')], List[bytes]] (compare to above)
  • Allow to be more explicit/flexible about binding of type variables in generic callbacks (see tests for examples)
  • Support overloaded callbacks (this is simply impossible with the current extended callable syntax)
  • Are easy to implement

If this will get some traction, I would actually propose to deprecate extended callable syntax in favor of callback protocols.

@ilevkivskyi ilevkivskyi requested a review from JukkaL August 12, 2018 23:25
@emmatyping
Copy link
Member

If this will get some traction, I would actually propose to deprecate extended callable syntax in favor of callback protocols.

I really like the new syntax, and think we should go ahead with your proposal.

@msullivan
Copy link
Collaborator

One snag to replacing extended callables is that protocols don't seem to enforce argument name equivalence.

@ilevkivskyi
Copy link
Member Author

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

@msullivan
Copy link
Collaborator

I mean that mypy will reject the following:

def foo(f: Callable[[NamedArg(int, 'x')], None]) -> None:
    f(x = 10)

def bar(y: int) -> None:
    pass

foo(bar)

while the protocol version won't.

@msullivan
Copy link
Collaborator

Oh which maybe that is a bug in protocols more generally?

Copy link
Collaborator

@msullivan msullivan left a 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.

@ilevkivskyi
Copy link
Member Author

@msullivan maybe I am missing something, but isn't testCallableImplementsProtocolArgName exactly testing what you wrote?

@ilevkivskyi
Copy link
Member Author

(the error message is cryptic in this case, because arg names are not shown, but we already have a high priority issue about this)

@msullivan
Copy link
Collaborator

Oh, oops, nevermind.

@msullivan
Copy link
Collaborator

Ah, my confusion was caused by testing that I did using regular protocols, where it doesn't seem to check argument names:

from typing import Protocol

class Caller(Protocol):
    def foo(self, x: str) -> None: ...

def func(caller: Caller) -> None:
    caller.foo(x='lol')

class Foo:
    def foo(self, y: str) -> None: ...

func(Foo())

@ilevkivskyi
Copy link
Member Author

Yes, and this is intentional, see this code in is_protocol_implementation:

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

Copy link
Collaborator

@JukkaL JukkaL left a 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"
Copy link
Collaborator

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.

Copy link
Member Author

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*]'
Copy link
Collaborator

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

Copy link
Member Author

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]
Copy link
Collaborator

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 callable Y (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?

Copy link
Member Author

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__']:
Copy link
Collaborator

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?

Copy link
Member Author

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.

@JukkaL
Copy link
Collaborator

JukkaL commented Aug 15, 2018

Also, there is a merge conflict.

@ilevkivskyi
Copy link
Member Author

@JukkaL Thanks for review! Could you please take a look again at this?

Copy link
Collaborator

@JukkaL JukkaL 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 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):
Copy link
Collaborator

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?

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"
Copy link
Collaborator

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]'
Copy link
Collaborator

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)]'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Try also [b, a]?

Callback protocols
******************

Protocols can be used to define flexible callback types that are impossible
Copy link
Collaborator

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.

Copy link
Member Author

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.

******************

Protocols can be used to define flexible callback types that are impossible
to express using the ``Callable[...]`` syntax, for example variadic, overloaded,
Copy link
Collaborator

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

# 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,
Copy link
Collaborator

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.

@ilevkivskyi
Copy link
Member Author

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.

@ilevkivskyi ilevkivskyi merged commit 5c73e6a into python:master Aug 16, 2018
@ilevkivskyi ilevkivskyi deleted the add-proto-call branch August 16, 2018 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants