Skip to content

Commit 0fe46b2

Browse files
committed
[ty] Allow protocols to participate in nominal subtyping as well as structural subtyping
1 parent c65bd20 commit 0fe46b2

File tree

5 files changed

+89
-36
lines changed

5 files changed

+89
-36
lines changed

crates/ty_python_semantic/resources/mdtest/protocols.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2090,6 +2090,61 @@ static_assert(is_subtype_of(TypeOf[satisfies_foo], Foo))
20902090
static_assert(is_assignable_to(TypeOf[satisfies_foo], Foo))
20912091
```
20922092

2093+
## Nominal subtyping of protocols
2094+
2095+
Protocols can participate in nominal subtyping as well as structural subtyping. The main use case
2096+
for this is that it allows users an "escape hatch" to force a type checker to consider another type
2097+
to be a subtype of a given protocol, even if the other type violates the Liskov Substitution
2098+
Principle in some way.
2099+
2100+
```py
2101+
from typing import Protocol
2102+
from ty_extensions import static_assert, is_subtype_of
2103+
2104+
class X(Protocol):
2105+
x: int
2106+
2107+
class YProto(X, Protocol):
2108+
x: None = None # TODO: we should emit an error here due to the Liskov violation
2109+
2110+
class YNominal(X):
2111+
x = None # TODO: we should emit an error here due to the Liskov violation
2112+
2113+
static_assert(is_subtype_of(YProto, X))
2114+
static_assert(is_subtype_of(YNominal, X))
2115+
```
2116+
2117+
A common use case for this behaviour is that a lot of ecosystem code depends on type checkers
2118+
considering `str` to be a subtype of `Container[str]`. From a structural-subtyping perspective, this
2119+
is not the case, since `str.__contains__` only accepts `str`, while the `Container` interface
2120+
specifies that a type must have a `__contains__` method which accepts `object` in order for that
2121+
type to be considered a subtype of `Container`. Nonetheless, `str` has `Container[str]` in its MRO,
2122+
and other type checkers therefore consider it to be a subtype of `Container[str]` -- as such, so do
2123+
we:
2124+
2125+
```py
2126+
from typing import Container
2127+
2128+
static_assert(is_subtype_of(str, Container[str]))
2129+
```
2130+
2131+
This behaviour can have some counter-intuitive repercussions. For example, one implication of this
2132+
is that not all subtype of `Iterable` are necessarily considered iterable by ty if a given subtype
2133+
violates the Liskov principle (this also matches the behaviour of other type checkers):
2134+
2135+
```py
2136+
from typing import Iterable
2137+
2138+
class Foo(Iterable[int]):
2139+
__iter__ = None
2140+
2141+
static_assert(is_subtype_of(Foo, Iterable[int]))
2142+
2143+
def _(x: Foo):
2144+
for item in x: # error: [not-iterable]
2145+
pass
2146+
```
2147+
20932148
## Protocols are never singleton types, and are never single-valued types
20942149

20952150
It *might* be possible to have a singleton protocol-instance type...?

crates/ty_python_semantic/src/types.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1613,16 +1613,13 @@ impl<'db> Type<'db> {
16131613
callable.has_relation_to_impl(db, target, relation, visitor)
16141614
}),
16151615

1616-
(Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => left
1617-
.interface(db)
1618-
.extends_interface_of(db, right.interface(db), relation, visitor),
1619-
1620-
// A protocol instance can never be a subtype of a nominal type, with the *sole* exception of `object`.
1621-
(Type::ProtocolInstance(_), _) => C::unsatisfiable(db),
16221616
(_, Type::ProtocolInstance(protocol)) => {
16231617
self.satisfies_protocol(db, protocol, relation, visitor)
16241618
}
16251619

1620+
// A protocol instance can never be a subtype of a nominal type, with the *sole* exception of `object`.
1621+
(Type::ProtocolInstance(_), _) => C::unsatisfiable(db),
1622+
16261623
// All `StringLiteral` types are a subtype of `LiteralString`.
16271624
(Type::StringLiteral(_), Type::LiteralString) => C::always_satisfiable(db),
16281625

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,17 +1058,9 @@ impl<'db> Bindings<'db> {
10581058
// `tuple(range(42))` => `tuple[int, ...]`
10591059
// BUT `tuple((1, 2))` => `tuple[Literal[1], Literal[2]]` rather than `tuple[Literal[1, 2], ...]`
10601060
if let [Some(argument)] = overload.parameter_types() {
1061-
let Ok(tuple_spec) = argument.try_iterate(db) else {
1062-
tracing::debug!(
1063-
"type" = %argument.display(db),
1064-
"try_iterate() should not fail on a type \
1065-
assignable to `Iterable`",
1066-
);
1067-
continue;
1068-
};
10691061
overload.set_return_type(Type::tuple(TupleType::new(
10701062
db,
1071-
tuple_spec.as_ref(),
1063+
&argument.iterate(db),
10721064
)));
10731065
}
10741066
}

crates/ty_python_semantic/src/types/instance.rs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,36 @@ impl<'db> Type<'db> {
107107
relation: TypeRelation,
108108
visitor: &HasRelationToVisitor<'db, C>,
109109
) -> C {
110-
protocol
111-
.inner
112-
.interface(db)
113-
.members(db)
114-
.when_all(db, |member| {
115-
member.is_satisfied_by(db, self, relation, visitor)
116-
})
110+
let structurally_satisfied = if let Type::ProtocolInstance(self_protocol) = self {
111+
self_protocol.interface(db).extends_interface_of(
112+
db,
113+
protocol.interface(db),
114+
relation,
115+
visitor,
116+
)
117+
} else {
118+
protocol
119+
.inner
120+
.interface(db)
121+
.members(db)
122+
.when_all(db, |member| {
123+
member.is_satisfied_by(db, self, relation, visitor)
124+
})
125+
};
126+
127+
// Even if `self` does not satisfy the protocol from a structural perspective,
128+
// we may still need to consider it as satisfying the protocol if `protocol` is
129+
// a class-based protocol and `self` has the protocol class in its MRO.
130+
//
131+
// This matches the behaviour of other type checkers, and is required for us to
132+
// recognise `str` as a subtype of `Container[str]`.
133+
structurally_satisfied.or(db, || {
134+
if let Protocol::FromClass(class) = protocol.inner {
135+
self.has_relation_to_impl(db, Type::non_tuple_instance(class), relation, visitor)
136+
} else {
137+
C::unsatisfiable(db)
138+
}
139+
})
117140
}
118141
}
119142

crates/ty_python_semantic/src/types/property_tests.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,6 @@ mod stable {
217217
mod flaky {
218218
use itertools::Itertools;
219219

220-
use crate::types::{KnownClass, Type};
221-
222220
use super::{intersection, union};
223221

224222
// Negating `T` twice is equivalent to `T`.
@@ -313,16 +311,4 @@ mod flaky {
313311
bottom_materialization_of_type_is_assigneble_to_type, db,
314312
forall types t. t.bottom_materialization(db).is_assignable_to(db, t)
315313
);
316-
317-
// Any type assignable to `Iterable[object]` should be considered iterable.
318-
//
319-
// Note that the inverse is not true, due to the fact that we recognize the old-style
320-
// iteration protocol as well as the new-style iteration protocol: not all objects that
321-
// we consider iterable are assignable to `Iterable[object]`.
322-
//
323-
// Currently flaky due to <https://github.com/astral-sh/ty/issues/889>
324-
type_property_test!(
325-
all_type_assignable_to_iterable_are_iterable, db,
326-
forall types t. t.is_assignable_to(db, KnownClass::Iterable.to_specialized_instance(db, [Type::object(db)])) => t.try_iterate(db).is_ok()
327-
);
328314
}

0 commit comments

Comments
 (0)