-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[red-knot] Correct modeling of dunder calls #16368
Changes from all commits
6e5af88
1a7e9df
726b448
1e926ea
001033c
e907248
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -82,7 +82,7 @@ class C: | |||||
|
||||||
c = C() | ||||||
|
||||||
# error: 15 [invalid-argument-type] "Object of type `Literal["foo"]` cannot be assigned to parameter 2 (`x`) of function `__call__`; expected type `int`" | ||||||
# error: 15 [invalid-argument-type] "Object of type `Literal["foo"]` cannot be assigned to parameter 2 (`x`) of bound method `__call__`; expected type `int`" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. better might be
Suggested change
(definitely not a blocking comment!) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to do something like this at first, but then we probably need to construct a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had the same thought on the previous diff that improved error messages, that ultimately probably we don't need to describe "function" vs "bound method" vs "wrapper-descriptor", we can probably replace all of that with fully-qualified (within the module) function names (e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider the CPython error messages here (and imagine that everything "above the fold" is not visible in the current view): class C:
def f(self, x: int) -> str:
return str(x)
c = C()
# ------------
# C.f() missing 2 required positional arguments: 'self' and 'x'
C.f()
# C.f() missing 1 required positional argument: 'x'
c.f() The notation
It feels to me like innovation in this area is not necessarily a bad thing, as long as we don't digress too far from established norms. We already do provide much better error messages than CPython. But that does not mean that they can't be better still. That's a general comment and not necessarily related to my changes earlier today. I'm happy to revisit them. I can open a ticket to discuss this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, I agree this is helpful.
And I agree with this too! I still think qualified name display will help messages be less ambiguous overall, and may allow us to remove some of the distinctions between function kinds -- but I definitely don't think we should closely hew to whatever the runtime does, and I think you're right that specifically distinguishing bound methods may be useful. |
||||||
reveal_type(c("foo")) # revealed: int | ||||||
``` | ||||||
|
||||||
|
@@ -96,7 +96,7 @@ class C: | |||||
|
||||||
c = C() | ||||||
|
||||||
# error: 13 [invalid-argument-type] "Object of type `C` cannot be assigned to parameter 1 (`self`) of function `__call__`; expected type `int`" | ||||||
# error: 13 [invalid-argument-type] "Object of type `C` cannot be assigned to parameter 1 (`self`) of bound method `__call__`; expected type `int`" | ||||||
reveal_type(c()) # revealed: int | ||||||
``` | ||||||
|
||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
# 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 | ||
carljm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
`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: | ||
|
||
```py | ||
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: | ||
|
||
```py | ||
class Meta(type): | ||
def __getitem__(cls, key: int) -> str: | ||
return str(key) | ||
|
||
class DunderOnMetaClass(metaclass=Meta): | ||
pass | ||
|
||
reveal_type(DunderOnMetaClass[0]) # revealed: str | ||
``` | ||
|
||
## Operating on instances | ||
|
||
When invoking a dunder method on an instance of a class, it is looked up on the class: | ||
|
||
```py | ||
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: | ||
|
||
```py | ||
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: | ||
|
||
```py | ||
# TODO: `this_fails.__getitem__` is incorrectly treated as a bound method. This | ||
# should be fixed with https://github.com/astral-sh/ruff/issues/16367 | ||
# error: [too-many-positional-arguments] | ||
# error: [invalid-argument-type] | ||
reveal_type(this_fails.__getitem__(this_fails, 0)) # revealed: Unknown | str | ||
``` | ||
|
||
## When the dunder is not a method | ||
|
||
A dunder can also be a non-method callable: | ||
|
||
```py | ||
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`: | ||
|
||
```py | ||
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 | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -78,7 +78,7 @@ error: lint:not-iterable | |
| | ||
26 | # error: [not-iterable] | ||
27 | for y in Iterable2(): | ||
| ^^^^^^^^^^^ 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 | ||
| ^^^^^^^^^^^ 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @AlexWaygood Looks like the half-life of these snapshot tests was rather small 😄. Do these new messages look okay to you? I think they are more correct now, but not necessarily more helpful. If we want to keep the current display-output of bound-method callable types, maybe we should consider reducing the verbosity in a different way here? I can look at this in a follow up, if we think it's worth doing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh no, what are you doing to my beautiful but yes, no need for the PR to be blocked on this. Ideally I think we'd make several changes to how we talk about callable types in diagnostic messages:
All told, the function-literal display issues were already the weakest part of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alright, thanks! |
||
28 | # TODO... `int` might be ideal here? | ||
29 | reveal_type(y) # revealed: int | Unknown | ||
| | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -52,7 +52,7 @@ error: lint:not-iterable | |
| | ||
16 | # error: [not-iterable] | ||
17 | for x in Iterable1(): | ||
| ^^^^^^^^^^^ 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): ...`) | ||
| ^^^^^^^^^^^ 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): ...`) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh no, I didn't think it could get worse than |
||
18 | reveal_type(x) # revealed: int | ||
| | ||
|
||
|
@@ -78,7 +78,7 @@ error: lint:not-iterable | |
| | ||
27 | # error: [not-iterable] | ||
28 | for x in Iterable2(): | ||
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `Literal[__iter__] | None`) may not be callable | ||
| ^^^^^^^^^^^ Object of type `Iterable2` may not be iterable because its `__iter__` attribute (with type `<bound method `__iter__` of `Iterable2`> | None`) may not be callable | ||
29 | # TODO: `int` would probably be better here: | ||
30 | reveal_type(x) # revealed: int | Unknown | ||
| | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can also get rid of the
Unknown
by declaring it asA
(__add__: A = A()
), so I don't think it's worthy a TODO. The test is fine as is, I think. In fact, declaring it using a callable type would "hide" the actual type of this specific callable (A
).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think this is less a TODO and more a commentary on why
Unknown
is in the revealed type, since it may not be obvious at first.