mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-30 22:01:18 +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))
|
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...?
|
||||||
|
|
|
@ -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], ...]`
|
||||||
|
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue