diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index a074e19fb08d7..e57f5a270267c 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -1534,7 +1534,7 @@ fn hydra(criterion: &mut Criterion) { max_dep_date: TY_ECOSYSTEM_PIN, python_version: SupportedPythonVersion::Py311, }, - 510, + 520, ); bench_project(&benchmark, criterion); diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 66e72d320060a..a284a3e8a1205 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -775,6 +775,7 @@ assigned a value in their scope. A `Final` symbol must be initialized with a value at the time of declaration or in a subsequent assignment. At module or function scope, the assignment must occur in the same scope. In a class body, the assignment may occur in `__init__`. +Protocol members are declarations of an interface and do not require a value. **Examples** @@ -4184,8 +4185,8 @@ Checks for redundant combinations of the `ClassVar` and `Final` type qualifiers. An attribute that is marked `Final` in a class body is implicitly a class variable. Marking it as `ClassVar` is therefore redundant. -Note that this diagnostic is not emitted for dataclass fields, where -`ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. +Note that this diagnostic is not emitted for dataclass fields or protocol members, +where `ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. **Examples** diff --git a/crates/ty_python_semantic/resources/lint_docs/final-without-value.md b/crates/ty_python_semantic/resources/lint_docs/final-without-value.md index 98e3ff5a83d0d..d788e852291f0 100644 --- a/crates/ty_python_semantic/resources/lint_docs/final-without-value.md +++ b/crates/ty_python_semantic/resources/lint_docs/final-without-value.md @@ -8,6 +8,7 @@ assigned a value in their scope. A `Final` symbol must be initialized with a value at the time of declaration or in a subsequent assignment. At module or function scope, the assignment must occur in the same scope. In a class body, the assignment may occur in `__init__`. +Protocol members are declarations of an interface and do not require a value. ## Examples diff --git a/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md b/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md index 258f986979668..3a2e63de25e74 100644 --- a/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md +++ b/crates/ty_python_semantic/resources/lint_docs/redundant-final-classvar.md @@ -7,8 +7,8 @@ Checks for redundant combinations of the `ClassVar` and `Final` type qualifiers. An attribute that is marked `Final` in a class body is implicitly a class variable. Marking it as `ClassVar` is therefore redundant. -Note that this diagnostic is not emitted for dataclass fields, where -`ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. +Note that this diagnostic is not emitted for dataclass fields or protocol members, +where `ClassVar[Final[int]]` has a distinct meaning from `Final[int]`. ## Examples diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md index d23934d62450b..51458742b393a 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/callable.md @@ -488,8 +488,6 @@ If users want to read/write to attributes such as `__qualname__`, they need to c of the attribute first: ```py -from inspect import getattr_static - def f_okay(c: Callable[[], None]): if hasattr(c, "__qualname__"): reveal_type(c.__qualname__) # revealed: object @@ -503,10 +501,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) & `" 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] ``` ## From a class diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md index 27fb73c417296..b28d347e893a3 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md @@ -1945,6 +1945,23 @@ def _(obj: BasicAlias): obj.a = 3 # error: [invalid-assignment] ``` +A converted field satisfies a writable property protocol using the converter input type as its write +type: + +```py +from typing import Protocol +from ty_extensions import is_assignable_to, is_subtype_of, static_assert + +class HasConvertedField(Protocol): + @property + def a(self) -> int: ... + @a.setter + def a(self, value: str) -> None: ... + +static_assert(is_subtype_of(Basic, HasConvertedField)) +static_assert(is_assignable_to(Basic, HasConvertedField)) +``` + The default parameter for a converter field should also be verified against the converter's input type: diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 48ed34d7391c5..21860c9fec481 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -1781,6 +1781,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] ``` @@ -1801,8 +1802,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 ``" asdict(Foo) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 97306b483f77c..6eef10187d9df 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -472,9 +472,9 @@ 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 -# revealed: {"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` }} +# revealed: {"method_member": MethodMember(`(self, /) -> bytes`), "x": AttributeMember(`int`), "y": PropertyMember { read: `str` }, "z": PropertyMember { read: `int`, write: `int` }} reveal_protocol_interface(Foo) -# revealed: {"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` }} +# revealed: {"method_member": MethodMember(`(self, /) -> bytes`), "x": AttributeMember(`int`), "y": PropertyMember { read: `str` }, "z": PropertyMember { read: `int`, write: `int` }} reveal_protocol_interface(protocol=Foo) # revealed: {"__index__": MethodMember(`(self, /) -> int`)} reveal_protocol_interface(SupportsIndex) @@ -643,7 +643,7 @@ python-version = "3.12" ``` ```py -from typing import Protocol, Any, ClassVar +from typing import Protocol, Any, ClassVar, Final from collections.abc import Sequence from ty_extensions import static_assert, is_assignable_to, is_subtype_of @@ -751,7 +751,6 @@ class FooWithZero: static_assert(is_subtype_of(FooWithZero, HasXWithDefault)) static_assert(is_assignable_to(FooWithZero, HasXWithDefault)) - # TODO: whether or not any of these four assertions should pass is not clearly specified. # # A test in the typing conformance suite implies that they all should: @@ -775,12 +774,20 @@ 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)) + +class FinalClassVarX: + x: Final[int] = 0 + +# A mutable ClassVar protocol member requires a writable class attribute. +static_assert(not is_subtype_of(FinalClassVarX, HasClassVarX)) +static_assert(not is_assignable_to(FinalClassVarX, 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])) @@ -800,16 +807,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): ... @@ -841,16 +846,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` @@ -1055,15 +1058,15 @@ class AssignmentForms(Protocol): ```snapshot warning[ambiguous-protocol-member]: Cannot assign to an undeclared attribute in a protocol method - --> src/mdtest_snippet.py:321:9 + --> src/mdtest_snippet.py:324:9 | -321 | self.augmented += 1 # snapshot: ambiguous-protocol-member +324 | self.augmented += 1 # snapshot: ambiguous-protocol-member | ^^^^^^^^^^^^^^ `augmented` is not declared as a protocol member | info: Assigning to an undeclared attribute in a protocol method leads to an ambiguous interface - --> src/mdtest_snippet.py:313:7 + --> src/mdtest_snippet.py:316:7 | -313 | class AssignmentForms(Protocol): +316 | class AssignmentForms(Protocol): | ^^^^^^^^^^^^^^^^^^^^^^^^^ `AssignmentForms` declared as a protocol here | info: No declarations found for `augmented` in the body of `AssignmentForms` or any of its superclasses @@ -1814,8 +1817,8 @@ static_assert(is_assignable_to(UsesMeta, HasX)) # error: [static-assert-error] ## `ClassVar` attribute members If a protocol `ClassVarX` has a `ClassVar` attribute member `x` with type `int`, this indicates that -a readable `x` attribute must be accessible on any inhabitant of `ClassVarX`, and that a readable -`x` attribute must *also* be accessible on the *type* of that inhabitant: +the non-callable attribute must be readable with the same type through both an inhabitant of +`ClassVarX` and the type of that inhabitant: `classvars.py`: @@ -1843,9 +1846,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 @@ -1891,8 +1893,8 @@ A read-only property on a protocol can be satisfied by a mutable attribute, a re read/write property, a `Final` attribute, or a `ClassVar` attribute: ```py -from typing import ClassVar, Final, Protocol -from ty_extensions import is_subtype_of, is_assignable_to, static_assert +from typing import ClassVar, Final, Protocol, final +from ty_extensions import is_subtype_of, is_assignable_to, is_disjoint_from, static_assert class HasXProperty(Protocol): @property @@ -1912,6 +1914,12 @@ class XReadProperty: static_assert(is_subtype_of(XReadProperty, HasXProperty)) static_assert(is_assignable_to(XReadProperty, HasXProperty)) +@final +class FinalXReadProperty: + @property + def x(self) -> int: + return 42 + class XReadWriteProperty: @property def x(self) -> int: @@ -1952,10 +1960,9 @@ class HasStrXProperty(Protocol): @property def x(self) -> str: ... -# TODO: these should pass -static_assert(not is_assignable_to(XAttrBad, HasXProperty)) # error: [static-assert-error] -static_assert(not is_assignable_to(HasStrXProperty, HasXProperty)) # error: [static-assert-error] -static_assert(not is_assignable_to(HasXProperty, HasStrXProperty)) # error: [static-assert-error] +static_assert(not is_assignable_to(XAttrBad, HasXProperty)) +static_assert(not is_assignable_to(HasStrXProperty, HasXProperty)) +static_assert(not is_assignable_to(HasXProperty, HasStrXProperty)) ``` A read-only property on a protocol, unlike a mutable attribute, is covariant: `XSub` in the below @@ -1973,12 +1980,49 @@ static_assert(is_assignable_to(XSub, HasXProperty)) class XSubProto(Protocol): @property - def x(self) -> XSub: ... + def x(self) -> MyInt: ... static_assert(is_subtype_of(XSubProto, HasXProperty)) static_assert(is_assignable_to(XSubProto, HasXProperty)) ``` +A `Final` attribute on a protocol is also read-only: + +```py +class HasFinalX(Protocol): + # A Final protocol member is an instance declaration and does not require a value. + x: Final[int] + +class HasFinalClassVarX(Protocol): + # The ClassVar qualifier is meaningful in a protocol and should not trigger + # redundant-final-classvar. + x: ClassVar[Final[int]] + +static_assert(is_subtype_of(XFinal, HasFinalX)) +static_assert(is_assignable_to(XFinal, HasFinalX)) +static_assert(is_subtype_of(XReadProperty, HasFinalX)) +static_assert(is_assignable_to(XReadProperty, HasFinalX)) +static_assert(is_subtype_of(HasXProperty, HasFinalX)) +static_assert(is_assignable_to(HasXProperty, HasFinalX)) +static_assert(is_subtype_of(HasFinalClassVarX, HasFinalX)) +static_assert(is_assignable_to(HasFinalClassVarX, HasFinalX)) +static_assert(not is_subtype_of(HasFinalX, HasFinalClassVarX)) +static_assert(not is_assignable_to(HasFinalX, HasFinalClassVarX)) +static_assert(not is_subtype_of(XReadProperty, HasFinalClassVarX)) +static_assert(not is_assignable_to(XReadProperty, HasFinalClassVarX)) + +class MutableClassVarX: + x: int = 0 + +class FinalClassVarImplementation: + x: Final[int] = 0 + +static_assert(is_subtype_of(MutableClassVarX, HasFinalClassVarX)) +static_assert(is_assignable_to(MutableClassVarX, HasFinalClassVarX)) +static_assert(is_subtype_of(FinalClassVarImplementation, HasFinalClassVarX)) +static_assert(is_assignable_to(FinalClassVarImplementation, HasFinalClassVarX)) +``` + A read/write property on a protocol, where the getter returns the same type that the setter takes, is equivalent to a normal mutable attribute on a protocol. @@ -2000,9 +2044,8 @@ class XReadProperty: def x(self) -> int: return 42 -# TODO: these should pass -static_assert(not is_subtype_of(XReadProperty, HasMutableXProperty)) # error: [static-assert-error] -static_assert(not is_assignable_to(XReadProperty, HasMutableXProperty)) # error: [static-assert-error] +static_assert(not is_subtype_of(XReadProperty, HasMutableXProperty)) +static_assert(not is_assignable_to(XReadProperty, HasMutableXProperty)) class XReadWriteProperty: @property @@ -2018,9 +2061,8 @@ static_assert(is_assignable_to(XReadWriteProperty, HasMutableXProperty)) class XSub: x: MyInt -# TODO: these should pass -static_assert(not is_subtype_of(XSub, HasMutableXProperty)) # error: [static-assert-error] -static_assert(not is_assignable_to(XSub, HasMutableXProperty)) # error: [static-assert-error] +static_assert(not is_subtype_of(XSub, HasMutableXProperty)) +static_assert(not is_assignable_to(XSub, HasMutableXProperty)) ``` A protocol with a read/write property `x` is exactly equivalent to a protocol with a mutable @@ -2033,6 +2075,10 @@ class HasMutableXAttr(Protocol): x: int static_assert(is_equivalent_to(HasMutableXAttr, HasMutableXProperty)) +static_assert(not is_disjoint_from(FinalXReadProperty, HasXProperty)) +static_assert(is_disjoint_from(FinalXReadProperty, HasMutableXAttr)) +static_assert(not is_subtype_of(HasFinalX, HasMutableXAttr)) +static_assert(not is_assignable_to(HasFinalX, HasMutableXAttr)) static_assert(is_subtype_of(HasMutableXAttr, HasXProperty)) static_assert(is_assignable_to(HasMutableXAttr, HasXProperty)) @@ -2049,10 +2095,22 @@ static_assert(is_assignable_to(HasMutableXProperty, HasMutableXAttr)) class HasMutableXAttrWrongType(Protocol): x: str -# TODO: these should pass -static_assert(not is_assignable_to(HasMutableXAttrWrongType, HasXProperty)) # error: [static-assert-error] -static_assert(not is_assignable_to(HasMutableXAttrWrongType, HasMutableXProperty)) # error: [static-assert-error] -static_assert(not is_assignable_to(HasMutableXProperty, HasMutableXAttrWrongType)) # error: [static-assert-error] +static_assert(not is_assignable_to(HasMutableXAttrWrongType, HasXProperty)) +static_assert(not is_assignable_to(HasMutableXAttrWrongType, HasMutableXProperty)) +static_assert(not is_assignable_to(HasMutableXProperty, HasMutableXAttrWrongType)) +``` + +Literal values use their fallback instance type when checking writable property requirements: + +```py +class JustInt(Protocol): + @property + def __class__(self) -> type[int]: ... + @__class__.setter + def __class__(self, value: type[int]) -> None: ... + +int_value: JustInt = 1 +bool_value: JustInt = True # error: [invalid-assignment] ``` A read/write property on a protocol, where the setter accepts a subtype of the type returned by the @@ -2100,9 +2158,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 @@ -2120,6 +2177,25 @@ class XAsymmetricProperty: static_assert(is_subtype_of(XAsymmetricProperty, HasAsymmetricXProperty)) static_assert(is_assignable_to(XAsymmetricProperty, HasAsymmetricXProperty)) + +from typing import Any + +class ObjectReadAnyWriteProperty: + @property + def x(self) -> object: + return object() + + @x.setter + def x(self, value: Any) -> None: ... + +class HasObjectReadIntWriteProperty(Protocol): + @property + def x(self) -> object: ... + @x.setter + def x(self, value: int) -> None: ... + +static_assert(not is_subtype_of(ObjectReadAnyWriteProperty, HasObjectReadIntWriteProperty)) +static_assert(is_assignable_to(ObjectReadAnyWriteProperty, HasObjectReadIntWriteProperty)) ``` A custom descriptor attribute on the nominal class will also suffice: @@ -2136,6 +2212,64 @@ class XCustomDescriptor: static_assert(is_subtype_of(XCustomDescriptor, HasAsymmetricXProperty)) static_assert(is_assignable_to(XCustomDescriptor, HasAsymmetricXProperty)) + +from typing import overload + +class HasIntOrStrWriteProperty(Protocol): + @property + def x(self) -> object: ... + @x.setter + def x(self, value: int | str) -> None: ... + +class OverloadedSetterDescriptor: + def __get__(self, instance, owner) -> object: + return object() + + @overload + def __set__(self, instance, value: int) -> None: ... + @overload + def __set__(self, instance, value: str) -> None: ... + def __set__(self, instance, value: int | str) -> None: ... + +class ObjectReadOverloadedWriteDescriptor: + x: OverloadedSetterDescriptor = OverloadedSetterDescriptor() + +static_assert(is_subtype_of(ObjectReadOverloadedWriteDescriptor, HasIntOrStrWriteProperty)) +static_assert(is_assignable_to(ObjectReadOverloadedWriteDescriptor, HasIntOrStrWriteProperty)) + +class AnySetterDescriptor: + def __get__(self, instance, owner) -> object: + return object() + + def __set__(self, instance, value: Any) -> None: ... + +class ObjectReadAnyWriteDescriptor: + x: AnySetterDescriptor = AnySetterDescriptor() + +static_assert(not is_subtype_of(ObjectReadAnyWriteDescriptor, HasObjectReadIntWriteProperty)) +static_assert(is_assignable_to(ObjectReadAnyWriteDescriptor, HasObjectReadIntWriteProperty)) +``` + +A property's setter return type does not affect whether it satisfies a writable protocol member. +Ordinary assignment still reports an error if the setter never returns: + +```py +from typing_extensions import Never + +class TerminalPropertySetter: + @property + def x(self) -> int: + return 1 + + @x.setter + def x(self, value: int) -> Never: + raise RuntimeError + +static_assert(is_subtype_of(TerminalPropertySetter, HasMutableXProperty)) +static_assert(is_assignable_to(TerminalPropertySetter, HasMutableXProperty)) + +terminal_property = TerminalPropertySetter() +terminal_property.x = 1 # error: [invalid-assignment] ``` Moreover, a read-only property on a protocol can be satisfied by a nominal class that defines a @@ -2151,17 +2285,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: @@ -2172,6 +2304,15 @@ class HasGetAttrAndSetAttr: static_assert(is_subtype_of(HasGetAttrAndSetAttr, HasXProperty)) static_assert(is_assignable_to(HasGetAttrAndSetAttr, HasXProperty)) +class HasGetAttrAndAnySetAttr: + def __getattr__(self, attr: str) -> object: + return object() + + def __setattr__(self, attr: str, value: Any) -> None: ... + +static_assert(not is_subtype_of(HasGetAttrAndAnySetAttr, HasObjectReadIntWriteProperty)) +static_assert(is_assignable_to(HasGetAttrAndAnySetAttr, HasObjectReadIntWriteProperty)) + # 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] @@ -2182,9 +2323,128 @@ class HasSetAttrWithUnsuitableInput: 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)) + +class ExplicitXWithBroadSetAttr: + x: int + + def __setattr__(self, attr: str, value: object) -> None: ... + +class HasStringSetter(Protocol): + @property + def x(self) -> int: ... + @x.setter + def x(self, value: str) -> None: ... + +static_assert(not is_subtype_of(ExplicitXWithBroadSetAttr, HasStringSetter)) +static_assert(not is_assignable_to(ExplicitXWithBroadSetAttr, HasStringSetter)) + +explicit_x = ExplicitXWithBroadSetAttr() +explicit_x.x = "string" # error: [invalid-assignment] +``` + +Writable attributes annotated with `Self` are checked after binding `Self` to the implementation +type: + +```py +from typing_extensions import Self + +class WritableSelfAttr: + x: Self + +class RecursiveWritableSelfAttr(Protocol): + x: Self + +class HasWritableSelfAttr(Protocol): + @property + def x(self) -> WritableSelfAttr: ... + @x.setter + def x(self, value: WritableSelfAttr) -> None: ... + +static_assert(is_subtype_of(WritableSelfAttr, HasWritableSelfAttr)) +static_assert(is_assignable_to(WritableSelfAttr, HasWritableSelfAttr)) + +def _(value: WritableSelfAttr) -> None: + value.x = WritableSelfAttr() + +def assign_protocol_member(left: RecursiveWritableSelfAttr, right: RecursiveWritableSelfAttr) -> None: + left.x = right +``` + +Property members annotated with `Self` bind it to the implementation type: + +```py +class HasReadableSelfProperty(Protocol): + @property + def x(self) -> Self: ... + +class ReadableSelfProperty: + @property + def x(self) -> "ReadableSelfProperty": + return self + +static_assert(is_subtype_of(ReadableSelfProperty, HasReadableSelfProperty)) +static_assert(is_assignable_to(ReadableSelfProperty, HasReadableSelfProperty)) + +class HasWritableSelfProperty(Protocol): + @property + def x(self) -> object: ... + @x.setter + def x(self, value: Self) -> None: ... + +class WritableSelfProperty: + @property + def x(self) -> "WritableSelfProperty": + return self + + @x.setter + def x(self, value: "WritableSelfProperty") -> None: ... + +static_assert(is_subtype_of(WritableSelfProperty, HasWritableSelfProperty)) +static_assert(is_assignable_to(WritableSelfProperty, HasWritableSelfProperty)) + +class PropertyWithSelfSetter: + @property + def x(self) -> object: + return self + + @x.setter + def x(self, value: Self) -> None: ... + +class HasConcretePropertySetter(Protocol): + @property + def x(self) -> object: ... + @x.setter + def x(self, value: PropertyWithSelfSetter) -> None: ... + +static_assert(is_subtype_of(PropertyWithSelfSetter, HasConcretePropertySetter)) +static_assert(is_assignable_to(PropertyWithSelfSetter, HasConcretePropertySetter)) +``` + +## Variance of generic protocols with `Final` members + +A `Final` attribute is readable but not writable, so it constrains an inferred type parameter +covariantly: + +```toml +[environment] +python-version = "3.12" +``` + +```py +from typing import Final, Protocol, cast +from ty_extensions import is_assignable_to, is_subtype_of, static_assert + +class MyInt(int): ... + +class GenericFinalX[T](Protocol): + x: Final[T] = cast(T, None) + +static_assert(is_subtype_of(GenericFinalX[MyInt], GenericFinalX[int])) +static_assert(is_assignable_to(GenericFinalX[MyInt], GenericFinalX[int])) +static_assert(not is_subtype_of(GenericFinalX[int], GenericFinalX[MyInt])) +static_assert(not is_assignable_to(GenericFinalX[int], GenericFinalX[MyInt])) ``` ## Subtyping of protocols with method members @@ -2217,6 +2477,10 @@ class NominalWithStaticMethod: @staticmethod def m(_, x: int) -> None: ... +class NominalWithStaticMethodGood: + @staticmethod + def m(x: int) -> None: ... + class DefinitelyNotSubtype: m = None @@ -2228,15 +2492,15 @@ static_assert(not is_assignable_to(NotSubtype, P)) static_assert(not is_assignable_to(NominalSubtype | NotSubtype, P)) static_assert(not is_assignable_to(NominalSubtype2 | DefinitelyNotSubtype, P)) -# `m` has the correct signature when accessed on instances of `NominalWithClassMethod`, -# but not when accessed on the class object `NominalWithClassMethod` itself -# -# TODO: these should pass -static_assert(not is_assignable_to(NominalWithClassMethod, P)) # error: [static-assert-error] -static_assert(not is_assignable_to(NominalSubtype | NominalWithClassMethod, P)) # error: [static-assert-error] +# A classmethod or staticmethod can satisfy a regular method member if it has the correct +# signature when accessed on an instance. The class-side check only establishes that the member +# is present on the class. +static_assert(is_assignable_to(NominalWithClassMethod, P)) +static_assert(is_assignable_to(NominalWithStaticMethodGood, P)) +static_assert(is_assignable_to(NominalSubtype | NominalWithClassMethod, P)) +static_assert(is_assignable_to(NominalSubtype | NominalWithStaticMethodGood, P)) -# Conversely, `m` has the correct signature when accessed on the class object -# `NominalWithStaticMethod`, but not when accessed on instances of `NominalWithStaticMethod` +# This staticmethod has an extra parameter when accessed on an instance. static_assert(not is_assignable_to(NominalWithStaticMethod, P)) static_assert(not is_assignable_to(NominalSubtype | NominalWithStaticMethod, P)) ``` @@ -2372,6 +2636,49 @@ def g(x: int) -> None: reveal_type(x2(1)) # revealed: int ``` +The class-side check for a method member only establishes that the member is present. Its signature +is checked through the instance, so the class-side check must not add the same generic constraints a +second time. This matters when checking a covariant protocol that also has non-method members: + +```py +from collections.abc import Iterator +from typing import Any, Protocol, TypeVar +from ty_extensions import is_assignable_to, static_assert + +T_co = TypeVar("T_co", covariant=True) + +class CovariantList(Protocol[T_co]): + @property + def __class__(self) -> type[list[Any]]: ... + @__class__.setter + def __class__(self, value: type[list[Any]], /) -> None: ... + def __iter__(self) -> Iterator[T_co]: ... + +static_assert(is_assignable_to(list[int], CovariantList[float])) +``` + +Protocol method return types can contain mutually recursive protocols. Reducing methods to their +instance and class access capabilities must preserve callable-specific cycle normalization: + +```py +from collections.abc import Iterable +from typing import Protocol +from ty_extensions import is_assignable_to, is_subtype_of, static_assert + +class RichCast(Protocol): + def __rich__(self) -> "ConsoleRenderable | RichCast": ... + +class ConsoleRenderable(Protocol): + def __rich_console__(self) -> "Iterable[ConsoleRenderable | RichCast | int]": ... + +class Text: + def __rich_console__(self) -> Iterable[int]: + raise NotImplementedError + +static_assert(is_subtype_of(Text, ConsoleRenderable)) +static_assert(is_assignable_to(Text, ConsoleRenderable)) +``` + ## Subtyping of protocols with generic method members Protocol method members can be generic. They can have generic contexts scoped to the class: @@ -2540,7 +2847,10 @@ of `N` or inhabitants of `type[N]`, *and* the signature of `N.x` is equivalent t `P.x` after the descriptor protocol has been invoked on `P.x`: ```py -from typing import Protocol +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Protocol, overload +from typing_extensions import Self from ty_extensions import static_assert, is_subtype_of, is_assignable_to, is_equivalent_to, is_disjoint_from class PClassMethod(Protocol): @@ -2551,6 +2861,9 @@ class PStaticMethod(Protocol): @staticmethod def x(val: int) -> str: ... +class PInstanceMethod(Protocol): + def x(self, val: int) -> str: ... + class NNotCallable: x = None @@ -2578,23 +2891,129 @@ class NStaticMethodBad: def x(cls, val: int) -> str: return "foo" +class PFactory(Protocol): + @classmethod + def create(cls) -> Self: ... + +class POverloadedClassMethod(Protocol): + @overload + @classmethod + def convert(cls, value: int) -> int: ... + @overload + @classmethod + def convert(cls, value: str) -> str: ... + +class POverloadedStaticMethod(Protocol): + @overload + @staticmethod + def convert(value: int) -> int: ... + @overload + @staticmethod + def convert(value: str) -> str: ... + +class OverloadedClassMethod: + @overload + @classmethod + def convert(cls, value: int) -> int: ... + @overload + @classmethod + def convert(cls, value: str) -> str: ... + @classmethod + def convert(cls, value: int | str) -> int | str: + return value + +class POverloadedSelf(Protocol): + @overload + @classmethod + def copy(cls, value: int) -> Self: ... + @overload + @classmethod + def copy(cls, value: str) -> tuple[Self, Self]: ... + +class OverloadedSelf: + @overload + @classmethod + def copy(cls, value: int) -> Self: ... + @overload + @classmethod + def copy(cls, value: str) -> tuple[Self, Self]: ... + @classmethod + def copy(cls, value: int | str) -> Self | tuple[Self, Self]: + if isinstance(value, int): + return cls() + return cls(), cls() + +class Factory: + @classmethod + def create(cls) -> Self: + return cls() + +class BadFactory: + @classmethod + def create(cls) -> int: + return 42 + +class PContextManager1(Protocol): + @classmethod + @contextmanager + def open(cls) -> Iterator[Self]: ... + +class PContextManager2(Protocol): + @classmethod + @contextmanager + def open(cls) -> Iterator[Self]: ... + +class ContextManagerImplementation: + @classmethod + @contextmanager + def open(cls) -> Iterator[Self]: + yield cls() + +class PRegularContextManager(Protocol): + @contextmanager + def open(self) -> Iterator[int]: ... + +class ClassContextManagerImplementation: + @classmethod + @contextmanager + def open(cls) -> Iterator[int]: + yield 1 + +class StaticContextManagerImplementation: + @staticmethod + @contextmanager + def open() -> Iterator[int]: + yield 1 + +def use_decorated_protocol_methods(class_method: PClassMethod, static_method: PStaticMethod) -> None: + reveal_type(class_method.x(1)) # revealed: str + reveal_type(static_method.x(1)) # revealed: str + # `PClassMethod.x` and `PStaticMethod.x` evaluate to callable types with equivalent signatures # whether you access them on the protocol class or instances of the protocol. # That means that they are equivalent protocols! static_assert(is_equivalent_to(PClassMethod, PStaticMethod)) - -# TODO: these should all pass -static_assert(not is_assignable_to(NNotCallable, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NNotCallable, PStaticMethod)) # error: [static-assert-error] -static_assert(is_disjoint_from(NNotCallable, PClassMethod)) # error: [static-assert-error] -static_assert(is_disjoint_from(NNotCallable, PStaticMethod)) # error: [static-assert-error] +static_assert(is_equivalent_to(POverloadedClassMethod, POverloadedStaticMethod)) +static_assert(is_equivalent_to(PContextManager1, PContextManager2)) + +# Like nominal classmethod and staticmethod implementations, protocols containing these members +# satisfy an ordinary method requirement with the same bound signature. +static_assert(is_subtype_of(PClassMethod, PInstanceMethod)) +static_assert(is_assignable_to(PClassMethod, PInstanceMethod)) +static_assert(is_subtype_of(PStaticMethod, PInstanceMethod)) +static_assert(is_assignable_to(PStaticMethod, PInstanceMethod)) +static_assert(not is_assignable_to(PInstanceMethod, PClassMethod)) +static_assert(not is_assignable_to(PInstanceMethod, PStaticMethod)) + +static_assert(not is_assignable_to(NNotCallable, PClassMethod)) +static_assert(not is_assignable_to(NNotCallable, PStaticMethod)) +static_assert(is_disjoint_from(NNotCallable, PClassMethod)) +static_assert(is_disjoint_from(NNotCallable, PStaticMethod)) # `NInstanceMethod.x` has the correct type when accessed on an instance of # `NInstanceMethod`, but not when accessed on the class object itself -# -# TODO: these should pass -static_assert(not is_assignable_to(NInstanceMethod, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # error: [static-assert-error] +static_assert(not is_assignable_to(NInstanceMethod, PClassMethod)) +static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # A nominal type with a `@staticmethod` can satisfy a protocol with a `@classmethod` # if the staticmethod duck-types the same as the classmethod member @@ -2603,21 +3022,64 @@ static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # error: [s # with a `@staticmethod` member static_assert(is_assignable_to(NClassMethodGood, PClassMethod)) static_assert(is_assignable_to(NClassMethodGood, PStaticMethod)) -# TODO: these should all pass: -static_assert(is_subtype_of(NClassMethodGood, PClassMethod)) # error: [static-assert-error] -static_assert(is_subtype_of(NClassMethodGood, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NClassMethodBad, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NClassMethodBad, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NClassMethodGood | NClassMethodBad, PClassMethod)) # error: [static-assert-error] +static_assert(is_subtype_of(NClassMethodGood, PClassMethod)) +static_assert(is_subtype_of(NClassMethodGood, PStaticMethod)) +static_assert(not is_assignable_to(NClassMethodBad, PClassMethod)) +static_assert(not is_assignable_to(NClassMethodBad, PStaticMethod)) +static_assert(not is_assignable_to(NClassMethodGood | NClassMethodBad, PClassMethod)) static_assert(is_assignable_to(NStaticMethodGood, PClassMethod)) static_assert(is_assignable_to(NStaticMethodGood, PStaticMethod)) -# TODO: these should all pass: -static_assert(is_subtype_of(NStaticMethodGood, PClassMethod)) # error: [static-assert-error] -static_assert(is_subtype_of(NStaticMethodGood, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NStaticMethodBad, PClassMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NStaticMethodBad, PStaticMethod)) # error: [static-assert-error] -static_assert(not is_assignable_to(NStaticMethodGood | NStaticMethodBad, PStaticMethod)) # error: [static-assert-error] +static_assert(is_subtype_of(NStaticMethodGood, PClassMethod)) +static_assert(is_subtype_of(NStaticMethodGood, PStaticMethod)) +static_assert(not is_assignable_to(NStaticMethodBad, PClassMethod)) +static_assert(not is_assignable_to(NStaticMethodBad, PStaticMethod)) +static_assert(not is_assignable_to(NStaticMethodGood | NStaticMethodBad, PStaticMethod)) + +# `Self` in the classmethod signature is bound to the implementation type. +static_assert(is_subtype_of(Factory, PFactory)) +static_assert(is_assignable_to(Factory, PFactory)) +static_assert(not is_subtype_of(BadFactory, PFactory)) +static_assert(not is_assignable_to(BadFactory, PFactory)) +static_assert(is_subtype_of(ContextManagerImplementation, PContextManager1)) +static_assert(is_assignable_to(ContextManagerImplementation, PContextManager1)) + +# Wrapped classmethods and staticmethods can also satisfy regular method members. +static_assert(is_subtype_of(ClassContextManagerImplementation, PRegularContextManager)) +static_assert(is_assignable_to(ClassContextManagerImplementation, PRegularContextManager)) +static_assert(is_subtype_of(StaticContextManagerImplementation, PRegularContextManager)) +static_assert(is_assignable_to(StaticContextManagerImplementation, PRegularContextManager)) + +static_assert(is_subtype_of(OverloadedClassMethod, POverloadedClassMethod)) +static_assert(is_subtype_of(OverloadedClassMethod, POverloadedStaticMethod)) +static_assert(is_subtype_of(OverloadedSelf, POverloadedSelf)) +static_assert(is_assignable_to(OverloadedSelf, POverloadedSelf)) +``` + +Until classmethod protocol members are fully supported, their placeholder representation should not +incorrectly require a mutable instance attribute. In particular, a frozen dataclass can satisfy a +protocol bound through a classmethod: + +```py +from dataclasses import dataclass +from typing import Protocol, TypeVar +from typing_extensions import Self + +class Factory(Protocol): + @classmethod + def make(cls, value: int) -> Self: ... + +T = TypeVar("T", bound=Factory) + +def load(target: type[T]) -> None: ... + +@dataclass(frozen=True) +class Frozen: + @classmethod + def make(cls, value: int) -> Self: + return cls() + +load(Frozen) ``` ## Subtyping of protocols with decorated method members @@ -2667,9 +3129,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 @@ -2763,9 +3237,8 @@ class Method(Protocol): static_assert(is_subtype_of(Method, PropertyInt)) static_assert(is_subtype_of(Method, PropertyBool)) -# TODO: these should pass -static_assert(not is_assignable_to(Method, PropertyNotReturningCallable)) # error: [static-assert-error] -static_assert(not is_assignable_to(Method, PropertyWithIncorrectSignature)) # error: [static-assert-error] +static_assert(not is_assignable_to(Method, PropertyNotReturningCallable)) +static_assert(not is_assignable_to(Method, PropertyWithIncorrectSignature)) ``` However, a protocol with a method member can never be considered a subtype of a protocol with a @@ -2778,8 +3251,7 @@ class ReadWriteProperty(Protocol): @f.setter def f(self, val: Callable[[], bool]): ... -# TODO: should pass -static_assert(not is_assignable_to(Method, ReadWriteProperty)) # error: [static-assert-error] +static_assert(not is_assignable_to(Method, ReadWriteProperty)) ``` And for the same reason, they are never assignable to attribute members (which are also mutable): @@ -2801,9 +3273,10 @@ static_assert(not is_assignable_to(PropertyBool, Method)) static_assert(not is_assignable_to(Attribute, Method)) ``` -But an exception to this rule is if an attribute member is marked as `ClassVar`, as this guarantees -that the member will be available on the meta-type as well as the instance type for inhabitants of -the protocol: +The `ClassVar[int]` example above demonstrates that a `ClassVar` member is readable through both the +instance and the class. That availability alone does not make a callable `ClassVar` a method. Both +reads of a `ClassVar[Callable[[], bool]]` have the same callable type, whereas a method has a bound +instance type and a distinct unbound class type: ```py from typing import ClassVar @@ -2811,8 +3284,8 @@ from typing import ClassVar class ClassVarAttribute(Protocol): f: ClassVar[Callable[[], bool]] -static_assert(is_subtype_of(ClassVarAttribute, Method)) -static_assert(is_assignable_to(ClassVarAttribute, Method)) +static_assert(not is_subtype_of(ClassVarAttribute, Method)) +static_assert(not is_assignable_to(ClassVarAttribute, Method)) class ClassVarAttributeBad(Protocol): f: ClassVar[Callable[[], str]] @@ -3996,7 +4469,7 @@ Add tests for: - Protocols with methods that have parameters or the return type annotated with `Any` - Assignability of non-instance types to protocols with instance-method members (e.g. a class-literal type can be a subtype of `Sized` if its metaclass has a `__len__` method) -- Protocols with methods that have annotated `self` parameters. +- Protocols with methods or property getters that have annotated `self` parameters. [Spec reference][self_types_protocols_spec]. - Protocols with overloaded method members - `super()` on nominal subtypes (explicit and implicit) of protocol classes diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md index 2d03505659c94..6864703235795 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_disjoint_from.md @@ -762,7 +762,7 @@ static_assert(is_disjoint_from(type[UsesMeta1], type[UsesMeta2])) ```py from ty_extensions import is_disjoint_from, static_assert, TypeOf -from typing import final +from typing import final, Protocol, Literal class C: @property @@ -781,6 +781,29 @@ static_assert(not is_disjoint_from(Whatever, TypeOf[C.prop])) static_assert(not is_disjoint_from(TypeOf[C.prop], Whatever)) static_assert(is_disjoint_from(TypeOf[C.prop], D)) static_assert(is_disjoint_from(D, TypeOf[C.prop])) + +@final +class E: + @property + def prop(self) -> int: + return 1 + +class F: + prop: Literal["a"] + +class HasIntProp(Protocol): + @property + def prop(self) -> int: ... + +class HasReadWriteIntProp(Protocol): + @property + def prop(self) -> int: ... + @prop.setter + def prop(self, value: int) -> None: ... + +static_assert(not is_disjoint_from(HasIntProp, E)) +static_assert(is_disjoint_from(HasIntProp, F)) +static_assert(is_disjoint_from(HasReadWriteIntProp, E)) ``` ### `TypeGuard` and `TypeIs` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 2c0a301cf702f..6c73fd0b0d808 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -115,6 +115,7 @@ use ty_python_core::place::ScopedPlaceId; use ty_python_core::scope::ScopeId; use ty_python_core::{Truthiness, place_table, semantic_index}; +mod attribute_write; mod bool; mod bound_super; mod call; diff --git a/crates/ty_python_semantic/src/types/attribute_write.rs b/crates/ty_python_semantic/src/types/attribute_write.rs new file mode 100644 index 0000000000000..117759966f0c5 --- /dev/null +++ b/crates/ty_python_semantic/src/types/attribute_write.rs @@ -0,0 +1,557 @@ +//! Attribute-write resolution shared by assignment inference and protocol compatibility. +//! +//! This module resolves the Python lookup semantics for `object.attribute = value` into an +//! [`AttributeWriteRequirement`]. The requirement retains alternatives such as union elements, +//! descriptors, instance fallbacks, and `__setattr__` instead of deciding whether a particular +//! value type is valid. Assignment inference can therefore provide contextual inference and +//! diagnostics, while protocol checking can evaluate the same lookup result using its active type +//! relation and constraint set. + +use ty_module_resolver::KnownModule; + +use super::call::CallArguments; +use super::callable::CallableTypeKind; +use super::{IntersectionType, KnownClass, MemberLookupPolicy, Type, TypeQualifiers}; +use crate::Db; +use crate::place::{DefinedPlace, Definedness, Place, PlaceAndQualifiers, builtins_symbol}; + +/// The operation required to write an attribute. +/// +/// This representation is shared between real assignments and synthetic protocol +/// writes. It deliberately does not infer a value expression, emit diagnostics, or +/// decide whether assigning to a `Final` member is allowed: those decisions differ +/// between its two consumers. +pub(super) enum AttributeWriteRequirement<'db> { + /// A write to a union must be valid for every union element. + All { + object_ty: Type<'db>, + element_tys: &'db [Type<'db>], + }, + /// A write to an intersection may target any positive intersection element. + Any { + object_ty: Type<'db>, + intersection: IntersectionType<'db>, + }, + /// A value may be assigned without an attribute-specific constraint. + Unconstrained, + /// The object type does not permit writes at all. + CannotAssign, + /// A module symbol, with its declared type when the symbol is known. + /// + /// `None` represents an unresolved module attribute rather than an unconstrained write. + Module(Option>), + /// The effective instance-write type of a declared protocol member. + /// + /// `write_ty` is `None` for a read-only member. Qualifiers are retained so assignment + /// inference can distinguish `Final` and `ClassVar` diagnostics from other non-writable + /// members. + ProtocolMember { + write_ty: Option>, + qualifiers: TypeQualifiers, + }, + /// A write through an instance, resolved against its class and instance attributes. + Instance { + object_ty: Type<'db>, + member: InstanceAttributeWriteMember<'db>, + }, + /// A write through a class object, resolved against its metaclass and class attributes. + Class { + object_ty: Type<'db>, + member: ClassAttributeWriteMember<'db>, + }, +} + +/// The member that governs a write through an instance. +/// +/// A declared class member takes precedence over an instance fallback. A custom `__setattr__` is +/// used only when neither lookup produces a declared write target; callers separately account for +/// a terminal `__setattr__` that blocks every write. +pub(super) enum InstanceAttributeWriteMember<'db> { + /// The resolved declaration is a `ClassVar`, which cannot be assigned through an instance. + ClassVar, + /// A class or MRO member governs the write. + /// + /// The fallback is also required when the explicit member is only possibly defined. + Explicit { + member: ExplicitAttributeWriteRequirement<'db>, + fallback: Option>, + }, + /// No class member governs the write, but an instance declaration does. + Instance(FallbackAttributeWriteRequirement<'db>), + /// No declared member was found, so the write is governed by `__setattr__`. + SetAttr, +} + +/// The member that governs a write through a class object. +/// +/// The primary lookup is on the metaclass. If that lookup is absent or possibly undefined, the +/// class object's own attributes form the fallback. +pub(super) enum ClassAttributeWriteMember<'db> { + /// A metaclass member governs the write, optionally alongside a class-attribute fallback. + Explicit { + member: ExplicitAttributeWriteRequirement<'db>, + fallback: Option>, + }, + /// No metaclass member governs the write, but a class attribute does. + ClassAttribute(FallbackAttributeWriteRequirement<'db>), + /// Neither lookup found a writable declaration. + /// + /// `has_instance_attribute` distinguishes assigning an instance-only declaration through the + /// class from assigning a wholly unknown attribute. + Unresolved { has_instance_attribute: bool }, +} + +/// How an explicitly resolved member accepts a write. +pub(super) enum ExplicitAttributeWriteRequirement<'db> { + /// Invoke a concrete descriptor's `__set__` method. + /// + /// `setter_ty` is the unbound method and is called with `descriptor_ty`, the object, and the + /// assigned value. + Descriptor { + descriptor_ty: Type<'db>, + setter_ty: Type<'db>, + qualifiers: TypeQualifiers, + }, + /// Check the assigned value directly against the member's effective write type. + AssignableTo { + ty: Type<'db>, + qualifiers: TypeQualifiers, + }, +} + +impl ExplicitAttributeWriteRequirement<'_> { + pub(super) fn qualifiers(&self) -> TypeQualifiers { + match self { + Self::Descriptor { qualifiers, .. } | Self::AssignableTo { qualifiers, .. } => { + *qualifiers + } + } + } +} + +/// A write target found through a possibly absent fallback lookup. +pub(super) enum FallbackAttributeWriteRequirement<'db> { + /// Check the value against `ty`, retaining whether the declaration may be absent at runtime. + AssignableTo { + ty: Type<'db>, + qualifiers: TypeQualifiers, + possibly_missing: bool, + }, + /// The fallback may exist, but lookup did not produce a usable write type. + PossiblyMissing, +} + +/// Resolve the receiver-level requirements for writing `object_ty.attribute`. +/// +/// This expands aliases, preserves the all-arms rule for unions and the any-positive-arm rule for +/// intersections, and dispatches instance and class-object writes to their respective lookup +/// paths. It does not compare the assigned value with the resulting types. +pub(super) fn attribute_write_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, +) -> AttributeWriteRequirement<'db> { + match object_ty { + Type::Union(union) => AttributeWriteRequirement::All { + object_ty, + element_tys: union.elements(db), + }, + + Type::Intersection(intersection) => { + // TODO: Handle negative intersection elements. + AttributeWriteRequirement::Any { + object_ty, + intersection, + } + } + + Type::EnumComplement(complement) => { + attribute_write_requirement(db, complement.remaining_literal_union(db), attribute) + } + + Type::TypeAlias(alias) => attribute_write_requirement(db, alias.value_type(db), attribute), + + Type::NominalInstance(instance) if instance.has_known_class(db, KnownClass::Super) => { + AttributeWriteRequirement::CannotAssign + } + Type::BoundSuper(_) => AttributeWriteRequirement::CannotAssign, + + Type::Dynamic(..) | Type::Divergent(_) | Type::Never => { + AttributeWriteRequirement::Unconstrained + } + + Type::ProtocolInstance(protocol) => protocol + .interface(db) + .instance_write_requirement(db, object_ty, attribute) + .map_or_else( + || instance_attribute_write_requirement(db, object_ty, attribute), + |(write_ty, qualifiers)| AttributeWriteRequirement::ProtocolMember { + write_ty, + qualifiers, + }, + ), + + Type::NominalInstance(..) + | Type::LiteralValue(..) + | Type::SpecialForm(..) + | Type::KnownInstance(..) + | Type::PropertyInstance(..) + | Type::FunctionLiteral(..) + | Type::Callable(..) + | Type::BoundMethod(_) + | Type::KnownBoundMethod(_) + | Type::WrapperDescriptor(_) + | Type::DataclassDecorator(_) + | Type::DataclassTransformer(_) + | Type::TypeVar(..) + | Type::AlwaysTruthy + | Type::AlwaysFalsy + | Type::TypeIs(_) + | Type::TypeGuard(_) + | Type::TypeForm(_) + | Type::TypedDict(_) + | Type::NewTypeInstance(_) => { + instance_attribute_write_requirement(db, object_ty, attribute) + } + + Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { + class_attribute_write_requirement(db, object_ty, attribute) + } + + Type::ModuleLiteral(module) => { + let symbol = if module + .module(db) + .known(db) + .is_some_and(KnownModule::is_builtins) + { + builtins_symbol(db, attribute) + } else { + module.static_member(db, attribute) + }; + AttributeWriteRequirement::Module(match symbol.place { + Place::Defined(DefinedPlace { ty, .. }) => Some(ty), + Place::Undefined => None, + }) + } + } +} + +fn instance_attribute_write_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, +) -> AttributeWriteRequirement<'db> { + AttributeWriteRequirement::Instance { + object_ty, + member: instance_attribute_write_member_requirement(db, object_ty, attribute), + } +} + +/// Resolve the declared member that governs an instance attribute write. +/// +/// The returned requirement preserves a possibly-defined class member and its instance fallback, +/// because both runtime paths must accept the write. If neither exists, the caller must evaluate +/// `__setattr__`. +fn instance_attribute_write_member_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, +) -> InstanceAttributeWriteMember<'db> { + let Some((meta_attr, fallback_attr)) = assignment_attribute_members(db, object_ty, attribute) + else { + return InstanceAttributeWriteMember::SetAttr; + }; + + match meta_attr { + meta_attr if meta_attr.is_class_var() => InstanceAttributeWriteMember::ClassVar, + PlaceAndQualifiers { + place: Place::Defined(DefinedPlace { ty, .. }), + qualifiers, + } => InstanceAttributeWriteMember::Explicit { + member: explicit_attribute_write_requirement( + db, + object_ty, + attribute, + ty.bind_self_typevars(db, object_ty), + qualifiers, + ), + fallback: fallback_attr.map(|fallback| { + instance_fallback_write_requirement(db, object_ty, attribute, fallback) + }), + }, + PlaceAndQualifiers { + place: Place::Undefined, + .. + } => match fallback_attr { + Some( + fallback @ PlaceAndQualifiers { + place: Place::Defined(_), + .. + }, + ) => InstanceAttributeWriteMember::Instance(instance_fallback_write_requirement( + db, object_ty, attribute, fallback, + )), + _ => InstanceAttributeWriteMember::SetAttr, + }, + } +} + +/// Resolve a class-object write against the metaclass and then the class object's attributes. +/// +/// The receiver must be convertible to an instance type so that `Self` in class-attribute +/// declarations can be bound consistently with normal class-object member lookup. +fn class_attribute_write_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, +) -> AttributeWriteRequirement<'db> { + let Some((meta_attr, fallback_attr)) = assignment_attribute_members(db, object_ty, attribute) + else { + return AttributeWriteRequirement::Unconstrained; + }; + let Some(class_attr_self_ty) = object_ty.to_instance(db) else { + return AttributeWriteRequirement::Unconstrained; + }; + + let member = match meta_attr { + PlaceAndQualifiers { + place: Place::Defined(DefinedPlace { ty, .. }), + qualifiers, + } => ClassAttributeWriteMember::Explicit { + member: explicit_attribute_write_requirement(db, object_ty, attribute, ty, qualifiers), + fallback: fallback_attr.map(|fallback| { + class_fallback_write_requirement(db, object_ty, class_attr_self_ty, fallback) + }), + }, + PlaceAndQualifiers { + place: Place::Undefined, + .. + } => match fallback_attr { + Some( + fallback @ PlaceAndQualifiers { + place: Place::Defined(_), + .. + }, + ) => ClassAttributeWriteMember::ClassAttribute(class_fallback_write_requirement( + db, + object_ty, + class_attr_self_ty, + fallback, + )), + _ => ClassAttributeWriteMember::Unresolved { + has_instance_attribute: !class_attr_self_ty + .instance_member(db, attribute) + .place + .is_undefined(), + }, + }, + }; + + AttributeWriteRequirement::Class { object_ty, member } +} + +/// Convert an explicitly resolved member into either a descriptor call or a direct type check. +/// +/// Descriptor behavior is used only when `__set__` is found with +/// [`MemberLookupPolicy::REQUIRE_CONCRETE`]. An `Any` or `Unknown` base therefore does not cause an +/// ordinary attribute to be treated as a data descriptor. +fn explicit_attribute_write_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, + attr_ty: Type<'db>, + qualifiers: TypeQualifiers, +) -> ExplicitAttributeWriteRequirement<'db> { + if let Place::Defined(DefinedPlace { ty: setter_ty, .. }) = attr_ty + .class_member_with_policy(db, "__set__".into(), MemberLookupPolicy::REQUIRE_CONCRETE) + .place + { + ExplicitAttributeWriteRequirement::Descriptor { + descriptor_ty: attr_ty, + setter_ty, + qualifiers, + } + } else { + ExplicitAttributeWriteRequirement::AssignableTo { + ty: effective_write_type(db, object_ty, attribute, attr_ty), + qualifiers, + } + } +} + +/// Convert an instance fallback into a write type, binding `Self` to the receiver. +/// +/// This also applies dataclass converter semantics and preserves possible undefinedness for the +/// assignment diagnostic layer. +fn instance_fallback_write_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, + fallback: PlaceAndQualifiers<'db>, +) -> FallbackAttributeWriteRequirement<'db> { + let PlaceAndQualifiers { + place: Place::Defined(DefinedPlace { + ty, definedness, .. + }), + qualifiers, + } = fallback + else { + return FallbackAttributeWriteRequirement::PossiblyMissing; + }; + let ty = ty.bind_self_typevars(db, object_ty); + FallbackAttributeWriteRequirement::AssignableTo { + ty: effective_write_type(db, object_ty, attribute, ty), + qualifiers, + possibly_missing: definedness == Definedness::PossiblyUndefined, + } +} + +/// Convert a class-attribute fallback into a write type, binding `Self` to the class instance. +fn class_fallback_write_requirement<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + class_attr_self_ty: Type<'db>, + fallback: PlaceAndQualifiers<'db>, +) -> FallbackAttributeWriteRequirement<'db> { + let PlaceAndQualifiers { + place: Place::Defined(DefinedPlace { + ty, definedness, .. + }), + qualifiers, + } = fallback + else { + return FallbackAttributeWriteRequirement::PossiblyMissing; + }; + let ty = ty.bind_self_typevars(db, class_attr_self_ty); + let ty = if matches!(object_ty, Type::ClassLiteral(_)) + && let Type::FunctionLiteral(function) = ty + && function.callable_type_kind(db) == CallableTypeKind::FunctionLike + { + Type::Callable(function.into_callable_type(db)) + } else { + ty + }; + FallbackAttributeWriteRequirement::AssignableTo { + ty, + qualifiers, + possibly_missing: definedness == Definedness::PossiblyUndefined, + } +} + +/// Return the accepted type for writes to a declared attribute. +/// +/// A dataclass field with a converter accepts the converter's input type, not +/// the field's post-conversion type. For example, a field declared as `int` with a +/// `(str) -> int` converter is read as `int` but accepts `str` assignments. +fn effective_write_type<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, + attr_ty: Type<'db>, +) -> Type<'db> { + if let Type::NominalInstance(instance) = object_ty + && let Some(converter_ty) = instance + .class(db) + .converter_input_type_for_field(db, attribute) + { + converter_ty + } else { + attr_ty + } +} + +/// Return whether a property setter is terminal for this receiver and value type. +/// +/// This is intentionally specific to `property`: other descriptors are governed by the result of +/// their concrete `__set__` call. Both successful and failed call bindings retain the declared +/// return type, so either can establish that the selected setter returns `Never`/`NoReturn`. +/// +/// ```python +/// from typing import Never +/// +/// class Model: +/// @property +/// def value(self) -> int: ... +/// +/// @value.setter +/// def value(self, value: int) -> Never: ... +/// ``` +pub(super) fn property_setter_returns_never<'db>( + db: &'db dyn Db, + property_ty: Type<'db>, + object_ty: Type<'db>, + value_ty: Type<'db>, +) -> bool { + property_ty.as_property_instance().is_some_and(|property| { + property.setter(db).is_some_and(|setter| { + match setter.try_call(db, &CallArguments::positional([object_ty, value_ty])) { + Ok(result) => result.return_type(db).is_never(), + Err(error) => error.return_type(db).is_never(), + } + }) + }) +} + +/// Return the primary and optional fallback members considered by attribute assignment. +/// +/// The primary member comes from class-member lookup. The fallback is queried only when that +/// member is absent or possibly undefined, and is an instance member for ordinary receivers or a +/// class-object member for class receivers. Composite and dynamic receiver types return `None`; +/// their callers either decompose them before this point or handle them without member lookup. +/// +/// This helper deliberately does not bind `Self` or interpret descriptors so that assignment, +/// protocol compatibility, and `Final` validation share exactly the same lookup precedence. +pub(super) fn assignment_attribute_members<'db>( + db: &'db dyn Db, + object_ty: Type<'db>, + attribute: &str, +) -> Option<(PlaceAndQualifiers<'db>, Option>)> { + let meta_attr = object_ty.class_member(db, attribute.into()); + let needs_fallback = matches!( + meta_attr.place, + Place::Defined(DefinedPlace { + definedness: Definedness::PossiblyUndefined, + .. + }) | Place::Undefined + ); + let fallback_attr = if needs_fallback { + Some(match object_ty { + Type::NominalInstance(..) + | Type::ProtocolInstance(_) + | Type::LiteralValue(..) + | Type::SpecialForm(..) + | Type::KnownInstance(..) + | Type::PropertyInstance(..) + | Type::FunctionLiteral(..) + | Type::Callable(..) + | Type::BoundMethod(_) + | Type::KnownBoundMethod(_) + | Type::WrapperDescriptor(_) + | Type::DataclassDecorator(_) + | Type::DataclassTransformer(_) + | Type::EnumComplement(_) + | Type::TypeVar(..) + | Type::AlwaysTruthy + | Type::AlwaysFalsy + | Type::TypeIs(_) + | Type::TypeGuard(_) + | Type::TypeForm(_) + | Type::TypedDict(_) + | Type::NewTypeInstance(_) => object_ty.instance_member(db, attribute), + Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { + object_ty.class_object_member(db, attribute, MemberLookupPolicy::default()) + } + Type::Union(..) + | Type::Intersection(..) + | Type::TypeAlias(..) + | Type::Dynamic(..) + | Type::Divergent(_) + | Type::Never + | Type::ModuleLiteral(..) + | Type::BoundSuper(..) => return None, + }) + } else { + None + }; + Some((meta_attr, fallback_attr)) +} diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 367e8ff2496fe..6f765b361159f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -17,7 +17,7 @@ use ruff_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; use strum::IntoEnumIterator; -use ty_module_resolver::{KnownModule, ModuleName, resolve_module}; +use ty_module_resolver::{ModuleName, resolve_module}; use ty_python_core::ast_ids::HasScopedUseId; use ty_python_core::statement::StatementInner; @@ -39,6 +39,7 @@ use crate::place::{ }; use crate::reachability::{ReachabilityEvaluationCache, evaluate_reachability_with_cache}; use crate::types::add_inferred_python_version_hint_to_diagnostic; +use crate::types::attribute_write::assignment_attribute_members; use crate::types::call::bind::MatchingOverloadIndex; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::callable::{CallableFunctionProvenance, CallableTypeKind}; @@ -48,20 +49,19 @@ use crate::types::context::InferContext; use crate::types::diagnostic::{ self, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CYCLIC_TYPE_ALIAS_DEFINITION, GeneratorMismatchKind, INEFFECTIVE_FINAL, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, - INVALID_ATTRIBUTE_ACCESS, INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, - INVALID_LEGACY_TYPE_VARIABLE, INVALID_NEWTYPE, INVALID_PARAMSPEC, INVALID_TYPE_ALIAS_TYPE, - INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_BOUND, - INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_SUBMODULE, - UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, - UNSUPPORTED_OPERATOR, UNUSED_AWAITABLE, hint_if_stdlib_attribute_exists_on_other_versions, + INVALID_DECLARATION, INVALID_ENUM_MEMBER_ANNOTATION, INVALID_LEGACY_TYPE_VARIABLE, + INVALID_NEWTYPE, INVALID_PARAMSPEC, INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, + INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_BOUND, INVALID_TYPE_VARIABLE_CONSTRAINTS, + POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_SUBMODULE, UNDEFINED_REVEAL, + UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, + UNUSED_AWAITABLE, hint_if_stdlib_attribute_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_delattr_call, - report_bad_dunder_delete_call, report_bad_dunder_set_call, report_call_to_abstract_method, + report_bad_dunder_delete_call, report_call_to_abstract_method, report_cannot_pop_required_field_on_typed_dict, report_invalid_assignment, - report_invalid_attribute_assignment, report_invalid_class_match_pattern, - report_invalid_exception_caught, report_invalid_exception_cause, - report_invalid_exception_raised, report_invalid_exception_tuple_caught, - report_invalid_generator_yield_type, report_invalid_key_on_typed_dict, - report_invalid_type_checking_constant, + report_invalid_class_match_pattern, report_invalid_exception_caught, + report_invalid_exception_cause, report_invalid_exception_raised, + report_invalid_exception_tuple_caught, report_invalid_generator_yield_type, + report_invalid_key_on_typed_dict, report_invalid_type_checking_constant, report_match_pattern_against_non_runtime_checkable_protocol, report_match_pattern_against_typed_dict, report_mismatched_type_name, report_possibly_missing_attribute, report_possibly_unresolved_reference, @@ -131,6 +131,7 @@ use ty_python_core::{ use ty_python_core::{ExpressionNodeKey, Statement}; mod annotation_expression; +mod attribute_assignment; mod binary_expressions; mod class; mod dict; @@ -2656,25 +2657,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - /// Returns `true` if `property_ty` is a property whose setter returns `Never`/`NoReturn` - /// when called for an assignment to `object_ty` with `value_ty`. - fn property_setter_returns_never( - &self, - property_ty: Type<'db>, - object_ty: Type<'db>, - value_ty: Type<'db>, - ) -> bool { - let db = self.db(); - property_ty.as_property_instance().is_some_and(|property| { - property.setter(db).is_some_and(|setter| { - match setter.try_call(db, &CallArguments::positional([object_ty, value_ty])) { - Ok(result) => result.return_type(db).is_never(), - Err(err) => err.return_type(db).is_never(), - } - }) - }) - } - /// Returns `true` if `property_ty` is a property whose deleter returns `Never`/`NoReturn` /// when called for deletion on `object_ty`. fn property_deleter_returns_never(&self, property_ty: Type<'db>, object_ty: Type<'db>) -> bool { @@ -2689,764 +2671,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }) } - /// Make sure that the attribute assignment `obj.attribute = value` is valid. - /// - /// `target` is the node for the left-hand side, `object_ty` is the type of `obj`, `attribute` is - /// the name of the attribute being assigned, and `value_ty` is the type of the right-hand side of - /// the assignment. If the assignment is invalid, emit diagnostics. - fn validate_attribute_assignment( - &mut self, - target: &ast::ExprAttribute, - object_ty: Type<'db>, - attribute: &str, - infer_value_ty: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, - emit_diagnostics: bool, - ) -> bool { - let db = self.db(); - - // This closure should only be called if `value_ty` was inferred with `attr_ty` as type context. - let ensure_assignable_to = - |builder: &Self, value_ty: Type<'db>, attr_ty: Type<'db>| -> bool { - let assignable = value_ty.is_assignable_to(db, attr_ty); - if !assignable && emit_diagnostics { - report_invalid_attribute_assignment( - &builder.context, - target.range(), - attr_ty, - value_ty, - attribute, - ); - } - assignable - }; - - // For dataclass fields with converters, the write type is the converter's - // input type (the type of the first positional parameter), not the field's - // declared type. - let effective_write_type = |attr_ty: Type<'db>| -> Type<'db> { - if let Type::NominalInstance(instance) = object_ty { - if let Some(converter_ty) = instance - .class(db) - .converter_input_type_for_field(db, attribute) - { - return converter_ty; - } - } - attr_ty - }; - - // Allow monkeypatching an ordinary method with a compatible function: - // - // ```python - // class C: - // def method(self, value: int) -> str: ... - // - // def replacement(self: C, value: int) -> str: ... - // C.method = replacement - // ``` - let class_attribute_write_type = |attr_ty: Type<'db>| -> Type<'db> { - if matches!(object_ty, Type::ClassLiteral(_)) - && let Type::FunctionLiteral(function) = attr_ty - && function.callable_type_kind(db) == CallableTypeKind::FunctionLike - { - Type::Callable(function.into_callable_type(db)) - } else { - attr_ty - } - }; - - match object_ty { - Type::Union(union) => { - let mut infer_value_ty = MultiInferenceGuard::new(infer_value_ty); - - // Perform loud inference without type context, as there may be multiple - // equally applicable type contexts for each union member. - let value_ty = infer_value_ty.infer_loud(self, TypeContext::default()); - - if union.elements(self.db()).iter().all(|elem| { - self.validate_attribute_assignment( - target, - *elem, - attribute, - &mut |builder, tcx| infer_value_ty.infer_silent(builder, tcx), - false, - ) - }) { - if emit_diagnostics { - self.validate_final_attribute_assignment(target, object_ty, attribute); - } - true - } else { - // TODO: This is not a very helpful error message, as it does not include the underlying reason - // why the assignment is invalid. This would be a good use case for sub-diagnostics. - if emit_diagnostics - && let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - builder.into_diagnostic(format_args!( - "Object of type `{}` is not assignable \ - to attribute `{attribute}` on type `{}`", - value_ty.display(self.db()), - object_ty.display(self.db()), - )); - } - - false - } - } - - Type::Intersection(intersection) => { - let mut infer_value_ty = MultiInferenceGuard::new(infer_value_ty); - - // TODO: Handle negative intersection elements - if intersection.positive(db).iter().any(|elem| { - self.validate_attribute_assignment( - target, - *elem, - attribute, - &mut |builder, tcx| infer_value_ty.infer_silent(builder, tcx), - false, - ) - }) { - // Perform loud inference using the narrowed type context. - infer_value_ty.infer_loud(self, infer_value_ty.last_tcx()); - if emit_diagnostics { - self.validate_final_attribute_assignment(target, object_ty, attribute); - } - true - } else { - // Otherwise, perform loud inference without type context, as we failed to - // narrow to any given intersection element. - let value_ty = infer_value_ty.infer_loud(self, TypeContext::default()); - - if emit_diagnostics - && let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - // TODO: same here, see above - builder.into_diagnostic(format_args!( - "Object of type `{}` is not assignable \ - to attribute `{attribute}` on type `{}`", - value_ty.display(self.db()), - object_ty.display(self.db()), - )); - } - - false - } - } - - Type::EnumComplement(complement) => self.validate_attribute_assignment( - target, - complement.remaining_literal_union(db), - attribute, - infer_value_ty, - emit_diagnostics, - ), - - Type::TypeAlias(alias) => self.validate_attribute_assignment( - target, - alias.value_type(self.db()), - attribute, - infer_value_ty, - emit_diagnostics, - ), - - // Super instances do not allow attribute assignment - Type::NominalInstance(instance) if instance.has_known_class(db, KnownClass::Super) => { - infer_value_ty(self, TypeContext::default()); - - if emit_diagnostics - && let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to attribute `{attribute}` on type `{}`", - object_ty.display(self.db()), - )); - } - - false - } - Type::BoundSuper(_) => { - infer_value_ty(self, TypeContext::default()); - - if emit_diagnostics - && let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to attribute `{attribute}` on type `{}`", - object_ty.display(self.db()), - )); - } - false - } - - Type::Dynamic(..) | Type::Divergent(_) | Type::Never => { - infer_value_ty(self, TypeContext::default()); - true - } - - Type::NominalInstance(..) - | Type::ProtocolInstance(_) - | Type::LiteralValue(..) - | Type::SpecialForm(..) - | Type::KnownInstance(..) - | Type::PropertyInstance(..) - | Type::FunctionLiteral(..) - | Type::Callable(..) - | Type::BoundMethod(_) - | Type::KnownBoundMethod(_) - | Type::WrapperDescriptor(_) - | Type::DataclassDecorator(_) - | Type::DataclassTransformer(_) - | Type::TypeVar(..) - | Type::AlwaysTruthy - | Type::AlwaysFalsy - | Type::TypeIs(_) - | Type::TypeGuard(_) - | Type::TypeForm(_) - | Type::TypedDict(_) - | Type::NewTypeInstance(_) => { - // We may infer the value type multiple times with distinct type context during - // attribute resolution. - let mut infer_value_ty = MultiInferenceGuard::new(infer_value_ty); - - // Perform loud inference without type context, as we may encounter multiple equally - // applicable type contexts during attribute resolution. - let value_ty = infer_value_ty.infer_loud(self, TypeContext::default()); - - // Infer `__setattr__` once upfront. We use this result for: - // 1. Checking if it returns `Never` (indicating an immutable class) - // 2. As a fallback when no explicit attribute is found - // - // TODO: We could re-infer `value_ty` with type context here. - let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy( - db, - "__setattr__", - &mut CallArguments::positional([Type::string_literal(db, attribute), value_ty]), - TypeContext::default(), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ); - - // Check if `__setattr__` returns `Never` (indicating an immutable class). - // If so, block all attribute assignments regardless of explicit attributes. - let setattr_returns_never = match &setattr_dunder_call_result { - Ok(result) => result.return_type(db).is_never(), - Err(err) => err.return_type(db).is_some_and(|ty| ty.is_never()), - }; - - if setattr_returns_never { - if emit_diagnostics { - if let Some(builder) = self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - let is_setattr_synthesized = match object_ty.class_member_with_policy( - db, - "__setattr__".into(), - MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, - ) { - PlaceAndQualifiers { - place: Place::Defined(DefinedPlace { ty: attr_ty, .. }), - qualifiers: _, - } => attr_ty.is_callable_type(), - _ => false, - }; - - let member_exists = - !object_ty.member(db, attribute).place.is_undefined(); - - let msg = if !member_exists { - format!( - "Cannot assign to unresolved attribute `{attribute}` on type `{}`", - object_ty.display(db) - ) - } else if is_setattr_synthesized { - format!( - "Property `{attribute}` defined in `{}` is read-only", - object_ty.display(db) - ) - } else { - format!( - "Cannot assign to attribute `{attribute}` on type `{}` \ - whose `__setattr__` method returns `Never`/`NoReturn`", - object_ty.display(db) - ) - }; - - builder.into_diagnostic(msg); - } - } - return false; - } - - // Now check for explicit attributes (class member or instance member). - // If an explicit attribute exists, validate against its type. - // Only fall back to `__setattr__` when no explicit attribute is found. - let Some((meta_attr, fallback_attr)) = - self.assignment_attribute_members(object_ty, attribute) - else { - infer_value_ty.infer_loud(self, TypeContext::default()); - return true; - }; - - match meta_attr { - meta_attr @ PlaceAndQualifiers { .. } if meta_attr.is_class_var() => { - if emit_diagnostics - && let Some(builder) = - self.context.report_lint(&INVALID_ATTRIBUTE_ACCESS, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to ClassVar `{attribute}` \ - from an instance of type `{ty}`", - ty = object_ty.display(self.db()), - )); - } - false - } - PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - ty: meta_attr_ty, .. - }), - qualifiers, - } => { - // Resolve `Self` type variables to the concrete instance type. - let meta_attr_ty = meta_attr_ty.bind_self_typevars(db, object_ty); - - if emit_diagnostics - && self.invalid_assignment_to_final_attribute( - object_ty, target, attribute, qualifiers, - ) - { - return false; - } - - let assignable_to_meta_attr = if let Place::Defined(DefinedPlace { - ty: meta_dunder_set, - .. - }) = - meta_attr_ty.class_member(db, "__set__".into()).place - { - // TODO: We could use the annotated parameter type of `__set__` as - // type context here. - let dunder_set_result = meta_dunder_set.try_call( - db, - &CallArguments::positional([meta_attr_ty, object_ty, value_ty]), - ); - - if self.property_setter_returns_never(meta_attr_ty, object_ty, value_ty) - { - if emit_diagnostics - && let Some(builder) = - self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to attribute `{attribute}` on type `{}` \ - whose `__set__` method returns `Never`/`NoReturn`", - object_ty.display(db), - )); - } - return false; - } - - if emit_diagnostics - && let Err(dunder_set_failure) = dunder_set_result.as_ref() - { - report_bad_dunder_set_call( - &self.context, - dunder_set_failure, - attribute, - object_ty, - target, - ); - } - - dunder_set_result.is_ok() - } else { - let write_ty = effective_write_type(meta_attr_ty); - let value_ty = - infer_value_ty.infer_silent(self, TypeContext::new(Some(write_ty))); - - ensure_assignable_to(self, value_ty, write_ty) - }; - - let assignable_to_instance_attribute = - if let Some(fallback_attr) = fallback_attr { - let (assignable, boundness) = if let PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - ty: instance_attr_ty, - definedness: instance_attr_boundness, - .. - }), - qualifiers, - } = fallback_attr - { - // Bind `Self` via MRO matching. - let instance_attr_ty = - instance_attr_ty.bind_self_typevars(db, object_ty); - let write_ty = effective_write_type(instance_attr_ty); - let value_ty = infer_value_ty - .infer_silent(self, TypeContext::new(Some(write_ty))); - if emit_diagnostics - && self.invalid_assignment_to_final_attribute( - object_ty, target, attribute, qualifiers, - ) - { - return false; - } - - ( - ensure_assignable_to(self, value_ty, write_ty), - instance_attr_boundness, - ) - } else { - (true, Definedness::PossiblyUndefined) - }; - - if boundness == Definedness::PossiblyUndefined { - report_possibly_missing_attribute( - &self.context, - target, - attribute, - object_ty, - ); - } - - assignable - } else { - true - }; - - assignable_to_meta_attr && assignable_to_instance_attribute - } - - PlaceAndQualifiers { - place: Place::Undefined, - .. - } => { - if let Some(PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - ty: instance_attr_ty, - definedness: instance_attr_boundness, - .. - }), - qualifiers, - }) = fallback_attr - { - // Bind `Self` via MRO matching. - let instance_attr_ty = - instance_attr_ty.bind_self_typevars(db, object_ty); - let write_ty = effective_write_type(instance_attr_ty); - let value_ty = - infer_value_ty.infer_silent(self, TypeContext::new(Some(write_ty))); - if emit_diagnostics - && self.invalid_assignment_to_final_attribute( - object_ty, target, attribute, qualifiers, - ) - { - return false; - } - - if instance_attr_boundness == Definedness::PossiblyUndefined { - report_possibly_missing_attribute( - &self.context, - target, - attribute, - object_ty, - ); - } - - ensure_assignable_to(self, value_ty, write_ty) - } else { - // No explicit attribute found. Use `__setattr__` (already inferred - // above) as a fallback for dynamic attribute assignment. - match setattr_dunder_call_result { - // If __setattr__ succeeded, allow the assignment. - Ok(_) | Err(CallDunderError::PossiblyUnbound { .. }) => true, - Err(CallDunderError::CallError(..)) => { - if emit_diagnostics - && let Some(builder) = - self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign object of type `{}` to attribute \ - `{attribute}` on type `{}` with \ - custom `__setattr__` method.", - value_ty.display(db), - object_ty.display(db) - )); - } - false - } - Err(CallDunderError::MethodNotAvailable) => { - if emit_diagnostics - && let Some(builder) = - self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target) - { - builder.into_diagnostic(format_args!( - "Unresolved attribute `{}` on type `{}`", - attribute, - object_ty.display(db) - )); - } - false - } - } - } - } - } - } - - Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { - let Some((meta_attr, fallback_attr)) = - self.assignment_attribute_members(object_ty, attribute) - else { - infer_value_ty(self, TypeContext::default()); - return true; - }; - let Some(class_attr_self_ty) = object_ty.to_instance(db) else { - infer_value_ty(self, TypeContext::default()); - return true; - }; - - match meta_attr { - PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - ty: meta_attr_ty, .. - }), - qualifiers, - } => { - if emit_diagnostics - && self.invalid_assignment_to_final_attribute( - object_ty, target, attribute, qualifiers, - ) - { - infer_value_ty(self, TypeContext::default()); - return false; - } - - // We may infer the value type multiple times with distinct type context during - // attribute resolution. - let mut infer_value_ty = MultiInferenceGuard::new(infer_value_ty); - - // Perform loud inference without type context, as we may encounter multiple equally - // applicable type contexts during attribute resolution. - let value_ty = infer_value_ty.infer_loud(self, TypeContext::default()); - - let assignable_to_meta_attr = if let Place::Defined(DefinedPlace { - ty: meta_dunder_set, - .. - }) = - meta_attr_ty.class_member(db, "__set__".into()).place - { - // TODO: We could use the annotated parameter type of `__set__` as - // type context here. - let dunder_set_result = meta_dunder_set.try_call( - db, - &CallArguments::positional([meta_attr_ty, object_ty, value_ty]), - ); - - if self.property_setter_returns_never(meta_attr_ty, object_ty, value_ty) - { - if emit_diagnostics - && let Some(builder) = - self.context.report_lint(&INVALID_ASSIGNMENT, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to attribute `{attribute}` on type `{}` \ - whose `__set__` method returns `Never`/`NoReturn`", - object_ty.display(db), - )); - } - return false; - } - - if emit_diagnostics - && let Err(dunder_set_failure) = dunder_set_result.as_ref() - { - report_bad_dunder_set_call( - &self.context, - dunder_set_failure, - attribute, - object_ty, - target, - ); - } - - dunder_set_result.is_ok() - } else { - let value_ty = infer_value_ty - .infer_silent(self, TypeContext::new(Some(meta_attr_ty))); - ensure_assignable_to(self, value_ty, meta_attr_ty) - }; - - let assignable_to_class_attr = if let Some(fallback_attr) = fallback_attr { - let (assignable, boundness) = if let PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - ty: class_attr_ty, - definedness: class_attr_boundness, - .. - }), - .. - } = fallback_attr - { - let class_attr_ty = - class_attr_ty.bind_self_typevars(db, class_attr_self_ty); - let class_attr_ty = class_attribute_write_type(class_attr_ty); - let value_ty = infer_value_ty - .infer_silent(self, TypeContext::new(Some(class_attr_ty))); - ( - ensure_assignable_to(self, value_ty, class_attr_ty), - class_attr_boundness, - ) - } else { - (true, Definedness::PossiblyUndefined) - }; - - if boundness == Definedness::PossiblyUndefined { - report_possibly_missing_attribute( - &self.context, - target, - attribute, - object_ty, - ); - } - - assignable - } else { - true - }; - - assignable_to_meta_attr && assignable_to_class_attr - } - PlaceAndQualifiers { - place: Place::Undefined, - .. - } => { - if let Some(PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - ty: class_attr_ty, - definedness: class_attr_boundness, - .. - }), - qualifiers, - }) = fallback_attr - { - let class_attr_ty = - class_attr_ty.bind_self_typevars(db, class_attr_self_ty); - let class_attr_ty = class_attribute_write_type(class_attr_ty); - let value_ty = - infer_value_ty(self, TypeContext::new(Some(class_attr_ty))); - if emit_diagnostics - && self.invalid_assignment_to_final_attribute( - object_ty, target, attribute, qualifiers, - ) - { - return false; - } - - if class_attr_boundness == Definedness::PossiblyUndefined { - report_possibly_missing_attribute( - &self.context, - target, - attribute, - object_ty, - ); - } - - ensure_assignable_to(self, value_ty, class_attr_ty) - } else { - infer_value_ty(self, TypeContext::default()); - - let attribute_is_bound_on_instance = - object_ty.to_instance(self.db()).is_some_and(|instance| { - !instance - .instance_member(self.db(), attribute) - .place - .is_undefined() - }); - - // Attribute is declared or bound on instance. Forbid access from the class object - if emit_diagnostics { - if attribute_is_bound_on_instance { - if let Some(builder) = - self.context.report_lint(&INVALID_ATTRIBUTE_ACCESS, target) - { - builder.into_diagnostic(format_args!( - "Cannot assign to instance attribute \ - `{attribute}` from the class object `{ty}`", - ty = object_ty.display(self.db()), - )); - } - } else { - if let Some(builder) = - self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target) - { - builder.into_diagnostic(format_args!( - "Unresolved attribute `{}` on type `{}`.", - attribute, - object_ty.display(db) - )); - } - } - } - - false - } - } - } - } - - Type::ModuleLiteral(module) => { - let sym = if module - .module(db) - .known(db) - .is_some_and(KnownModule::is_builtins) - { - builtins_symbol(db, attribute) - } else { - module.static_member(db, attribute) - }; - if let Place::Defined(DefinedPlace { ty: attr_ty, .. }) = sym.place { - let value_ty = infer_value_ty(self, TypeContext::new(Some(attr_ty))); - - let assignable = value_ty.is_assignable_to(db, attr_ty); - if assignable { - true - } else { - if emit_diagnostics { - report_invalid_attribute_assignment( - &self.context, - target.range(), - attr_ty, - value_ty, - attribute, - ); - } - false - } - } else { - infer_value_ty(self, TypeContext::default()); - - if emit_diagnostics - && let Some(builder) = - self.context.report_lint(&UNRESOLVED_ATTRIBUTE, target) - { - builder.into_diagnostic(format_args!( - "Unresolved attribute `{}` on type `{}`.", - attribute, - object_ty.display(db) - )); - } - - false - } - } - } - } - fn validate_attribute_deletion( &mut self, target: &ast::ExprAttribute, @@ -3595,8 +2819,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .. }), .. - }) = self - .assignment_attribute_members(object_ty, attribute) + }) = assignment_attribute_members(db, object_ty, attribute) .map(|(meta_attr, _)| meta_attr) { let attr_ty = attr_ty.bind_self_typevars(db, object_ty); @@ -3651,63 +2874,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - fn assignment_attribute_members( - &self, - object_ty: Type<'db>, - attribute: &str, - ) -> Option<(PlaceAndQualifiers<'db>, Option>)> { - let db = self.db(); - let meta_attr = object_ty.class_member(db, attribute.into()); - let needs_fallback = matches!( - meta_attr.place, - Place::Defined(DefinedPlace { - definedness: Definedness::PossiblyUndefined, - .. - }) | Place::Undefined - ); - let fallback_attr = if needs_fallback { - Some(match object_ty { - Type::NominalInstance(..) - | Type::ProtocolInstance(_) - | Type::LiteralValue(..) - | Type::SpecialForm(..) - | Type::KnownInstance(..) - | Type::PropertyInstance(..) - | Type::FunctionLiteral(..) - | Type::Callable(..) - | Type::BoundMethod(_) - | Type::KnownBoundMethod(_) - | Type::WrapperDescriptor(_) - | Type::DataclassDecorator(_) - | Type::DataclassTransformer(_) - | Type::EnumComplement(_) - | Type::TypeVar(..) - | Type::AlwaysTruthy - | Type::AlwaysFalsy - | Type::TypeIs(_) - | Type::TypeGuard(_) - | Type::TypeForm(_) - | Type::TypedDict(_) - | Type::NewTypeInstance(_) => object_ty.instance_member(db, attribute), - Type::ClassLiteral(..) | Type::GenericAlias(..) | Type::SubclassOf(..) => { - object_ty.class_object_member(db, attribute, MemberLookupPolicy::default()) - } - Type::Union(..) - | Type::Intersection(..) - | Type::TypeAlias(..) - | Type::Dynamic(..) - | Type::Divergent(_) - | Type::Never - | Type::ModuleLiteral(..) - | Type::BoundSuper(..) => return None, - }) - } else { - None - }; - - Some((meta_attr, fallback_attr)) - } - #[expect(clippy::type_complexity)] fn infer_target_impl( &mut self, diff --git a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs index febb05043806d..c2dbb2598e4f0 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs @@ -267,8 +267,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { PEP613Policy::Disallowed, ); - // Emit a diagnostic if ClassVar and Final are combined in a class that is - // not a dataclass, since Final already implies the semantics of ClassVar. + // Emit a diagnostic if ClassVar and Final are combined in a class where + // Final already implies the semantics of ClassVar. Dataclasses and + // protocols treat an unqualified Final declaration as an instance + // attribute, so the combination is meaningful in those classes. let classvar_and_final = match qualifier { TypeQualifier::Final => type_and_qualifiers .qualifiers @@ -280,7 +282,10 @@ impl<'db> TypeInferenceBuilder<'db, '_> { }; if classvar_and_final && nearest_enclosing_class(self.db(), self.index, self.scope()) - .is_none_or(|class| !class.is_dataclass_like(self.db())) + .is_none_or(|class| { + !class.is_dataclass_like(self.db()) + && !class.is_protocol(self.db()) + }) && let Some(builder) = self .context .report_lint(&REDUNDANT_FINAL_CLASSVAR, subscript) diff --git a/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs new file mode 100644 index 0000000000000..27cd7a0db19a4 --- /dev/null +++ b/crates/ty_python_semantic/src/types/infer/builder/attribute_assignment.rs @@ -0,0 +1,674 @@ +use ruff_python_ast as ast; +use ruff_text_size::Ranged; + +use super::{MultiInferenceGuard, TypeInferenceBuilder}; +use crate::place::{DefinedPlace, Place, PlaceAndQualifiers}; +use crate::types::attribute_write::{ + AttributeWriteRequirement, ClassAttributeWriteMember, ExplicitAttributeWriteRequirement, + FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, attribute_write_requirement, + property_setter_returns_never, +}; +use crate::types::call::{CallArguments, CallError}; +use crate::types::diagnostic::{ + INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, UNRESOLVED_ATTRIBUTE, report_bad_dunder_set_call, + report_invalid_attribute_assignment, report_possibly_missing_attribute, +}; +use crate::types::{CallDunderError, MemberLookupPolicy, Type, TypeContext, TypeQualifiers}; + +impl<'db> TypeInferenceBuilder<'db, '_> { + /// Make sure that the attribute assignment `obj.attribute = value` is valid. + /// + /// `target` is the node for the left-hand side, `object_ty` is the type of `obj`, `attribute` is + /// the name of the attribute being assigned, and `value_ty` is the type of the right-hand side of + /// the assignment. If the assignment is invalid, emit diagnostics. + pub(super) fn validate_attribute_assignment( + &mut self, + target: &ast::ExprAttribute, + object_ty: Type<'db>, + attribute: &str, + infer_value_ty: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, + emit_diagnostics: bool, + ) -> bool { + let requirement = attribute_write_requirement(self.db(), object_ty, attribute); + let mut evaluator = AssignmentAttributeWriteEvaluator { + builder: self, + target, + object_ty, + attribute, + infer_value_ty: MultiInferenceGuard::new(infer_value_ty), + }; + evaluator.evaluate(&requirement, emit_diagnostics) + } +} + +enum AssignmentAttributeWriteDiagnostic<'db> { + InvalidCompositeAssignment { + object_ty: Type<'db>, + value_ty: Type<'db>, + }, + CannotAssign, + CannotAssignToClassVar, + TerminalSetAttr { + member_exists: bool, + is_setattr_synthesized: bool, + }, + TerminalDescriptor, + BadDunderSet(CallError<'db>), + PossiblyMissing, + BadSetAttr { + value_ty: Type<'db>, + }, + Unresolved { + with_period: bool, + }, + CannotAssignToInstanceAttribute, +} + +#[derive(Clone, Copy)] +enum ContextualInference { + Commit, + Speculate, +} + +struct AssignmentAttributeWriteEvaluator<'a, 'db, 'ast, 'infer> { + builder: &'a mut TypeInferenceBuilder<'db, 'ast>, + target: &'a ast::ExprAttribute, + object_ty: Type<'db>, + attribute: &'a str, + infer_value_ty: MultiInferenceGuard<'db, 'ast, 'infer>, +} + +impl<'db> AssignmentAttributeWriteEvaluator<'_, 'db, '_, '_> { + fn infer_value(&mut self, tcx: TypeContext<'db>, emit_diagnostics: bool) -> Type<'db> { + if emit_diagnostics { + self.infer_value_ty.infer_loud(self.builder, tcx) + } else { + self.infer_value_ty.infer_silent(self.builder, tcx) + } + } + + /// Infer the value again using the context that succeeded. + /// + /// The earlier inference was only a trial, so its result was not saved. + fn infer_with_last_context(&mut self, emit_diagnostics: bool) -> Type<'db> { + self.infer_value(self.infer_value_ty.last_tcx(), emit_diagnostics) + } + + fn evaluate( + &mut self, + requirement: &AttributeWriteRequirement<'db>, + emit_diagnostics: bool, + ) -> bool { + match requirement { + AttributeWriteRequirement::All { + object_ty, + element_tys, + } => { + let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); + let mut valid = true; + for element_ty in *element_tys { + let requirement = + attribute_write_requirement(self.builder.db(), *element_ty, self.attribute); + if !self.evaluate(&requirement, false) { + valid = false; + break; + } + } + if valid { + self.validate_composite_final_assignment(*object_ty, emit_diagnostics); + true + } else { + if emit_diagnostics { + self.report( + AssignmentAttributeWriteDiagnostic::InvalidCompositeAssignment { + object_ty: *object_ty, + value_ty, + }, + ); + } + false + } + } + AttributeWriteRequirement::Any { + object_ty, + intersection, + } => { + let mut valid = false; + for element_ty in intersection.positive(self.builder.db()) { + let requirement = + attribute_write_requirement(self.builder.db(), *element_ty, self.attribute); + if self.evaluate(&requirement, false) { + valid = true; + break; + } + } + if valid { + self.infer_with_last_context(emit_diagnostics); + self.validate_composite_final_assignment(*object_ty, emit_diagnostics); + true + } else { + let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); + if emit_diagnostics { + self.report( + AssignmentAttributeWriteDiagnostic::InvalidCompositeAssignment { + object_ty: *object_ty, + value_ty, + }, + ); + } + false + } + } + AttributeWriteRequirement::Unconstrained => { + self.infer_value(TypeContext::default(), emit_diagnostics); + true + } + AttributeWriteRequirement::CannotAssign => { + self.infer_value(TypeContext::default(), emit_diagnostics); + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::CannotAssign); + } + false + } + AttributeWriteRequirement::Module(write_ty) => { + if let Some(write_ty) = write_ty { + let value_ty = + self.infer_value(TypeContext::new(Some(*write_ty)), emit_diagnostics); + self.check_type_pair(value_ty, *write_ty, emit_diagnostics) + } else { + self.infer_value(TypeContext::default(), emit_diagnostics); + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::Unresolved { + with_period: true, + }); + } + false + } + } + AttributeWriteRequirement::ProtocolMember { + write_ty, + qualifiers, + } => { + if let Some(write_ty) = write_ty { + let value_ty = + self.infer_value(TypeContext::new(Some(*write_ty)), emit_diagnostics); + self.check_type_pair(value_ty, *write_ty, emit_diagnostics) + } else { + self.infer_value(TypeContext::default(), emit_diagnostics); + let reported_final = !qualifiers.contains(TypeQualifiers::CLASS_VAR) + && qualifiers.contains(TypeQualifiers::FINAL) + && !self.final_assignment_is_valid( + self.object_ty, + *qualifiers, + emit_diagnostics, + ); + if emit_diagnostics && !reported_final { + self.report(if qualifiers.contains(TypeQualifiers::CLASS_VAR) { + AssignmentAttributeWriteDiagnostic::CannotAssignToClassVar + } else { + AssignmentAttributeWriteDiagnostic::CannotAssign + }); + } + false + } + } + AttributeWriteRequirement::Instance { object_ty, member } => { + self.evaluate_instance(*object_ty, member, emit_diagnostics) + } + AttributeWriteRequirement::Class { object_ty, member } => { + self.evaluate_class(*object_ty, member, emit_diagnostics) + } + } + } + + fn check_type_pair( + &mut self, + value_ty: Type<'db>, + target_ty: Type<'db>, + emit_diagnostics: bool, + ) -> bool { + let db = self.builder.db(); + let assignable = value_ty.is_assignable_to(db, target_ty); + if !assignable && emit_diagnostics { + report_invalid_attribute_assignment( + &self.builder.context, + self.target.range(), + target_ty, + value_ty, + self.attribute, + ); + } + assignable + } + + fn final_assignment_is_valid( + &mut self, + object_ty: Type<'db>, + qualifiers: TypeQualifiers, + emit_diagnostics: bool, + ) -> bool { + !(emit_diagnostics + && self.builder.invalid_assignment_to_final_attribute( + object_ty, + self.target, + self.attribute, + qualifiers, + )) + } + + fn validate_composite_final_assignment( + &mut self, + object_ty: Type<'db>, + emit_diagnostics: bool, + ) { + if emit_diagnostics { + self.builder.validate_final_attribute_assignment( + self.target, + object_ty, + self.attribute, + ); + } + } + + fn evaluate_instance( + &mut self, + object_ty: Type<'db>, + member: &InstanceAttributeWriteMember<'db>, + emit_diagnostics: bool, + ) -> bool { + let db = self.builder.db(); + let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); + + // A terminal `__setattr__` blocks even explicitly declared attributes. + let setattr_result = object_ty.try_call_dunder_with_policy( + db, + "__setattr__", + &mut CallArguments::positional([Type::string_literal(db, self.attribute), value_ty]), + TypeContext::default(), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ); + let setattr_returns_never = match &setattr_result { + Ok(bindings) => bindings.return_type(db).is_never(), + Err(error) => error.return_type(db).is_some_and(|ty| ty.is_never()), + }; + if setattr_returns_never { + if emit_diagnostics { + let is_setattr_synthesized = match object_ty.class_member_with_policy( + db, + "__setattr__".into(), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ) { + PlaceAndQualifiers { + place: Place::Defined(DefinedPlace { ty, .. }), + .. + } => ty.is_callable_type(), + _ => false, + }; + let member_exists = !object_ty.member(db, self.attribute).place.is_undefined(); + self.report(AssignmentAttributeWriteDiagnostic::TerminalSetAttr { + member_exists, + is_setattr_synthesized, + }); + } + return false; + } + + match member { + InstanceAttributeWriteMember::ClassVar => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::CannotAssignToClassVar); + } + false + } + InstanceAttributeWriteMember::Explicit { member, fallback } => { + if !self.final_assignment_is_valid(object_ty, member.qualifiers(), emit_diagnostics) + { + return false; + } + let member_valid = + self.evaluate_explicit_member(object_ty, member, value_ty, emit_diagnostics); + if let Some(fallback) = fallback { + let fallback_valid = + self.evaluate_instance_fallback(object_ty, fallback, emit_diagnostics); + member_valid && fallback_valid + } else { + member_valid + } + } + InstanceAttributeWriteMember::Instance(fallback) => { + self.evaluate_instance_fallback(object_ty, fallback, emit_diagnostics) + } + InstanceAttributeWriteMember::SetAttr => match setattr_result { + Ok(_) | Err(CallDunderError::PossiblyUnbound { .. }) => true, + Err(CallDunderError::CallError(..)) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::BadSetAttr { value_ty }); + } + false + } + Err(CallDunderError::MethodNotAvailable) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::Unresolved { + with_period: false, + }); + } + false + } + }, + } + } + + fn evaluate_class( + &mut self, + object_ty: Type<'db>, + member: &ClassAttributeWriteMember<'db>, + emit_diagnostics: bool, + ) -> bool { + match member { + ClassAttributeWriteMember::Explicit { member, fallback } => { + if !self.final_assignment_is_valid(object_ty, member.qualifiers(), emit_diagnostics) + { + self.infer_value(TypeContext::default(), emit_diagnostics); + return false; + } + let value_ty = self.infer_value(TypeContext::default(), emit_diagnostics); + let member_valid = + self.evaluate_explicit_member(object_ty, member, value_ty, emit_diagnostics); + if let Some(fallback) = fallback { + let fallback_valid = self.evaluate_class_fallback( + object_ty, + fallback, + emit_diagnostics, + ContextualInference::Speculate, + ); + member_valid && fallback_valid + } else { + member_valid + } + } + ClassAttributeWriteMember::ClassAttribute(fallback) => self.evaluate_class_fallback( + object_ty, + fallback, + emit_diagnostics, + ContextualInference::Commit, + ), + ClassAttributeWriteMember::Unresolved { + has_instance_attribute, + } => { + self.infer_value(TypeContext::default(), emit_diagnostics); + if emit_diagnostics { + self.report(if *has_instance_attribute { + AssignmentAttributeWriteDiagnostic::CannotAssignToInstanceAttribute + } else { + AssignmentAttributeWriteDiagnostic::Unresolved { with_period: true } + }); + } + false + } + } + } + + fn evaluate_explicit_member( + &mut self, + object_ty: Type<'db>, + requirement: &ExplicitAttributeWriteRequirement<'db>, + value_ty: Type<'db>, + emit_diagnostics: bool, + ) -> bool { + match requirement { + ExplicitAttributeWriteRequirement::Descriptor { + descriptor_ty, + setter_ty, + .. + } => { + let db = self.builder.db(); + let result = setter_ty.try_call( + db, + &CallArguments::positional([*descriptor_ty, object_ty, value_ty]), + ); + if property_setter_returns_never(db, *descriptor_ty, object_ty, value_ty) { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::TerminalDescriptor); + } + false + } else { + match result { + Ok(_) => true, + Err(error) => { + if emit_diagnostics { + self.report(AssignmentAttributeWriteDiagnostic::BadDunderSet( + error, + )); + } + false + } + } + } + } + ExplicitAttributeWriteRequirement::AssignableTo { ty, .. } => { + let value_ty = self.infer_value(TypeContext::new(Some(*ty)), false); + self.check_type_pair(value_ty, *ty, emit_diagnostics) + } + } + } + + fn evaluate_instance_fallback( + &mut self, + object_ty: Type<'db>, + requirement: &FallbackAttributeWriteRequirement<'db>, + emit_diagnostics: bool, + ) -> bool { + match requirement { + FallbackAttributeWriteRequirement::AssignableTo { + ty, + qualifiers, + possibly_missing, + } => { + if !self.final_assignment_is_valid(object_ty, *qualifiers, emit_diagnostics) { + return false; + } + let value_ty = self.infer_value(TypeContext::new(Some(*ty)), false); + let valid = self.check_type_pair(value_ty, *ty, emit_diagnostics); + if *possibly_missing { + self.report(AssignmentAttributeWriteDiagnostic::PossiblyMissing); + } + valid + } + FallbackAttributeWriteRequirement::PossiblyMissing => { + self.report(AssignmentAttributeWriteDiagnostic::PossiblyMissing); + true + } + } + } + + fn evaluate_class_fallback( + &mut self, + object_ty: Type<'db>, + requirement: &FallbackAttributeWriteRequirement<'db>, + emit_diagnostics: bool, + inference: ContextualInference, + ) -> bool { + match requirement { + FallbackAttributeWriteRequirement::AssignableTo { + ty, + qualifiers, + possibly_missing, + } => { + let value_ty = self.infer_value( + TypeContext::new(Some(*ty)), + matches!(inference, ContextualInference::Commit) && emit_diagnostics, + ); + if !self.final_assignment_is_valid(object_ty, *qualifiers, emit_diagnostics) { + return false; + } + let valid = self.check_type_pair(value_ty, *ty, emit_diagnostics); + if *possibly_missing { + self.report(AssignmentAttributeWriteDiagnostic::PossiblyMissing); + } + valid + } + FallbackAttributeWriteRequirement::PossiblyMissing => { + self.report(AssignmentAttributeWriteDiagnostic::PossiblyMissing); + true + } + } + } + + fn report(&mut self, diagnostic: AssignmentAttributeWriteDiagnostic<'db>) { + let db = self.builder.db(); + match diagnostic { + AssignmentAttributeWriteDiagnostic::InvalidCompositeAssignment { + object_ty, + value_ty, + } => { + if let Some(builder) = self + .builder + .context + .report_lint(&INVALID_ASSIGNMENT, self.target) + { + builder.into_diagnostic(format_args!( + "Object of type `{}` is not assignable to attribute `{}` on type `{}`", + value_ty.display(db), + self.attribute, + object_ty.display(db), + )); + } + } + AssignmentAttributeWriteDiagnostic::CannotAssign => { + if let Some(builder) = self + .builder + .context + .report_lint(&INVALID_ASSIGNMENT, self.target) + { + builder.into_diagnostic(format_args!( + "Cannot assign to attribute `{}` on type `{}`", + self.attribute, + self.object_ty.display(db), + )); + } + } + AssignmentAttributeWriteDiagnostic::CannotAssignToClassVar => { + if let Some(builder) = self + .builder + .context + .report_lint(&INVALID_ATTRIBUTE_ACCESS, self.target) + { + builder.into_diagnostic(format_args!( + "Cannot assign to ClassVar `{}` from an instance of type `{}`", + self.attribute, + self.object_ty.display(db), + )); + } + } + AssignmentAttributeWriteDiagnostic::TerminalSetAttr { + member_exists, + is_setattr_synthesized, + } => { + if let Some(builder) = self + .builder + .context + .report_lint(&INVALID_ASSIGNMENT, self.target) + { + let message = if !member_exists { + format!( + "Cannot assign to unresolved attribute `{}` on type `{}`", + self.attribute, + self.object_ty.display(db) + ) + } else if is_setattr_synthesized { + format!( + "Property `{}` defined in `{}` is read-only", + self.attribute, + self.object_ty.display(db) + ) + } else { + format!( + "Cannot assign to attribute `{}` on type `{}` whose `__setattr__` method returns `Never`/`NoReturn`", + self.attribute, + self.object_ty.display(db) + ) + }; + builder.into_diagnostic(message); + } + } + AssignmentAttributeWriteDiagnostic::TerminalDescriptor => { + if let Some(builder) = self + .builder + .context + .report_lint(&INVALID_ASSIGNMENT, self.target) + { + builder.into_diagnostic(format_args!( + "Cannot assign to attribute `{}` on type `{}` whose `__set__` method returns `Never`/`NoReturn`", + self.attribute, + self.object_ty.display(db), + )); + } + } + AssignmentAttributeWriteDiagnostic::BadDunderSet(failure) => { + report_bad_dunder_set_call( + &self.builder.context, + &failure, + self.attribute, + self.object_ty, + self.target, + ); + } + AssignmentAttributeWriteDiagnostic::PossiblyMissing => { + report_possibly_missing_attribute( + &self.builder.context, + self.target, + self.attribute, + self.object_ty, + ); + } + AssignmentAttributeWriteDiagnostic::BadSetAttr { value_ty } => { + if let Some(builder) = self + .builder + .context + .report_lint(&UNRESOLVED_ATTRIBUTE, self.target) + { + builder.into_diagnostic(format_args!( + "Cannot assign object of type `{}` to attribute `{}` on type `{}` with custom `__setattr__` method.", + value_ty.display(db), + self.attribute, + self.object_ty.display(db) + )); + } + } + AssignmentAttributeWriteDiagnostic::Unresolved { with_period } => { + if let Some(builder) = self + .builder + .context + .report_lint(&UNRESOLVED_ATTRIBUTE, self.target) + { + if with_period { + builder.into_diagnostic(format_args!( + "Unresolved attribute `{}` on type `{}`.", + self.attribute, + self.object_ty.display(db) + )); + } else { + builder.into_diagnostic(format_args!( + "Unresolved attribute `{}` on type `{}`", + self.attribute, + self.object_ty.display(db) + )); + } + } + } + AssignmentAttributeWriteDiagnostic::CannotAssignToInstanceAttribute => { + if let Some(builder) = self + .builder + .context + .report_lint(&INVALID_ATTRIBUTE_ACCESS, self.target) + { + builder.into_diagnostic(format_args!( + "Cannot assign to instance attribute `{}` from the class object `{}`", + self.attribute, + self.object_ty.display(db) + )); + } + } + } + } +} diff --git a/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs b/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs index 0451fa20f6721..5de7f6b16e5ac 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/final_attribute.rs @@ -7,7 +7,10 @@ use crate::place::place_from_declarations; use crate::types::infer::nearest_enclosing_function; use crate::{ TypeQualifiers, - types::{Type, diagnostic::INVALID_ASSIGNMENT, infer::TypeInferenceBuilder}, + types::{ + Type, attribute_write::assignment_attribute_members, diagnostic::INVALID_ASSIGNMENT, + infer::TypeInferenceBuilder, + }, }; use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::place::{PlaceExpr, ScopedPlaceId}; @@ -315,7 +318,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { attribute: &str, ) { let Some((meta_attr, fallback_attr)) = - self.assignment_attribute_members(object_ty, attribute) + assignment_attribute_members(self.db(), object_ty, attribute) else { return; }; @@ -344,7 +347,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { emit_diagnostics: bool, ) -> bool { let Some((meta_attr, fallback_attr)) = - self.assignment_attribute_members(object_ty, attribute) + assignment_attribute_members(self.db(), object_ty, attribute) else { return false; }; diff --git a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs index 331db868361d7..5a6231ae86049 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/post_inference/static_class.rs @@ -1364,8 +1364,10 @@ fn check_class_final_without_value<'db>( let place_table = index.place_table(body_scope_id); // In dataclasses (and similar code-generated classes), Final fields without - // defaults are initialized by the synthesized __init__, so they are valid. - if CodeGeneratorKind::from_class(db, class.into()).is_some() { + // defaults are initialized by the synthesized __init__. In protocols, the + // declaration describes a required instance attribute rather than storage + // that must be initialized by the protocol class itself. + if CodeGeneratorKind::from_class(db, class.into()).is_some() || class.is_protocol(db) { return; } diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 5d7ed184a8463..acd62cfebb696 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -6,6 +6,11 @@ use itertools::Itertools; use ruff_python_ast::name::Name; use rustc_hash::FxHashMap; +use crate::types::attribute_write::{ + AttributeWriteRequirement, ClassAttributeWriteMember, ExplicitAttributeWriteRequirement, + FallbackAttributeWriteRequirement, InstanceAttributeWriteMember, attribute_write_requirement, +}; +use crate::types::call::{CallArguments, CallDunderError}; use crate::types::callable::CallableTypeKind; use crate::types::relation::{DisjointnessChecker, TypeRelationChecker}; use crate::types::{TypeContext, UpcastPolicy}; @@ -16,15 +21,14 @@ use crate::{ place_from_declarations, }, types::{ - ApplyTypeMappingVisitor, BoundTypeVarInstance, CallableType, ClassBase, ClassType, - ErrorContext, FindLegacyTypeVarsVisitor, InstanceFallbackShadowsNonDataDescriptor, - KnownFunction, MemberLookupPolicy, PropertyInstanceType, ProtocolInstanceType, Signature, - StaticClassLiteral, Type, TypeMapping, TypeQualifiers, TypeVarVariance, VarianceInferable, + ApplyTypeMappingVisitor, BindingContext, BoundTypeVarInstance, CallableType, ClassBase, + ClassType, ErrorContext, FindLegacyTypeVarsVisitor, + InstanceFallbackShadowsNonDataDescriptor, KnownFunction, MemberLookupPolicy, + PropertyInstanceType, ProtocolInstanceType, SelfBinding, StaticClassLiteral, Type, + TypeMapping, TypeQualifiers, TypeVarVariance, UnionType, VarianceInferable, constraints::{ConstraintSet, IteratorConstraintsExtension, OptionConstraintsExtension}, context::InferContext, diagnostic::report_undeclared_protocol_member, - signatures::{Parameter, Parameters}, - todo_type, }, }; use ty_python_core::{definition::Definition, place::ScopedPlaceId, place_table, use_def_map}; @@ -214,24 +218,9 @@ impl<'db> ProtocolInterface<'db> { let members: BTreeMap<_, _> = members .into_iter() .map(|(name, ty)| { - // Synthesize a read-only property (one that has a getter but no setter) - // which returns the specified type from its getter. - let property_getter_signature = Signature::new( - Parameters::new( - db, - [Parameter::positional_only(Some(Name::new_static("self")))], - ), - ty, - ); - let property_getter = Type::single_callable(db, property_getter_signature); - let property = PropertyInstanceType::new(db, Some(property_getter), None, None); ( Name::new(name), - ProtocolMemberData { - qualifiers: TypeQualifiers::default(), - kind: ProtocolMemberKind::Property(property), - definition: None, - }, + ProtocolMemberData::property(Some(ProtocolMemberType::new(ty)), None, None), ) }) .collect(); @@ -248,11 +237,7 @@ impl<'db> ProtocolInterface<'db> { .map(|(name, callable)| { ( Name::new(name), - ProtocolMemberData { - qualifiers: TypeQualifiers::default(), - kind: ProtocolMemberKind::Method(callable), - definition: None, - }, + ProtocolMemberData::method(db, callable, None), ) }) .collect(); @@ -288,12 +273,9 @@ impl<'db> ProtocolInterface<'db> { where 'db: 'a, { - self.inner(db).iter().map(|(name, data)| ProtocolMember { - name, - kind: data.kind, - qualifiers: data.qualifiers, - definition: data.definition, - }) + self.inner(db) + .iter() + .map(|(name, data)| ProtocolMember { name, data }) } fn member_count(self, db: &'db dyn Db) -> usize { @@ -302,38 +284,76 @@ impl<'db> ProtocolInterface<'db> { pub(super) fn non_method_members(self, db: &'db dyn Db) -> Vec> { self.members(db) - .filter(|member| !member.is_method() && !member.ty().is_todo()) + .filter(|member| !member.is_method() && !member.has_todo_type()) .collect() } 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, - qualifiers: data.qualifiers, - definition: data.definition, - }) + self.inner(db) + .get(name) + .map(|data| ProtocolMember { name, data }) } pub(super) fn includes_member(self, db: &'db dyn Db, name: &str) -> bool { self.inner(db).contains_key(name) } + /// Returns the declared instance-write requirement for a protocol member. + /// + /// `None` means that the protocol does not declare `name`; `Some((None, _))` means that the + /// member exists but is read-only. A writable member's type is bound to `receiver_ty` before + /// it is returned. + pub(super) fn instance_write_requirement( + self, + db: &'db dyn Db, + receiver_ty: Type<'db>, + name: &str, + ) -> Option<(Option>, TypeQualifiers)> { + self.member_by_name(db, name).map(|member| { + let capabilities = member.capabilities(db); + ( + capabilities + .instance + .write + .and_then(|write| write.bind_self(db, receiver_ty)), + member.qualifiers(), + ) + }) + } + /// Returns the `__call__` method's callable type if this protocol has a `__call__` method member. pub(super) fn call_method(self, db: &'db dyn Db) -> Option> { - self.member_by_name(db, "__call__") - .and_then(|member| match member.kind { - ProtocolMemberKind::Method(callable) => Some(callable), + self.member_by_name(db, "__call__").and_then(|member| { + if !member.is_instance_method() { + return None; + } + match member + .capabilities(db) + .class + .read + .and_then(|read| read.resolve(db)) + .map(ProtocolMemberType::ty) + { + Some(Type::Callable(callable)) => Some(callable), _ => None, - }) + } + }) } 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()) - .with_provenance(Provenance::from_definition(member.definition())), - qualifiers: member.qualifiers(), + .map(|member| { + let capabilities = member.capabilities(db); + PlaceAndQualifiers { + place: capabilities + .instance + .read + .and_then(|read| read.resolve(db)) + .map(|read| Place::bound(read.ty())) + .unwrap_or(Place::Undefined) + .with_provenance(Provenance::from_definition(member.definition())), + qualifiers: member.qualifiers(), + } }) .unwrap_or_else(|| Type::object().member(db, name)) } @@ -420,12 +440,228 @@ impl<'db> ProtocolInterface<'db> { impl<'db> VarianceInferable<'db> for ProtocolInterface<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { self.members(db) - // TODO do we need to switch on member kind? - .map(|member| member.ty().variance_of(db, typevar)) + .flat_map(|member| { + let capabilities = member.capabilities(db); + [capabilities.instance, capabilities.class] + .into_iter() + .flat_map(|access| access.variances(db)) + }) + .map(|(ty, variance)| ty.with_polarity(variance).variance_of(db, typevar)) .collect() } } +/// A protocol member's exposed type and the context required to resolve it lazily. +/// +/// Property accessors remain as callables until a relation needs their read or write type. Once +/// resolved, `Value` retains the accessor's binding context so that only its own `Self` type is +/// rebound during protocol checks. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +enum ProtocolMemberType<'db> { + Value { + ty: Type<'db>, + // Member annotations can contain `Self` scoped to one definition or, for overloads, to + // each signature's definition. Retain the binding strategy so relation checks only bind + // the `Self` that belongs to this member. + self_binding: ProtocolMemberSelfBinding<'db>, + }, + // Property accessors remain as raw callable types until a relation or ordinary member access + // needs the exposed value type. Resolving every property while constructing a protocol + // interface causes unrelated protocol checks to materialize large return-type unions. + PropertyGetter(Type<'db>), + PropertySetter(Type<'db>), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +enum ProtocolMemberSelfBinding<'db> { + /// Bind `Self` belonging to the given member context. + Context(Option>), + /// Bind each callable signature using its own definition as the context. + CallableSignatures, +} + +impl<'db> ProtocolMemberType<'db> { + const fn new(ty: Type<'db>) -> Self { + Self::Value { + ty, + self_binding: ProtocolMemberSelfBinding::Context(None), + } + } + + fn with_definition(ty: Type<'db>, definition: Option>) -> Self { + Self::Value { + ty, + self_binding: ProtocolMemberSelfBinding::Context( + definition.map(BindingContext::Definition), + ), + } + } + + const fn with_callable_signatures(ty: Type<'db>) -> Self { + Self::Value { + ty, + self_binding: ProtocolMemberSelfBinding::CallableSignatures, + } + } + + const fn property_getter(ty: Type<'db>) -> Self { + Self::PropertyGetter(ty) + } + + const fn property_setter(ty: Type<'db>) -> Self { + Self::PropertySetter(ty) + } + + const fn ty(self) -> Type<'db> { + match self { + Self::Value { ty, .. } | Self::PropertyGetter(ty) | Self::PropertySetter(ty) => ty, + } + } + + const fn with_ty(self, ty: Type<'db>) -> Self { + match self { + Self::Value { self_binding, .. } => Self::Value { ty, self_binding }, + Self::PropertyGetter(_) => Self::PropertyGetter(ty), + Self::PropertySetter(_) => Self::PropertySetter(ty), + } + } + + /// Resolves a stored property accessor to the value type exposed by that access. + fn resolve(self, db: &'db dyn Db) -> Option { + match self { + Self::Value { .. } => Some(self), + Self::PropertyGetter(getter) => property_get_member_type(db, getter), + Self::PropertySetter(setter) => property_set_member_type(db, setter), + } + } + + /// Resolves this member type and binds member-local `Self` occurrences to `self_type`. + fn bind_self(self, db: &'db dyn Db, self_type: Type<'db>) -> Option> { + let Self::Value { ty, self_binding } = self.resolve(db)? else { + return None; + }; + if !ty.contains_self(db) { + return Some(ty); + } + + match self_binding { + ProtocolMemberSelfBinding::Context(context) => Some(ty.apply_type_mapping( + db, + &TypeMapping::BindSelf(SelfBinding::new(db, self_type, context)), + TypeContext::default(), + )), + ProtocolMemberSelfBinding::CallableSignatures => { + let Type::Callable(callable) = ty else { + return None; + }; + Some(Type::Callable(callable.apply_self(db, self_type))) + } + } + } + + fn cycle_normalized(self, db: &'db dyn Db, previous: Self, cycle: &salsa::Cycle) -> Self { + let ty = self.ty().cycle_normalized(db, previous.ty(), cycle); + self.with_ty(ty) + } + + fn recursive_type_normalized_impl( + self, + db: &'db dyn Db, + div: Type<'db>, + nested: bool, + ) -> Option { + let ty = if nested { + self.ty().recursive_type_normalized_impl(db, div, true)? + } else { + self.ty() + .recursive_type_normalized_impl(db, div, true) + .unwrap_or(div) + }; + Some(self.with_ty(ty)) + } + + fn apply_type_mapping_impl<'a>( + self, + db: &'db dyn Db, + type_mapping: &TypeMapping<'a, 'db>, + tcx: TypeContext<'db>, + visitor: &ApplyTypeMappingVisitor<'db>, + ) -> Self { + let ty = self + .ty() + .apply_type_mapping_impl(db, type_mapping, tcx, visitor); + self.with_ty(ty) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +/// The types supported by one way of accessing a protocol member. +/// +/// `read` is covariant and `write` is contravariant. Either operation can be absent: for example, +/// an instance cannot write a `ClassVar`, while a normal instance attribute has no class access. +struct ProtocolMemberAccess<'db> { + read: Option>, + write: Option>, +} + +impl<'db> ProtocolMemberAccess<'db> { + const NONE: Self = Self { + read: None, + write: None, + }; + + const fn new( + read: Option>, + write: Option>, + ) -> Self { + Self { read, write } + } + + fn variances(self, db: &'db dyn Db) -> impl Iterator, TypeVarVariance)> { + self.read + .and_then(|member| member.resolve(db)) + .map(|member| (member.ty(), TypeVarVariance::Covariant)) + .into_iter() + .chain( + self.write + .and_then(|member| member.resolve(db)) + .map(|member| (member.ty(), TypeVarVariance::Contravariant)), + ) + } +} + +/// The readable and writable types exposed through instance and class access. +/// +/// Instance access and class access each independently record readable and writable types. For +/// example, a mutable `ClassVar` is readable through both, writable through the class, and +/// read-only through an instance. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ProtocolMemberCapabilities<'db> { + instance: ProtocolMemberAccess<'db>, + class: ProtocolMemberAccess<'db>, +} + +#[derive(Copy, Clone, Eq, PartialEq)] +enum ProtocolMemberAccessMode { + Instance, + Class, +} + +fn cycle_normalized_optional_type<'db>( + db: &'db dyn Db, + current: Option>, + previous: Option>, + cycle: &salsa::Cycle, +) -> Option> { + match (current, previous) { + (Some(current), Some(previous)) => Some(current.cycle_normalized(db, previous, cycle)), + (Some(current), None) => { + Some(current.with_ty(current.ty().recursive_type_normalized(db, cycle))) + } + (None, _) => None, + } +} + #[derive(Debug, PartialEq, Eq, Clone, Hash, salsa::Update, get_size2::GetSize)] pub(super) struct ProtocolMemberData<'db> { kind: ProtocolMemberKind<'db>, @@ -434,9 +670,124 @@ pub(super) struct ProtocolMemberData<'db> { } impl<'db> ProtocolMemberData<'db> { + fn method( + db: &'db dyn Db, + callable: CallableType<'db>, + definition: Option>, + ) -> Self { + let kind = if callable.is_classmethod_like(db) || callable.is_staticmethod_like(db) { + ProtocolMethodKind::ClassOrStatic + } else { + ProtocolMethodKind::Instance + }; + let ty = match kind { + ProtocolMethodKind::Instance => ProtocolMemberType::new(Type::Callable(callable)), + ProtocolMethodKind::ClassOrStatic if callable.signatures(db).overloads.len() > 1 => { + ProtocolMemberType::with_callable_signatures(Type::Callable(callable)) + } + ProtocolMethodKind::ClassOrStatic => { + ProtocolMemberType::with_definition(Type::Callable(callable), definition) + } + }; + Self { + kind: ProtocolMemberKind::Method { ty, kind }, + qualifiers: TypeQualifiers::default(), + definition, + } + } + + fn property( + read: Option>, + write: Option>, + definition: Option>, + ) -> Self { + Self { + kind: ProtocolMemberKind::Property { read, write }, + qualifiers: TypeQualifiers::default(), + definition, + } + } + + fn attribute( + ty: Type<'db>, + qualifiers: TypeQualifiers, + definition: Option>, + ) -> Self { + Self { + kind: ProtocolMemberKind::Attribute(ProtocolMemberType::with_definition( + ty, definition, + )), + qualifiers, + definition, + } + } + + /// Derives the instance/class read/write capabilities exposed by this member. + /// + /// These are views of the canonical method, property, or attribute representation below; + /// keeping them derived prevents the stored member kind and its capabilities from diverging. + fn capabilities(&self, db: &'db dyn Db) -> ProtocolMemberCapabilities<'db> { + match self.kind { + ProtocolMemberKind::Method { ty, kind } => { + let instance_method = match ty.ty() { + Type::Callable(callable) => { + let callable = match kind { + ProtocolMethodKind::ClassOrStatic + if !callable.is_classmethod_like(db) => + { + callable.into_regular(db) + } + ProtocolMethodKind::Instance | ProtocolMethodKind::ClassOrStatic => { + protocol_bind_self(db, callable, None) + } + }; + ty.with_ty(Type::Callable(callable)) + } + _ => ty, + }; + ProtocolMemberCapabilities { + instance: ProtocolMemberAccess::new(Some(instance_method), None), + class: ProtocolMemberAccess::new( + Some(match kind { + ProtocolMethodKind::Instance => ty, + ProtocolMethodKind::ClassOrStatic => instance_method, + }), + None, + ), + } + } + ProtocolMemberKind::Property { read, write } => ProtocolMemberCapabilities { + instance: ProtocolMemberAccess::new(read, write), + class: ProtocolMemberAccess::NONE, + }, + ProtocolMemberKind::Attribute(member_ty) => { + let is_class_var = self.qualifiers.contains(TypeQualifiers::CLASS_VAR); + let is_final = self.qualifiers.contains(TypeQualifiers::FINAL); + // A `Todo` records a protocol member form that is not modeled yet. In particular, + // classmethod and staticmethod members currently use the attribute representation; + // do not infer a write requirement from that temporary representation. + let is_todo = member_ty.ty().is_todo(); + ProtocolMemberCapabilities { + instance: ProtocolMemberAccess::new( + Some(member_ty), + (!is_class_var && !is_final && !is_todo).then_some(member_ty), + ), + class: if is_class_var { + ProtocolMemberAccess::new( + Some(member_ty), + (!is_final && !is_todo).then_some(member_ty), + ) + } else { + ProtocolMemberAccess::NONE + }, + } + } + } + } + fn cycle_normalized(&self, db: &'db dyn Db, previous: &Self, cycle: &salsa::Cycle) -> Self { Self { - kind: self.kind.cycle_normalized(db, &previous.kind, cycle), + kind: self.kind.cycle_normalized(db, previous.kind, cycle), qualifiers: self.qualifiers, definition: self.definition, } @@ -449,21 +800,7 @@ impl<'db> ProtocolMemberData<'db> { nested: bool, ) -> Option { Some(Self { - kind: match &self.kind { - ProtocolMemberKind::Method(callable) => ProtocolMemberKind::Method( - callable.recursive_type_normalized_impl(db, div, nested)?, - ), - ProtocolMemberKind::Property(property) => ProtocolMemberKind::Property( - property.recursive_type_normalized_impl(db, div, nested)?, - ), - ProtocolMemberKind::Other(ty) if nested => { - ProtocolMemberKind::Other(ty.recursive_type_normalized_impl(db, div, true)?) - } - ProtocolMemberKind::Other(ty) => ProtocolMemberKind::Other( - ty.recursive_type_normalized_impl(db, div, true) - .unwrap_or(div), - ), - }, + kind: self.kind.recursive_type_normalized_impl(db, div, nested)?, qualifiers: self.qualifiers, definition: self.definition, }) @@ -490,38 +827,41 @@ impl<'db> ProtocolMemberData<'db> { db: &'db dyn Db, binding_context: Option>, typevars: &mut FxOrderSet>, - visitor: &FindLegacyTypeVarsVisitor<'db>, + _visitor: &FindLegacyTypeVarsVisitor<'db>, ) { - self.kind - .find_legacy_typevars_impl(db, binding_context, typevars, visitor); + for member_type in self.kind.member_types() { + member_type + .ty() + .find_legacy_typevars(db, binding_context, typevars); + } } fn display(&self, db: &'db dyn Db) -> impl std::fmt::Display { struct ProtocolMemberDataDisplay<'db> { db: &'db dyn Db, - data: ProtocolMemberKind<'db>, + kind: ProtocolMemberKind<'db>, qualifiers: TypeQualifiers, } impl std::fmt::Display for ProtocolMemberDataDisplay<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.data { - ProtocolMemberKind::Method(callable) => { - write!(f, "MethodMember(`{}`)", callable.display(self.db)) + match self.kind { + ProtocolMemberKind::Method { ty, .. } => { + write!(f, "MethodMember(`{}`)", ty.ty().display(self.db)) } - ProtocolMemberKind::Property(property) => { + ProtocolMemberKind::Property { read, write } => { let mut d = f.debug_struct("PropertyMember"); - if let Some(getter) = property.getter(self.db) { - d.field("getter", &format_args!("`{}`", &getter.display(self.db))); + if let Some(read) = read.and_then(|read| read.resolve(self.db)) { + d.field("read", &format_args!("`{}`", &read.ty().display(self.db))); } - if let Some(setter) = property.setter(self.db) { - d.field("setter", &format_args!("`{}`", &setter.display(self.db))); + if let Some(write) = write.and_then(|write| write.resolve(self.db)) { + d.field("write", &format_args!("`{}`", &write.ty().display(self.db))); } d.finish() } - ProtocolMemberKind::Other(ty) => { + ProtocolMemberKind::Attribute(attribute) => { f.write_str("AttributeMember(")?; - write!(f, "`{}`", ty.display(self.db))?; + write!(f, "`{}`", attribute.ty().display(self.db))?; if self.qualifiers.contains(TypeQualifiers::CLASS_VAR) { f.write_str("; ClassVar")?; } @@ -533,101 +873,140 @@ impl<'db> ProtocolMemberData<'db> { ProtocolMemberDataDisplay { db, - data: self.kind, + kind: self.kind, qualifiers: self.qualifiers, } } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, salsa::Update, Hash, get_size2::GetSize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] enum ProtocolMemberKind<'db> { - Method(CallableType<'db>), - Property(PropertyInstanceType<'db>), - Other(Type<'db>), + Method { + ty: ProtocolMemberType<'db>, + kind: ProtocolMethodKind, + }, + Property { + read: Option>, + write: Option>, + }, + Attribute(ProtocolMemberType<'db>), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +enum ProtocolMethodKind { + /// Instance and class access expose bound and unbound callables respectively. + Instance, + /// Instance and class access expose the same bound callable. + /// + /// Classmethods and staticmethods are structurally equivalent once their descriptor behavior + /// has been applied, so protocol compatibility does not need to distinguish between them. + ClassOrStatic, } impl<'db> ProtocolMemberKind<'db> { - fn cycle_normalized(&self, db: &'db dyn Db, previous: &Self, cycle: &salsa::Cycle) -> Self { + fn member_types(self) -> impl Iterator> { + match self { + Self::Method { ty, .. } => [Some(ty), None], + Self::Property { read, write } => [read, write], + Self::Attribute(attribute) => [Some(attribute), None], + } + .into_iter() + .flatten() + } + + fn cycle_normalized(self, db: &'db dyn Db, previous: Self, cycle: &salsa::Cycle) -> Self { match (self, previous) { - (Self::Method(curr), Self::Method(prev)) => { - debug_assert_eq!(curr.kind(db), prev.kind(db)); - let normalized = - curr.signatures(db) - .cycle_normalized(db, prev.signatures(db), cycle); - Self::Method(CallableType::new( - db, - normalized, - curr.kind(db), - curr.provenance(db), - )) - } - (Self::Property(curr), Self::Property(prev)) => { - let getter = match (curr.getter(db), prev.getter(db)) { - (Some(curr), Some(prev)) => Some(curr.cycle_normalized(db, prev, cycle)), - (Some(curr), None) => Some(curr.recursive_type_normalized(db, cycle)), - (None, _) => None, - }; - let setter = match (curr.setter(db), prev.setter(db)) { - (Some(curr), Some(prev)) => Some(curr.cycle_normalized(db, prev, cycle)), - (Some(curr), None) => Some(curr.recursive_type_normalized(db, cycle)), - (None, _) => None, - }; - let deleter = match (curr.deleter(db), prev.deleter(db)) { - (Some(curr), Some(prev)) => Some(curr.cycle_normalized(db, prev, cycle)), - (Some(curr), None) => Some(curr.recursive_type_normalized(db, cycle)), - (None, _) => None, + (Self::Method { ty: current, kind }, Self::Method { ty: previous, .. }) => { + let (Type::Callable(current_callable), Type::Callable(previous_callable)) = + (current.ty(), previous.ty()) + else { + return Self::Method { + ty: current.cycle_normalized(db, previous, cycle), + kind, + }; }; - Self::Property(PropertyInstanceType::new(db, getter, setter, deleter)) - } - (Self::Other(curr), Self::Other(prev)) => { - Self::Other(curr.cycle_normalized(db, *prev, cycle)) + debug_assert_eq!(current_callable.kind(db), previous_callable.kind(db)); + let signatures = current_callable.signatures(db).cycle_normalized( + db, + previous_callable.signatures(db), + cycle, + ); + Self::Method { + ty: current.with_ty(Type::Callable(CallableType::new( + db, + signatures, + current_callable.kind(db), + current_callable.provenance(db), + ))), + kind, + } } - _ => { - debug_assert!(matches!(previous, Self::Other(ty) if ty.is_divergent())); - *self + ( + Self::Property { + read: current_read, + write: current_write, + }, + Self::Property { + read: previous_read, + write: previous_write, + }, + ) => Self::Property { + read: cycle_normalized_optional_type(db, current_read, previous_read, cycle), + write: cycle_normalized_optional_type(db, current_write, previous_write, cycle), + }, + (Self::Attribute(current), Self::Attribute(previous)) => { + Self::Attribute(current.cycle_normalized(db, previous, cycle)) } + (current, _) => current, } } + fn recursive_type_normalized_impl( + self, + db: &'db dyn Db, + div: Type<'db>, + nested: bool, + ) -> Option { + Some(match self { + Self::Method { ty, kind } => Self::Method { + ty: ty.recursive_type_normalized_impl(db, div, nested)?, + kind, + }, + Self::Property { read, write } => Self::Property { + read: match read { + Some(read) => Some(read.recursive_type_normalized_impl(db, div, nested)?), + None => None, + }, + write: match write { + Some(write) => Some(write.recursive_type_normalized_impl(db, div, nested)?), + None => None, + }, + }, + Self::Attribute(attribute) => { + Self::Attribute(attribute.recursive_type_normalized_impl(db, div, nested)?) + } + }) + } + fn apply_type_mapping_impl<'a>( - &self, + self, db: &'db dyn Db, type_mapping: &TypeMapping<'a, 'db>, tcx: TypeContext<'db>, visitor: &ApplyTypeMappingVisitor<'db>, ) -> Self { match self { - ProtocolMemberKind::Method(callable) => ProtocolMemberKind::Method( - callable.apply_type_mapping_impl(db, type_mapping, tcx, visitor), - ), - ProtocolMemberKind::Property(property) => ProtocolMemberKind::Property( - property.apply_type_mapping_impl(db, type_mapping, tcx, visitor), - ), - ProtocolMemberKind::Other(ty) => ProtocolMemberKind::Other(ty.apply_type_mapping_impl( - db, - type_mapping, - tcx, - visitor, - )), - } - } - - fn find_legacy_typevars_impl( - &self, - db: &'db dyn Db, - binding_context: Option>, - typevars: &mut FxOrderSet>, - visitor: &FindLegacyTypeVarsVisitor<'db>, - ) { - match self { - ProtocolMemberKind::Method(callable) => { - callable.find_legacy_typevars_impl(db, binding_context, typevars, visitor); - } - ProtocolMemberKind::Property(property) => { - property.find_legacy_typevars_impl(db, binding_context, typevars, visitor); - } - ProtocolMemberKind::Other(ty) => { - ty.find_legacy_typevars(db, binding_context, typevars); + Self::Method { ty, kind } => Self::Method { + ty: ty.apply_type_mapping_impl(db, type_mapping, tcx, visitor), + kind, + }, + Self::Property { read, write } => Self::Property { + read: read.map(|read| read.apply_type_mapping_impl(db, type_mapping, tcx, visitor)), + write: write + .map(|write| write.apply_type_mapping_impl(db, type_mapping, tcx, visitor)), + }, + Self::Attribute(attribute) => { + Self::Attribute(attribute.apply_type_mapping_impl(db, type_mapping, tcx, visitor)) } } } @@ -637,9 +1016,7 @@ impl<'db> ProtocolMemberKind<'db> { #[derive(Debug, PartialEq, Eq)] pub(super) struct ProtocolMember<'a, 'db> { name: &'a str, - kind: ProtocolMemberKind<'db>, - qualifiers: TypeQualifiers, - definition: Option>, + data: &'a ProtocolMemberData<'db>, } fn walk_protocol_member<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( @@ -647,12 +1024,8 @@ fn walk_protocol_member<'db, V: super::visitor::TypeVisitor<'db> + ?Sized>( member: &ProtocolMember<'_, 'db>, visitor: &V, ) { - match member.kind { - ProtocolMemberKind::Method(method) => visitor.visit_callable_type(db, method), - ProtocolMemberKind::Property(property) => { - visitor.visit_property_instance_type(db, property); - } - ProtocolMemberKind::Other(ty) => visitor.visit_type(db, ty), + for member_type in member.data.kind.member_types() { + visitor.visit_type(db, member_type.ty()); } } @@ -662,133 +1035,627 @@ impl<'a, 'db> ProtocolMember<'a, 'db> { } pub(super) fn qualifiers(&self) -> TypeQualifiers { - self.qualifiers + self.data.qualifiers + } + + pub(super) fn is_method(&self) -> bool { + matches!(self.data.kind, ProtocolMemberKind::Method { .. }) + } + + fn is_instance_method(&self) -> bool { + matches!( + self.data.kind, + ProtocolMemberKind::Method { + kind: ProtocolMethodKind::Instance, + .. + } + ) } - pub(super) const fn is_method(&self) -> bool { - matches!(self.kind, ProtocolMemberKind::Method(_)) + fn is_property(&self) -> bool { + matches!(self.data.kind, ProtocolMemberKind::Property { .. }) } pub(super) fn definition(&self) -> Option> { - self.definition + self.data.definition } - fn ty(&self) -> Type<'db> { - match &self.kind { - ProtocolMemberKind::Method(callable) => Type::Callable(*callable), - ProtocolMemberKind::Property(property) => Type::PropertyInstance(*property), - ProtocolMemberKind::Other(ty) => *ty, + fn capabilities(&self, db: &'db dyn Db) -> ProtocolMemberCapabilities<'db> { + self.data.capabilities(db) + } + + fn has_todo_type(&self) -> bool { + self.data + .kind + .member_types() + .any(|ty| matches!(ty, ProtocolMemberType::Value { ty, .. } if ty.is_todo())) + } +} + +fn property_get_member_type<'db>( + db: &'db dyn Db, + getter: Type<'db>, +) -> Option> { + let mut get_types = Vec::new(); + let mut definition = None; + for callable in &getter.try_upcast_to_callable(db)? { + for signature in callable.signatures(db) { + get_types.push(signature.return_ty); + definition = definition.or(signature.definition()); } } + Some(ProtocolMemberType::with_definition( + UnionType::from_elements(db, get_types), + definition, + )) } -impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { - /// Return `true` if `other` contains an attribute/method/property that satisfies - /// the part of the interface defined by this protocol member. - pub(super) fn type_satisfies_protocol_member( - &self, - db: &'db dyn Db, - ty: Type<'db>, - member: &ProtocolMember<'_, 'db>, - ) -> ConstraintSet<'db, 'c> { - let result = match &member.kind { - ProtocolMemberKind::Method(method) => { - // `__call__` members must be special cased for several reasons: - // - // 1. Looking up `__call__` on the meta-type of a `Callable` type returns `Place::Undefined` currently - // 2. Looking up `__call__` on the meta-type of a function-literal type currently returns a type that - // has an extremely vague signature (`(*args, **kwargs) -> Any`), which is not useful for protocol - // checking. - // 3. Looking up `__call__` on the meta-type of a class-literal, generic-alias or subclass-of type is - // unfortunately not sufficient to obtain the `Callable` supertypes of these types, due to the - // complex interaction between `__new__`, `__init__` and metaclass `__call__`. - let attribute_type = if member.name == "__call__" { - ty - } else { - let Place::Defined(DefinedPlace { - ty: attribute_type, - definedness: Definedness::AlwaysDefined, - .. - }) = ty - .invoke_descriptor_protocol( - db, - ty, - member.name, - Place::Undefined.into(), - InstanceFallbackShadowsNonDataDescriptor::No, - MemberLookupPolicy::default(), - ) - .place - else { - self.provide_context(|| ErrorContext::ProtocolMemberNotDefined { - member_name: member.name.into(), - ty, - }); - return self.never(); - }; - attribute_type - }; +fn property_set_member_type<'db>( + db: &'db dyn Db, + setter: Type<'db>, +) -> Option> { + let mut set_types = Vec::new(); + let mut definition = None; + for callable in &setter.try_upcast_to_callable(db)? { + for signature in callable.signatures(db) { + set_types.push(signature.parameters().get_positional(1)?.annotated_type()); + definition = definition.or(signature.definition()); + } + } + Some(ProtocolMemberType::with_definition( + UnionType::from_elements(db, set_types), + definition, + )) +} - // TODO: Instances of `typing.Self` in the protocol member should specialize to the - // type that we are checking. Without this, we will treat `Self` as an inferable - // typevar, and allow it to match against _any_ type. - // - // It's not very principled, but we also use the literal fallback type, instead of - // `other` directly. This lets us check whether things like `Literal[0]` satisfy a - // protocol that includes methods that have `typing.Self` annotations, without - // overly constraining `Self` to that specific literal. - // - // With the new solver, we should be to replace all of this with an additional - // constraint that enforces what `Self` can specialize to. - let fallback_other = ty.literal_fallback_instance(db).unwrap_or(ty); - attribute_type - .try_upcast_to_callable_with_policy(db, UpcastPolicy::from(self.relation)) - .when_some_and(db, self.constraints, |callables| { - self.check_callables_vs_callable( - db, - &callables.map(|callable| callable.apply_self(db, fallback_other)), - protocol_bind_self(db, *method, Some(fallback_other)), - ) - }) +fn property_set_type<'db>( + db: &'db dyn Db, + property: PropertyInstanceType<'db>, + receiver_ty: Type<'db>, +) -> Option> { + property_set_member_type(db, property.setter(db)?)?.bind_self(db, receiver_ty) +} + +fn protocol_member_read_type<'db>( + db: &'db dyn Db, + ty: Type<'db>, + receiver_ty: Type<'db>, + member: &ProtocolMember<'_, 'db>, + access: ProtocolMemberAccessMode, +) -> Option> { + if access == ProtocolMemberAccessMode::Instance + && member.is_instance_method() + && member.name == "__call__" + { + return Some(ty); + } + + let place = if access == ProtocolMemberAccessMode::Instance && member.is_method() { + ty.invoke_descriptor_protocol( + db, + ty, + member.name, + Place::Undefined.into(), + InstanceFallbackShadowsNonDataDescriptor::No, + MemberLookupPolicy::default(), + ) + .place + } else { + receiver_ty.member(db, member.name).place + }; + + match place { + Place::Defined(DefinedPlace { + ty: attribute_type, + definedness: Definedness::AlwaysDefined, + .. + }) => Some(attribute_type), + _ => None, + } +} + +impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { + /// Checks a synthetic protocol-member write using normal attribute-assignment lookup. + /// + /// Resolution is shared with real assignments, but this path evaluates the result using the + /// active type relation and constraints instead of inferring an expression or emitting an + /// assignment diagnostic. + fn check_property_write( + &self, + db: &'db dyn Db, + ty: Type<'db>, + member_name: &str, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + let requirement = attribute_write_requirement(db, ty, member_name); + self.check_property_write_requirement(db, &requirement, member_name, value_ty) + } + + fn check_property_write_requirement( + &self, + db: &'db dyn Db, + requirement: &AttributeWriteRequirement<'db>, + member_name: &str, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + match requirement { + AttributeWriteRequirement::All { element_tys, .. } => { + let mut result = self.always(); + for element_ty in *element_tys { + let requirement = attribute_write_requirement(db, *element_ty, member_name); + let element_result = self.check_property_write_requirement( + db, + &requirement, + member_name, + value_ty, + ); + result = result.and(db, self.constraints, || element_result); + if result.is_never_satisfied(db) { + break; + } + } + result } - // TODO: consider the types of the attribute on `other` for property members - ProtocolMemberKind::Property(_) => { - let is_defined = matches!( - ty.member(db, member.name).place, - Place::Defined(DefinedPlace { - definedness: Definedness::AlwaysDefined, - .. - }) - ); - if !is_defined { - self.provide_context(|| ErrorContext::ProtocolMemberNotDefined { - member_name: member.name.into(), - ty, - }); + AttributeWriteRequirement::Any { intersection, .. } => { + let mut result = self.never(); + for element_ty in intersection.positive(db) { + let requirement = attribute_write_requirement(db, *element_ty, member_name); + let element_result = self.check_property_write_requirement( + db, + &requirement, + member_name, + value_ty, + ); + result = result.or(db, self.constraints, || element_result); + if result.is_always_satisfied(db) { + break; + } + } + result + } + AttributeWriteRequirement::Unconstrained => self.always(), + AttributeWriteRequirement::CannotAssign => self.never(), + AttributeWriteRequirement::Module(Some(write_ty)) + | AttributeWriteRequirement::ProtocolMember { + write_ty: Some(write_ty), + .. + } => self.check_type_pair(db, value_ty, *write_ty), + AttributeWriteRequirement::Module(None) + | AttributeWriteRequirement::ProtocolMember { write_ty: None, .. } => self.never(), + AttributeWriteRequirement::Instance { object_ty, member } => { + self.check_instance_property_write(db, *object_ty, member, member_name, value_ty) + } + AttributeWriteRequirement::Class { object_ty, member } => { + self.check_class_property_write(db, *object_ty, member, value_ty) + } + } + } + + fn check_instance_property_write( + &self, + db: &'db dyn Db, + object_ty: Type<'db>, + member: &InstanceAttributeWriteMember<'db>, + member_name: &str, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + let setattr_result = object_ty.try_call_dunder_with_policy( + db, + "__setattr__", + &mut CallArguments::positional([Type::string_literal(db, member_name), value_ty]), + TypeContext::default(), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK, + ); + if match &setattr_result { + Ok(bindings) => bindings.return_type(db).is_never(), + Err(error) => error.return_type(db).is_some_and(|ty| ty.is_never()), + } { + return self.never(); + } + + match member { + InstanceAttributeWriteMember::ClassVar => self.never(), + InstanceAttributeWriteMember::Explicit { member, fallback } => { + let member_result = + self.check_explicit_property_write(db, object_ty, member, value_ty); + if let Some(fallback) = fallback { + let fallback_result = + self.check_fallback_property_write(db, fallback, value_ty); + member_result.and(db, self.constraints, || fallback_result) + } else { + member_result + } + } + InstanceAttributeWriteMember::Instance(fallback) => { + self.check_fallback_property_write(db, fallback, value_ty) + } + InstanceAttributeWriteMember::SetAttr => { + if !matches!( + setattr_result, + Ok(_) | Err(CallDunderError::PossiblyUnbound { .. }) + ) { return self.never(); } - ConstraintSet::from_bool(self.constraints, true) + self.check_setattr_property_write(db, object_ty, value_ty) } - ProtocolMemberKind::Other(member_type) => { - let Place::Defined(DefinedPlace { - ty: attribute_type, - definedness: Definedness::AlwaysDefined, - .. - }) = ty.member(db, member.name).place + } + } + + fn check_class_property_write( + &self, + db: &'db dyn Db, + object_ty: Type<'db>, + member: &ClassAttributeWriteMember<'db>, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + match member { + ClassAttributeWriteMember::Explicit { member, fallback } => { + let member_result = + self.check_explicit_property_write(db, object_ty, member, value_ty); + if member_result.is_never_satisfied(db) { + return member_result; + } + if let Some(fallback) = fallback { + let fallback_result = + self.check_fallback_property_write(db, fallback, value_ty); + member_result.and(db, self.constraints, || fallback_result) + } else { + member_result + } + } + ClassAttributeWriteMember::ClassAttribute(fallback) => { + self.check_fallback_property_write(db, fallback, value_ty) + } + ClassAttributeWriteMember::Unresolved { .. } => self.never(), + } + } + + fn check_explicit_property_write( + &self, + db: &'db dyn Db, + object_ty: Type<'db>, + requirement: &ExplicitAttributeWriteRequirement<'db>, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if requirement.qualifiers().contains(TypeQualifiers::FINAL) { + return self.never(); + } + match requirement { + ExplicitAttributeWriteRequirement::Descriptor { + descriptor_ty, + setter_ty, + .. + } => { + if let Some(property) = descriptor_ty.as_property_instance() + && let Some(set_type) = property_set_type(db, property, object_ty) + { + return self.check_type_pair(db, value_ty, set_type); + } + self.check_descriptor_property_write( + db, + *descriptor_ty, + *setter_ty, + object_ty, + value_ty, + ) + } + ExplicitAttributeWriteRequirement::AssignableTo { ty, .. } => { + self.check_type_pair(db, value_ty, *ty) + } + } + } + + fn check_descriptor_property_write( + &self, + db: &'db dyn Db, + descriptor_ty: Type<'db>, + setter_ty: Type<'db>, + object_ty: Type<'db>, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if setter_ty + .try_call( + db, + &CallArguments::positional([descriptor_ty, object_ty, Type::unknown()]), + ) + .is_err() + { + return self.never(); + } + + self.check_callable_write_parameter(db, setter_ty, 2, descriptor_ty, value_ty) + } + + fn check_setattr_property_write( + &self, + db: &'db dyn Db, + object_ty: Type<'db>, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + let Place::Defined(DefinedPlace { ty: setattr_ty, .. }) = object_ty + .member_lookup_with_policy( + db, + "__setattr__".into(), + MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK + | MemberLookupPolicy::NO_INSTANCE_FALLBACK, + ) + .place + else { + return self.never(); + }; + + self.check_callable_write_parameter(db, setattr_ty, 1, object_ty, value_ty) + } + + fn check_callable_write_parameter( + &self, + db: &'db dyn Db, + callable_ty: Type<'db>, + parameter_index: usize, + self_ty: Type<'db>, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + if let Type::Union(union) = value_ty { + return union + .elements(db) + .iter() + .when_all(db, self.constraints, |value_ty| { + self.check_callable_write_parameter( + db, + callable_ty, + parameter_index, + self_ty, + *value_ty, + ) + }); + } + + callable_ty + .try_upcast_to_callable_with_policy(db, UpcastPolicy::from(self.relation)) + .when_some_and(db, self.constraints, |callables| { + callables.iter().when_all(db, self.constraints, |callable| { + callable.signatures(db).into_iter().when_any( + db, + self.constraints, + |signature| { + let parameters = signature.parameters(); + parameters + .get_positional(parameter_index) + .or_else(|| { + parameters.variadic().and_then(|(index, parameter)| { + (index <= parameter_index).then_some(parameter) + }) + }) + .map(|parameter| { + parameter.annotated_type().bind_self_typevars(db, self_ty) + }) + .when_some_and(db, self.constraints, |write_ty| { + self.check_type_pair(db, value_ty, write_ty) + }) + }, + ) + }) + }) + } + + fn check_fallback_property_write( + &self, + db: &'db dyn Db, + requirement: &FallbackAttributeWriteRequirement<'db>, + value_ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + match requirement { + FallbackAttributeWriteRequirement::AssignableTo { ty, qualifiers, .. } => { + if qualifiers.contains(TypeQualifiers::FINAL) { + self.never() + } else { + self.check_type_pair(db, value_ty, *ty) + } + } + FallbackAttributeWriteRequirement::PossiblyMissing => self.always(), + } + } + + fn check_protocol_member_read( + &self, + db: &'db dyn Db, + ty: Type<'db>, + receiver_ty: Type<'db>, + member: &ProtocolMember<'_, 'db>, + required_ty: ProtocolMemberType<'db>, + access: ProtocolMemberAccessMode, + ) -> ConstraintSet<'db, 'c> { + let fallback_ty = ty.literal_fallback_instance(db).unwrap_or(ty); + let Some(attribute_type) = protocol_member_read_type(db, ty, receiver_ty, member, access) + else { + return self.never(); + }; + + // Checking a class object against a protocol's instance capabilities can expose the + // property descriptor itself rather than the value returned by its getter. Compatibility + // for properties on class objects is not yet modeled; retain the previous name-only + // behavior until generic upper-bound solving can handle the large recursive unions this + // otherwise creates. + if member.is_property() && matches!(attribute_type, Type::PropertyInstance(_)) { + return self.always(); + } + + if member.is_method() && access == ProtocolMemberAccessMode::Instance { + let required_callable = if member.is_instance_method() { + let Some(required_ty) = required_ty.resolve(db) else { + return self.never(); + }; + let Type::Callable(required_callable) = required_ty.ty() else { + return self.never(); + }; + required_callable.apply_self(db, fallback_ty) + } else { + let Some(Type::Callable(required_callable)) = + required_ty.bind_self(db, fallback_ty) else { - self.provide_context(|| ErrorContext::ProtocolMemberNotDefined { - member_name: member.name.into(), - ty, - }); return self.never(); }; - self.check_type_pair(db, *member_type, attribute_type).and( + required_callable + }; + attribute_type + .try_upcast_to_callable_with_policy(db, UpcastPolicy::from(self.relation)) + .when_some_and(db, self.constraints, |callables| { + self.check_callables_vs_callable( + db, + &callables.map(|callable| callable.apply_self(db, fallback_ty)), + required_callable, + ) + }) + } else if member.is_instance_method() { + let Some(required_ty) = required_ty.resolve(db) else { + return self.never(); + }; + let Type::Callable(required_callable) = required_ty.ty() else { + return self.never(); + }; + attribute_type + .try_upcast_to_callable_with_policy(db, UpcastPolicy::from(self.relation)) + .when_some_and(db, self.constraints, |callables| { + callables.iter().when_all(db, self.constraints, |callable| { + if callable.is_function_like(db) { + self.check_callable_pair( + db, + callable.bind_self(db, Some(fallback_ty)), + protocol_bind_self(db, required_callable, Some(fallback_ty)), + ) + } else { + self.check_callable_pair(db, *callable, required_callable) + } + }) + }) + } else { + required_ty.bind_self(db, fallback_ty).when_some_and( + db, + self.constraints, + |required_ty| self.check_type_pair(db, attribute_type, required_ty), + ) + } + } + + /// Checks the read and write capabilities required through instance access or class access. + /// + /// Reads are checked covariantly and writes contravariantly. For ordinary methods, the + /// instance-side signature check is authoritative and class access only establishes presence. + fn type_satisfies_protocol_member_access( + &self, + db: &'db dyn Db, + ty: Type<'db>, + receiver_ty: Type<'db>, + member: &ProtocolMember<'_, 'db>, + required: ProtocolMemberAccess<'db>, + access: ProtocolMemberAccessMode, + ) -> ConstraintSet<'db, 'c> { + if access == ProtocolMemberAccessMode::Class && member.is_instance_method() { + // The instance-side check is authoritative for the signature of a method + // implementation. Class access only establishes that the member is present. Callable + // types and several callable literal forms do not expose a useful `__call__` member + // through their meta-type. + return ConstraintSet::from_bool( + self.constraints, + member.name == "__call__" + || protocol_member_read_type( + db, + ty, + receiver_ty, + member, + ProtocolMemberAccessMode::Class, + ) + .is_some(), + ); + } + + let read_result = required.read.map_or_else( + || self.always(), + |required_ty| { + self.check_protocol_member_read(db, ty, receiver_ty, member, required_ty, access) + }, + ); + + read_result.and(db, self.constraints, || { + required.write.map_or_else( + || self.always(), + |write_ty| { + let fallback_ty = ty.literal_fallback_instance(db).unwrap_or(ty); + let receiver_ty = if access == ProtocolMemberAccessMode::Instance + && matches!(ty, Type::LiteralValue(_)) + { + fallback_ty + } else { + receiver_ty + }; + write_ty.bind_self(db, fallback_ty).when_some_and( + db, + self.constraints, + |write_ty| { + self.check_property_write(db, receiver_ty, member.name, write_ty) + }, + ) + }, + ) + }) + } + + /// Return `true` if `ty` provides every access required by this protocol member. + pub(super) fn type_satisfies_protocol_member( + &self, + db: &'db dyn Db, + ty: Type<'db>, + member: &ProtocolMember<'_, 'db>, + ) -> ConstraintSet<'db, 'c> { + let capabilities = member.capabilities(db); + if self.is_context_collection_enabled() { + let instance_read_missing = capabilities.instance.read.is_some() + && protocol_member_read_type( db, - self.constraints, - || self.check_type_pair(db, attribute_type, *member_type), + ty, + ty, + member, + ProtocolMemberAccessMode::Instance, + ) + .is_none(); + let class_read_missing = capabilities.class.read.is_some() + && !(member.is_instance_method() && member.name == "__call__") + && protocol_member_read_type( + db, + ty, + ty.to_meta_type(db), + member, + ProtocolMemberAccessMode::Class, ) + .is_none(); + if instance_read_missing || class_read_missing { + self.provide_context(|| ErrorContext::ProtocolMemberNotDefined { + member_name: member.name.into(), + ty, + }); + return self.never(); } - }; + } + + let result = self + .type_satisfies_protocol_member_access( + db, + ty, + ty, + member, + capabilities.instance, + ProtocolMemberAccessMode::Instance, + ) + .and(db, self.constraints, || { + self.type_satisfies_protocol_member_access( + db, + ty, + ty.to_meta_type(db), + member, + capabilities.class, + ProtocolMemberAccessMode::Class, + ) + }); if result.is_never_satisfied(db) { self.provide_context(|| ErrorContext::ProtocolMemberIncompatible { member_name: member.name.into(), @@ -797,6 +1664,80 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { result } + /// Compares either instance access or class access when relating two protocol members. + /// + /// Both members bind `Self` to the source protocol type; readable types are compared + /// covariantly and writable types contravariantly. + fn check_protocol_member_access_pair( + &self, + db: &'db dyn Db, + source_type: Type<'db>, + source_member: &ProtocolMember<'_, 'db>, + target_member: &ProtocolMember<'_, 'db>, + access: ProtocolMemberAccessMode, + ) -> ConstraintSet<'db, 'c> { + let source_capabilities = source_member.capabilities(db); + let target_capabilities = target_member.capabilities(db); + let (source, target) = match access { + ProtocolMemberAccessMode::Instance => { + (source_capabilities.instance, target_capabilities.instance) + } + ProtocolMemberAccessMode::Class + if source_member.is_method() && target_member.is_instance_method() => + { + // The receiver type of an unbound method is specific to the class that defines + // it. Compare the corresponding bound access types; this also applies when the + // source is a classmethod or staticmethod, which can satisfy an ordinary method + // requirement using its bound signature. + (source_capabilities.instance, target_capabilities.instance) + } + ProtocolMemberAccessMode::Class => { + (source_capabilities.class, target_capabilities.class) + } + }; + + let read_result = match (source.read, target.read) { + (_, None) => self.always(), + (None, Some(_)) => self.never(), + (Some(source), Some(target)) => { + let bind_read = |member_type: ProtocolMemberType<'db>, + member: &ProtocolMember<'_, 'db>| { + if member.is_instance_method() + && let member_type = member_type.resolve(db)? + && let Type::Callable(callable) = member_type.ty() + { + Some(Type::Callable(callable.apply_self(db, source_type))) + } else { + member_type.bind_self(db, source_type) + } + }; + let (Some(source), Some(target)) = ( + bind_read(source, source_member), + bind_read(target, target_member), + ) else { + return self.never(); + }; + self.check_type_pair(db, source, target) + } + }; + + read_result.and(db, self.constraints, || { + match (source.write, target.write) { + (_, None) => self.always(), + (None, Some(_)) => self.never(), + (Some(source), Some(target)) => { + let (Some(target), Some(source)) = ( + target.bind_self(db, source_type), + source.bind_self(db, source_type), + ) else { + return self.never(); + }; + self.check_type_pair(db, target, source) + } + } + }) + } + pub(super) fn check_protocol_interface_pair( &self, db: &'db dyn Db, @@ -824,74 +1765,22 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } let result = source_member.when_some_and(db, self.constraints, |source_member| { - match (source_member.kind, target_member.kind) { - // Method members are always immutable; - // they can never be subtypes of/assignable to mutable attribute members. - (ProtocolMemberKind::Method(_), ProtocolMemberKind::Other(_)) => { - self.never() - } - - // A property member can only be a subtype of an attribute member - // if the property is readable *and* writable. - // - // TODO: this should also consider the types of the members on both sides. - (ProtocolMemberKind::Property(property), ProtocolMemberKind::Other(_)) => { - ConstraintSet::from_bool( - self.constraints, - property.getter(db).is_some() && property.setter(db).is_some(), - ) - } - - // A `@property` member can never be a subtype of a method member, as it is not necessarily - // accessible on the meta-type, whereas a method member must be. - (ProtocolMemberKind::Property(_), ProtocolMemberKind::Method(_)) => { - self.never() - } - - // But an attribute member *can* be a subtype of a method member, - // providing it is marked `ClassVar` - ( - ProtocolMemberKind::Other(source_type), - ProtocolMemberKind::Method(target_callable), - ) => ConstraintSet::from_bool( - self.constraints, - source_member.qualifiers.contains(TypeQualifiers::CLASS_VAR), - ) - .and(db, self.constraints, || { - self.check_type_pair( - db, - source_type, - Type::Callable(protocol_bind_self(db, target_callable, None)), - ) - }), - - ( - ProtocolMemberKind::Method(source_method), - ProtocolMemberKind::Method(target_method), - ) => self.check_callable_pair( - db, - source_method.bind_self(db, None), - protocol_bind_self(db, target_method, None), - ), - - ( - ProtocolMemberKind::Other(source_type), - ProtocolMemberKind::Other(target_type), - ) => self.check_type_pair(db, source_type, target_type).and( + self.check_protocol_member_access_pair( + db, + source_type, + &source_member, + &target_member, + ProtocolMemberAccessMode::Instance, + ) + .and(db, self.constraints, || { + self.check_protocol_member_access_pair( db, - self.constraints, - || self.check_type_pair(db, target_type, source_type), - ), - - // TODO: finish assignability/subtyping between two `@property` members, - // and between a `@property` member and a member of a different kind. - ( - ProtocolMemberKind::Property(_) - | ProtocolMemberKind::Method(_) - | ProtocolMemberKind::Other(_), - ProtocolMemberKind::Property(_), - ) => self.always(), - } + source_type, + &source_member, + &target_member, + ProtocolMemberAccessMode::Class, + ) + }) }); if result.is_never_satisfied(db) { self.provide_context(|| ErrorContext::ProtocolMemberIncompatible { @@ -904,34 +1793,84 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> { } impl<'c, 'db> DisjointnessChecker<'_, 'c, 'db> { - pub(super) fn protocol_member_has_disjoint_type_from_ty( + /// Conservatively proves that `ty` lacks an instance write required by `member`. + /// + /// This currently recognizes only a concrete read-only property. Unknown or unresolved write + /// behavior is not sufficient to prove disjointness. + pub(super) fn protocol_member_write_is_definitely_missing_from_ty( &self, db: &'db dyn Db, member: &ProtocolMember<'_, 'db>, ty: Type<'db>, ) -> ConstraintSet<'db, 'c> { - match &member.kind { - // TODO: implement disjointness for property members as well as attribute/method members. - ProtocolMemberKind::Property(_) => self.never(), - ProtocolMemberKind::Method(method) => { - let Some(method_return_type) = non_never_callable_return_type(db, *method) else { - return self.never(); - }; + if member.capabilities(db).instance.write.is_none() { + return self.never(); + } - ty.try_upcast_to_callable_with_policy(db, UpcastPolicy::Sound) - .when_some_and(db, self.constraints, |callables| { - callables.iter().when_all(db, self.constraints, |callable| { - non_never_callable_return_type(db, *callable).when_some_and( - db, - self.constraints, - |return_type| { - self.check_type_pair(db, method_return_type, return_type) - }, - ) + let Place::Defined(DefinedPlace { + ty: Type::PropertyInstance(actual_property), + definedness: Definedness::AlwaysDefined, + .. + }) = ty.class_member(db, member.name().into()).place + else { + return self.never(); + }; + + ConstraintSet::from_bool(self.constraints, actual_property.setter(db).is_none()) + } + + /// Checks whether `ty` is disjoint from the readable type required by `member`. + /// + /// Method members are compared conservatively through their non-`Never` return types rather + /// than their full callable signatures. + pub(super) fn protocol_member_has_disjoint_type_from_ty( + &self, + db: &'db dyn Db, + member: &ProtocolMember<'_, 'db>, + ty: Type<'db>, + ) -> ConstraintSet<'db, 'c> { + // An unbound property descriptor does not establish that the value returned by its + // getter is disjoint from the required property type. + if member.is_property() && matches!(ty, Type::PropertyInstance(_)) { + return self.never(); + } + let capabilities = member.capabilities(db); + if !member.is_method() { + capabilities + .instance + .read + .when_some_and(db, self.constraints, |read_ty| { + read_ty + .resolve(db) + .when_some_and(db, self.constraints, |read_ty| { + self.check_type_pair(db, ty, read_ty.ty()) }) - }) - } - ProtocolMemberKind::Other(other_type) => self.check_type_pair(db, ty, *other_type), + }) + } else { + let Some(Type::Callable(method)) = capabilities + .instance + .read + .and_then(|read| read.resolve(db)) + .map(ProtocolMemberType::ty) + else { + return self.never(); + }; + let Some(method_return_type) = non_never_callable_return_type(db, method) else { + return self.never(); + }; + + let Some(callables) = ty.try_upcast_to_callable_with_policy(db, UpcastPolicy::Sound) + else { + return ConstraintSet::from_bool(self.constraints, !member.is_instance_method()); + }; + + callables.iter().when_all(db, self.constraints, |callable| { + non_never_callable_return_type(db, *callable).when_some_and( + db, + self.constraints, + |return_type| self.check_type_pair(db, method_return_type, return_type), + ) + }) } } } @@ -1073,33 +2012,27 @@ fn cached_protocol_interface<'db>( let ty = ty.apply_optional_specialization(db, specialization); let member = match ty { - Type::PropertyInstance(property) => ProtocolMemberKind::Property(property), + Type::PropertyInstance(property) => ProtocolMemberData::property( + property.getter(db).map(ProtocolMemberType::property_getter), + property.setter(db).map(ProtocolMemberType::property_setter), + definition, + ), Type::Callable(callable) - if bound_on_class.is_yes() && callable.is_function_like(db) => + if bound_on_class.is_yes() && callable.is_method_like(db) => { - ProtocolMemberKind::Method(callable) + ProtocolMemberData::method(db, callable, definition) } Type::FunctionLiteral(function) - if function.is_staticmethod(db) || function.is_classmethod(db) => + if bound_on_class.is_yes() + || function.is_staticmethod(db) + || function.is_classmethod(db) => { - ProtocolMemberKind::Other(todo_type!( - "classmethod and staticmethod protocol members" - )) + ProtocolMemberData::method(db, function.into_callable_type(db), definition) } - Type::FunctionLiteral(function) if bound_on_class.is_yes() => { - ProtocolMemberKind::Method(function.into_callable_type(db)) - } - _ => ProtocolMemberKind::Other(ty), + _ => ProtocolMemberData::attribute(ty, qualifiers, definition), }; - members.insert( - name.clone(), - ProtocolMemberData { - kind: member, - qualifiers, - definition, - }, - ); + members.insert(name.clone(), member); } } diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index dfc834f129cb2..be90dfc4fe5f1 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -2474,6 +2474,11 @@ impl<'a, 'c, 'db> DisjointnessChecker<'a, 'c, 'db> { .ignore_possibly_undefined() .when_none_or(db, self.constraints, |attribute_type| { self.protocol_member_has_disjoint_type_from_ty(db, &member, attribute_type) + .or(db, self.constraints, || { + self.protocol_member_write_is_definitely_missing_from_ty( + db, &member, other, + ) + }) }) }) } diff --git a/ty.schema.json b/ty.schema.json index 11fd52f332894..9037ebaf038bc 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -522,7 +522,7 @@ }, "final-without-value": { "title": "detects `Final` declarations without a value", - "description": "## What it does\n\nChecks for `Final` symbols that are declared without a value and are never\nassigned a value in their scope.\n\n## Why is this bad?\n\nA `Final` symbol must be initialized with a value at the time of declaration\nor in a subsequent assignment. At module or function scope, the assignment must\noccur in the same scope. In a class body, the assignment may occur in `__init__`.\n\n## Examples\n\n```python\nfrom typing import Final\n\n# `Final` symbol without a value\nMY_CONSTANT: Final[int] # error\n\n# OK: `Final` symbol with a value\nINITIALIZED_CONSTANT: Final[int] = 1\n```", + "description": "## What it does\n\nChecks for `Final` symbols that are declared without a value and are never\nassigned a value in their scope.\n\n## Why is this bad?\n\nA `Final` symbol must be initialized with a value at the time of declaration\nor in a subsequent assignment. At module or function scope, the assignment must\noccur in the same scope. In a class body, the assignment may occur in `__init__`.\nProtocol members are declarations of an interface and do not require a value.\n\n## Examples\n\n```python\nfrom typing import Final\n\n# `Final` symbol without a value\nMY_CONSTANT: Final[int] # error\n\n# OK: `Final` symbol with a value\nINITIALIZED_CONSTANT: Final[int] = 1\n```", "default": "error", "oneOf": [ { @@ -1292,7 +1292,7 @@ }, "redundant-final-classvar": { "title": "detects redundant combinations of `ClassVar` and `Final`", - "description": "## What it does\n\nChecks for redundant combinations of the `ClassVar` and `Final` type qualifiers.\n\n## Why is this bad?\n\nAn attribute that is marked `Final` in a class body is implicitly a class variable.\nMarking it as `ClassVar` is therefore redundant.\n\nNote that this diagnostic is not emitted for dataclass fields, where\n`ClassVar[Final[int]]` has a distinct meaning from `Final[int]`.\n\n## Examples\n\n```python\nfrom typing import ClassVar, Final\n\n\nclass C:\n # redundant\n x: ClassVar[Final[int]] = 1 # error\n # redundant\n y: Final[ClassVar[int]] = 1 # error\n```", + "description": "## What it does\n\nChecks for redundant combinations of the `ClassVar` and `Final` type qualifiers.\n\n## Why is this bad?\n\nAn attribute that is marked `Final` in a class body is implicitly a class variable.\nMarking it as `ClassVar` is therefore redundant.\n\nNote that this diagnostic is not emitted for dataclass fields or protocol members,\nwhere `ClassVar[Final[int]]` has a distinct meaning from `Final[int]`.\n\n## Examples\n\n```python\nfrom typing import ClassVar, Final\n\n\nclass C:\n # redundant\n x: ClassVar[Final[int]] = 1 # error\n # redundant\n y: Final[ClassVar[int]] = 1 # error\n```", "default": "warn", "oneOf": [ {