Skip to content

Commit e78054d

Browse files
committed
[ty] Do not consider a type T to satisfy a method member on a protocol unless the method is available on the meta-type of T
1 parent e16473d commit e78054d

File tree

5 files changed

+112
-19
lines changed

5 files changed

+112
-19
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from unresolved_module import SomethingUnknown
2+
3+
class Foo(SomethingUnknown): ...
4+
5+
tuple(Foo)

crates/ty_python_semantic/resources/mdtest/loops/for.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,3 +747,42 @@ def f(never: Never):
747747
for x in never:
748748
reveal_type(x) # revealed: Never
749749
```
750+
751+
## A class literal is iterable if it inherits from `Any`
752+
753+
A class literal can be iterated over if it has `Any` or `Unknown` in its MRO, since the
754+
`Any`/`Unknown` element in the MRO could materialize to a class with a custom metaclass that defines
755+
`__iter__` for all instances of the metaclass:
756+
757+
```py
758+
from unresolved_module import SomethingUnknown # error: [unresolved-import]
759+
from typing import Any, Iterable
760+
from ty_extensions import static_assert, is_assignable_to, TypeOf, Unknown
761+
762+
class Foo(SomethingUnknown): ...
763+
764+
reveal_type(Foo.__mro__) # revealed: tuple[<class 'Foo'>, Unknown, <class 'object'>]
765+
766+
# TODO: these should pass
767+
static_assert(is_assignable_to(TypeOf[Foo], Iterable[Unknown])) # error: [static-assert-error]
768+
static_assert(is_assignable_to(type[Foo], Iterable[Unknown])) # error: [static-assert-error]
769+
770+
# TODO: should not error
771+
# error: [not-iterable]
772+
for x in Foo:
773+
reveal_type(x) # revealed: Unknown
774+
775+
class Bar(Any): ...
776+
777+
reveal_type(Bar.__mro__) # revealed: tuple[<class 'Bar'>, Any, <class 'object'>]
778+
779+
# TODO: these should pass
780+
static_assert(is_assignable_to(TypeOf[Bar], Iterable[Any])) # error: [static-assert-error]
781+
static_assert(is_assignable_to(type[Bar], Iterable[Any])) # error: [static-assert-error]
782+
783+
# TODO: should not error
784+
# error: [not-iterable]
785+
for x in Bar:
786+
# TODO: should reveal `Any`
787+
reveal_type(x) # revealed: Unknown
788+
```

crates/ty_python_semantic/resources/mdtest/protocols.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,48 @@ static_assert(is_subtype_of(NominalSubtype, P))
14601460
static_assert(not is_subtype_of(NotSubtype, P)) # error: [static-assert-error]
14611461
```
14621462

1463+
A callable instance attribute is not sufficient for a type to satisfy a protocol with a method
1464+
member: a method member specified by a protocol `P` must exist on the *meta-type* of `T` for `T` to
1465+
be a subtype of `P`:
1466+
1467+
```py
1468+
from typing import Callable, Protocol
1469+
from ty_extensions import static_assert, is_assignable_to
1470+
1471+
class SupportsFooMethod(Protocol):
1472+
def foo(self): ...
1473+
1474+
class SupportsFooAttr(Protocol):
1475+
foo: Callable[..., object]
1476+
1477+
class Foo:
1478+
def __init__(self):
1479+
self.foo: Callable[..., object] = lambda *args, **kwargs: None
1480+
1481+
static_assert(not is_assignable_to(Foo, SupportsFooMethod))
1482+
static_assert(is_assignable_to(Foo, SupportsFooAttr))
1483+
```
1484+
1485+
The reason for this is that some methods, such as dunder methods, are always looked up on the class
1486+
directly. If a class with an `__iter__` instance attribute satisfied the `Iterable` protocol, for
1487+
example, the `Iterable` protocol would not accurately describe the requirements Python has for a
1488+
class to be iterable at runtime. Allowing callable instance attributes to satisfy method members of
1489+
protocols would also make `issubclass()` narrowing of runtime-checkable protocols unsound, as the
1490+
`issubclass()` mechanism at runtime for protocols only checks whether a method is accessible on the
1491+
class object, not the instance. (Protocols with non-method members cannot be passed to
1492+
`issubclass()` at all at runtime.)
1493+
1494+
```py
1495+
from typing import Iterable, Any
1496+
from ty_extensions import static_assert, is_assignable_to
1497+
1498+
class Foo:
1499+
def __init__(self):
1500+
self.__iter__: Callable[..., object] = lambda *args, **kwargs: None
1501+
1502+
static_assert(not is_assignable_to(Foo, Iterable[Any]))
1503+
```
1504+
14631505
## Equivalence of protocols with method members
14641506

