[red-knot] Add more tests for protocols (#17603)

This commit is contained in:
Alex Waygood 2025-04-24 13:11:31 +01:00 committed by GitHub
parent 21fd28d713
commit e93fa7062c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -242,7 +242,7 @@ def f(
Nonetheless, `Protocol` can still be used as the second argument to `issubclass()` at runtime:
```py
# TODO: should be `Literal[True]`
# Could also be `Literal[True]`, but `bool` is fine:
reveal_type(issubclass(MyProtocol, Protocol)) # revealed: bool
```
@ -667,7 +667,11 @@ reveal_type(get_protocol_members(LotsOfBindings))
Attribute members are allowed to have assignments in methods on the protocol class, just like
non-protocol classes. Unlike other classes, however, instance attributes that are not declared in
the class body are disallowed:
the class body are disallowed. This is mandated by [the spec][spec_protocol_members]:
> Additional attributes *only* defined in the body of a method by assignment via `self` are not
> allowed. The rationale for this is that the protocol class implementation is often not shared by
> subtypes, so the interface should not depend on the default implementation.
```py
class Foo(Protocol):
@ -690,6 +694,21 @@ class Foo(Protocol):
reveal_type(get_protocol_members(Foo)) # revealed: tuple[Literal["non_init_method"], Literal["x"], Literal["y"]]
```
If a member is declared in a superclass of a protocol class, it is fine for it to be assigned to in
the sub-protocol class without a redeclaration:
```py
class Super(Protocol):
x: int
class Sub(Super, Protocol):
x = 42 # no error here, since it's declared in the superclass
# TODO: actually frozensets
reveal_type(get_protocol_members(Super)) # revealed: tuple[Literal["x"]]
reveal_type(get_protocol_members(Sub)) # revealed: tuple[Literal["x"]]
```
If a protocol has 0 members, then all other types are assignable to it, and all fully static types
are subtypes of it:
@ -1265,6 +1284,140 @@ def f(arg1: type, arg2: type):
reveal_type(arg2) # revealed: type & ~type[OnlyMethodMembers]
```
## Truthiness of protocol instance
An instance of a protocol type generally has ambiguous truthiness:
```py
from typing import Protocol
class Foo(Protocol):
x: int
def f(foo: Foo):
reveal_type(bool(foo)) # revealed: bool
```
But this is not the case if the protocol has a `__bool__` method member that returns `Literal[True]`
or `Literal[False]`:
```py
from typing import Literal
class Truthy(Protocol):
def __bool__(self) -> Literal[True]: ...
class FalsyFoo(Foo, Protocol):
def __bool__(self) -> Literal[False]: ...
class FalsyFooSubclass(FalsyFoo, Protocol):
y: str
def g(a: Truthy, b: FalsyFoo, c: FalsyFooSubclass):
reveal_type(bool(a)) # revealed: Literal[True]
reveal_type(bool(b)) # revealed: Literal[False]
reveal_type(bool(c)) # revealed: Literal[False]
```
It is not sufficient for a protocol to have a callable `__bool__` instance member that returns
`Literal[True]` for it to be considered always truthy. Dunder methods are looked up on the class
rather than the instance. If a protocol `X` has an instance-attribute `__bool__` member, it is
unknowable whether that attribute can be accessed on the type of an object that satisfies `X`'s
interface:
```py
from typing import Callable
class InstanceAttrBool(Protocol):
__bool__: Callable[[], Literal[True]]
def h(obj: InstanceAttrBool):
reveal_type(bool(obj)) # revealed: bool
```
## Fully static protocols; gradual protocols
A protocol is only fully static if all of its members are fully static:
```py
from typing import Protocol, Any
from knot_extensions import is_fully_static, static_assert
class FullyStatic(Protocol):
x: int
class NotFullyStatic(Protocol):
x: Any
static_assert(is_fully_static(FullyStatic))
# TODO: should pass
static_assert(not is_fully_static(NotFullyStatic)) # error: [static-assert-error]
```
Non-fully-static protocols do not participate in subtyping, only assignability:
```py
from knot_extensions import is_subtype_of, is_assignable_to
class NominalWithX:
x: int = 42
# TODO: these should pass
static_assert(is_assignable_to(NominalWithX, FullyStatic)) # error: [static-assert-error]
static_assert(is_assignable_to(NominalWithX, NotFullyStatic)) # error: [static-assert-error]
static_assert(is_subtype_of(NominalWithX, FullyStatic)) # error: [static-assert-error]
static_assert(not is_subtype_of(NominalWithX, NotFullyStatic))
```
Empty protocols are fully static; this follows from the fact that an empty protocol is equivalent to
the nominal type `object` (as described above):
```py
class Empty(Protocol): ...
static_assert(is_fully_static(Empty))
```
A method member is only considered fully static if all its parameter annotations and its return
annotation are fully static:
```py
class FullyStaticMethodMember(Protocol):
def method(self, x: int) -> str: ...
class DynamicParameter(Protocol):
def method(self, x: Any) -> str: ...
class DynamicReturn(Protocol):
def method(self, x: int) -> Any: ...
static_assert(is_fully_static(FullyStaticMethodMember))
# TODO: these should pass
static_assert(not is_fully_static(DynamicParameter)) # error: [static-assert-error]
static_assert(not is_fully_static(DynamicReturn)) # error: [static-assert-error]
```
The [typing spec][spec_protocol_members] states:
> If any parameters of a protocol method are not annotated, then their types are assumed to be `Any`
Thus, a partially unannotated method member can also not be considered to be fully static:
```py
class NoParameterAnnotation(Protocol):
def method(self, x) -> str: ...
class NoReturnAnnotation(Protocol):
def method(self, x: int): ...
# TODO: these should pass
static_assert(not is_fully_static(NoParameterAnnotation)) # error: [static-assert-error]
static_assert(not is_fully_static(NoReturnAnnotation)) # error: [static-assert-error]
```
## `typing.SupportsIndex` and `typing.Sized`
`typing.SupportsIndex` is already somewhat supported through some special-casing in red-knot.
@ -1294,7 +1447,9 @@ def _(some_list: list, some_tuple: tuple[int, str], some_sized: Sized):
Add tests for:
- More tests for protocols inside `type[]`. [Spec reference][protocols_inside_type_spec].
- Protocols with instance-method members
- Protocols with instance-method members, including:
- Protocols with methods that have parameters or the return type unannotated
- Protocols with methods that have parameters or the return type annotated with `Any`
- Protocols with `@classmethod` and `@staticmethod`
- Assignability of non-instance types to protocols with instance-method members (e.g. a
class-literal type can be a subtype of `Sized` if its metaclass has a `__len__` method)
@ -1308,9 +1463,6 @@ Add tests for:
- Protocols with instance attributes annotated with `Callable` (can a nominal type with a method
satisfy that protocol, and if so in what cases?)
- Protocols decorated with `@final`
- Protocols with attribute members annotated with `Any`
- Protocols with methods that have parameters or the return type unannotated
- Protocols with methods that have parameters or the return type annotated with `Any`
- Equivalence and subtyping between `Callable` types and protocols that define `__call__`
[mypy_protocol_docs]: https://mypy.readthedocs.io/en/stable/protocols.html#protocols-and-structural-subtyping
@ -1319,4 +1471,5 @@ Add tests for:
[protocols_inside_type_spec]: https://typing.python.org/en/latest/spec/protocol.html#type-and-class-objects-vs-protocols
[recursive_protocols_spec]: https://typing.python.org/en/latest/spec/protocol.html#recursive-protocols
[self_types_protocols_spec]: https://typing.python.org/en/latest/spec/protocol.html#self-types-in-protocols
[spec_protocol_members]: https://typing.python.org/en/latest/spec/protocol.html#protocol-members
[typing_spec_protocols]: https://typing.python.org/en/latest/spec/protocol.html