mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-29 21:34:57 +00:00
[ty] Allow protocols to participate in nominal subtyping as well as structural subtyping (#20314)
This commit is contained in:
parent
4de7d653bd
commit
fd7eb1e22f
6 changed files with 118 additions and 23 deletions
|
@ -2220,6 +2220,65 @@ static_assert(is_subtype_of(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
|
||||
|
||||
It *might* be possible to have a singleton protocol-instance type...?
|
||||
|
|
|
@ -36,7 +36,11 @@ reveal_type(tuple[int, *tuple[str, ...]]((1,))) # revealed: tuple[int, *tuple[s
|
|||
reveal_type(().__class__()) # revealed: tuple[()]
|
||||
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(y)) # revealed: tuple[str, ...]
|
||||
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
|
||||
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]]
|
||||
|
||||
# TODO: should be `tuple[Literal[1], ...]`
|
||||
|
|
|
@ -1613,16 +1613,13 @@ impl<'db> Type<'db> {
|
|||
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)) => {
|
||||
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`.
|
||||
(Type::StringLiteral(_), Type::LiteralString) => C::always_satisfiable(db),
|
||||
|
||||
|
|
|
@ -1058,17 +1058,15 @@ impl<'db> Bindings<'db> {
|
|||
// `tuple(range(42))` => `tuple[int, ...]`
|
||||
// BUT `tuple((1, 2))` => `tuple[Literal[1], Literal[2]]` rather than `tuple[Literal[1, 2], ...]`
|
||||
if let [Some(argument)] = overload.parameter_types() {
|
||||
let Ok(tuple_spec) = argument.try_iterate(db) else {
|
||||
tracing::debug!(
|
||||
"type" = %argument.display(db),
|
||||
"try_iterate() should not fail on a type \
|
||||
assignable to `Iterable`",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// We deliberately use `.iterate()` here (falling back to `Unknown` if it isn't iterable)
|
||||
// rather than `.try_iterate().expect()`. Even though we know at this point that the input
|
||||
// type is assignable to `Iterable`, that doesn't mean that the input type is *actually*
|
||||
// iterable (it could be a Liskov-uncompliant subtype of the `Iterable` class that sets
|
||||
// `__iter__ = None`, for example). That would be badly written Python code, but we still
|
||||
// need to be able to handle it without crashing.
|
||||
overload.set_return_type(Type::tuple(TupleType::new(
|
||||
db,
|
||||
tuple_spec.as_ref(),
|
||||
&argument.iterate(db),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,13 +107,36 @@ impl<'db> Type<'db> {
|
|||
relation: TypeRelation,
|
||||
visitor: &HasRelationToVisitor<'db, C>,
|
||||
) -> C {
|
||||
protocol
|
||||
.inner
|
||||
.interface(db)
|
||||
.members(db)
|
||||
.when_all(db, |member| {
|
||||
member.is_satisfied_by(db, self, relation, visitor)
|
||||
})
|
||||
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
|
||||
.inner
|
||||
.interface(db)
|
||||
.members(db)
|
||||
.when_all(db, |member| {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -320,6 +320,15 @@ mod flaky {
|
|||
// iteration protocol as well as the new-style iteration protocol: not all objects that
|
||||
// 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>
|
||||
type_property_test!(
|
||||
all_type_assignable_to_iterable_are_iterable, db,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue