Skip to content

Commit 86b01d2

Browse files
authored
[red-knot] Correct modeling of dunder calls (#16368)
## Summary Model dunder-calls correctly (and in one single place), by implementing this behavior (using `__getitem__` as an example). ```py def getitem_desugared(obj: object, key: object) -> object: getitem_callable = find_in_mro(type(obj), "__getitem__") if hasattr(getitem_callable, "__get__"): getitem_callable = getitem_callable.__get__(obj, type(obj)) return getitem_callable(key) ``` See the new `calls/dunder.md` test suite for more information. The new behavior also needs much fewer lines of code (the diff is positive due to new tests). ## Test Plan New tests; fix TODOs in existing tests.
1 parent f88328e commit 86b01d2

12 files changed

+210
-97
lines changed

crates/red_knot_python_semantic/resources/mdtest/binary/instances.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,17 @@ class A:
259259
class B:
260260
__add__ = A()
261261

262-
# TODO: this could be `int` if we declare `B.__add__` using a `Callable` type
263-
# TODO: Should not be an error: `A` instance is not a method descriptor, don't prepend `self` arg.
264-
# Revealed type should be `Unknown | int`.
265-
# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `B` and `B`"
266-
reveal_type(B() + B()) # revealed: Unknown
262+
reveal_type(B() + B()) # revealed: Unknown | int
263+
```
264+
265+
Note that we union with `Unknown` here because `__add__` is not declared. We do infer just `int` if
266+
the callable is declared:
267+
268+
```py
269+
class B2:
270+
__add__: A = A()
271+
272+
reveal_type(B2() + B2()) # revealed: int
267273
```
268274

269275
## Integration test: numbers from typeshed

crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class C:
8282

8383
c = C()
8484

85-
# error: 15 [invalid-argument-type] "Object of type `Literal["foo"]` cannot be assigned to parameter 2 (`x`) of function `__call__`; expected type `int`"
85+
# error: 15 [invalid-argument-type] "Object of type `Literal["foo"]` cannot be assigned to parameter 2 (`x`) of bound method `__call__`; expected type `int`"
8686
reveal_type(c("foo")) # revealed: int
8787
```
8888

@@ -96,7 +96,7 @@ class C:
9696

9797
c = C()
9898

99-
# error: 13 [invalid-argument-type] "Object of type `C` cannot be assigned to parameter 1 (`self`) of function `__call__`; expected type `int`"
99+
# error: 13 [invalid-argument-type] "Object of type `C` cannot be assigned to parameter 1 (`self`) of bound method `__call__`; expected type `int`"
100100
reveal_type(c()) # revealed: int
101101
```
102102

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Dunder calls
2+
3+
## Introduction
4+
5+
This test suite explains and documents how dunder methods are looked up and called. Throughout the
6+
document, we use `__getitem__` as an example, but the same principles apply to other dunder methods.
7+
8+
Dunder methods are implicitly called when using certain syntax. For example, the index operator
9+
`obj[key]` calls the `__getitem__` method under the hood. Exactly *how* a dunder method is looked up
10+
and called works slightly different from regular methods. Dunder methods are not looked up on `obj`
11+
directly, but rather on `type(obj)`. But in many ways, they still *act* as if they were called on
12+
`obj` directly. If the `__getitem__` member of `type(obj)` is a descriptor, it is called with `obj`
13+
as the `instance` argument to `__get__`. A desugared version of `obj[key]` is roughly equivalent to
14+
`getitem_desugared(obj, key)` as defined below:
15+
16+
```py
17+
from typing import Any
18+
19+
def find_name_in_mro(typ: type, name: str) -> Any:
20+
# See implementation in https://docs.python.org/3/howto/descriptor.html#invocation-from-an-instance
21+
pass
22+
23+
def getitem_desugared(obj: object, key: object) -> object:
24+
getitem_callable = find_name_in_mro(type(obj), "__getitem__")
25+
if hasattr(getitem_callable, "__get__"):
26+
getitem_callable = getitem_callable.__get__(obj, type(obj))
27+
28+
return getitem_callable(key)
29+
```
30+
31+
In the following tests, we demonstrate that we implement this behavior correctly.
32+
33+
## Operating on class objects
34+
35+
If we invoke a dunder method on a class, it is looked up on the *meta* class, since any class is an
36+
instance of its metaclass:
37+
38+
```py
39+
class Meta(type):
40+
def __getitem__(cls, key: int) -> str:
41+
return str(key)
42+
43+
class DunderOnMetaClass(metaclass=Meta):
44+
pass
45+
46+
reveal_type(DunderOnMetaClass[0]) # revealed: str
47+
```
48+
49+
## Operating on instances
50+
51+
When invoking a dunder method on an instance of a class, it is looked up on the class:
52+
53+
```py
54+
class ClassWithNormalDunder:
55+
def __getitem__(self, key: int) -> str:
56+
return str(key)
57+
58+
class_with_normal_dunder = ClassWithNormalDunder()
59+
60+
reveal_type(class_with_normal_dunder[0]) # revealed: str
61+
```
62+
63+
Which can be demonstrated by trying to attach a dunder method to an instance, which will not work:
64+
65+
```py
66+
def external_getitem(instance, key: int) -> str:
67+
return str(key)
68+
69+
class ThisFails:
70+
def __init__(self):
71+
self.__getitem__ = external_getitem
72+
73+
this_fails = ThisFails()
74+
75+
# error: [non-subscriptable] "Cannot subscript object of type `ThisFails` with no `__getitem__` method"
76+
reveal_type(this_fails[0]) # revealed: Unknown
77+
```
78+
79+
However, the attached dunder method *can* be called if accessed directly:
80+
81+
```py
82+
# TODO: `this_fails.__getitem__` is incorrectly treated as a bound method. This
83+
# should be fixed with https://github.com/astral-sh/ruff/issues/16367
84+
# error: [too-many-positional-arguments]
85+
# error: [invalid-argument-type]
86+
reveal_type(this_fails.__getitem__(this_fails, 0)) # revealed: Unknown | str
87+
```
88+
89+
## When the dunder is not a method
90+
91+
A dunder can also be a non-method callable:
92+
93+
```py
94+
class SomeCallable:
95+
def __call__(self, key: int) -> str:
96+
return str(key)
97+
98+
class ClassWithNonMethodDunder:
99+
__getitem__: SomeCallable = SomeCallable()
100+
101+
class_with_callable_dunder = ClassWithNonMethodDunder()
102+
103+
reveal_type(class_with_callable_dunder[0]) # revealed: str
104+
```
105+
106+
## Dunders are looked up using the descriptor protocol
107+
108+
Here, we demonstrate that the descriptor protocol is invoked when looking up a dunder method. Note
109+
that the `instance` argument is on object of type `ClassWithDescriptorDunder`:
110+
111+
```py
112+
from __future__ import annotations
113+
114+
class SomeCallable:
115+
def __call__(self, key: int) -> str:
116+
return str(key)
117+
118+
class Descriptor:
119+
def __get__(self, instance: ClassWithDescriptorDunder, owner: type[ClassWithDescriptorDunder]) -> SomeCallable:
120+
return SomeCallable()
121+
122+
class ClassWithDescriptorDunder:
123+
__getitem__: Descriptor = Descriptor()
124+
125+
class_with_descriptor_dunder = ClassWithDescriptorDunder()
126+
127+
reveal_type(class_with_descriptor_dunder[0]) # revealed: str
128+
```

crates/red_knot_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,21 @@ class Comparable:
371371

372372
Comparable() < Comparable() # fine
373373
```
374+
375+
## Callables as comparison dunders
376+
377+
```py
378+
from typing import Literal
379+
380+
class AlwaysTrue:
381+
def __call__(self, other: object) -> Literal[True]:
382+
return True
383+
384+
class A:
385+
__eq__: AlwaysTrue = AlwaysTrue()
386+
__lt__: AlwaysTrue = AlwaysTrue()
387+
388+
reveal_type(A() == A()) # revealed: Literal[True]
389+
reveal_type(A() < A()) # revealed: Literal[True]
390+
reveal_type(A() > A()) # revealed: Literal[True]
391+
```

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ def _(flag: bool):
321321
# TODO... `int` might be ideal here?
322322
reveal_type(x) # revealed: int | Unknown
323323

324-
# error: [not-iterable] "Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `Literal[__iter__] | None`) may not be callable"
324+
# error: [not-iterable] "Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `<bound method `__iter__` of `Iterable2`> | None`) may not be callable"
325325
for y in Iterable2():
326326
# TODO... `int` might be ideal here?
327327
reveal_type(y) # revealed: int | Unknown

crates/red_knot_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly-not-callable_`__getitem__`_method.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ error: lint:not-iterable
7878
|
7979
26 | # error: [not-iterable]
8080
27 | for y in Iterable2():
81-
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because it has no `__iter__` method and its `__getitem__` attribute (with type `Literal[__getitem__] | None`) may not be callable
81+
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because it has no `__iter__` method and its `__getitem__` attribute (with type `<bound method `__getitem__` of `Iterable2`> | None`) may not be callable
8282
28 | # TODO... `int` might be ideal here?
8383
29 | reveal_type(y) # revealed: int | Unknown
8484
|

crates/red_knot_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__getitem__`_methods.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ error: lint:not-iterable
4848
|
4949
19 | # error: [not-iterable]
5050
20 | for x in Iterable1():
51-
| ^^^^^^^^^^^ Object of type `Iterable1` may not be iterable because it has no `__iter__` method and its `__getitem__` attribute (with type `Literal[__getitem__] | None`) may not be callable
51+
| ^^^^^^^^^^^ Object of type `Iterable1` may not be iterable because it has no `__iter__` method and its `__getitem__` attribute (with type `<bound method `__getitem__` of `Iterable1`> | None`) may not be callable
5252
21 | # TODO: `str` might be better
5353
22 | reveal_type(x) # revealed: str | Unknown
5454
|
@@ -75,7 +75,7 @@ error: lint:not-iterable
7575
|
7676
24 | # error: [not-iterable]
7777
25 | for y in Iterable2():
78-
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because it has no `__iter__` method and its `__getitem__` method (with type `Literal[__getitem__, __getitem__]`) may have an incorrect signature for the old-style iteration protocol (expected a signature at least as permissive as `def __getitem__(self, key: int): ...`)
78+
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because it has no `__iter__` method and its `__getitem__` method (with type `<bound method `__getitem__` of `Iterable2`> | <bound method `__getitem__` of `Iterable2`>`) may have an incorrect signature for the old-style iteration protocol (expected a signature at least as permissive as `def __getitem__(self, key: int): ...`)
7979
26 | reveal_type(y) # revealed: str | int
8080
|
8181

crates/red_knot_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_invalid_`__iter__`_methods.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ error: lint:not-iterable
5252
|
5353
16 | # error: [not-iterable]
5454
17 | for x in Iterable1():
55-
| ^^^^^^^^^^^ Object of type `Iterable1` may not be iterable because its `__iter__` method (with type `Literal[__iter__, __iter__]`) may have an invalid signature (expected `def __iter__(self): ...`)
55+
| ^^^^^^^^^^^ Object of type `Iterable1` may not be iterable because its `__iter__` method (with type `<bound method `__iter__` of `Iterable1`> | <bound method `__iter__` of `Iterable1`>`) may have an invalid signature (expected `def __iter__(self): ...`)
5656
18 | reveal_type(x) # revealed: int
5757
|
5858
@@ -78,7 +78,7 @@ error: lint:not-iterable
7878
|
7979
27 | # error: [not-iterable]
8080
28 | for x in Iterable2():
81-
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `Literal[__iter__] | None`) may not be callable
81+
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `<bound method `__iter__` of `Iterable2`> | None`) may not be callable
8282
29 | # TODO: `int` would probably be better here:
8383
30 | reveal_type(x) # revealed: int | Unknown
8484
|

crates/red_knot_python_semantic/resources/mdtest/snapshots/for.md_-_For_loops_-_Possibly_unbound_`__iter__`_and_possibly_invalid_`__getitem__`.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ error: lint:not-iterable
5959
|
6060
30 | # error: [not-iterable]
6161
31 | for x in Iterable1():
62-
| ^^^^^^^^^^^ Object of type `Iterable1` may not be iterable because it may not have an `__iter__` method and its `__getitem__` attribute (with type `Literal[__getitem__] | None`) may not be callable
62+
| ^^^^^^^^^^^ Object of type `Iterable1` may not be iterable because it may not have an `__iter__` method and its `__getitem__` attribute (with type `<bound method `__getitem__` of `Iterable1`> | None`) may not be callable
6363
32 | # TODO: `bytes | str` might be better
6464
33 | reveal_type(x) # revealed: bytes | str | Unknown
6565
|
@@ -86,7 +86,7 @@ error: lint:not-iterable
8686
|
8787
35 | # error: [not-iterable]
8888
36 | for y in Iterable2():
89-
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because it may not have an `__iter__` method and its `__getitem__` method (with type `Literal[__getitem__, __getitem__]`)
89+
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because it may not have an `__iter__` method and its `__getitem__` method (with type `<bound method `__getitem__` of `Iterable2`> | <bound method `__getitem__` of `Iterable2`>`)
9090
may have an incorrect signature for the old-style iteration protocol (expected a signature at least as permissive as `def __getitem__(self, key: int): ...`)
9191
37 | reveal_type(y) # revealed: bytes | str | int
9292
|

crates/red_knot_python_semantic/resources/mdtest/snapshots/invalid_argument_type.md_-_Invalid_argument_type_diagnostics_-_Tests_for_a_variety_of_argument_types_-_Synthetic_arguments.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ error: lint:invalid-argument-type
2828
|
2929
5 | c = C()
3030
6 | c("wrong") # error: [invalid-argument-type]
31-
| ^^^^^^^ Object of type `Literal["wrong"]` cannot be assigned to parameter 2 (`x`) of function `__call__`; expected type `int`
31+
| ^^^^^^^ Object of type `Literal["wrong"]` cannot be assigned to parameter 2 (`x`) of bound method `__call__`; expected type `int`
3232
|
3333
::: /src/mdtest_snippet.py:2:24
3434
|

0 commit comments

Comments
 (0)