mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
[ty] Validate generic protocol variance (#27531)
## Summary Follow-up to #27534. We now validate explicitly declared generic protocol variance against its structural interface: ```python from typing import Protocol, TypeVar T = TypeVar("T") class Source(Protocol[T]): # error: [invalid-protocol] def read(self) -> T: ... ``` A type variable appearing only in a return position must be covariant, while writable protocol attributes must remain invariant. We derive both declared and inferred variance from the same member read/write capabilities already used for structural compatibility, including inferred legacy and PEP 695 protocol parameters: ```python class Writable[T](Protocol): _value: T def overwrite(value: Writable[object]) -> None: value._value = object() def unsound(value: Writable[int]) -> None: overwrite(value) # error: [invalid-argument-type] ``` For now, I've limited this to the easier cases, so recursive interfaces, custom descriptors with unrepresentable setter domains, generic defaults, and inherited or malformed headers are unchanged.
This commit is contained in:
1 parent
a9fa5e59e5
commit
f41e263298
23 files changed
+510
-35
No files matched your search
Generated
+21
-2
@@ -2690,13 +2690,15 @@ Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.1">0.
|
||||
**What it does**
|
||||
|
||||
|
||||
Checks for protocol classes that will raise `TypeError` at runtime.
|
||||
Checks for protocol classes that are invalid at runtime or do not satisfy the typing
|
||||
specification.
|
||||
|
||||
**Why is this bad?**
|
||||
|
||||
|
||||
An invalidly defined protocol class may lead to the type checker inferring
|
||||
unexpected things. It may also lead to `TypeError`s at runtime.
|
||||
unexpected things or accepting unsafe operations. Some invalid protocol definitions
|
||||
also raise `TypeError` at runtime.
|
||||
|
||||
**Examples**
|
||||
|
||||
@@ -2713,6 +2715,23 @@ Traceback (most recent call last):
|
||||
TypeError: Protocols can only inherit from other protocols, got <class 'int'>
|
||||
```
|
||||
|
||||
A generic protocol's declared type-variable variance must match how that variable is
|
||||
used by its protocol members. For example, a type variable that appears only in a
|
||||
method's return type must be covariant:
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Source(Protocol[T]): # error: [invalid-protocol]
|
||||
def read(self) -> T: ...
|
||||
```
|
||||
|
||||
Although Python constructs this protocol successfully at runtime, it is invalid for
|
||||
static typing. Declare the type variable with `TypeVar("T", covariant=True)` instead.
|
||||
|
||||
## `invalid-raise`
|
||||
|
||||
<small>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
## What it does
|
||||
|
||||
Checks for protocol classes that will raise `TypeError` at runtime.
|
||||
Checks for protocol classes that are invalid at runtime or do not satisfy the typing
|
||||
specification.
|
||||
|
||||
## Why is this bad?
|
||||
|
||||
An invalidly defined protocol class may lead to the type checker inferring
|
||||
unexpected things. It may also lead to `TypeError`s at runtime.
|
||||
unexpected things or accepting unsafe operations. Some invalid protocol definitions
|
||||
also raise `TypeError` at runtime.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -20,3 +22,20 @@ Traceback (most recent call last):
|
||||
class Foo(int, Protocol): ...
|
||||
TypeError: Protocols can only inherit from other protocols, got <class 'int'>
|
||||
```
|
||||
|
||||
A generic protocol's declared type-variable variance must match how that variable is
|
||||
used by its protocol members. For example, a type variable that appears only in a
|
||||
method's return type must be covariant:
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Source(Protocol[T]): # error: [invalid-protocol]
|
||||
def read(self) -> T: ...
|
||||
```
|
||||
|
||||
Although Python constructs this protocol successfully at runtime, it is invalid for
|
||||
static typing. Declare the type variable with `TypeVar("T", covariant=True)` instead.
|
||||
@@ -373,7 +373,7 @@ item deletion. The `__delitem__` method is independent of `__getitem__`.
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
KT = TypeVar("KT")
|
||||
KT = TypeVar("KT", contravariant=True)
|
||||
|
||||
class CanDelItem(Protocol[KT]):
|
||||
def __delitem__(self, k: KT, /) -> None: ...
|
||||
|
||||
@@ -70,7 +70,7 @@ class Baz(Bar):
|
||||
# error: [empty-body]
|
||||
def f(self) -> int: ...
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
|
||||
class Qux(Protocol[T]):
|
||||
def f(self) -> int: ...
|
||||
@@ -975,7 +975,7 @@ unsound-return-statement = "error"
|
||||
```py
|
||||
from typing import Generator, Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
|
||||
class Phantom(Protocol[T]):
|
||||
def ping(self) -> int: ...
|
||||
|
||||
@@ -1372,13 +1372,14 @@ from typing_extensions import Generic, ParamSpec, Protocol, TypeVar
|
||||
|
||||
P = ParamSpec("P")
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class GenericClass(Generic[P, T]):
|
||||
def hint(self) -> Callable[P, T]:
|
||||
raise NotImplementedError
|
||||
|
||||
class GenericProtocol(Protocol[P, T]):
|
||||
def hint(self) -> Callable[P, T]: ...
|
||||
class GenericProtocol(Protocol[P, T_co]):
|
||||
def hint(self) -> Callable[P, T_co]: ...
|
||||
|
||||
def class_case(x: GenericClass[[int], str]) -> None:
|
||||
# revealed: bound method GenericClass[(int, /), str].hint() -> ((int, /) -> str)
|
||||
|
||||
@@ -75,9 +75,10 @@ argument _explicitly_ implements the protocol by listing it as a base class.
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class CanIndex(Protocol[T]):
|
||||
def __getitem__(self, index: int, /) -> T: ...
|
||||
class CanIndex(Protocol[T_co]):
|
||||
def __getitem__(self, index: int, /) -> T_co: ...
|
||||
|
||||
class ExplicitlyImplements(CanIndex[T]):
|
||||
def __getitem__(self, index: int, /) -> T:
|
||||
|
||||
@@ -296,6 +296,285 @@ equivalent to) each other.
|
||||
|
||||
It is not possible to construct a legacy typevar that is explicitly bivariant.
|
||||
|
||||
## Generic protocol variance
|
||||
|
||||
A protocol's declared variance must match whether its members consume or produce that type variable.
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
T_contra = TypeVar("T_contra", contravariant=True)
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T` in protocol `InvariantSource` should be covariant, but is invariant"
|
||||
class InvariantSource(Protocol[T]):
|
||||
def read(self) -> T: ...
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T` in protocol `InvariantSink` should be contravariant, but is invariant"
|
||||
class InvariantSink(Protocol[T]):
|
||||
def write(self, value: T) -> None: ...
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantSink` should be contravariant, but is covariant"
|
||||
class CovariantSink(Protocol[T_co]):
|
||||
def write(self, value: T_co) -> None: ...
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T_contra` in protocol `ContravariantSource` should be covariant, but is contravariant"
|
||||
class ContravariantSource(Protocol[T_contra]):
|
||||
def read(self) -> T_contra: ...
|
||||
|
||||
class CovariantSource(Protocol[T_co]):
|
||||
def read(self) -> T_co: ...
|
||||
|
||||
class ContravariantSink(Protocol[T_contra]):
|
||||
def write(self, value: T_contra) -> None: ...
|
||||
|
||||
class InvariantReadWrite(Protocol[T]):
|
||||
def read(self) -> T: ...
|
||||
def write(self, value: T) -> None: ...
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantReadWrite` should be invariant, but is covariant"
|
||||
class CovariantReadWrite(Protocol[T_co]):
|
||||
def read(self) -> T_co: ...
|
||||
def write(self, value: T_co) -> None: ...
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T_contra` in protocol `ContravariantReadWrite` should be invariant, but is contravariant"
|
||||
class ContravariantReadWrite(Protocol[T_contra]):
|
||||
def read(self) -> T_contra: ...
|
||||
def write(self, value: T_contra) -> None: ...
|
||||
```
|
||||
|
||||
A type variable used in an invariant return type makes the protocol invariant, even though it only
|
||||
appears in a return position.
|
||||
|
||||
```py
|
||||
class InvariantReturn(Protocol[T]):
|
||||
def read(self) -> list[T]: ...
|
||||
```
|
||||
|
||||
## Protocol properties and writable attributes
|
||||
|
||||
Read-only properties are covariant. Writable properties and attributes are invariant, including
|
||||
underscore-prefixed attributes and annotated special-method attributes.
|
||||
|
||||
```py
|
||||
from typing import Callable, Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class ReadOnlyProperty(Protocol[T_co]):
|
||||
@property
|
||||
def value(self) -> T_co: ...
|
||||
|
||||
class WritableProperty(Protocol[T]):
|
||||
@property
|
||||
def value(self) -> T: ...
|
||||
@value.setter
|
||||
def value(self, value: T) -> None: ...
|
||||
|
||||
class WritableAttribute(Protocol[T]):
|
||||
_value: T
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantAttribute` should be invariant, but is covariant"
|
||||
class CovariantAttribute(Protocol[T_co]):
|
||||
_value: T_co
|
||||
|
||||
class CallableAttribute(Protocol[T]):
|
||||
__call__: Callable[..., T]
|
||||
|
||||
class CallableMethod(Protocol[T_co]):
|
||||
def __call__(self) -> T_co: ...
|
||||
```
|
||||
|
||||
## Protocol attributes containing class types
|
||||
|
||||
Although `type[T]` is covariant, a writable protocol attribute containing `type[T]` must make the
|
||||
protocol invariant.
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
from ty_extensions import static_assert
|
||||
from ty_extensions._internal import is_assignable_to, is_subtype_of
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class WritableClassAttribute(Protocol[T]):
|
||||
value: type[T]
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T_co` in protocol `CovariantClassAttribute` should be invariant, but is covariant"
|
||||
class CovariantClassAttribute(Protocol[T_co]):
|
||||
value: type[T_co]
|
||||
|
||||
class InferredClassAttribute[T](Protocol):
|
||||
value: type[T]
|
||||
|
||||
class Wrapper[T]:
|
||||
def value(self) -> InferredClassAttribute[T]:
|
||||
raise NotImplementedError
|
||||
|
||||
static_assert(not is_subtype_of(Wrapper[int], Wrapper[object]))
|
||||
static_assert(not is_assignable_to(Wrapper[int], Wrapper[object]))
|
||||
```
|
||||
|
||||
## Descriptor-decorated protocol variance
|
||||
|
||||
A descriptor with a known setter domain contributes its actual read and write types to protocol
|
||||
variance. A descriptor that returns `T` but accepts any `object` for writes is covariant in `T`.
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```py
|
||||
from typing import Callable, Generic, Protocol, TypeVar
|
||||
from ty_extensions import static_assert
|
||||
from ty_extensions._internal import is_assignable_to, is_subtype_of
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class Descriptor(Generic[T_co]):
|
||||
def __init__(self, getter: Callable[..., T_co]) -> None: ...
|
||||
def __get__(self, instance: object, owner: type | None = None) -> T_co:
|
||||
raise NotImplementedError
|
||||
def __set__(self, instance: object, value: object) -> None: ...
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T` in protocol `InvariantDescriptor` should be covariant, but is invariant"
|
||||
class InvariantDescriptor(Protocol[T]):
|
||||
@Descriptor
|
||||
def value(self) -> T: ...
|
||||
|
||||
class CovariantDescriptor(Protocol[T_co]):
|
||||
@Descriptor
|
||||
def value(self) -> T_co: ...
|
||||
|
||||
class InferredDescriptor[T](Protocol):
|
||||
@Descriptor
|
||||
def value(self) -> T: ...
|
||||
|
||||
class Wrapper[T]:
|
||||
def value(self) -> InferredDescriptor[T]:
|
||||
raise NotImplementedError
|
||||
|
||||
static_assert(is_subtype_of(Wrapper[int], Wrapper[object]))
|
||||
static_assert(is_assignable_to(Wrapper[int], Wrapper[object]))
|
||||
```
|
||||
|
||||
## Protocol constructors
|
||||
|
||||
Constructors are not protocol members, so their parameters do not constrain protocol variance.
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# error: [invalid-protocol] "Type variable `T` in protocol `ConstructorOnly` should be covariant, but is invariant"
|
||||
class ConstructorOnly(Protocol[T]):
|
||||
def __init__(self, value: T) -> None: ...
|
||||
```
|
||||
|
||||
## Protocol method receivers
|
||||
|
||||
Explicit receiver annotations that reference the protocol trigger the recursion guard, so these
|
||||
examples currently skip declared-variance validation. The covariant version should be rejected
|
||||
because `send` consumes its type parameter, but it is also accepted. This demonstrates the guard's
|
||||
limitation rather than correct handling of receiver variance.
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T_contra = TypeVar("T_contra", contravariant=True)
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class ExplicitReceivers(Protocol[T_contra]):
|
||||
def send(self: "ExplicitReceivers[T_contra]", value: T_contra) -> None: ...
|
||||
@classmethod
|
||||
def configure(cls: "type[ExplicitReceivers[T_contra]]") -> None: ...
|
||||
|
||||
# TODO: Reject the covariant declaration; this protocol should be contravariant.
|
||||
class CovariantExplicitReceivers(Protocol[T_co]):
|
||||
def send(self: "CovariantExplicitReceivers[T_co]", value: T_co) -> None: ...
|
||||
@classmethod
|
||||
def configure(cls: "type[CovariantExplicitReceivers[T_co]]") -> None: ...
|
||||
```
|
||||
|
||||
## Inferred legacy protocol variance
|
||||
|
||||
Inferred legacy type variables use the same structural interface as explicitly declared protocol
|
||||
parameters. An underscore-prefixed protocol attribute remains writable and therefore invariant.
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```py
|
||||
from typing import ParamSpec, Protocol, TypeVar
|
||||
from ty_extensions import static_assert
|
||||
from ty_extensions._internal import is_assignable_to, is_subtype_of
|
||||
|
||||
P = ParamSpec("P")
|
||||
R_co = TypeVar("R_co", covariant=True)
|
||||
T = TypeVar("T", infer_variance=True)
|
||||
|
||||
class Callback(Protocol[P, R_co]):
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
||||
|
||||
class WritableProtocol(Protocol[T]):
|
||||
_value: T
|
||||
|
||||
static_assert(not is_subtype_of(WritableProtocol[int], WritableProtocol[object]))
|
||||
static_assert(not is_assignable_to(WritableProtocol[int], WritableProtocol[object]))
|
||||
```
|
||||
|
||||
## Protocol members referencing other protocols
|
||||
|
||||
The recursion guard currently skips declared-variance validation whenever a member type contains a
|
||||
protocol, even if that protocol is unrelated and the interface is not recursive. `Source` should be
|
||||
covariant because only `read` uses `T`, in a return position.
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class Marker(Protocol):
|
||||
def ready(self) -> bool: ...
|
||||
|
||||
# TODO: Reject the invariant declaration even though `marker` returns another protocol.
|
||||
class Source(Protocol[T]):
|
||||
def read(self) -> T: ...
|
||||
def marker(self) -> Marker: ...
|
||||
```
|
||||
|
||||
## Inherited protocol variance
|
||||
|
||||
A protocol's variance also depends on its inherited members. `Child` only produces `T` through
|
||||
`Base.read`, so it should be covariant. Declared-variance validation currently skips protocols with
|
||||
additional bases, leaving this mismatch undiagnosed.
|
||||
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class Base(Protocol[T_co]):
|
||||
def read(self) -> T_co: ...
|
||||
|
||||
# TODO: Reject the invariant declaration; the inherited interface is covariant.
|
||||
class Child(Base[T], Protocol[T]): ...
|
||||
```
|
||||
|
||||
## Inheriting from generic classes with explicit variance
|
||||
|
||||
A generic subclass cannot claim a variance that is less restrictive than the variance required by
|
||||
|
||||
@@ -128,8 +128,9 @@ def _(doubly_specialized: Tuple[int]):
|
||||
reveal_type(doubly_specialized) # revealed: Unknown
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class LegacyProto(Protocol[T]):
|
||||
class LegacyProto(Protocol[T_co]):
|
||||
pass
|
||||
|
||||
type LegacyProtoInt = LegacyProto[int]
|
||||
|
||||
@@ -131,7 +131,7 @@ argument _explicitly_ implements the protocol by listing it as a base class.
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
S = TypeVar("S")
|
||||
S = TypeVar("S", covariant=True)
|
||||
|
||||
class CanIndex(Protocol[S]):
|
||||
def __getitem__(self, index: int, /) -> S: ...
|
||||
|
||||
@@ -536,6 +536,58 @@ static_assert(not is_subtype_of(DescriptorOwner[B], DescriptorOwner[A]))
|
||||
static_assert(not is_subtype_of(DescriptorOwner[A], DescriptorOwner[B]))
|
||||
```
|
||||
|
||||
### Mutable protocol attributes
|
||||
|
||||
Underscore-prefixed protocol attributes remain writable through their structural interface, so their
|
||||
inferred type parameters are invariant.
|
||||
|
||||
```py
|
||||
from typing import Protocol
|
||||
from ty_extensions import static_assert
|
||||
from ty_extensions._internal import is_assignable_to, is_subtype_of
|
||||
|
||||
class WritableProtocol[T](Protocol):
|
||||
_value: T
|
||||
|
||||
static_assert(not is_subtype_of(WritableProtocol[int], WritableProtocol[object]))
|
||||
static_assert(not is_assignable_to(WritableProtocol[int], WritableProtocol[object]))
|
||||
|
||||
def overwrite(value: WritableProtocol[object]) -> None:
|
||||
value._value = object()
|
||||
|
||||
def unsound(value: WritableProtocol[int]) -> None:
|
||||
overwrite(value) # error: [invalid-argument-type]
|
||||
```
|
||||
|
||||
### Mutable protocol attributes with unrelated protocol members
|
||||
|
||||
The recursion guard currently disables structural variance inference whenever a member type contains
|
||||
a protocol, even if the interface is not recursive. The fallback to ordinary class inference treats
|
||||
`_value` as private and incorrectly infers covariance. This also makes a class that returns the
|
||||
protocol covariant, allowing callers to mutate `_value` through a wider specialization.
|
||||
|
||||
```py
|
||||
from typing import Protocol
|
||||
from ty_extensions import static_assert
|
||||
from ty_extensions._internal import is_assignable_to, is_subtype_of
|
||||
|
||||
class Marker(Protocol):
|
||||
def ready(self) -> bool: ...
|
||||
|
||||
class WritableProtocol[T](Protocol):
|
||||
_value: T
|
||||
|
||||
def marker(self) -> Marker: ...
|
||||
|
||||
class Wrapper[T]:
|
||||
def value(self) -> WritableProtocol[T]:
|
||||
raise NotImplementedError
|
||||
|
||||
# TODO: Both assertions should hold: the writable protocol attribute makes `Wrapper` invariant.
|
||||
static_assert(not is_subtype_of(Wrapper[int], Wrapper[object])) # error: [static-assert-error]
|
||||
static_assert(not is_assignable_to(Wrapper[int], Wrapper[object])) # error: [static-assert-error]
|
||||
```
|
||||
|
||||
### Immutable Attributes
|
||||
|
||||
Immutable attributes can't be written to, and thus constrain the typevar to covariance, not
|
||||
|
||||
@@ -850,8 +850,9 @@ def _(doubly_specialized: Tuple[int]):
|
||||
reveal_type(doubly_specialized) # revealed: Unknown
|
||||
|
||||
T = TypeVar("T")
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
class LegacyProto(Protocol[T]):
|
||||
class LegacyProto(Protocol[T_co]):
|
||||
pass
|
||||
|
||||
LegacyProtoInt = LegacyProto[int]
|
||||
|
||||
@@ -691,7 +691,7 @@ reveal_mro(Sub)
|
||||
```py
|
||||
from typing_extensions import Protocol, TypeVar, Generic
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
|
||||
class Foo(Protocol): ...
|
||||
class Bar(Protocol[T]): ...
|
||||
|
||||
@@ -315,7 +315,7 @@ The same applies to generic protocols and the `typing_extensions` backport.
|
||||
from typing import TypeVar
|
||||
from typing_extensions import Protocol as ExtensionsProtocol
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
|
||||
class GenericProtocol(Protocol[T]): ...
|
||||
class BackportedProtocol(ExtensionsProtocol): ...
|
||||
@@ -3984,8 +3984,8 @@ reveal_type(abs(5)) # revealed: int
|
||||
def f(x: Literal[5]) -> None:
|
||||
reveal_type(abs(x)) # revealed: int
|
||||
|
||||
InT = TypeVar("InT")
|
||||
OutT = TypeVar("OutT")
|
||||
InT = TypeVar("InT", contravariant=True)
|
||||
OutT = TypeVar("OutT", covariant=True)
|
||||
|
||||
class CanMul(Protocol[InT, OutT]):
|
||||
def __mul__(self, x: InT, /) -> OutT: ...
|
||||
@@ -4233,7 +4233,7 @@ from ty_extensions._internal import is_equivalent_to, is_assignable_to, is_subty
|
||||
class NewStyleClassScoped[T](Protocol):
|
||||
def method(self, input: T) -> None: ...
|
||||
|
||||
S = TypeVar("S")
|
||||
S = TypeVar("S", contravariant=True)
|
||||
|
||||
class LegacyClassScoped(Protocol[S]):
|
||||
def method(self, input: S) -> None: ...
|
||||
@@ -6008,7 +6008,7 @@ Other type variables in the same call are still inferred from their correspondin
|
||||
```py
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
U = TypeVar("U")
|
||||
|
||||
class Box(Protocol[T]):
|
||||
@@ -7625,8 +7625,8 @@ Protocols can have TypeVars with forward reference bounds that form cycles.
|
||||
```py
|
||||
from typing import Any, Protocol, TypeVar
|
||||
|
||||
T1 = TypeVar("T1", bound="A2[Any]")
|
||||
T2 = TypeVar("T2", bound="A1[Any]")
|
||||
T1 = TypeVar("T1", bound="A2[Any]", covariant=True)
|
||||
T2 = TypeVar("T2", bound="A1[Any]", covariant=True)
|
||||
T3 = TypeVar("T3", bound="B2[Any]")
|
||||
T4 = TypeVar("T4", bound="B1[Any]")
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ python-version = "3.13"
|
||||
from typing import Generic, Protocol, TypeVar, overload
|
||||
|
||||
T = TypeVar("T")
|
||||
T_contra = TypeVar("T_contra")
|
||||
T_contra = TypeVar("T_contra", contravariant=True)
|
||||
S2 = TypeVar("S2")
|
||||
|
||||
class ElementOpsMixin(Generic[S2]):
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ from typing_extensions import TypeVar
|
||||
if TYPE_CHECKING:
|
||||
from .message import UserMessage
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
|
||||
class Messageable(Protocol[T]): ...
|
||||
class WrapsUser(Messageable["UserMessage"]): ...
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md
|
||||
```
|
||||
1 | from typing_extensions import Protocol, TypeVar, Generic
|
||||
2 |
|
||||
3 | T = TypeVar("T")
|
||||
3 | T = TypeVar("T", covariant=True)
|
||||
4 |
|
||||
5 | class Foo(Protocol): ...
|
||||
6 | class Bar(Protocol[T]): ...
|
||||
|
||||
@@ -2560,7 +2560,7 @@ A legacy type variable in the protocol's type arguments still makes the enclosin
|
||||
from typing import Any, Protocol, TypeVar
|
||||
from ty_extensions import Top
|
||||
|
||||
T = TypeVar("T")
|
||||
T = TypeVar("T", covariant=True)
|
||||
|
||||
class LegacyProtocol(Protocol[T]):
|
||||
value: Any
|
||||
|
||||
@@ -853,7 +853,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final, Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T", covariant=True)
|
||||
T = TypeVar("T")
|
||||
|
||||
class Owned(Protocol[T]):
|
||||
owner: Final[T]
|
||||
|
||||
@@ -3115,7 +3115,7 @@ from _collections_abc import dict_items
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, TypeVar, TypedDict, runtime_checkable
|
||||
|
||||
ItemsT = TypeVar("ItemsT")
|
||||
ItemsT = TypeVar("ItemsT", covariant=True)
|
||||
|
||||
class HasItems(Protocol[ItemsT]):
|
||||
def items(self) -> ItemsT: ...
|
||||
@@ -3256,7 +3256,7 @@ Common constraints must preserve correlations in mutable protocols:
|
||||
```py
|
||||
from typing import Any, Protocol, TypeVar, TypedDict
|
||||
|
||||
Key = TypeVar("Key")
|
||||
Key = TypeVar("Key", contravariant=True)
|
||||
Value = TypeVar("Value")
|
||||
|
||||
class SetAndGet(Protocol[Key, Value]):
|
||||
|
||||
@@ -3397,6 +3397,14 @@ impl<'db> StaticClassLiteral<'db> {
|
||||
if !typevar_in_generic_context {
|
||||
return TypeVarVariance::Bivariant;
|
||||
}
|
||||
|
||||
if self.is_protocol(db)
|
||||
&& let Some(protocol) = self.identity_specialization(db).into_protocol_class(db)
|
||||
&& protocol.interface(db).supports_variance_inference(db)
|
||||
{
|
||||
return protocol.interface(db).variance_of(db, &env, typevar);
|
||||
}
|
||||
|
||||
let class_body_scope = self.body_scope(db);
|
||||
let program_file = class_body_scope.program_file(db);
|
||||
let python_version = env.python_version(db);
|
||||
|
||||
@@ -1142,6 +1142,7 @@ pub(crate) fn check_static_class_definitions<'db>(
|
||||
|
||||
if let Some(protocol) = class.into_protocol_class(db) {
|
||||
protocol.validate_members(context);
|
||||
protocol.validate_type_parameter_variance(context);
|
||||
}
|
||||
|
||||
if class.is_typed_dict(db) {
|
||||
|
||||
@@ -27,12 +27,13 @@ use crate::{
|
||||
ApplyTypeMappingVisitor, BindingContext, BoundTypeVarIdentity, BoundTypeVarInstance,
|
||||
CallableType, ClassBase, ClassType, ErrorContext, FindLegacyTypeVarsVisitor, GenericAlias,
|
||||
GenericContext, InstanceFallbackShadowsNonDataDescriptor, IntersectionType, KnownFunction,
|
||||
MaterializationKind, MemberLookupKey, MemberLookupPolicy, Parameter, PropertyInstanceType,
|
||||
ProtocolInstanceType, SelfBinding, Signature, StaticClassLiteral, Type, TypeMapping,
|
||||
TypeQualifiers, TypeVarBoundOrConstraints, TypeVarVariance, UnionType, VarianceInferable,
|
||||
KnownInstanceType, MaterializationKind, MemberLookupKey, MemberLookupPolicy, Parameter,
|
||||
PropertyInstanceType, ProtocolInstanceType, SelfBinding, Signature, StaticClassLiteral,
|
||||
Type, TypeMapping, TypeQualifiers, TypeVarBoundOrConstraints, TypeVarVariance, UnionType,
|
||||
VarianceInferable,
|
||||
constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension},
|
||||
context::InferContext,
|
||||
diagnostic::report_undeclared_protocol_member,
|
||||
diagnostic::{INVALID_PROTOCOL, report_undeclared_protocol_member},
|
||||
generics::Specialization,
|
||||
signatures::walk_signature,
|
||||
},
|
||||
@@ -286,6 +287,70 @@ impl<'db> ProtocolClass<'db> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate explicitly declared type-variable variance against this protocol's interface.
|
||||
pub(super) fn validate_type_parameter_variance(self, context: &InferContext) {
|
||||
if !context.is_lint_enabled(&INVALID_PROTOCOL) {
|
||||
return;
|
||||
}
|
||||
|
||||
let db = context.db();
|
||||
let Some((class, _)) = self.static_class_literal(db) else {
|
||||
return;
|
||||
};
|
||||
// TODO: Validate protocols with inherited members too. This single-base pattern skips
|
||||
// subclasses such as `class Child(Base[T], Protocol[T])`, even when their declared
|
||||
// variance disagrees with the inherited interface.
|
||||
let [Type::KnownInstance(KnownInstanceType::SubscriptedProtocol(generic_context))] =
|
||||
class.explicit_bases(db)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if class.has_pep_695_type_params(db) || class.try_mro(db, None).is_err() {
|
||||
return;
|
||||
}
|
||||
let env = ProgramEnvironment::from_scope(class.body_scope(db));
|
||||
if generic_context.variables(db).any(|typevar| {
|
||||
typevar.is_typevartuple(db) || typevar.typevar(db).default_type(db, &env).is_some()
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let Some(protocol) = class.identity_specialization(db).into_protocol_class(db) else {
|
||||
return;
|
||||
};
|
||||
if !protocol.interface(db).supports_variance_inference(db) {
|
||||
return;
|
||||
}
|
||||
|
||||
for typevar in generic_context.variables(db) {
|
||||
if typevar.is_paramspec(db) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(declared_variance) = typevar.typevar(db).explicit_variance(db) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let inferred_variance = match class.variance_of(db, &env, typevar.identity(db)) {
|
||||
TypeVarVariance::Bivariant => TypeVarVariance::Covariant,
|
||||
variance => variance,
|
||||
};
|
||||
|
||||
if inferred_variance == declared_variance {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(builder) = context.report_lint(&INVALID_PROTOCOL, class.header_range(db)) {
|
||||
builder.into_diagnostic(format_args!(
|
||||
"Type variable `{}` in protocol `{}` should be {}, but is {}",
|
||||
typevar.typevar(db).name(db),
|
||||
self.name(db),
|
||||
inferred_variance.as_str(),
|
||||
declared_variance.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_type_mapping_impl<'a>(
|
||||
self,
|
||||
db: &'db dyn Db,
|
||||
@@ -786,6 +851,28 @@ impl<'db> ProtocolInterface<'db> {
|
||||
self.inner(db).contains_key(name)
|
||||
}
|
||||
|
||||
/// Return whether this interface is currently supported by structural variance inference.
|
||||
///
|
||||
/// The finite-member guard conservatively rejects any member type containing a protocol,
|
||||
/// including unrelated protocols and explicit receiver annotations that refer to this protocol.
|
||||
/// This skips declared-variance validation and falls back to ordinary class variance inference
|
||||
/// for inferred parameters, even when the interface is not recursive.
|
||||
///
|
||||
/// TODO: Narrow the guard to actual recursion, and support recursive interfaces and descriptor
|
||||
/// writes with unrepresentable domains.
|
||||
pub(super) fn supports_variance_inference(self, db: &'db dyn Db) -> bool {
|
||||
ProtocolInterfaceView::new(self, None).has_only_finite_members(db)
|
||||
&& self.members(db).all(|member| {
|
||||
!matches!(
|
||||
member.data.kind,
|
||||
ProtocolMemberKind::Property {
|
||||
write: Some(ProtocolMemberWrite::Descriptor { domain: None, .. }),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns whether `name` has an instance-write requirement of `type[T]`, where `T` belongs
|
||||
/// to `generic_context`.
|
||||
pub(super) fn includes_generic_writable_instance_member(
|
||||
@@ -1130,7 +1217,13 @@ impl<'db> VarianceInferable<'db> for ProtocolInterface<'db> {
|
||||
self.members(db)
|
||||
.flat_map(|member| {
|
||||
let capabilities = member.capabilities(db, env);
|
||||
[capabilities.instance, capabilities.class]
|
||||
// Instance methods are checked only through their bound instance signature.
|
||||
let class_access = if member.is_instance_method() {
|
||||
ProtocolMemberAccess::NONE
|
||||
} else {
|
||||
capabilities.class
|
||||
};
|
||||
[capabilities.instance, class_access]
|
||||
.into_iter()
|
||||
.flat_map(|access| access.variances(db, env))
|
||||
})
|
||||
|
||||
Generated
+1
-1
@@ -936,7 +936,7 @@
|
||||
},
|
||||
"invalid-protocol": {
|
||||
"title": "detects invalid protocol class definitions",
|
||||
"description": "## What it does\n\nChecks for protocol classes that will raise `TypeError` at runtime.\n\n## Why is this bad?\n\nAn invalidly defined protocol class may lead to the type checker inferring\nunexpected things. It may also lead to `TypeError`s at runtime.\n\n## Examples\n\nA `Protocol` class cannot inherit from a non-`Protocol` class;\nthis raises a `TypeError` at runtime:\n\n```pycon\n>>> from typing import Protocol\n>>> class Foo(int, Protocol): ...\nTraceback (most recent call last):\n File \"<python-input-1>\", line 1, in <module>\n class Foo(int, Protocol): ...\nTypeError: Protocols can only inherit from other protocols, got <class 'int'>\n```",
|
||||
"description": "## What it does\n\nChecks for protocol classes that are invalid at runtime or do not satisfy the typing\nspecification.\n\n## Why is this bad?\n\nAn invalidly defined protocol class may lead to the type checker inferring\nunexpected things or accepting unsafe operations. Some invalid protocol definitions\nalso raise `TypeError` at runtime.\n\n## Examples\n\nA `Protocol` class cannot inherit from a non-`Protocol` class;\nthis raises a `TypeError` at runtime:\n\n```pycon\n>>> from typing import Protocol\n>>> class Foo(int, Protocol): ...\nTraceback (most recent call last):\n File \"<python-input-1>\", line 1, in <module>\n class Foo(int, Protocol): ...\nTypeError: Protocols can only inherit from other protocols, got <class 'int'>\n```\n\nA generic protocol's declared type-variable variance must match how that variable is\nused by its protocol members. For example, a type variable that appears only in a\nmethod's return type must be covariant:\n\n```py\nfrom typing import Protocol, TypeVar\n\nT = TypeVar(\"T\")\n\n\nclass Source(Protocol[T]): # error: [invalid-protocol]\n def read(self) -> T: ...\n```\n\nAlthough Python constructs this protocol successfully at runtime, it is invalid for\nstatic typing. Declare the type variable with `TypeVar(\"T\", covariant=True)` instead.",
|
||||
"default": "error",
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user