Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
445ee31
[ty] Implement protocol property check
mtshiba Jun 29, 2025
7e349f1
Implement disjointness for property members
mtshiba Jun 30, 2025
a2168eb
Merge remote-tracking branch 'upstream/main' into protocol-property-c…
mtshiba Jul 1, 2025
6355389
Merge remote-tracking branch 'upstream/main' into protocol-property-c…
mtshiba Jul 3, 2025
a823074
refactor
mtshiba Jul 3, 2025
7e95c48
Merge remote-tracking branch 'upstream/main' into protocol-property-c…
mtshiba Jul 9, 2025
e3823da
refactor
mtshiba Jul 10, 2025
7d76688
Merge remote-tracking branch 'upstream/main' into protocol-property-c…
mtshiba Jul 18, 2025
8c5e8c3
Merge remote-tracking branch 'upstream/main' into protocol-property-c…
mtshiba Jul 21, 2025
94bfbf5
Update types.rs
mtshiba Jul 21, 2025
c3ad9b6
Merge remote-tracking branch 'upstream/main' into protocol-property-c…
mtshiba Aug 4, 2025
8f99377
Merge branch 'main' into protocol-property-check
mtshiba Aug 15, 2025
03e9c7b
Update crates/ty_python_semantic/src/types/protocol_class.rs
mtshiba Aug 16, 2025
889c43a
Update crates/ty_python_semantic/src/types/protocol_class.rs
mtshiba Aug 16, 2025
8f694a9
add `PropertyMember`
mtshiba Aug 16, 2025
b15509e
Avoid infinite recursion using `HasRelationToVisitor`
mtshiba Aug 16, 2025
66a0a1a
Fix incorrect shadowing of an argument passed to `IsDisjointVisitor::…
mtshiba Aug 16, 2025
ed4cc36
change the diagnostic message wording: `of` -> `on`
mtshiba Aug 16, 2025
4545bfb
Various improvements
AlexWaygood Aug 16, 2025
23f9644
Partially revert "Avoid infinite recursion using `HasRelationToVisitor`"
AlexWaygood Aug 16, 2025
489e3f5
put tests with the other tests
AlexWaygood Aug 16, 2025
2669197
more improvements
AlexWaygood Aug 16, 2025
c378287
use a `Result`
AlexWaygood Aug 16, 2025
386b011
fix inference of interfaces for protocols that extend other protocols
AlexWaygood Aug 16, 2025
8379673
cleanup
AlexWaygood Aug 16, 2025
b8ad92d
Merge branch 'main' into alex/protocol-property-check-2
AlexWaygood Aug 19, 2025
1bff6ca
Merge branch 'main' into alex/protocol-property-check-2
AlexWaygood Aug 27, 2025
f855a0d
Delete crates/ty_python_semantic/resources/corpus/protocol_property_c…
AlexWaygood Aug 27, 2025
d7f36cb
Merge branch 'main' into alex/protocol-property-check-2
AlexWaygood Aug 27, 2025
971156a
cleanup
AlexWaygood Aug 27, 2025
8c97325
Merge branch 'main' into alex/protocol-property-check-2
AlexWaygood Aug 27, 2025
d10ea72
cleanup
AlexWaygood Aug 27, 2025
94338fa
Merge branch 'main' into alex/protocol-property-check-2
AlexWaygood Aug 28, 2025
4a759b7
more variance
AlexWaygood Aug 28, 2025
f57ea60
Merge branch 'main' into alex/protocol-property-check-2
AlexWaygood Aug 29, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,14 @@ def f_okay(c: Callable[[], None]):
# error: [invalid-assignment] "Object of type `Literal["my_callable"]` is not assignable to attribute `__qualname__` on type `(() -> None) & <Protocol with members '__qualname__'>`"
c.__qualname__ = "my_callable"

result = getattr_static(c, "__qualname__")
reveal_type(result) # revealed: property
if isinstance(result, property) and result.fset:
c.__qualname__ = "my_callable" # okay
# TODO: should we have some way for users to narrow a read-only attribute
# into a writable attribute...? What would that look like? Something like this?
if (
hasattr(type(c), "__qualname__")
and isinstance(type(c).__qualname__, property)
and type(c).__qualname__.fset is not None
):
c.__qualname__ = "my_callable" # error: [invalid-assignment]
```

