[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:
Charlie Marsh authored and GitHub committed 2026-08-26 06:45:42 -06:00
1 parent a9fa5e59e5
commit f41e263298
23 files changed
+510 -35

No files matched your search

+21 -2
View File
@@ -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]")
@@ -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]):
@@ -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"]): ...
@@ -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))
})
+1 -1
View File
@@ -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": [
{