Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/ty_python_semantic/resources/mdtest/mro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 & <Protocol with members '__mro__'>
# TODO: this should be `Unknown & <Protocol with members '__mro__'>` 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[<class 'Foo'>, Unknown, <class 'object'>]
Expand Down
99 changes: 99 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -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
Expand All @@ -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
Expand 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
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)) => {
Expand Down
66 changes: 39 additions & 27 deletions crates/ty_python_semantic/src/types/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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.
Expand All @@ -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(
Expand All @@ -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<C: Constraints<'db>>(
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
Expand Down
10 changes: 6 additions & 4 deletions crates/ty_python_semantic/src/types/protocol_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C: Constraints<'db>>(
pub(super) fn extends_interface_of<C: Constraints<'db>>(
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))
})
}

Expand Down
Loading