[gradual form]: https://typing.python.org/en/latest/spec/glossary.html#term-gradual-form
20 changes: 10 additions & 10 deletions crates/ty_python_semantic/resources/mdtest/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ c_instance.inferred_from_value = "value set on instance"
# This assignment is also fine:
c_instance.declared_and_bound = False

# error: [invalid-assignment] "Object of type `Literal["incompatible"]` is not assignable to attribute `declared_and_bound` of type `bool`"
# error: [invalid-assignment] "Object of type `Literal["incompatible"]` is not assignable to attribute `declared_and_bound` on type `bool`"
c_instance.declared_and_bound = "incompatible"

# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
Expand Down Expand Up @@ -92,7 +92,7 @@ reveal_type(C.declared_and_bound) # revealed: str | None

C.declared_and_bound = "overwritten on class"

# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `declared_and_bound` of type `str | None`"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `declared_and_bound` on type `str | None`"
c_instance.declared_and_bound = 1
```

Expand Down Expand Up @@ -704,7 +704,7 @@ c_instance.pure_class_variable1 = "value set on instance"

C.pure_class_variable1 = "overwritten on class"

# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `pure_class_variable1` of type `str`"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `pure_class_variable1` on type `str`"
C.pure_class_variable1 = 1

class Subclass(C):
Expand Down Expand Up @@ -1118,7 +1118,7 @@ def _(flag: bool):
reveal_type(C2.y) # revealed: int | str

C2.y = 100
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` of type `int | str`"
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` on type `int | str`"
C2.y = None
# TODO: should be an error, needs more sophisticated union handling in `validate_attribute_assignment`
C2.y = "problematic"
Expand All @@ -1138,7 +1138,7 @@ def _(flag: bool):
reveal_type(C3.y) # revealed: int | str

C3.y = 100
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` of type `int | str`"
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` on type `int | str`"
C3.y = None
# TODO: should be an error, needs more sophisticated union handling in `validate_attribute_assignment`
C3.y = "problematic"
Expand All @@ -1156,7 +1156,7 @@ def _(flag: bool):
reveal_type(C4.y) # revealed: int | str

C4.y = 100
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` of type `int | str`"
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` on type `int | str`"
C4.y = None
# TODO: should be an error, needs more sophisticated union handling in `validate_attribute_assignment`
C4.y = "problematic"
Expand Down Expand Up @@ -1253,7 +1253,7 @@ def _(flag: bool):
# see a type of `int | Any` above because we have the full union handling of possibly-unbound
# *instance* attributes.

# error: [invalid-assignment] "Object of type `Literal["a"]` is not assignable to attribute `x` of type `int`"
# error: [invalid-assignment] "Object of type `Literal["a"]` is not assignable to attribute `x` on type `int`"
Derived().x = "a"
```

Expand Down Expand Up @@ -1958,10 +1958,10 @@ import mod
reveal_type(mod.global_symbol) # revealed: str
mod.global_symbol = "b"

# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` of type `str`"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` on type `str`"
mod.global_symbol = 1

# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` of type `str`"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` on type `str`"
(_, mod.global_symbol) = (..., 1)

# TODO: this should be an error, but we do not understand list unpackings yet.
Expand All @@ -1975,7 +1975,7 @@ class IntIterable:
def __iter__(self) -> IntIterator:
return IntIterator()

# error: [invalid-assignment] "Object of type `int` is not assignable to attribute `global_symbol` of type `str`"
# error: [invalid-assignment] "Object of type `int` is not assignable to attribute `global_symbol` on type `str`"
for mod.global_symbol in IntIterable():
pass
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ class Foo:
foo = Foo(1)

