Skip to content

Commit ba5ddaa

Browse files
mishamskcarljmsharkdp
authored andcommitted
[red-knot] Add __init__ arguments check when doing try_call on a class literal (astral-sh#16512)
## Summary * Addresses #16511 for simple cases where only `__init__` method is bound on class or doesn't exist at all. * fixes a bug with argument counting in bound method diagnostics Caveats: * No handling of `__new__` or modified `__call__` on metaclass. * This leads to a couple of false positive errors in tests ## Test Plan - A couple new cases in mdtests - cargo nextest run -p red_knot_python_semantic --no-fail-fast --------- Co-authored-by: Carl Meyer <carl@astral.sh> Co-authored-by: David Peter <sharkdp@users.noreply.github.com>
1 parent c5f070d commit ba5ddaa

File tree

11 files changed

+853
-121
lines changed

11 files changed

+853
-121
lines changed

crates/red_knot_python_semantic/resources/mdtest/annotations/new_types.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ Currently, red-knot doesn't support `typing.NewType` in type annotations.
88
from typing_extensions import NewType
99
from types import GenericAlias
1010

11+
X = GenericAlias(type, ())
1112
A = NewType("A", int)
13+
# TODO: typeshed for `typing.GenericAlias` uses `type` for the first argument. `NewType` should be special-cased
14+
# to be compatible with `type`
15+
# error: [invalid-argument-type] "Object of type `NewType` cannot be assigned to parameter 2 (`origin`) of function `__new__`; expected type `type`"
1216
B = GenericAlias(A, ())
1317

1418
def _(
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,325 @@
11
# Constructor
22

3+
When classes are instantiated, Python calls the meta-class `__call__` method, which can either be
4+
customized by the user or `type.__call__` is used.
5+
6+
The latter calls the `__new__` method of the class, which is responsible for creating the instance
7+
and then calls the `__init__` method on the resulting instance to initialize it with the same
8+
arguments.
9+
10+
Both `__new__` and `__init__` are looked up using full descriptor protocol, but `__new__` is then
11+
called as an implicit static, rather than bound method with `cls` passed as the first argument.
12+
`__init__` has no special handling, it is fetched as bound method and is called just like any other
13+
dunder method.
14+
15+
`type.__call__` does other things too, but this is not yet handled by us.
16+
17+
Since every class has `object` in it's MRO, the default implementations are `object.__new__` and
18+
`object.__init__`. They have some special behavior, namely:
19+
20+
- If neither `__new__` nor `__init__` are defined anywhere in the MRO of class (except for `object`)
21+
\- no arguments are accepted and `TypeError` is raised if any are passed.
22+
- If `__new__` is defined, but `__init__` is not - `object.__init__` will allow arbitrary arguments!
23+
24+
As of today there are a number of behaviors that we do not support:
25+
26+
- `__new__` is assumed to return an instance of the class on which it is called
27+
- User defined `__call__` on metaclass is ignored
28+
29+
## Creating an instance of the `object` class itself
30+
31+
Test the behavior of the `object` class itself. As implementation has to ignore `object` own methods
32+
as defined in typeshed due to behavior not expressible in typeshed (see above how `__init__` behaves
33+
differently depending on whether `__new__` is defined or not), we have to test the behavior of
34+
`object` itself.
35+
36+
```py
37+
reveal_type(object()) # revealed: object
38+
39+
# error: [too-many-positional-arguments] "Too many positional arguments to class `object`: expected 0, got 1"
40+
reveal_type(object(1)) # revealed: object
41+
```
42+
43+
## No init or new
44+
345
```py
446
class Foo: ...
547

648
reveal_type(Foo()) # revealed: Foo
49+
50+
# error: [too-many-positional-arguments] "Too many positional arguments to bound method `__init__`: expected 0, got 1"
51+
reveal_type(Foo(1)) # revealed: Foo
52+
```
53+
54+
## `__new__` present on the class itself
55+
56+
```py
57+
class Foo:
58+
def __new__(cls, x: int) -> "Foo":
59+
return object.__new__(cls)
60+
61+
reveal_type(Foo(1)) # revealed: Foo
62+
63+
# error: [missing-argument] "No argument provided for required parameter `x` of function `__new__`"
64+
reveal_type(Foo()) # revealed: Foo
65+
# error: [too-many-positional-arguments] "Too many positional arguments to function `__new__`: expected 1, got 2"
66+
reveal_type(Foo(1, 2)) # revealed: Foo
67+
```
68+
69+
## `__new__` present on a superclass
70+
71+
If the `__new__` method is defined on a superclass, we can still infer the signature of the
72+
constructor from it.
73+
74+
```py
75+
from typing_extensions import Self
76+
77+
class Base:
78+
def __new__(cls, x: int) -> Self: ...
79+
80+
class Foo(Base): ...
81+
82+
reveal_type(Foo(1)) # revealed: Foo
83+
84+
# error: [missing-argument] "No argument provided for required parameter `x` of function `__new__`"
85+
reveal_type(Foo()) # revealed: Foo
86+
# error: [too-many-positional-arguments] "Too many positional arguments to function `__new__`: expected 1, got 2"
87+
reveal_type(Foo(1, 2)) # revealed: Foo
88+
```
89+
90+
## Conditional `__new__`
91+
92+
```py
93+
def _(flag: bool) -> None:
94+
class Foo:
95+
if flag:
96+
def __new__(cls, x: int): ...
97+
else:
98+
def __new__(cls, x: int, y: int = 1): ...
99+
100+
reveal_type(Foo(1)) # revealed: Foo
101+
# error: [invalid-argument-type] "Object of type `Literal["1"]` cannot be assigned to parameter 2 (`x`) of function `__new__`; expected type `int`"
102+
reveal_type(Foo("1")) # revealed: Foo
103+
# error: [missing-argument] "No argument provided for required parameter `x` of function `__new__`"
104+
reveal_type(Foo()) # revealed: Foo
105+
# error: [too-many-positional-arguments] "Too many positional arguments to function `__new__`: expected 1, got 2"
106+
reveal_type(Foo(1, 2)) # revealed: Foo
107+
```
108+
109+
## A descriptor in place of `__new__`
110+
111+
```py
112+
class SomeCallable:
113+
def __call__(self, cls, x: int) -> "Foo":
114+
obj = object.__new__(cls)
115+
obj.x = x
116+
return obj
117+
118+
class Descriptor:
119+
def __get__(self, instance, owner) -> SomeCallable:
120+
return SomeCallable()
121+
122+
class Foo:
123+
__new__: Descriptor = Descriptor()
124+
125+
reveal_type(Foo(1)) # revealed: Foo
126+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__call__`"
127+
reveal_type(Foo()) # revealed: Foo
128+
```
129+
130+
## A callable instance in place of `__new__`
131+
132+
### Bound
133+
134+
```py
135+
class Callable:
136+
def __call__(self, cls, x: int) -> "Foo":
137+
return object.__new__(cls)
138+
139+
class Foo:
140+
__new__ = Callable()
141+
142+
reveal_type(Foo(1)) # revealed: Foo
143+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__call__`"
144+
reveal_type(Foo()) # revealed: Foo
145+
```
146+
147+
### Possibly Unbound
148+
149+
```py
150+
def _(flag: bool) -> None:
151+
class Callable:
152+
if flag:
153+
def __call__(self, cls, x: int) -> "Foo":
154+
return object.__new__(cls)
155+
156+
class Foo:
157+
__new__ = Callable()
158+
159+
# error: [call-non-callable] "Object of type `Callable` is not callable (possibly unbound `__call__` method)"
160+
reveal_type(Foo(1)) # revealed: Foo
161+
# TODO should be - error: [missing-argument] "No argument provided for required parameter `x` of bound method `__call__`"
162+
# but we currently infer the signature of `__call__` as unknown, so it accepts any arguments
163+
# error: [call-non-callable] "Object of type `Callable` is not callable (possibly unbound `__call__` method)"
164+
reveal_type(Foo()) # revealed: Foo
165+
```
166+
167+
## `__init__` present on the class itself
168+
169+
If the class has an `__init__` method, we can infer the signature of the constructor from it.
170+
171+
```py
172+
class Foo:
173+
def __init__(self, x: int): ...
174+
175+
reveal_type(Foo(1)) # revealed: Foo
176+
177+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__init__`"
178+
reveal_type(Foo()) # revealed: Foo
179+
# error: [too-many-positional-arguments] "Too many positional arguments to bound method `__init__`: expected 1, got 2"
180+
reveal_type(Foo(1, 2)) # revealed: Foo
181+
```
182+
183+
## `__init__` present on a superclass
184+
185+
If the `__init__` method is defined on a superclass, we can still infer the signature of the
186+
constructor from it.
187+
188+
```py
189+
class Base:
190+
def __init__(self, x: int): ...
191+
192+
class Foo(Base): ...
193+
194+
reveal_type(Foo(1)) # revealed: Foo
195+
196+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__init__`"
197+
reveal_type(Foo()) # revealed: Foo
198+
# error: [too-many-positional-arguments] "Too many positional arguments to bound method `__init__`: expected 1, got 2"
199+
reveal_type(Foo(1, 2)) # revealed: Foo
200+
```
201+
202+
## Conditional `__init__`
203+
204+
```py
205+
def _(flag: bool) -> None:
206+
class Foo:
207+
if flag:
208+
def __init__(self, x: int): ...
209+
else:
210+
def __init__(self, x: int, y: int = 1): ...
211+
212+
reveal_type(Foo(1)) # revealed: Foo
213+
# error: [invalid-argument-type] "Object of type `Literal["1"]` cannot be assigned to parameter 2 (`x`) of bound method `__init__`; expected type `int`"
214+
reveal_type(Foo("1")) # revealed: Foo
215+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__init__`"
216+
reveal_type(Foo()) # revealed: Foo
217+
# error: [too-many-positional-arguments] "Too many positional arguments to bound method `__init__`: expected 1, got 2"
218+
reveal_type(Foo(1, 2)) # revealed: Foo
219+
```
220+
221+
## A descriptor in place of `__init__`
222+
223+
```py
224+
class SomeCallable:
225+
# TODO: at runtime `__init__` is checked to return `None` and
226+
# a `TypeError` is raised if it doesn't. However, apparently
227+
# this is not true when the descriptor is used as `__init__`.
228+
# However, we may still want to check this.
229+
def __call__(self, x: int) -> str:
230+
return "a"
231+
232+
class Descriptor:
233+
def __get__(self, instance, owner) -> SomeCallable:
234+
return SomeCallable()
235+
236+
class Foo:
237+
__init__: Descriptor = Descriptor()
238+
239+
reveal_type(Foo(1)) # revealed: Foo
240+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__call__`"
241+
reveal_type(Foo()) # revealed: Foo
242+
```
243+
244+
## A callable instance in place of `__init__`
245+
246+
### Bound
247+
248+
```py
249+
class Callable:
250+
def __call__(self, x: int) -> None:
251+
pass
252+
253+
class Foo:
254+
__init__ = Callable()
255+
256+
reveal_type(Foo(1)) # revealed: Foo
257+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__call__`"
258+
reveal_type(Foo()) # revealed: Foo
259+
```
260+
261+
### Possibly Unbound
262+
263+
```py
264+
def _(flag: bool) -> None:
265+
class Callable:
266+
if flag:
267+
def __call__(self, x: int) -> None:
268+
pass
269+
270+
class Foo:
271+
__init__ = Callable()
272+
273+
# error: [call-non-callable] "Object of type `Callable` is not callable (possibly unbound `__call__` method)"
274+
reveal_type(Foo(1)) # revealed: Foo
275+
# TODO should be - error: [missing-argument] "No argument provided for required parameter `x` of bound method `__call__`"
276+
# but we currently infer the signature of `__call__` as unknown, so it accepts any arguments
277+
# error: [call-non-callable] "Object of type `Callable` is not callable (possibly unbound `__call__` method)"
278+
reveal_type(Foo()) # revealed: Foo
279+
```
280+
281+
## `__new__` and `__init__` both present
282+
283+
### Identical signatures
284+
285+
A common case is to have `__new__` and `__init__` with identical signatures (except for the first
286+
argument). We report errors for both `__new__` and `__init__` if the arguments are incorrect.
287+
288+
At runtime `__new__` is called first and will fail without executing `__init__` if the arguments are
289+
incorrect. However, we decided that it is better to report errors for both methods, since after
290+
fixing the `__new__` method, the user may forget to fix the `__init__` method.
291+
292+
```py
293+
class Foo:
294+
def __new__(cls, x: int) -> "Foo":
295+
return object.__new__(cls)
296+
297+
def __init__(self, x: int): ...
298+
299+
# error: [missing-argument] "No argument provided for required parameter `x` of function `__new__`"
300+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__init__`"
301+
reveal_type(Foo()) # revealed: Foo
302+
303+
reveal_type(Foo(1)) # revealed: Foo
304+
```
305+
306+
### Compatible signatures
307+
308+
But they can also be compatible, but not identical. We should correctly report errors only for the
309+
mthod that would fail.
310+
311+
```py
312+
class Foo:
313+
def __new__(cls, *args, **kwargs):
314+
return object.__new__(cls)
315+
316+
def __init__(self, x: int) -> None:
317+
self.x = x
318+
319+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__init__`"
320+
reveal_type(Foo()) # revealed: Foo
321+
reveal_type(Foo(1)) # revealed: Foo
322+
323+
# error: [too-many-positional-arguments] "Too many positional arguments to bound method `__init__`: expected 1, got 2"
324+
reveal_type(Foo(1, 2)) # revealed: Foo
7325
```

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ class C:
2020
def _(subclass_of_c: type[C]):
2121
reveal_type(subclass_of_c(1)) # revealed: C
2222

23-
# TODO: Those should all be errors
23+
# error: [invalid-argument-type] "Object of type `Literal["a"]` cannot be assigned to parameter 2 (`x`) of bound method `__init__`; expected type `int`"
2424
reveal_type(subclass_of_c("a")) # revealed: C
25+
# error: [missing-argument] "No argument provided for required parameter `x` of bound method `__init__`"
2526
reveal_type(subclass_of_c()) # revealed: C
27+
# error: [too-many-positional-arguments] "Too many positional arguments to bound method `__init__`: expected 1, got 2"
2628
reveal_type(subclass_of_c(1, 2)) # revealed: C
2729
```
2830

crates/red_knot_python_semantic/resources/mdtest/generics/classes.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,17 @@ class E[T]:
111111
def __init__(self, x: T) -> None: ...
112112

113113
# TODO: revealed: E[int] or E[Literal[1]]
114+
# TODO should not emit an error
115+
# error: [invalid-argument-type] "Object of type `Literal[1]` cannot be assigned to parameter 2 (`x`) of bound method `__init__`; expected type `T`"
114116
reveal_type(E(1)) # revealed: E
115117
```
116118

117119
The types inferred from a type context and from a constructor parameter must be consistent with each
118120
other:
119121

120122
```py
121-
# TODO: error
123+
# TODO: the error should not leak the `T` typevar and should mention `E[int]`
124+
# error: [invalid-argument-type] "Object of type `Literal["five"]` cannot be assigned to parameter 2 (`x`) of bound method `__init__`; expected type `T`"
122125
wrong_innards: E[int] = E("five")
123126
```
124127

crates/red_knot_python_semantic/resources/mdtest/unary/invert_add_usub.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class Number:
1818
def __invert__(self) -> Literal[True]:
1919
return True
2020

21-
a = Number()
21+
a = Number(0)
2222

2323
reveal_type(+a) # revealed: int
2424
reveal_type(-a) # revealed: int

0 commit comments

Comments
 (0)