[ty] Rework disjointness of protocol instances vs types with possibly unbound attributes (#19043)

This commit is contained in:
Alex Waygood 2025-07-01 12:47:27 +01:00 committed by GitHub
parent c6fd11fe36
commit ebf59e2bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 207 additions and 52 deletions

View file

@ -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

View file

@ -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
// (<https://github.com/rust-lang/rust/issues/129967>)
(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)
)
})
}

View file

@ -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),
}
}

View file

@ -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<ProtocolMember<'a, 'db>> {
fn member_by_name<'a>(self, db: &'db dyn Db, name: &'a str) -> Option<ProtocolMember<'a, 'db>> {
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