reveal_type(foo.__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(type(foo).__dataclass_fields__) # revealed: dict[str, Field[Any]]
reveal_type(fields(Foo)) # revealed: tuple[Field[Any], ...]
reveal_type(asdict(foo)) # revealed: dict[str, Any]
```
Expand All @@ -918,8 +919,7 @@ reveal_type(fields(Foo)) # revealed: tuple[Field[Any], ...]
But calling `asdict` on the class object is not allowed:

```py
# TODO: this should be a invalid-argument-type error, but we don't properly check the
# types (and more importantly, the `ClassVar` type qualifier) of protocol members yet.
# error: [invalid-argument-type] "Argument to function `asdict` is incorrect: Expected `DataclassInstance`, found `<class 'Foo'>`"
asdict(Foo)
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ body, we do not allow these assignments, preventing users from accidentally over
descriptor, which is what would happen at runtime:

```py
# error: [invalid-assignment] "Object of type `Literal[10]` is not assignable to attribute `ten` of type `Ten`"
# error: [invalid-assignment] "Object of type `Literal[10]` is not assignable to attribute `ten` on type `Ten`"
C.ten = 10
# error: [invalid-assignment] "Object of type `Literal[11]` is not assignable to attribute `ten` of type `Ten`"
# error: [invalid-assignment] "Object of type `Literal[11]` is not assignable to attribute `ten` on type `Ten`"
C.ten = 11
```

Expand Down Expand Up @@ -213,7 +213,7 @@ reveal_type(C().ten) # revealed: Ten
C().ten = Ten()

# The instance attribute is declared as `Ten`, so this is an
# error: [invalid-assignment] "Object of type `Literal[10]` is not assignable to attribute `ten` of type `Ten`"
# error: [invalid-assignment] "Object of type `Literal[10]` is not assignable to attribute `ten` on type `Ten`"
C().ten = 10
```

Expand Down Expand Up @@ -280,7 +280,7 @@ overwrite the data descriptor, but the attribute is declared as `DataDescriptor`
so we do not allow this:

```py
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `class_data_descriptor` of type `DataDescriptor`"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `class_data_descriptor` on type `DataDescriptor`"
C1.class_data_descriptor = 1
```

Expand Down Expand Up @@ -372,7 +372,7 @@ def _(flag: bool):
# wrong, but they could be subsumed under a higher-level diagnostic.

# error: [invalid-assignment] "Invalid assignment to data descriptor attribute `meta_data_descriptor1` on type `<class 'C5'>` with custom `__set__` method"
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `meta_data_descriptor1` of type `Literal["value on class"]`"
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `meta_data_descriptor1` on type `Literal["value on class"]`"
C5.meta_data_descriptor1 = None

# error: [possibly-unbound-attribute]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ o = OptionalInt()
reveal_type(o.value)

# Incompatible assignments are now caught:
# error: "Object of type `Literal["a"]` is not assignable to attribute `value` of type `int | None`"
# error: "Object of type `Literal["a"]` is not assignable to attribute `value` on type `int | None`"
o.value = "a"
```

Expand Down
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/resources/mdtest/named_tuple.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ reveal_type(Person.id) # revealed: property
reveal_type(Person.name) # revealed: property
reveal_type(Person.age) # revealed: property

# error: [invalid-assignment] "Cannot assign to read-only property `id` on object of type `Person`"
# error: [invalid-assignment] "Attribute `id` on object of type `Person` is read-only"
alice.id = 42
# error: [invalid-assignment]
bob.age = None
Expand Down Expand Up @@ -218,7 +218,7 @@ james = SuperUser(0, "James", 42, "Jimmy")
# on the subclass
james.name = "Robert"

# error: [invalid-assignment] "Cannot assign to read-only property `nickname` on object of type `SuperUser`"
# error: [invalid-assignment] "Attribute `nickname` on object of type `SuperUser` is read-only"
james.nickname = "Bob"
```

Expand Down
84 changes: 40 additions & 44 deletions crates/ty_python_semantic/resources/mdtest/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ To see the kinds and types of the protocol members, you can use the debugging ai
from ty_extensions import reveal_protocol_interface
from typing import SupportsIndex, SupportsAbs, ClassVar, Iterator

# error: [revealed-type] "Revealed protocol interface: `{"method_member": MethodMember(`(self) -> bytes`), "x": AttributeMember(`int`), "y": PropertyMember { getter: `def y(self) -> str` }, "z": PropertyMember { getter: `def z(self) -> int`, setter: `def z(self, z: int) -> None` }}`"
# error: [revealed-type] "Revealed protocol interface: `{"method_member": MethodMember(`(self) -> bytes`), "x": AttributeMember(`int`), "y": PropertyMember { get_type: `str` }, "z": PropertyMember { get_type: `int`, set_type: `int` }}`"
reveal_protocol_interface(Foo)
# error: [revealed-type] "Revealed protocol interface: `{"__index__": MethodMember(`(self) -> int`)}`"
reveal_protocol_interface(SupportsIndex)
Expand Down Expand Up @@ -706,12 +706,13 @@ class HasClassVarX(Protocol):

static_assert(is_subtype_of(FooWithZero, HasClassVarX))
static_assert(is_assignable_to(FooWithZero, HasClassVarX))

# TODO: these should pass
static_assert(not is_subtype_of(Foo, HasClassVarX)) # error: [static-assert-error]
static_assert(not is_assignable_to(Foo, HasClassVarX)) # error: [static-assert-error]
static_assert(not is_subtype_of(Qux, HasClassVarX)) # error: [static-assert-error]
static_assert(not is_assignable_to(Qux, HasClassVarX)) # error: [static-assert-error]

static_assert(not is_subtype_of(Qux, HasClassVarX))
static_assert(not is_assignable_to(Qux, HasClassVarX))
static_assert(is_subtype_of(Sequence[Foo], Sequence[HasX]))
static_assert(is_assignable_to(Sequence[Foo], Sequence[HasX]))
static_assert(not is_subtype_of(list[Foo], list[HasX]))
Expand All @@ -731,16 +732,14 @@ class A:
def x(self) -> int:
return 42

# TODO: these should pass
static_assert(not is_subtype_of(A, HasX)) # error: [static-assert-error]
static_assert(not is_assignable_to(A, HasX)) # error: [static-assert-error]
static_assert(not is_subtype_of(A, HasX))
static_assert(not is_assignable_to(A, HasX))

class B:
x: Final = 42

# TODO: these should pass
static_assert(not is_subtype_of(A, HasX)) # error: [static-assert-error]
static_assert(not is_assignable_to(A, HasX)) # error: [static-assert-error]
static_assert(not is_subtype_of(A, HasX))
static_assert(not is_assignable_to(A, HasX))

class IntSub(int): ...

Expand Down Expand Up @@ -772,16 +771,14 @@ static_assert(is_assignable_to(MutableDataclass, HasX))
class ImmutableDataclass:
x: int

# TODO: these should pass
static_assert(not is_subtype_of(ImmutableDataclass, HasX)) # error: [static-assert-error]
static_assert(not is_assignable_to(ImmutableDataclass, HasX)) # error: [static-assert-error]
static_assert(not is_subtype_of(ImmutableDataclass, HasX))
static_assert(not is_assignable_to(ImmutableDataclass, HasX))

class NamedTupleWithX(NamedTuple):
x: int

# TODO: these should pass
static_assert(not is_subtype_of(NamedTupleWithX, HasX)) # error: [static-assert-error]
static_assert(not is_assignable_to(NamedTupleWithX, HasX)) # error: [static-assert-error]
static_assert(not is_subtype_of(NamedTupleWithX, HasX))
static_assert(not is_assignable_to(NamedTupleWithX, HasX))
```

However, a type with a read-write property `x` *does* satisfy the `HasX` protocol. The `HasX`
Expand Down Expand Up @@ -1401,9 +1398,8 @@ class PropertyX:
def x(self) -> int:
return 42

# TODO: these should pass
static_assert(not is_assignable_to(PropertyX, ClassVarXProto)) # error: [static-assert-error]
static_assert(not is_subtype_of(PropertyX, ClassVarXProto)) # error: [static-assert-error]
static_assert(not is_assignable_to(PropertyX, ClassVarXProto))
static_assert(not is_subtype_of(PropertyX, ClassVarXProto))

class ClassVarX:
x: ClassVar[int] = 42
Expand Down Expand Up @@ -1512,9 +1508,8 @@ class XReadProperty:
def x(self) -> int:
return 42

# TODO: these should pass
static_assert(not is_subtype_of(XReadProperty, HasXProperty)) # error: [static-assert-error]
static_assert(not is_assignable_to(XReadProperty, HasXProperty)) # error: [static-assert-error]
static_assert(is_subtype_of(XReadProperty, HasXProperty))
static_assert(is_assignable_to(XReadProperty, HasXProperty))

class XReadWriteProperty:
@property
Expand Down Expand Up @@ -1598,9 +1593,8 @@ class MyIntSub(MyInt):
class XAttrSubSub:
x: MyIntSub

# TODO: should pass
static_assert(not is_subtype_of(XAttrSubSub, HasAsymmetricXProperty)) # error: [static-assert-error]
static_assert(not is_assignable_to(XAttrSubSub, HasAsymmetricXProperty)) # error: [static-assert-error]
static_assert(not is_subtype_of(XAttrSubSub, HasAsymmetricXProperty))
static_assert(not is_assignable_to(XAttrSubSub, HasAsymmetricXProperty))
```

An asymmetric property on a protocol can also be satisfied by an asymmetric property on a nominal
Expand Down Expand Up @@ -1649,17 +1643,15 @@ class HasGetAttr:
static_assert(is_subtype_of(HasGetAttr, HasXProperty))
static_assert(is_assignable_to(HasGetAttr, HasXProperty))

# TODO: these should pass
static_assert(not is_subtype_of(HasGetAttr, HasMutableXAttr)) # error: [static-assert-error]
static_assert(not is_subtype_of(HasGetAttr, HasMutableXAttr)) # error: [static-assert-error]
static_assert(not is_subtype_of(HasGetAttr, HasMutableXAttr))
static_assert(not is_subtype_of(HasGetAttr, HasMutableXAttr))

class HasGetAttrWithUnsuitableReturn:
def __getattr__(self, attr: str) -> tuple[int, int]:
return (1, 2)

# TODO: these should pass
static_assert(not is_subtype_of(HasGetAttrWithUnsuitableReturn, HasXProperty)) # error: [static-assert-error]
static_assert(not is_assignable_to(HasGetAttrWithUnsuitableReturn, HasXProperty)) # error: [static-assert-error]
static_assert(not is_subtype_of(HasGetAttrWithUnsuitableReturn, HasXProperty))
static_assert(not is_assignable_to(HasGetAttrWithUnsuitableReturn, HasXProperty))

class HasGetAttrAndSetAttr:
def __getattr__(self, attr: str) -> MyInt:
Expand All @@ -1670,19 +1662,17 @@ class HasGetAttrAndSetAttr:
static_assert(is_subtype_of(HasGetAttrAndSetAttr, HasXProperty))
static_assert(is_assignable_to(HasGetAttrAndSetAttr, HasXProperty))

# TODO: these should pass
static_assert(is_subtype_of(HasGetAttrAndSetAttr, XAsymmetricProperty)) # error: [static-assert-error]
static_assert(is_assignable_to(HasGetAttrAndSetAttr, XAsymmetricProperty)) # error: [static-assert-error]
static_assert(is_subtype_of(HasGetAttrAndSetAttr, HasAsymmetricXProperty))
static_assert(is_assignable_to(HasGetAttrAndSetAttr, HasAsymmetricXProperty))

class HasSetAttrWithUnsuitableInput:
def __getattr__(self, attr: str) -> int:
return 1

def __setattr__(self, attr: str, value: str) -> None: ...

# TODO: these should pass
static_assert(not is_subtype_of(HasSetAttrWithUnsuitableInput, HasMutableXProperty)) # error: [static-assert-error]
static_assert(not is_assignable_to(HasSetAttrWithUnsuitableInput, HasMutableXProperty)) # error: [static-assert-error]
static_assert(not is_subtype_of(HasSetAttrWithUnsuitableInput, HasMutableXProperty))
static_assert(not is_assignable_to(HasSetAttrWithUnsuitableInput, HasMutableXProperty))
```

## Subtyping of protocols with method members
Expand Down Expand Up @@ -1789,9 +1779,21 @@ from ty_extensions import is_equivalent_to, static_assert

class P1(Protocol):
def x(self, y: int) -> None: ...
@property
def y(self) -> str: ...
@property
def z(self) -> bytes: ...
@z.setter
def z(self, value: int) -> None: ...

class P2(Protocol):
def x(self, y: int) -> None: ...
@property
def y(self) -> str: ...
@property
def z(self) -> bytes: ...
@z.setter
def z(self, value: int) -> None: ...

class P3(Protocol):
@property
Expand All @@ -1810,9 +1812,7 @@ class P4(Protocol):
def z(self, value: int) -> None: ...

static_assert(is_equivalent_to(P1, P2))

# TODO: should pass
static_assert(is_equivalent_to(P3, P4)) # error: [static-assert-error]
static_assert(is_equivalent_to(P3, P4))
```

As with protocols that only have non-method members, this also holds true when they appear in
Expand All @@ -1823,9 +1823,7 @@ class A: ...
class B: ...

static_assert(is_equivalent_to(A | B | P1, P2 | B | A))

# TODO: should pass
static_assert(is_equivalent_to(A | B | P3, P4 | B | A)) # error: [static-assert-error]
static_assert(is_equivalent_to(A | B | P3, P4 | B | A))
```

## Narrowing of protocols
Expand Down Expand Up @@ -2106,8 +2104,6 @@ class Bar(Protocol):
@property
def x(self) -> "Bar": ...

# TODO: this should pass
# error: [static-assert-error]
static_assert(is_equivalent_to(Foo, Bar))

T = TypeVar("T", bound="TypeVarRecursive")
Expand Down
Loading
Loading