Skip to content
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

Protocols #3132

Merged
merged 152 commits into from
Aug 16, 2017
Merged

Protocols #3132

merged 152 commits into from
Aug 16, 2017

Conversation

ilevkivskyi
Copy link
Member

@ilevkivskyi ilevkivskyi commented Apr 4, 2017

This is an implementation of protocols proposed in PEP 544. This PR adds support for:

  • generic protocols (including inference for protocols)
  • recursive protocols
  • special support for Type[] and isinstance()
  • structural subtyping for Callable and Tuple
  • other things

For example, now one can do this:

class Linked(Protocol[T]):
    val: T
    def next(self) -> 'Linked[T]': ...

class L:
    val: int
    def next(self) -> 'L': ...

def last(seq: Linked[T]) -> T:
    ...

reveal_type(last(L()))  # Revealed type is 'builtins.int*'

In general, the PR is practically feature complete, the main missing thing now is structural join/meet for partially overlapping protocols. For example, sometimes an explicit annotation is needed:

class A(Protocol):
    one: int
    def __len__(self) -> int: ...

class B(Protocol):
    another: int
    def __len__(self) -> int: ...

a: A
b: B
reveal_type([a, b])  # Revealed type is 'builtins.list[builtins.object*]'

y: List[Sized] = [a, b]  # This works OK

The reason for this is that I decided to re-use Instances for protocols, and real structural join/meet would require creating a "fake" TypeInfo to describe the resulting protocol. However, I have encountered problems with serialization/de-serialization of "fake" TypeInfos and postponed this feature (as proposed by Jukka). On the other hand, reusing Instances for protocols allowed to implement this in a very simple way. The PR is very small and 2/3 of it are extensive tests.

There is something I encountered while working on this PR. It looks like structural subtyping is significantly slower than nominal. This is why I also implement "subtype cache" for Instances, so that there is a net win in speed of few percent (even when I locally updated typeshed replacing all ABC in typing with protocols).


In terms of work-flow, I would be happy if people will try this PR and give also feedback on behaviour, not just a code review. I will soon submit a PR to typeshed with updates for typing stubs, and a PR with runtime implementation. As well, a PR to PEPs repo with an updated draft that takes into account comments that appeared so far.

@JukkaL @ambv @gvanrossum

Ivan Levkivskyi and others added 30 commits March 26, 2017 14:20
@ilevkivskyi
Copy link
Member Author

@JukkaL Thanks! I think I have implemented all comments so far. The only question left is how precise we should be with variances in typing-full.pyi fixture? If you think it makes sense to match typeshed, then I will also update 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.

Another batch of test case reviews.

def last(seq: Linked[T]) -> T:
pass

reveal_type(last(L())) # E: Revealed type is 'builtins.int*'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe add test where a class subtly does not implement a recursive protocol. For example, the return type of next() is not compatible but otherwise things are fine.


t: P1
t = A() # OK
t = B() # E: Incompatible types in assignment (expression has type "B", variable has type "P1")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again, consider testing more incompatible cases, such as attr2 existing but with an incompatible list item type.

def f(self, x: int) -> int: pass
@overload
def f(self, x: str) -> str: pass
def f(self, x): pass
Copy link
Collaborator

Choose a reason for hiding this comment

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

Overloaded method implementation should probably be optional in a protocol. If there is no implementation, the method would be treated as abstract.

def f(self, x: int) -> None:
pass

x: P = C()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Test that a class with an overloaded method with an identical signature is treated as compatible. Test that a class with an invalid overloaded method is treated as incompatible.


l = [x, y]

reveal_type(l) # E: Revealed type is 'builtins.list[__main__.P*]'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also test the trivial case of [x, x] or similar.

def bar(a: Sized) -> int:
return a.__len__()

bar(Foo())
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also call with a list argument?


[builtins fixtures/isinstancelist.pyi]
[out]
main:11: error: Argument 1 to "foo" has incompatible type "str"; expected "SupportsInt"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use # E: ... comment?

@property
def attr2(self) -> int: pass

x: P = C() # E: Incompatible types in assignment (expression has type "C", variable has type "P") \
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you test anywhere what happens if a class has method and the protocol has an attribute/property, and vice versa?

Copy link
Member Author

Choose a reason for hiding this comment

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

It looks like no, I added two tests.

fun_p(C()) # OK
[builtins fixtures/list.pyi]

[case testIpmlicitTypesInProtocols]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Typo: Ipmlicit

class P(Protocol[T]): # E: Invariant type variable 'T' used in protocol where covariant one is expected
attr: int

[case testGenericProtocolsInference1]
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't remember if I saw a test case that would have a related case where the protocol is non-generic and a non-generic class has a generic method. Say, protocol has signature int -> int and class has signature T -> T (but T has method scope).

Copy link
Member Author

Choose a reason for hiding this comment

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

I have one, but only with self types, I added two tests with just "usual" generic methods.

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.

Another batch of comments. Hopefully the next batch will be the last one.

down_args = [UninhabitedType() if i == j else AnyType(TypeOfAny.special_form)
for j, _ in enumerate(tvars)]
up, down = Instance(info, up_args), Instance(info, down_args)
# TODO: add advanced variance checks for recursive protocols
Copy link
Collaborator

