Skip to content

Commit 6e5af88

Browse files
committed
[red-knot] Correct modeling of dunder calls
1 parent f88328e commit 6e5af88

File tree

7 files changed

+189
-89
lines changed

7 files changed

+189
-89
lines changed

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,7 @@ 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
267263
```
268264

269265
## 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: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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_in_mro(typ: type, name: str) -> Any: ...
20+
def getitem_desugared(obj: object, key: object) -> object:
21+
getitem_callable = find_in_mro(type(obj), "__getitem__")
22+
if hasattr(getitem_callable, "__get__"):
23+
getitem_callable = getitem_callable.__get__(obj, type(obj))
24+
25+
return getitem_callable(key)
26+
```
27+
28+
In the following tests, we demonstrate that we implement this behavior correctly.
29+
30+
## Operating on class objects
31+
32+
If we invoke a dunder method on a class, it is looked up on the *meta* class:
33+
34+
```py
35+
class Meta(type):
36+
def __getitem__(cls, key: int) -> str:
37+
return str(key)
38+
39+
class DunderOnMetaClass(metaclass=Meta):
40+
pass
41+
42+
reveal_type(DunderOnMetaClass[0]) # revealed: str
43+
```
44+
45+
## Operating on instances
46+
47+
When invoking a dunder method on an instance of a class, it is looked up on the class:
48+
49+
```py
50+
class ClassWithNormalDunder:
51+
def __getitem__(self, key: int) -> str:
52+
return str(key)
53+
54+
class_with_normal_dunder = ClassWithNormalDunder()
55+
56+
reveal_type(class_with_normal_dunder[0]) # revealed: str
57+
```
58+
59+
Which can be demonstrated by trying to attach a dunder method to an instance, which will not work:
60+
61+
```py
62+
def some_function(instance, key: int) -> str:
63+
return str(key)
64+
65+
class ThisFails:
66+
def __init__(self):
67+
self.__getitem__ = some_function
68+
self.function = some_function
69+
70+
this_fails = ThisFails()
71+
72+
# error: [non-subscriptable] "Cannot subscript object of type `ThisFails` with no `__getitem__` method"
73+
reveal_type(this_fails[0]) # revealed: Unknown
74+
```
75+
76+
This is in contrast to regular functions, which *can* be attached to instances:
77+
78+
```py
79+
# TODO: `this_fails.function` is incorrectly treated as a bound method. This
80+
# should be fixed with https://github.com/astral-sh/ruff/issues/16367
81+
# error: [too-many-positional-arguments]
82+
# error: [invalid-argument-type]
83+
reveal_type(this_fails.function(this_fails, 0)) # revealed: Unknown | str
84+
```
85+
86+
## When the dunder is not a method
87+
88+
A dunder can also be a non-method callable:
89+
90+
```py
91+
class SomeCallable:
92+
def __call__(self, key: int) -> str:
93+
return str(key)
94+
95+
class ClassWithNonMethodDunder:
96+
__getitem__: SomeCallable = SomeCallable()
97+
98+
class_with_callable_dunder = ClassWithNonMethodDunder()
99+
100+
reveal_type(class_with_callable_dunder[0]) # revealed: str
101+
```
102+
103+
## Dunders are looked up using the descriptor protocol
104+
105+
Here, we demonstrate that the descriptor protocol is invoked when looking up a dunder method. Note
106+
that the `instance` argument is on object of type `ClassWithDescriptorDunder`:
107+
108+
```py
109+
from __future__ import annotations
110+
111+
class SomeCallable:
112+
def __call__(self, key: int) -> str:
113+
return str(key)
114+
115+
class Descriptor:
116+
def __get__(self, instance: ClassWithDescriptorDunder, owner: type[ClassWithDescriptorDunder]) -> SomeCallable:
117+
return SomeCallable()
118+
119+
class ClassWithDescriptorDunder:
120+
__getitem__: Descriptor = Descriptor()
121+
122+
class_with_descriptor_dunder = ClassWithDescriptorDunder()
123+
124+
reveal_type(class_with_descriptor_dunder[0]) # revealed: str
125+
```

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/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
|

crates/red_knot_python_semantic/src/types.rs

Lines changed: 18 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2284,44 +2284,6 @@ impl<'db> Type<'db> {
22842284
}
22852285
}
22862286

