## Summary Infer a type of `Self` for unannotated `self` parameters in methods of classes. part of https://github.com/astral-sh/ty/issues/159 closes https://github.com/astral-sh/ty/issues/1081 ## Conformance tests changes ```diff +enums_member_values.py:85:9: error[invalid-assignment] Object of type `int` is not assignable to attribute `_value_` of type `str` ``` A true positive ✔️ ```diff -generics_self_advanced.py:35:9: error[type-assertion-failure] Argument does not have asserted type `Self@method2` -generics_self_basic.py:14:9: error[type-assertion-failure] Argument does not have asserted type `Self@set_scale ``` Two false positives going away ✔️ ```diff +generics_syntax_infer_variance.py:82:9: error[invalid-assignment] Cannot assign to final attribute `x` on type `Self@__init__` ``` This looks like a true positive to me, even if it's not marked with `# E` ✔️ ```diff +protocols_explicit.py:56:9: error[invalid-assignment] Object of type `tuple[int, int, str]` is not assignable to attribute `rgb` of type `tuple[int, int, int]` ``` True positive ✔️ ``` +protocols_explicit.py:85:9: error[invalid-attribute-access] Cannot assign to ClassVar `cm1` from an instance of type `Self@__init__` ``` This looks like a true positive to me, even if it's not marked with `# E`. But this is consistent with our understanding of `ClassVar`, I think. ✔️ ```py +qualifiers_final_annotation.py:52:9: error[invalid-assignment] Cannot assign to final attribute `ID4` on type `Self@__init__` +qualifiers_final_annotation.py:65:9: error[invalid-assignment] Cannot assign to final attribute `ID7` on type `Self@method1` ``` New true positives ✔️ ```py +qualifiers_final_annotation.py:52:9: error[invalid-assignment] Cannot assign to final attribute `ID4` on type `Self@__init__` +qualifiers_final_annotation.py:57:13: error[invalid-assignment] Cannot assign to final attribute `ID6` on type `Self@__init__` +qualifiers_final_annotation.py:59:13: error[invalid-assignment] Cannot assign to final attribute `ID6` on type `Self@__init__` ``` This is a new false positive, but that's a pre-existing issue on main (if you annotate with `Self`): https://play.ty.dev/3ee1c56d-7e13-43bb-811a-7a81e236e6ab ❌ => reported as https://github.com/astral-sh/ty/issues/1409 ## Ecosystem * There are 5931 new `unresolved-attribute` and 3292 new `possibly-missing-attribute` attribute errors, way too many to look at all of them. I randomly sampled 15 of these errors and found: * 13 instances where there was simply no such attribute that we could plausibly see. Sometimes [I didn't find it anywhere](8644d886c6/openlibrary/plugins/openlibrary/tests/test_listapi.py (L33)). Sometimes it was set externally on the object. Sometimes there was some [`setattr` dynamicness going on](a49f6b927d/setuptools/wheel.py (L88-L94)). I would consider all of them to be true positives. * 1 instance where [attribute was set on `obj` in `__new__`](9e87b44fd4/sympy/tensor/array/array_comprehension.py (L45C1-L45C36)), which we don't support yet * 1 instance [where the attribute was defined via `__slots__` ](e250ec0fc8/lib/spack/spack/vendor/pyrsistent/_pdeque.py (L48C5-L48C14)) * I see 44 instances [of the false positive above](https://github.com/astral-sh/ty/issues/1409) with `Final` instance attributes being set in `__init__`. I don't think this should block this PR. ## Test Plan New Markdown tests. --------- Co-authored-by: Shaygan Hooshyari <sh.hooshyari@gmail.com>
7.9 KiB
Dunder calls
Introduction
This test suite explains and documents how dunder methods are looked up and called. Throughout the
document, we use __getitem__ as an example, but the same principles apply to other dunder methods.
Dunder methods are implicitly called when using certain syntax. For example, the index operator
obj[key] calls the __getitem__ method under the hood. Exactly how a dunder method is looked up
and called works slightly different from regular methods. Dunder methods are not looked up on obj
directly, but rather on type(obj). But in many ways, they still act as if they were called on
obj directly. If the __getitem__ member of type(obj) is a descriptor, it is called with obj
as the instance argument to __get__. A desugared version of obj[key] is roughly equivalent to
getitem_desugared(obj, key) as defined below:
from typing import Any
def find_name_in_mro(typ: type, name: str) -> Any:
# See implementation in https://docs.python.org/3/howto/descriptor.html#invocation-from-an-instance
pass
def getitem_desugared(obj: object, key: object) -> object:
getitem_callable = find_name_in_mro(type(obj), "__getitem__")
if hasattr(getitem_callable, "__get__"):
getitem_callable = getitem_callable.__get__(obj, type(obj))
return getitem_callable(key)
In the following tests, we demonstrate that we implement this behavior correctly.
Operating on class objects
If we invoke a dunder method on a class, it is looked up on the meta class, since any class is an instance of its metaclass:
class Meta(type):
def __getitem__(cls, key: int) -> str:
return str(key)
class DunderOnMetaclass(metaclass=Meta):
pass
reveal_type(DunderOnMetaclass[0]) # revealed: str
If the dunder method is only present on the class itself, it will not be called:
class ClassWithNormalDunder:
def __getitem__(self, key: int) -> str:
return str(key)
# error: [non-subscriptable]
ClassWithNormalDunder[0]
Operating on instances
Attaching dunder methods to instances in methods
When invoking a dunder method on an instance of a class, it is looked up on the class:
class ClassWithNormalDunder:
def __getitem__(self, key: int) -> str:
return str(key)
class_with_normal_dunder = ClassWithNormalDunder()
reveal_type(class_with_normal_dunder[0]) # revealed: str
Which can be demonstrated by trying to attach a dunder method to an instance, which will not work:
def external_getitem(instance, key: int) -> str:
return str(key)
class ThisFails:
def __init__(self):
self.__getitem__ = external_getitem
this_fails = ThisFails()
# error: [non-subscriptable] "Cannot subscript object of type `ThisFails` with no `__getitem__` method"
reveal_type(this_fails[0]) # revealed: Unknown
However, the attached dunder method can be called if accessed directly:
reveal_type(this_fails.__getitem__(this_fails, 0)) # revealed: Unknown | str
The instance-level method is also not called when the class-level method is present:
def external_getitem1(instance, key) -> str:
return "a"
def external_getitem2(key) -> int:
return 1
def _(flag: bool):
class ThisFails:
if flag:
__getitem__ = external_getitem1
def __init__(self):
self.__getitem__ = external_getitem2
this_fails = ThisFails()
# TODO: this would be a friendlier diagnostic if we propagated the error up the stack
# and transformed it into a `[not-subscriptable]` error with a subdiagnostic explaining
# that the cause of the error was a possibly missing `__getitem__` method
#
# error: [possibly-missing-implicit-call] "Method `__getitem__` of type `ThisFails` may be missing"
reveal_type(this_fails[0]) # revealed: Unknown | str
Dunder methods as class-level annotations with no value
Class-level annotations with no value assigned are considered to be accessible on the class:
from typing import Callable
class C:
__call__: Callable[..., None]
C()()
_: Callable[..., None] = C()
And of course the same is true if we have only an implicit assignment inside a method:
from typing import Callable
class C:
def __init__(self):
self.__call__ = lambda *a, **kw: None
# error: [call-non-callable]
C()()
# error: [invalid-assignment]
_: Callable[..., None] = C()
When the dunder is not a method
A dunder can also be a non-method callable:
class SomeCallable:
def __call__(self, key: int) -> str:
return str(key)
class ClassWithNonMethodDunder:
__getitem__: SomeCallable = SomeCallable()
class_with_callable_dunder = ClassWithNonMethodDunder()
reveal_type(class_with_callable_dunder[0]) # revealed: str
Dunders are looked up using the descriptor protocol
Here, we demonstrate that the descriptor protocol is invoked when looking up a dunder method. Note
that the instance argument is on object of type ClassWithDescriptorDunder:
from __future__ import annotations
class SomeCallable:
def __call__(self, key: int) -> str:
return str(key)
class Descriptor:
def __get__(self, instance: ClassWithDescriptorDunder, owner: type[ClassWithDescriptorDunder]) -> SomeCallable:
return SomeCallable()
class ClassWithDescriptorDunder:
__getitem__: Descriptor = Descriptor()
class_with_descriptor_dunder = ClassWithDescriptorDunder()
reveal_type(class_with_descriptor_dunder[0]) # revealed: str
Dunders can not be overwritten on instances
If we attempt to overwrite a dunder method on an instance, it does not affect the behavior of implicit dunder calls:
class C:
def __getitem__(self, key: int) -> str:
return str(key)
def f(self):
# error: [invalid-assignment] "Implicit shadowing of function `__getitem__`"
self.__getitem__ = None
# This is still fine, and simply calls the `__getitem__` method on the class
reveal_type(C()[0]) # revealed: str
Calling a union of dunder methods
def _(flag: bool):
class C:
if flag:
def __getitem__(self, key: int) -> str:
return str(key)
else:
def __getitem__(self, key: int) -> bytes:
return bytes()
c = C()
reveal_type(c[0]) # revealed: str | bytes
if flag:
class D:
def __getitem__(self, key: int) -> str:
return str(key)
else:
class D:
def __getitem__(self, key: int) -> bytes:
return bytes()
d = D()
reveal_type(d[0]) # revealed: str | bytes
Calling a union of types without dunder methods
We add instance attributes here to make sure that we don't treat the implicit dunder calls here like regular method calls.
def external_getitem(instance, key: int) -> str:
return str(key)
class NotSubscriptable1:
def __init__(self, value: int):
self.__getitem__ = external_getitem
class NotSubscriptable2:
def __init__(self, value: int):
self.__getitem__ = external_getitem
def _(union: NotSubscriptable1 | NotSubscriptable2):
# error: [non-subscriptable] "Cannot subscript object of type `NotSubscriptable2` with no `__getitem__` method"
# error: [non-subscriptable] "Cannot subscript object of type `NotSubscriptable1` with no `__getitem__` method"
union[0]
Calling a possibly-unbound dunder method
def _(flag: bool):
class C:
if flag:
def __getitem__(self, key: int) -> str:
return str(key)
c = C()
# TODO: this would be a friendlier diagnostic if we propagated the error up the stack
# and transformed it into a `[not-subscriptable]` error with a subdiagnostic explaining
# that the cause of the error was a possibly missing `__getitem__` method
#
# error: [possibly-missing-implicit-call] "Method `__getitem__` of type `C` may be missing"
reveal_type(c[0]) # revealed: str