[ty] Fix the inferred interface of specialized generic protocols (#19866)

This commit is contained in:
Alex Waygood 2025-08-27 18:16:15 +01:00 committed by GitHub
parent 7d0c8e045c
commit ce1dc21e7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 200 additions and 57 deletions

View file

@ -1038,6 +1038,49 @@ def _(int_str: tuple[int, str], int_any: tuple[int, Any], any_any: tuple[Any, An
reveal_type(f(*(any_any,))) # revealed: Unknown
```
### `Unknown` passed into an overloaded function annotated with protocols
`Foo.join()` here has similar annotations to `str.join()` in typeshed:
`module.pyi`:
```pyi
from typing_extensions import Iterable, overload, LiteralString, Protocol
from ty_extensions import Unknown, is_assignable_to
class Foo:
@overload
def join(self, iterable: Iterable[LiteralString], /) -> LiteralString: ...
@overload
def join(self, iterable: Iterable[str], /) -> str: ...
```
`main.py`:
```py
from module import Foo
from typing_extensions import LiteralString
def f(a: Foo, b: list[str], c: list[LiteralString], e):
reveal_type(e) # revealed: Unknown
# TODO: we should select the second overload here and reveal `str`
# (the incorrect result is due to missing logic in protocol subtyping/assignability)
reveal_type(a.join(b)) # revealed: LiteralString
reveal_type(a.join(c)) # revealed: LiteralString
# since both overloads match and they have return types that are not equivalent,
# step (5) of the overload evaluation algorithm says we must evaluate the result of the
# call as `Unknown`.
#
# Note: although the spec does not state as such (since intersections in general are not
# specified currently), `(str | LiteralString) & Unknown` might also be a reasonable type
# here (the union of all overload returns, intersected with `Unknown`) -- here that would
# simplify to `str & Unknown`.
reveal_type(a.join(e)) # revealed: Unknown
```
### Multiple arguments
`overloaded.pyi`:

View file

@ -95,6 +95,20 @@ class NotAProtocol: ...
reveal_type(is_protocol(NotAProtocol)) # revealed: Literal[False]
```
Note, however, that `is_protocol` returns `False` at runtime for specializations of generic
protocols. We still consider these to be "protocol classes" internally, regardless:
```py
class MyGenericProtocol[T](Protocol):
x: T
reveal_type(is_protocol(MyGenericProtocol)) # revealed: Literal[True]
# We still consider this a protocol class internally,
# but the inferred type of the call here reflects the result at runtime:
reveal_type(is_protocol(MyGenericProtocol[int])) # revealed: Literal[False]
```
A type checker should follow the typeshed stubs if a non-class is passed in, and typeshed's stubs
indicate that the argument passed in must be an instance of `type`.
@ -397,24 +411,38 @@ To see the kinds and types of the protocol members, you can use the debugging ai
```py
from ty_extensions import reveal_protocol_interface
from typing import SupportsIndex, SupportsAbs, ClassVar
from typing import SupportsIndex, SupportsAbs, ClassVar, Iterator
# error: [revealed-type] "Revealed protocol interface: `{"method_member": MethodMember(`(self) -> bytes`), "x": AttributeMember(`int`), "y": PropertyMember { getter: `def y(self) -> str` }, "z": PropertyMember { getter: `def z(self) -> int`, setter: `def z(self, z: int) -> None` }}`"
reveal_protocol_interface(Foo)
# error: [revealed-type] "Revealed protocol interface: `{"__index__": MethodMember(`(self) -> int`)}`"
reveal_protocol_interface(SupportsIndex)
# error: [revealed-type] "Revealed protocol interface: `{"__abs__": MethodMember(`(self) -> _T_co@SupportsAbs`)}`"
# error: [revealed-type] "Revealed protocol interface: `{"__abs__": MethodMember(`(self) -> Unknown`)}`"
reveal_protocol_interface(SupportsAbs)
# error: [revealed-type] "Revealed protocol interface: `{"__iter__": MethodMember(`(self) -> Iterator[Unknown]`), "__next__": MethodMember(`(self) -> Unknown`)}`"
reveal_protocol_interface(Iterator)
# error: [invalid-argument-type] "Invalid argument to `reveal_protocol_interface`: Only protocol classes can be passed to `reveal_protocol_interface`"
reveal_protocol_interface(int)
# error: [invalid-argument-type] "Argument to function `reveal_protocol_interface` is incorrect: Expected `type`, found `Literal["foo"]`"
reveal_protocol_interface("foo")
```
# TODO: this should be a `revealed-type` diagnostic rather than `invalid-argument-type`, and it should reveal `{"__abs__": MethodMember(`(self) -> int`)}` for the protocol interface
#
# error: [invalid-argument-type] "Invalid argument to `reveal_protocol_interface`: Only protocol classes can be passed to `reveal_protocol_interface`"
Similar to the way that `typing.is_protocol` returns `False` at runtime for all generic aliases,
`typing.get_protocol_members` raises an exception at runtime if you pass it a generic alias, so we
do not implement any special handling for generic aliases passed to the function.
`ty_extensions.reveal_protocol_interface` can be used on both, however:
```py
# TODO: these fail at runtime, but we don't emit `[invalid-argument-type]` diagnostics
# currently due to https://github.com/astral-sh/ty/issues/116
reveal_type(get_protocol_members(SupportsAbs[int])) # revealed: frozenset[str]
reveal_type(get_protocol_members(Iterator[int])) # revealed: frozenset[str]
# error: [revealed-type] "Revealed protocol interface: `{"__abs__": MethodMember(`(self) -> int`)}`"
reveal_protocol_interface(SupportsAbs[int])
# error: [revealed-type] "Revealed protocol interface: `{"__iter__": MethodMember(`(self) -> Iterator[int]`), "__next__": MethodMember(`(self) -> int`)}`"
reveal_protocol_interface(Iterator[int])
class BaseProto(Protocol):
def member(self) -> int: ...
@ -1032,6 +1060,11 @@ class A(Protocol):
## Equivalence of protocols
```toml
[environment]
python-version = "3.12"
```
Two protocols are considered equivalent types if they specify the same interface, even if they have
different names:
@ -1080,6 +1113,46 @@ static_assert(is_equivalent_to(UnionProto1, UnionProto2))
static_assert(is_equivalent_to(UnionProto1 | A | B, B | UnionProto2 | A))
```
Different generic protocols with equivalent specializations can be equivalent, but generic protocols
with different specializations are not considered equivalent:
```py
from typing import TypeVar
S = TypeVar("S")
class NonGenericProto1(Protocol):
x: int
y: str
class NonGenericProto2(Protocol):
y: str
x: int
class Nominal1: ...
class Nominal2: ...
class GenericProto[T](Protocol):
x: T
class LegacyGenericProto(Protocol[S]):
x: S
static_assert(is_equivalent_to(GenericProto[int], LegacyGenericProto[int]))
static_assert(is_equivalent_to(GenericProto[NonGenericProto1], LegacyGenericProto[NonGenericProto2]))
static_assert(
is_equivalent_to(
GenericProto[NonGenericProto1 | Nominal1 | Nominal2], LegacyGenericProto[Nominal2 | Nominal1 | NonGenericProto2]
)
)
static_assert(not is_equivalent_to(GenericProto[str], GenericProto[int]))
static_assert(not is_equivalent_to(GenericProto[str], LegacyGenericProto[int]))
static_assert(not is_equivalent_to(GenericProto, GenericProto[int]))
static_assert(not is_equivalent_to(LegacyGenericProto, LegacyGenericProto[int]))
```
## Intersections of protocols
An intersection of two protocol types `X` and `Y` is equivalent to a protocol type `Z` that inherits