14651507
Two protocols `P1` and `P2`, both with a method member `x`, are considered equivalent if the

crates/ty_python_semantic/src/types/property_tests.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ macro_rules! type_property_test {
6868

6969
mod stable {
7070
use super::union;
71-
use crate::types::{CallableType, Type};
71+
use crate::types::{CallableType, KnownClass, Type};
7272

7373
// Reflexivity: `T` is equivalent to itself.
7474
type_property_test!(
@@ -205,6 +205,16 @@ mod stable {
205205
all_fully_static_type_pairs_are_subtype_of_their_union, db,
206206
forall fully_static_types s, t. s.is_subtype_of(db, union(db, [s, t])) && t.is_subtype_of(db, union(db, [s, t]))
207207
);
208+
209+
// Any type assignable to `Iterable[object]` should be considered iterable.
210+
//
211+
// Note that the inverse is not true, due to the fact that we recognize the old-style
212+
// iteration protocol as well as the new-style iteration protocol: not all objects that
213+
// we consider iterable are assignable to `Iterable[object]`.
214+
type_property_test!(
215+
all_type_assignable_to_iterable_are_iterable, db,
216+
forall types t. t.is_assignable_to(db, KnownClass::Iterable.to_specialized_instance(db, [Type::object(db)])) => t.try_iterate(db).is_ok()
217+
);
208218
}
209219

210220
/// This module contains property tests that currently lead to many false positives.
@@ -218,7 +228,6 @@ mod flaky {
218228
use itertools::Itertools;
219229

220230
use super::{intersection, union};
221-
use crate::types::{KnownClass, Type};
222231

223232
// Negating `T` twice is equivalent to `T`.
224233
type_property_test!(
@@ -312,14 +321,4 @@ mod flaky {
312321
bottom_materialization_of_type_is_assigneble_to_type, db,
313322
forall types t. t.bottom_materialization(db).is_assignable_to(db, t)
314323
);
315-
316-
// Any type assignable to `Iterable[object]` should be considered iterable.
317-
//
318-
// Note that the inverse is not true, due to the fact that we recognize the old-style
319-
// iteration protocol as well as the new-style iteration protocol: not all objects that
320-
// we consider iterable are assignable to `Iterable[object]`.
321-
type_property_test!(
322-
all_type_assignable_to_iterable_are_iterable, db,
323-
forall types t. t.is_assignable_to(db, KnownClass::Iterable.to_specialized_instance(db, [Type::object(db)])) => t.try_iterate(db).is_ok()
324-
);
325324
}

crates/ty_python_semantic/src/types/protocol_class.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,15 +383,23 @@ impl<'a, 'db> ProtocolMember<'a, 'db> {
383383
other: Type<'db>,
384384
relation: TypeRelation,
385385
) -> bool {
386-
let Place::Type(attribute_type, Boundness::Bound) = other.member(db, self.name).place
387-
else {
388-
return false;
389-
};
390-
391386
match &self.kind {
392-
// TODO: consider the types of the attribute on `other` for property/method members
393-
ProtocolMemberKind::Method(_) | ProtocolMemberKind::Property(_) => true,
387+
// TODO: consider the types of the attribute on `other` for method members
388+
ProtocolMemberKind::Method(_) => matches!(
389+
other.to_meta_type(db).member(db, self.name).place,
390+
Place::Type(_, Boundness::Bound)
391+
),
392+
// TODO: consider the types of the attribute on `other` for property members
393+
ProtocolMemberKind::Property(_) => matches!(
394+
other.member(db, self.name).place,
395+
Place::Type(_, Boundness::Bound)
396+
),
394397
ProtocolMemberKind::Other(member_type) => {
398+
let Place::Type(attribute_type, Boundness::Bound) =
399+
other.member(db, self.name).place
400+
else {
401+
return false;
402+
};
395403
member_type.has_relation_to(db, attribute_type, relation)
396404
&& attribute_type.has_relation_to(db, *member_type, relation)
397405
}

0 commit comments

Comments
 (0)