Choose a reason for hiding this comment

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

Create issue about variance checks for recursive protocols? It seems that a set of mutually recursive protocols should be checked as a unit, since the variance in one protocol can affect variance in another protocol.

@@ -2697,6 +2720,8 @@ def overload_arg_similarity(actual: Type, formal: Type) -> int:
# subtyping algorithm if type promotions are possible (e.g., int vs. float).
if formal.type in actual.type.mro:
return 2
elif formal.type.is_protocol and is_subtype(actual, erasetype.erase_type(formal)):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we erase the actual type 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.

I am not sure, but in view of #3656 I am reluctant to increase similarity of anything to protocols.

@@ -336,6 +338,38 @@ def visit_instance(self, template: Instance) -> List[Constraint]:
res.extend(infer_constraints(
template.args[j], mapped.args[j], neg_op(self.direction)))
return res
if (template.type.is_protocol and self.direction == SUPERTYPE_OF and
# We avoid infinite recursion for structural subtypes by checking
# whether this type already appeared in the inference chain.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add something about the correctness of this. Maybe mention that this is a standard way to perform structural subtype checks.

for member in template.type.protocol_members:
inst = mypy.subtypes.find_member(member, instance, original_actual)
temp = mypy.subtypes.find_member(member, template, original_actual)
assert inst is not None and temp is not None
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add comment about why this is safe.

res.extend(infer_constraints(temp, inst, neg_op(self.direction)))
template.type.inferring.pop()
return res
elif (instance.type.is_protocol and self.direction == SUBTYPE_OF and
Copy link
Collaborator

Choose a reason for hiding this comment

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

This and the previous section of code look very similar. Can you merge them to avoid code duplication?

mypy/nodes.py Outdated
abstract_attributes = None # type: List[str]
protocol_members = None # type: List[str]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add comment. Some edge cases to discuss: What about inherited protocol members? What about members defined in object?

mypy/nodes.py Outdated
abstract_attributes = None # type: List[str]
protocol_members = None # type: List[str]

# These represent structural subtype matrices. Note that these are shared
Copy link
Collaborator

Choose a reason for hiding this comment

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

The first sentence is not descriptive enough. This is a pretty tricky concept so please explain it in more detail and maybe give an example.

mypy/nodes.py Outdated
# during the type checking phase.
assuming = None # type: List[Tuple[mypy.types.Instance, mypy.types.Instance]]
assuming_proper = None # type: List[Tuple[mypy.types.Instance, mypy.types.Instance]]
# Ditto for temporary stack of recursive constraint inference.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Explain what goes into the stack and why we need it.

mypy/nodes.py Outdated
inferring = None # type: List[mypy.types.Instance]
cache = None # type: Set[Tuple[mypy.types.Type, mypy.types.Type]]
cache_proper = None # type: Set[Tuple[mypy.types.Type, mypy.types.Type]]
# 'inferring' and 'assumig' can't be also made sets, since we need to use
Copy link
Collaborator

Choose a reason for hiding this comment

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

Typo: 'assumig'.

mypy/nodes.py Outdated
# there is a dependency infer_constraint -> is_subtype -> is_callable_subtype ->
# -> infer_constraints.
inferring = None # type: List[mypy.types.Instance]
cache = None # type: Set[Tuple[mypy.types.Type, mypy.types.Type]]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Document cache and cache_proper. Explain why we have them.

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.

This concludes my review. As I mentioned earlier, fixing most issues now is optional -- there wasn't anything super critical. They can be fixed after this has been merged. If you leave things out, create issues which mention comments that haven't been followed through yet to more easily track what is not done yet.

mypy/subtypes.py Outdated
# NOTO: left.type.mro may be None in quick mode if there
if (left, right) in right.type.cache:
return True
# NOTE: left.type.mro may be None in quick mode if there
# was an error somewhere.
if left.type.mro is not None:
for base in left.type.mro:
if base._promote and is_subtype(
base._promote, self.right, self.check_type_parameter,
ignore_pos_arg_names=self.ignore_pos_arg_names):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need to propagate ignore_declared_variance in all recursive calls?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ideally, yes, but there are too many places, this is related to #3828, I will add a TODO item here.

mypy/subtypes.py Outdated
# was an error somewhere.
if left.type.mro is not None:
for base in left.type.mro:
if base._promote and is_subtype(
base._promote, self.right, self.check_type_parameter,
ignore_pos_arg_names=self.ignore_pos_arg_names):
right.type.cache.add((left, right))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would be better style to use a TypeInfo method such as record_subtype_cache_entry(left, right) instead of directly accessing cache.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, should we only add to cache if no subtype flags such as ignore_pos_arg_names are being used?

Copy link
Member Author

Choose a reason for hiding this comment

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

I am not super sure, but I think it is OK to always add them, since these are only for Instances and they (at least currently) are always used with ignore_pos_arg_names.

mypy/subtypes.py Outdated
@@ -130,24 +143,33 @@ def visit_instance(self, left: Instance) -> bool:
if isinstance(right, TupleType) and right.fallback.type.is_enum:
return is_subtype(left, right.fallback)
if isinstance(right, Instance):
# NOTO: left.type.mro may be None in quick mode if there
if (left, right) in right.type.cache:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would be better style to use a method such as if right.type.is_cached_subtype_check(left, right):.

mypy/subtypes.py Outdated
# nominal subtyping currently ignores '__init__' and '__new__' signatures
if member in ('__init__', '__new__'):
continue
# The third argiment below indicates to what self type is bound.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Typo: argiment

mypy/subtypes.py Outdated
stack.pop()


def is_protocol_implementation(left: Instance, right: Instance, allow_any: bool = True) -> bool:
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about changing allow_any to proper_subtype (and negate the value) or similar for consistency?

mypy/subtypes.py Outdated
return missing


def get_conflict_types(left: Instance, right: Instance) -> List[Tuple[str, Type, Type]]:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again, it looks like this should be in messages.py (can still be a top-level function) and should include protocol in the name for clarity.

mypy/subtypes.py Outdated
return conflicts


def get_bad_flags(left: Instance, right: Instance) -> List[Tuple[str, Set[int], Set[int]]]:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Like above, move to messages.py and include 'protocol' in the function name.

mypy/subtypes.py Outdated
@@ -549,7 +809,8 @@ def restrict_subtype_away(t: Type, s: Type) -> Type:
# Since runtime type checks will ignore type arguments, erase the types.
erased_s = erase_type(s)
new_items = [item for item in t.relevant_items()
if (not is_proper_subtype(erase_type(item), erased_s)
if (not (is_proper_subtype(erase_type(item), erased_s) or
is_proper_subtype(item, erased_s))
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's this change?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is not only related to protocols, but it affected several tests. This is actually a step in the direction of #3827. The logic is following, if I erase both item and s, then there will be "too many" Anys there, and is_proper_subtype will return False, while at runtime isinstance() will actually return True for protocols. Maybe this is not 100% ideal, but it fixed several test failures without causing new failures in existing tests.

More generally, I think we could reconsider use of is_proper_subtype(erased_t, erased_s) for isinstance() with nominal types, maybe there could be a better concept, something like is_runtime_subtype? I don't think we need a separate issue for this, this is basically just an extended discussion for #3827

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, this seems okay. Maybe add a reference to #3827 here?

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, I will add a comment.

mypy/subtypes.py Outdated
@@ -601,26 +862,38 @@ def visit_deleted_type(self, left: DeletedType) -> bool:
def visit_instance(self, left: Instance) -> bool:
right = self.right
if isinstance(right, Instance):
if (left, right) in right.type.cache_proper:
Copy link
Collaborator

Choose a reason for hiding this comment

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

If you add helper methods for accessing the cache, use them here.

@@ -188,6 +188,15 @@ def __init__(self,
def accept(self, visitor: 'TypeVisitor[T]') -> T:
return visitor.visit_unbound_type(self)

def __hash__(self) -> int:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I haven't reviewed this file but I can do it later (even after the PR has been landed) since this looks pretty straightforward.

@JukkaL
Copy link
Collaborator

JukkaL commented Aug 15, 2017

Before protocols are usable, Protocol needs to be added to typing_extensions (this can happen after this PR has been merged).

@ilevkivskyi
Copy link
Member Author

@JukkaL

Protocol needs to be added to typing_extensions

Yes, I will do this soon.

@ilevkivskyi
Copy link
Member Author

@JukkaL It looks like all the comments are implemented now (or corresponding issues filed).

Thanks for review once more!

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.

This looks good now! Thanks for persevering with the very long review process!

@JukkaL JukkaL merged commit c55e48b into python:master Aug 16, 2017
@JukkaL
Copy link
Collaborator

JukkaL commented Aug 16, 2017

Here are some important follow-up tasks:

  • Add implementation of Protocol to typing_extensions (or if @gvanrossum feels that it's too early, it can go to mypy_extensions) and release a version of typing_extensions with Protocol.
  • Add definition of Protocol to typeshed (but don't turn existing ABCs in typeshed into protocols yet).

After the above steps user-defined protocols should work.

Changing typing ABCs to be protocols can happen later -- potentially we'll have a mypy release with only support for user-defined protocols first. It's still not clear how we'll move forward with the bigger typeshed change.

@JukkaL JukkaL mentioned this pull request Aug 16, 2017
@ilevkivskyi
Copy link
Member Author

Add implementation of Protocol to typing_extensions

I will hopefully make PRs for typing_extensions (implementation and stubs) tomorrow. I think it is OK to go straight with typing_extensions since @vlasovskikh (and maybe other) is already interested in this.

@ilevkivskyi
Copy link
Member Author

By the way, @matthiaskramm what do you (i.e. pytype team) think about protocols and making things like Iterable etc. protocols in typeshed?

@gvanrossum
Copy link
Member

Woot! Congrats and thanks everyone.

@vlasovskikh
Copy link
Member

Thanks!

@ilevkivskyi +1 for putting Protocol into typing_extensions.

@evhub evhub mentioned this pull request Sep 6, 2017
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.

6 participants