diff --git a/crates/ty_python_semantic/resources/mdtest/mro.md b/crates/ty_python_semantic/resources/mdtest/mro.md index 7226e8ffa1..a4a9368aa0 100644 --- a/crates/ty_python_semantic/resources/mdtest/mro.md +++ b/crates/ty_python_semantic/resources/mdtest/mro.md @@ -165,7 +165,9 @@ from does_not_exist import DoesNotExist # error: [unresolved-import] reveal_type(DoesNotExist) # revealed: Unknown if hasattr(DoesNotExist, "__mro__"): - reveal_type(DoesNotExist) # revealed: Unknown & + # TODO: this should be `Unknown & ` or similar + # (The second part of the intersection is incorrectly simplified to `object` due to https://github.com/astral-sh/ty/issues/986) + reveal_type(DoesNotExist) # revealed: Unknown class Foo(DoesNotExist): ... # no error! reveal_type(Foo.__mro__) # revealed: tuple[, Unknown, ] diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 3da3126b16..b791c01627 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -985,6 +985,13 @@ from ty_extensions import is_equivalent_to static_assert(is_equivalent_to(UniversalSet, object)) ``` +and that therefore `Any` is a subtype of `UniversalSet` (in general, `Any` can *only* ever be a +subtype of `object` and types that are equivalent to `object`): + +```py +static_assert(is_subtype_of(Any, UniversalSet)) +``` + `object` is a subtype of certain other protocols too. Since all fully static types (whether nominal or structural) are subtypes of `object`, these protocols are also subtypes of `object`; and this means that these protocols are also equivalent to `UniversalSet` and `object`: @@ -995,6 +1002,10 @@ class SupportsStr(Protocol): static_assert(is_equivalent_to(SupportsStr, UniversalSet)) static_assert(is_equivalent_to(SupportsStr, object)) +static_assert(is_subtype_of(SupportsStr, UniversalSet)) +static_assert(is_subtype_of(UniversalSet, SupportsStr)) +static_assert(is_assignable_to(UniversalSet, SupportsStr)) +static_assert(is_assignable_to(SupportsStr, UniversalSet)) class SupportsClass(Protocol): @property @@ -1003,6 +1014,11 @@ class SupportsClass(Protocol): static_assert(is_equivalent_to(SupportsClass, UniversalSet)) static_assert(is_equivalent_to(SupportsClass, SupportsStr)) static_assert(is_equivalent_to(SupportsClass, object)) + +static_assert(is_subtype_of(SupportsClass, SupportsStr)) +static_assert(is_subtype_of(SupportsStr, SupportsClass)) +static_assert(is_assignable_to(SupportsStr, SupportsClass)) +static_assert(is_assignable_to(SupportsClass, SupportsStr)) ``` If a protocol contains members that are not defined on `object`, then that protocol will (like all @@ -1024,6 +1040,47 @@ static_assert(not is_assignable_to(HasX, Foo)) static_assert(not is_subtype_of(HasX, Foo)) ``` +Since `object` defines a `__hash__` method, this means that the standard-library `Hashable` protocol +is currently understood by ty as being equivalent to `object`, much like `SupportsStr` and +`UniversalSet` above: + +```py +from typing import Hashable + +static_assert(is_equivalent_to(object, Hashable)) +static_assert(is_assignable_to(object, Hashable)) +static_assert(is_subtype_of(object, Hashable)) +``` + +This means that any type considered assignable to `object` (which is all types) is considered by ty +to be assignable to `Hashable`. This avoids false positives on code like this: + +```py +from typing import Sequence +from ty_extensions import is_disjoint_from + +def takes_hashable_or_sequence(x: Hashable | list[Hashable]): ... + +takes_hashable_or_sequence(["foo"]) # fine +takes_hashable_or_sequence(None) # fine + +static_assert(not is_disjoint_from(list[str], Hashable | list[Hashable])) +static_assert(not is_disjoint_from(list[str], Sequence[Hashable])) + +static_assert(is_subtype_of(list[Hashable], Sequence[Hashable])) +static_assert(is_subtype_of(list[str], Sequence[Hashable])) +``` + +but means that ty currently does not detect errors on code like this, which is flagged by other type +checkers: + +```py +def needs_something_hashable(x: Hashable): + hash(x) + +needs_something_hashable([]) +``` + ## Diagnostics for protocols with invalid attribute members This is a short appendix to the previous section with the `snapshot-diagnostics` directive enabled @@ -2553,6 +2610,48 @@ class E[T: B](Protocol): ... x: E[D] ``` +### Recursive supertypes of `object` + +A recursive protocol can be a supertype of `object` (though it is hard to create such a protocol +without violating the Liskov Substitution Principle, since all protocols are also subtypes of +`object`): + +```py +from typing import Protocol +from ty_extensions import static_assert, is_subtype_of, is_equivalent_to, is_disjoint_from + +class HasRepr(Protocol): + # TODO: we should emit a diagnostic here complaining about a Liskov violation + # (it incompatibly overrides `__repr__` from `object`, a supertype of `HasRepr`) + def __repr__(self) -> object: ... + +class HasReprRecursive(Protocol): + # TODO: we should emit a diagnostic here complaining about a Liskov violation + # (it incompatibly overrides `__repr__` from `object`, a supertype of `HasReprRecursive`) + def __repr__(self) -> "HasReprRecursive": ... + +class HasReprRecursiveAndFoo(Protocol): + # TODO: we should emit a diagnostic here complaining about a Liskov violation + # (it incompatibly overrides `__repr__` from `object`, a supertype of `HasReprRecursiveAndFoo`) + def __repr__(self) -> "HasReprRecursiveAndFoo": ... + foo: int + +static_assert(is_subtype_of(object, HasRepr)) +static_assert(is_subtype_of(HasRepr, object)) +static_assert(is_equivalent_to(object, HasRepr)) +static_assert(not is_disjoint_from(HasRepr, object)) + +static_assert(is_subtype_of(object, HasReprRecursive)) +static_assert(is_subtype_of(HasReprRecursive, object)) +static_assert(is_equivalent_to(object, HasReprRecursive)) +static_assert(not is_disjoint_from(HasReprRecursive, object)) + +static_assert(not is_subtype_of(object, HasReprRecursiveAndFoo)) +static_assert(is_subtype_of(HasReprRecursiveAndFoo, object)) +static_assert(not is_equivalent_to(object, HasReprRecursiveAndFoo)) +static_assert(not is_disjoint_from(HasReprRecursiveAndFoo, object)) +``` + ## Meta-protocols Where `P` is a protocol type, a class object `N` can be said to inhabit the type `type[P]` if: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 2e0ef99873..3aa89193ed 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1397,6 +1397,9 @@ impl<'db> Type<'db> { (_, Type::NominalInstance(instance)) if instance.is_object(db) => { C::always_satisfiable(db) } + (_, Type::ProtocolInstance(target)) if target.is_equivalent_to_object(db) => { + C::always_satisfiable(db) + } // `Never` is the bottom type, the empty set. // It is a subtype of all other types. @@ -1610,9 +1613,10 @@ impl<'db> Type<'db> { callable.has_relation_to_impl(db, target, relation, visitor) }), - (Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => { - left.has_relation_to_impl(db, right, 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)) => { diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 8714c824aa..d37a7ada31 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -482,6 +482,43 @@ impl<'db> ProtocolInstanceType<'db> { } } + /// Return `true` if this protocol is a supertype of `object`. + /// + /// This indicates that the protocol represents the same set of possible runtime objects + /// as `object` (since `object` is the universal set of *all* possible runtime objects!). + /// Such a protocol is therefore an equivalent type to `object`, which would in fact be + /// normalised to `object`. + pub(super) fn is_equivalent_to_object(self, db: &'db dyn Db) -> bool { + #[salsa::tracked(cycle_fn=recover, cycle_initial=initial, heap_size=ruff_memory_usage::heap_size)] + fn inner<'db>(db: &'db dyn Db, protocol: ProtocolInstanceType<'db>, _: ()) -> bool { + Type::object(db) + .satisfies_protocol( + db, + protocol, + TypeRelation::Subtyping, + &HasRelationToVisitor::new(ConstraintSet::always_satisfiable(db)), + ) + .is_always_satisfied(db) + } + + #[expect(clippy::trivially_copy_pass_by_ref)] + fn recover<'db>( + _db: &'db dyn Db, + _result: &bool, + _count: u32, + _value: ProtocolInstanceType<'db>, + _: (), + ) -> salsa::CycleRecoveryAction { + salsa::CycleRecoveryAction::Iterate + } + + fn initial<'db>(_db: &'db dyn Db, _value: ProtocolInstanceType<'db>, _: ()) -> bool { + true + } + + inner(db, self, ()) + } + /// Return a "normalized" version of this `Protocol` type. /// /// See [`Type::normalized`] for more details. @@ -497,17 +534,8 @@ impl<'db> ProtocolInstanceType<'db> { db: &'db dyn Db, visitor: &NormalizedVisitor<'db>, ) -> Type<'db> { - let object = Type::object(db); - if object - .satisfies_protocol( - db, - self, - TypeRelation::Subtyping, - &HasRelationToVisitor::new(ConstraintSet::always_satisfiable(db)), - ) - .is_always_satisfied(db) - { - return object; + if self.is_equivalent_to_object(db) { + return Type::object(db); } match self.inner { Protocol::FromClass(_) => Type::ProtocolInstance(Self::synthesized( @@ -517,22 +545,6 @@ impl<'db> ProtocolInstanceType<'db> { } } - /// Return `true` if this protocol type has the given type relation to the protocol `other`. - /// - /// TODO: consider the types of the members as well as their existence - pub(super) fn has_relation_to_impl>( - self, - db: &'db dyn Db, - other: Self, - _relation: TypeRelation, - visitor: &HasRelationToVisitor<'db, C>, - ) -> C { - other - .inner - .interface(db) - .is_sub_interface_of(db, self.inner.interface(db), visitor) - } - /// Return `true` if this protocol type is equivalent to the protocol `other`. /// /// TODO: consider the types of the members as well as their existence diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 6b27905a80..3705990d1c 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -230,19 +230,21 @@ impl<'db> ProtocolInterface<'db> { .unwrap_or_else(|| Type::object(db).member(db, name)) } - /// Return `true` if if all members on `self` are also members of `other`. + /// Return `true` if `self` extends the interface of `other`, i.e., + /// all members on `other` are also members of `self`. /// /// TODO: this method should consider the types of the members as well as their names. - pub(super) fn is_sub_interface_of>( + pub(super) fn extends_interface_of>( self, db: &'db dyn Db, other: Self, + _relation: TypeRelation, _visitor: &HasRelationToVisitor<'db, C>, ) -> C { // TODO: This could just return a bool as written, but this form is what will be needed to // combine the constraints when we do assignability checks on each member. - self.inner(db).keys().when_all(db, |member_name| { - C::from_bool(db, other.inner(db).contains_key(member_name)) + other.inner(db).keys().when_all(db, |member_name| { + C::from_bool(db, self.inner(db).contains_key(member_name)) }) }