[ty] Allow protocols to participate in nominal subtyping as well as structural subtyping (#20314)

This commit is contained in:
Alex Waygood 2025-09-10 12:05:50 +01:00 committed by GitHub
parent 4de7d653bd
commit fd7eb1e22f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 118 additions and 23 deletions

View file

@ -2220,6 +2220,65 @@ static_assert(is_subtype_of(TypeOf[satisfies_foo], Foo))
static_assert(is_assignable_to(TypeOf[satisfies_foo], Foo)) static_assert(is_assignable_to(TypeOf[satisfies_foo], Foo))
``` ```
## Nominal subtyping of protocols
Protocols can participate in nominal subtyping as well as structural subtyping. The main use case
for this is that it allows users an "escape hatch" to force a type checker to consider another type
to be a subtype of a given protocol, even if the other type violates the Liskov Substitution
Principle in some way.
```py
from typing import Protocol, final
from ty_extensions import static_assert, is_subtype_of, is_disjoint_from
class X(Protocol):
x: int
class YProto(X, Protocol):
x: None = None # TODO: we should emit an error here due to the Liskov violation
@final
class YNominal(X):
x: None = None # TODO: we should emit an error here due to the Liskov violation
static_assert(is_subtype_of(YProto, X))
static_assert(is_subtype_of(YNominal, X))
static_assert(not is_disjoint_from(YProto, X))
static_assert(not is_disjoint_from(YNominal, X))
```
A common use case for this behaviour is that a lot of ecosystem code depends on type checkers
considering `str` to be a subtype of `Container[str]`. From a structural-subtyping perspective, this
is not the case, since `str.__contains__` only accepts `str`, while the `Container` interface
specifies that a type must have a `__contains__` method which accepts `object` in order for that
type to be considered a subtype of `Container`. Nonetheless, `str` has `Container[str]` in its MRO,
and other type checkers therefore consider it to be a subtype of `Container[str]` -- as such, so do
we:
```py
from typing import Container
static_assert(is_subtype_of(str, Container[str]))
static_assert(not is_disjoint_from(str, Container[str]))
```
This behaviour can have some counter-intuitive repercussions. For example, one implication of this
is that not all subtype of `Iterable` are necessarily considered iterable by ty if a given subtype
violates the Liskov principle (this also matches the behaviour of other type checkers):
```py
from typing import Iterable
class Foo(Iterable[int]):
__iter__ = None
static_assert(is_subtype_of(Foo, Iterable[int]))
def _(x: Foo):
for item in x: # error: [not-iterable]
pass
```
## Protocols are never singleton types, and are never single-valued types ## Protocols are never singleton types, and are never single-valued types
It *might* be possible to have a singleton protocol-instance type...? It *might* be possible to have a singleton protocol-instance type...?

View file

@ -36,7 +36,11 @@ reveal_type(tuple[int, *tuple[str, ...]]((1,))) # revealed: tuple[int, *tuple[s
reveal_type(().__class__()) # revealed: tuple[()] reveal_type(().__class__()) # revealed: tuple[()]
reveal_type((1, 2).__class__((1, 2))) # revealed: tuple[Literal[1], Literal[2]] reveal_type((1, 2).__class__((1, 2))) # revealed: tuple[Literal[1], Literal[2]]
def f(x: Iterable[int], y: list[str], z: Never, aa: list[Never]): class LiskovUncompliantIterable(Iterable[int]):
# TODO we should emit an error here about the Liskov violation
__iter__ = None
def f(x: Iterable[int], y: list[str], z: Never, aa: list[Never], bb: LiskovUncompliantIterable):
reveal_type(tuple(x)) # revealed: tuple[int, ...] reveal_type(tuple(x)) # revealed: tuple[int, ...]
reveal_type(tuple(y)) # revealed: tuple[str, ...] reveal_type(tuple(y)) # revealed: tuple[str, ...]
reveal_type(tuple(z)) # revealed: tuple[Unknown, ...] reveal_type(tuple(z)) # revealed: tuple[Unknown, ...]
@ -44,6 +48,11 @@ def f(x: Iterable[int], y: list[str], z: Never, aa: list[Never]):
# This is correct as the only inhabitants of `list[Never]` can be empty lists # This is correct as the only inhabitants of `list[Never]` can be empty lists
reveal_type(tuple(aa)) # revealed: tuple[()] reveal_type(tuple(aa)) # revealed: tuple[()]
# `tuple[int, ...] would probably also be fine here since `LiskovUncompliantIterable`
# inherits from `Iterable[int]`. Ultimately all bets are off when the Liskov Principle is
# violated, though -- this test is really just to make sure we don't crash in this situation.
reveal_type(tuple(bb)) # revealed: tuple[Unknown, ...]
reveal_type(tuple((1, 2))) # revealed: tuple[Literal[1], Literal[2]] reveal_type(tuple((1, 2))) # revealed: tuple[Literal[1], Literal[2]]
# TODO: should be `tuple[Literal[1], ...]` # TODO: should be `tuple[Literal[1], ...]`

View file

@ -1613,16 +1613,13 @@ impl<'db> Type<'db> {
callable.has_relation_to_impl(db, target, relation, visitor) callable.has_relation_to_impl(db, target, relation, visitor)
}), }),
(Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => left
.interface(db)
.extends_interface_of(db, right.interface(db), relation, visitor),
// A protocol instance can never be a subtype of a nominal type, with the *sole* exception of `object`.
(Type::ProtocolInstance(_), _) => C::unsatisfiable(db),
(_, Type::ProtocolInstance(protocol)) => { (_, Type::ProtocolInstance(protocol)) => {
self.satisfies_protocol(db, protocol, relation, visitor) self.satisfies_protocol(db, protocol, relation, visitor)
} }
// A protocol instance can never be a subtype of a nominal type, with the *sole* exception of `object`.
(Type::ProtocolInstance(_), _) => C::unsatisfiable(db),
// All `StringLiteral` types are a subtype of `LiteralString`. // All `StringLiteral` types are a subtype of `LiteralString`.
(Type::StringLiteral(_), Type::LiteralString) => C::always_satisfiable(db), (Type::StringLiteral(_), Type::LiteralString) => C::always_satisfiable(db),

View file

@ -1058,17 +1058,15 @@ impl<'db> Bindings<'db> {
// `tuple(range(42))` => `tuple[int, ...]` // `tuple(range(42))` => `tuple[int, ...]`
// BUT `tuple((1, 2))` => `tuple[Literal[1], Literal[2]]` rather than `tuple[Literal[1, 2], ...]` // BUT `tuple((1, 2))` => `tuple[Literal[1], Literal[2]]` rather than `tuple[Literal[1, 2], ...]`
if let [Some(argument)] = overload.parameter_types() { if let [Some(argument)] = overload.parameter_types() {
let Ok(tuple_spec) = argument.try_iterate(db) else { // We deliberately use `.iterate()` here (falling back to `Unknown` if it isn't iterable)
tracing::debug!( // rather than `.try_iterate().expect()`. Even though we know at this point that the input
"type" = %argument.display(db), // type is assignable to `Iterable`, that doesn't mean that the input type is *actually*
"try_iterate() should not fail on a type \ // iterable (it could be a Liskov-uncompliant subtype of the `Iterable` class that sets
assignable to `Iterable`", // `__iter__ = None`, for example). That would be badly written Python code, but we still
); // need to be able to handle it without crashing.
continue;
};
overload.set_return_type(Type::tuple(TupleType::new( overload.set_return_type(Type::tuple(TupleType::new(
db, db,
tuple_spec.as_ref(), &argument.iterate(db),
))); )));
} }
} }

View file

@ -107,6 +107,14 @@ impl<'db> Type<'db> {
relation: TypeRelation, relation: TypeRelation,
visitor: &HasRelationToVisitor<'db, C>, visitor: &HasRelationToVisitor<'db, C>,
) -> C { ) -> C {
let structurally_satisfied = if let Type::ProtocolInstance(self_protocol) = self {
self_protocol.interface(db).extends_interface_of(
db,
protocol.interface(db),
relation,
visitor,
)
} else {
protocol protocol
.inner .inner
.interface(db) .interface(db)
@ -114,6 +122,21 @@ impl<'db> Type<'db> {
.when_all(db, |member| { .when_all(db, |member| {
member.is_satisfied_by(db, self, relation, visitor) member.is_satisfied_by(db, self, relation, visitor)
}) })
};
// Even if `self` does not satisfy the protocol from a structural perspective,
// we may still need to consider it as satisfying the protocol if `protocol` is
// a class-based protocol and `self` has the protocol class in its MRO.
//
// This matches the behaviour of other type checkers, and is required for us to
// recognise `str` as a subtype of `Container[str]`.
structurally_satisfied.or(db, || {
if let Protocol::FromClass(class) = protocol.inner {
self.has_relation_to_impl(db, Type::non_tuple_instance(class), relation, visitor)
} else {
C::unsatisfiable(db)
}
})
} }
} }

View file

@ -320,6 +320,15 @@ mod flaky {
// iteration protocol as well as the new-style iteration protocol: not all objects that // iteration protocol as well as the new-style iteration protocol: not all objects that
// we consider iterable are assignable to `Iterable[object]`. // we consider iterable are assignable to `Iterable[object]`.
// //
// Note also that (like other property tests in this module),
// this invariant will only hold true for Liskov-compliant types assignable to `Iterable`.
// Since protocols can participate in nominal assignability/subtyping as well as
// structural assignability/subtyping, it is possible to construct types that a type
// checker must consider to be subtypes of `Iterable` even though they are not in fact
// iterable (as long as the user `type: ignore`s any type-checker errors stemming from
// the Liskov violation). All you need to do is to create a class that subclasses
// `Iterable` but assigns `__iter__ = None` in the class body (or similar).
//
// Currently flaky due to <https://github.com/astral-sh/ty/issues/889> // Currently flaky due to <https://github.com/astral-sh/ty/issues/889>
type_property_test!( type_property_test!(
all_type_assignable_to_iterable_are_iterable, db, all_type_assignable_to_iterable_are_iterable, db,