2287-
/// Return the outcome of calling an class/instance attribute of this type
2288-
/// using descriptor protocol.
2289-
///
2290-
/// `receiver_ty` must be `Type::Instance(_)` or `Type::ClassLiteral`.
2291-
///
2292-
/// TODO: handle `super()` objects properly
2293-
fn try_call_bound(
2294-
self,
2295-
db: &'db dyn Db,
2296-
receiver_ty: &Type<'db>,
2297-
arguments: &CallArguments<'_, 'db>,
2298-
) -> Result<CallOutcome<'db>, CallError<'db>> {
2299-
match self {
2300-
Type::FunctionLiteral(..) => {
2301-
// Functions are always descriptors, so this would effectively call
2302-
// the function with the instance as the first argument
2303-
self.try_call(db, &arguments.with_self(*receiver_ty))
2304-
}
2305-
2306-
Type::Instance(_) | Type::ClassLiteral(_) => self.try_call(db, arguments),
2307-
2308-
Type::Union(union) => CallOutcome::try_call_union(db, union, |element| {
2309-
element.try_call_bound(db, receiver_ty, arguments)
2310-
}),
2311-
2312-
Type::Intersection(_) => Ok(CallOutcome::Single(CallBinding::from_return_type(
2313-
todo_type!("Type::Intersection.call_bound()"),
2314-
))),
2315-
2316-
// Cases that duplicate, and thus must be kept in sync with, `Type::call()`
2317-
Type::Dynamic(_) => Ok(CallOutcome::Single(CallBinding::from_return_type(self))),
2318-
2319-
_ => Err(CallError::NotCallable {
2320-
not_callable_type: self,
2321-
}),
2322-
}
2323-
}
2324-
23252287
/// Look up a dunder method on the meta type of `self` and call it.
23262288
///
23272289
/// Returns an `Err` if the dunder method can't be called,
@@ -2332,13 +2294,24 @@ impl<'db> Type<'db> {
23322294
name: &str,
23332295
arguments: &CallArguments<'_, 'db>,
23342296
) -> Result<CallOutcome<'db>, CallDunderError<'db>> {
2335-
match self.to_meta_type(db).member(db, name) {
2336-
Symbol::Type(callable_ty, Boundness::Bound) => {
2337-
Ok(callable_ty.try_call_bound(db, &self, arguments)?)
2338-
}
2339-
Symbol::Type(callable_ty, Boundness::PossiblyUnbound) => {
2340-
let call = callable_ty.try_call_bound(db, &self, arguments)?;
2341-
Err(CallDunderError::PossiblyUnbound(call))
2297+
let meta_type = self.to_meta_type(db);
2298+
2299+
match meta_type.static_member(db, name) {
2300+
Symbol::Type(callable_ty, boundness) => {
2301+
// Dunder methods are looked up on the meta type, but they invoke the descriptor
2302+
// protocol *as if they had been called on the instance itself*. This is why we
2303+
// pass `Some(self)` for the `instance` argument here.
2304+
let callable_ty = callable_ty
2305+
.try_call_dunder_get(db, Some(self), meta_type)
2306+
.unwrap_or(callable_ty);
2307+
2308+
let result = callable_ty.try_call(db, arguments)?;
2309+
2310+
if boundness == Boundness::Bound {
2311+
Ok(result)
2312+
} else {
2313+
Err(CallDunderError::PossiblyUnbound(result))
2314+
}
23422315
}
23432316
Symbol::Unbound => Err(CallDunderError::MethodNotAvailable),
23442317
}

crates/red_knot_python_semantic/src/types/infer.rs

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4177,36 +4177,27 @@ impl<'db> TypeInferenceBuilder<'db> {
41774177
}
41784178
}
41794179

4180-
// TODO: Use `call_dunder`?
4181-
let call_on_left_instance = if let Symbol::Type(class_member, _) =
4182-
left_class.member(self.db(), op.dunder())
4183-
{
4184-
class_member
4185-
.try_call(self.db(), &CallArguments::positional([left_ty, right_ty]))
4186-
.map(|outcome| outcome.return_type(self.db()))
4187-
.ok()
4188-
} else {
4189-
None
4190-
};
4180+
let call_on_left_instance = left_ty
4181+
.try_call_dunder(
4182+
self.db(),
4183+
op.dunder(),
4184+
&CallArguments::positional([right_ty]),
4185+
)
4186+
.map(|outcome| outcome.return_type(self.db()))
4187+
.ok();
41914188

41924189
call_on_left_instance.or_else(|| {
41934190
if left_ty == right_ty {
41944191
None
41954192
} else {
4196-
if let Symbol::Type(class_member, _) =
4197-
right_class.member(self.db(), op.reflected_dunder())
4198-
{
4199-
// TODO: Use `call_dunder`
4200-
class_member
4201-
.try_call(
4202-
self.db(),
4203-
&CallArguments::positional([right_ty, left_ty]),
4204-
)
4205-
.map(|outcome| outcome.return_type(self.db()))
4206-
.ok()
4207-
} else {
4208-
None
4209-
}
4193+
right_ty
4194+
.try_call_dunder(
4195+
self.db(),
4196+
op.reflected_dunder(),
4197+
&CallArguments::positional([left_ty]),
4198+
)
4199+
.map(|outcome| outcome.return_type(self.db()))
4200+
.ok()
42104201
}
42114202
})
42124203
}
@@ -4848,20 +4839,17 @@ impl<'db> TypeInferenceBuilder<'db> {
48484839
let db = self.db();
48494840
// The following resource has details about the rich comparison algorithm:
48504841
// https://snarky.ca/unravelling-rich-comparison-operators/
4851-
let call_dunder = |op: RichCompareOperator,
4852-
left: InstanceType<'db>,
4853-
right: InstanceType<'db>| {
4854-
match left.class().class_member(db, op.dunder()) {
4855-
Symbol::Type(class_member_dunder, Boundness::Bound) => class_member_dunder
4856-
.try_call(
4842+
let call_dunder =
4843+
|op: RichCompareOperator, left: InstanceType<'db>, right: InstanceType<'db>| {
4844+
Type::Instance(left)
4845+
.try_call_dunder(
48574846
db,
4858-
&CallArguments::positional([Type::Instance(left), Type::Instance(right)]),
4847+
op.dunder(),
4848+
&CallArguments::positional([Type::Instance(right)]),
48594849
)
48604850
.map(|outcome| outcome.return_type(db))
4861-
.ok(),
4862-
_ => None,
4863-
}
4864-
};
4851+
.ok()
4852+
};
48654853

48664854
// The reflected dunder has priority if the right-hand side is a strict subclass of the left-hand side.
48674855
if left != right && right.is_subtype_of(db, left) {

0 commit comments

Comments
 (0)