diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index c30f0ebbc7..6f18000bb3 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -964,6 +964,121 @@ def _(arg1: Intersection[HasX, NotFinalNominal], arg2: Intersection[HasX, FinalN reveal_type(arg2) # revealed: Never ``` +The disjointness of a single protocol member with the type of an attribute on another type is enough +to make the whole protocol disjoint from the other type, even if all other members on the protocol +are satisfied by the other type. This applies to both `@final` types and non-final types: + +```py +class Proto(Protocol): + x: int + y: str + z: bytes + +class Foo: + x: int + y: str + z: None + +static_assert(is_disjoint_from(Proto, Foo)) + +@final +class FinalFoo: + x: int + y: str + z: None + +static_assert(is_disjoint_from(Proto, FinalFoo)) +``` + +## Intersections of protocols with types that have possibly unbound attributes + +Note that if a `@final` class has a possibly unbound attribute corresponding to the protocol member, +instance types and class-literal types referring to that class cannot be a subtype of the protocol +but will also not be disjoint from the protocol: + +`a.py`: + +```py +from typing import final, ClassVar, Protocol +from ty_extensions import TypeOf, static_assert, is_subtype_of, is_disjoint_from, is_assignable_to + +def who_knows() -> bool: + return False + +@final +class Foo: + if who_knows(): + x: ClassVar[int] = 42 + +class HasReadOnlyX(Protocol): + @property + def x(self) -> int: ... + +static_assert(not is_subtype_of(Foo, HasReadOnlyX)) +static_assert(not is_assignable_to(Foo, HasReadOnlyX)) +static_assert(not is_disjoint_from(Foo, HasReadOnlyX)) + +static_assert(not is_subtype_of(type[Foo], HasReadOnlyX)) +static_assert(not is_assignable_to(type[Foo], HasReadOnlyX)) +static_assert(not is_disjoint_from(type[Foo], HasReadOnlyX)) + +static_assert(not is_subtype_of(TypeOf[Foo], HasReadOnlyX)) +static_assert(not is_assignable_to(TypeOf[Foo], HasReadOnlyX)) +static_assert(not is_disjoint_from(TypeOf[Foo], HasReadOnlyX)) +``` + +A similar principle applies to module-literal types that have possibly unbound attributes: + +`b.py`: + +```py +def who_knows() -> bool: + return False + +if who_knows(): + x: int = 42 +``` + +`c.py`: + +```py +import b +from a import HasReadOnlyX +from ty_extensions import TypeOf, static_assert, is_subtype_of, is_disjoint_from, is_assignable_to + +static_assert(not is_subtype_of(TypeOf[b], HasReadOnlyX)) +static_assert(not is_assignable_to(TypeOf[b], HasReadOnlyX)) +static_assert(not is_disjoint_from(TypeOf[b], HasReadOnlyX)) +``` + +If the possibly unbound attribute's type is disjoint from the type of the protocol member, though, +it is still disjoint from the protocol. This applies to both `@final` types and non-final types: + +`d.py`: + +```py +from a import HasReadOnlyX, who_knows +from typing import final, ClassVar, Protocol +from ty_extensions import static_assert, is_disjoint_from, TypeOf + +class Proto(Protocol): + x: int + +class Foo: + def __init__(self): + if who_knows(): + self.x: None = None + +@final +class FinalFoo: + def __init__(self): + if who_knows(): + self.x: None = None + +static_assert(is_disjoint_from(Foo, Proto)) +static_assert(is_disjoint_from(FinalFoo, Proto)) +``` + ## Satisfying a protocol's interface A type does not have to be an `Instance` type in order to be a subtype of a protocol. Other diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index b06799c238..55169291bf 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1698,6 +1698,20 @@ impl<'db> Type<'db> { /// Note: This function aims to have no false positives, but might return /// wrong `false` answers in some cases. pub(crate) fn is_disjoint_from(self, db: &'db dyn Db, other: Type<'db>) -> bool { + fn any_protocol_members_absent_or_disjoint<'db>( + db: &'db dyn Db, + protocol: ProtocolInstanceType<'db>, + other: Type<'db>, + ) -> bool { + protocol.interface(db).members(db).any(|member| { + other + .member(db, member.name()) + .place + .ignore_possibly_unbound() + .is_none_or(|attribute_type| member.has_disjoint_type_from(db, attribute_type)) + }) + } + match (self, other) { (Type::Never, _) | (_, Type::Never) => true, @@ -1864,6 +1878,57 @@ impl<'db> Type<'db> { Type::SubclassOf(_), ) => true, + (Type::AlwaysTruthy, ty) | (ty, Type::AlwaysTruthy) => { + // `Truthiness::Ambiguous` may include `AlwaysTrue` as a subset, so it's not guaranteed to be disjoint. + // Thus, they are only disjoint if `ty.bool() == AlwaysFalse`. + ty.bool(db).is_always_false() + } + (Type::AlwaysFalsy, ty) | (ty, Type::AlwaysFalsy) => { + // Similarly, they are only disjoint if `ty.bool() == AlwaysTrue`. + ty.bool(db).is_always_true() + } + + (Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => { + left.is_disjoint_from(db, right) + } + + (Type::ProtocolInstance(protocol), Type::SpecialForm(special_form)) + | (Type::SpecialForm(special_form), Type::ProtocolInstance(protocol)) => { + any_protocol_members_absent_or_disjoint(db, protocol, special_form.instance_fallback(db)) + } + + (Type::ProtocolInstance(protocol), Type::KnownInstance(known_instance)) + | (Type::KnownInstance(known_instance), Type::ProtocolInstance(protocol)) => { + any_protocol_members_absent_or_disjoint(db, protocol, known_instance.instance_fallback(db)) + } + + // The absence of a protocol member on one of these types guarantees + // that the type will be disjoint from the protocol, + // but the type will not be disjoint from the protocol if it has a member + // that is of the correct type but is possibly unbound. + // If accessing a member on this type returns a possibly unbound `Place`, + // the type will not be a subtype of the protocol but it will also not be + // disjoint from the protocol, since there are possible subtypes of the type + // that could satisfy the protocol. + // + // ```py + // class Foo: + // if coinflip(): + // X = 42 + // + // class HasX(Protocol): + // @property + // def x(self) -> int: ... + // + // # `TypeOf[Foo]` (a class-literal type) is not a subtype of `HasX`, + // # but `TypeOf[Foo]` & HasX` should not simplify to `Never`, + // # or this branch would be incorrectly understood to be unreachable, + // # since we would understand the type of `Foo` in this branch to be + // # `TypeOf[Foo] & HasX` due to `hasattr()` narrowing. + // + // if hasattr(Foo, "X"): + // print(Foo.X) + // ``` ( ty @ (Type::LiteralString | Type::StringLiteral(..) @@ -1887,51 +1952,24 @@ impl<'db> Type<'db> { | Type::ModuleLiteral(..) | Type::GenericAlias(..) | Type::IntLiteral(..)), - ) => !ty.satisfies_protocol(db, protocol, TypeRelation::Assignability), - - (Type::AlwaysTruthy, ty) | (ty, Type::AlwaysTruthy) => { - // `Truthiness::Ambiguous` may include `AlwaysTrue` as a subset, so it's not guaranteed to be disjoint. - // Thus, they are only disjoint if `ty.bool() == AlwaysFalse`. - ty.bool(db).is_always_false() - } - (Type::AlwaysFalsy, ty) | (ty, Type::AlwaysFalsy) => { - // Similarly, they are only disjoint if `ty.bool() == AlwaysTrue`. - ty.bool(db).is_always_true() - } - - (Type::ProtocolInstance(left), Type::ProtocolInstance(right)) => { - left.is_disjoint_from(db, right) - } - - (Type::ProtocolInstance(protocol), Type::SpecialForm(special_form)) - | (Type::SpecialForm(special_form), Type::ProtocolInstance(protocol)) => !special_form - .instance_fallback(db) - .satisfies_protocol(db, protocol, TypeRelation::Assignability), - - (Type::ProtocolInstance(protocol), Type::KnownInstance(known_instance)) - | (Type::KnownInstance(known_instance), Type::ProtocolInstance(protocol)) => { - !known_instance.instance_fallback(db).satisfies_protocol( - db, - protocol, - TypeRelation::Assignability, - ) - } + ) => any_protocol_members_absent_or_disjoint(db, protocol, ty), + // This is the same as the branch above -- + // once guard patterns are stabilised, it could be unified with that branch + // () (Type::ProtocolInstance(protocol), nominal @ Type::NominalInstance(n)) | (nominal @ Type::NominalInstance(n), Type::ProtocolInstance(protocol)) if n.class.is_final(db) => { - !nominal.satisfies_protocol(db, protocol, TypeRelation::Assignability) + any_protocol_members_absent_or_disjoint(db, protocol, nominal) } (Type::ProtocolInstance(protocol), other) | (other, Type::ProtocolInstance(protocol)) => { protocol.interface(db).members(db).any(|member| { - // TODO: implement disjointness for property/method members as well as attribute members - member.is_attribute_member() - && matches!( + matches!( other.member(db, member.name()).place, - Place::Type(ty, Boundness::Bound) if ty.is_disjoint_from(db, member.ty()) + Place::Type(attribute_type, _) if member.has_disjoint_type_from(db, attribute_type) ) }) } diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 6c65786429..f5038da6fc 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -4,7 +4,7 @@ use std::marker::PhantomData; use super::protocol_class::ProtocolInterface; use super::{ClassType, KnownClass, SubclassOfType, Type, TypeVarVariance}; -use crate::place::{Place, PlaceAndQualifiers}; +use crate::place::PlaceAndQualifiers; use crate::types::tuple::TupleType; use crate::types::{DynamicType, TypeMapping, TypeRelation, TypeVarInstance, TypeVisitor}; use crate::{Db, FxOrderSet}; @@ -272,14 +272,7 @@ impl<'db> ProtocolInstanceType<'db> { pub(crate) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { match self.inner { Protocol::FromClass(class) => class.instance_member(db, name), - Protocol::Synthesized(synthesized) => synthesized - .interface() - .member_by_name(db, name) - .map(|member| PlaceAndQualifiers { - place: Place::bound(member.ty()), - qualifiers: member.qualifiers(), - }) - .unwrap_or_else(|| KnownClass::Object.to_instance(db).instance_member(db, name)), + Protocol::Synthesized(synthesized) => synthesized.interface().instance_member(db, name), } } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index aadc54b210..5ac8324328 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -6,7 +6,7 @@ use ruff_python_ast::name::Name; use crate::{ Db, FxOrderSet, - place::{Boundness, Place, place_from_bindings, place_from_declarations}, + place::{Boundness, Place, PlaceAndQualifiers, place_from_bindings, place_from_declarations}, semantic_index::{place_table, use_def_map}, types::{ CallableType, ClassBase, ClassLiteral, KnownFunction, PropertyInstanceType, Signature, @@ -126,11 +126,7 @@ impl<'db> ProtocolInterface<'db> { }) } - pub(super) fn member_by_name<'a>( - self, - db: &'db dyn Db, - name: &'a str, - ) -> Option> { + fn member_by_name<'a>(self, db: &'db dyn Db, name: &'a str) -> Option> { self.inner(db).get(name).map(|data| ProtocolMember { name, kind: data.kind, @@ -138,6 +134,15 @@ impl<'db> ProtocolInterface<'db> { }) } + pub(super) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + self.member_by_name(db, name) + .map(|member| PlaceAndQualifiers { + place: Place::bound(member.ty()), + qualifiers: member.qualifiers(), + }) + .unwrap_or_else(|| Type::object(db).instance_member(db, name)) + } + /// Return `true` if if all members on `self` are also members of `other`. /// /// TODO: this method should consider the types of the members as well as their names. @@ -328,7 +333,7 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { self.qualifiers } - pub(super) fn ty(&self) -> Type<'db> { + fn ty(&self) -> Type<'db> { match &self.kind { ProtocolMemberKind::Method(callable) => *callable, ProtocolMemberKind::Property(property) => Type::PropertyInstance(*property), @@ -336,8 +341,12 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { } } - pub(super) const fn is_attribute_member(&self) -> bool { - matches!(self.kind, ProtocolMemberKind::Other(_)) + pub(super) fn has_disjoint_type_from(&self, db: &'db dyn Db, other: Type<'db>) -> bool { + match &self.kind { + // TODO: implement disjointness for property/method members as well as attribute members + ProtocolMemberKind::Property(_) | ProtocolMemberKind::Method(_) => false, + ProtocolMemberKind::Other(ty) => ty.is_disjoint_from(db, other), + } } /// Return `true` if `other` contains an attribute/method/property that satisfies