From 1feb3cf41a40bf382472495289675d0ac0a70e6a Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 24 Jan 2025 12:47:48 +0100 Subject: [PATCH] [red-knot] Use `Unknown | T_inferred` for undeclared public symbols (#15674) ## Summary Use `Unknown | T_inferred` as the type for *undeclared* public symbols. ## Test Plan - Updated existing tests - New test for external `__slots__` modifications. - New tests for external modifications of public symbols. --- .../resources/mdtest/annotations/literal.md | 2 +- .../resources/mdtest/attributes.md | 17 ++-- .../resources/mdtest/binary/instances.md | 3 +- .../mdtest/boundness_declaredness/public.md | 55 ++++++++--- .../mdtest/call/callable_instance.md | 2 +- .../resources/mdtest/comprehensions/basic.md | 6 +- .../resources/mdtest/expression/attribute.md | 17 +++- .../resources/mdtest/expression/boolean.md | 2 +- .../resources/mdtest/expression/len.md | 4 +- .../resources/mdtest/generics.md | 2 +- .../resources/mdtest/import/conditional.md | 6 +- .../resources/mdtest/loops/for.md | 6 +- .../resources/mdtest/narrow/type.md | 4 +- .../resources/mdtest/scopes/nonlocal.md | 10 +- .../resources/mdtest/scopes/unbound.md | 6 +- .../resources/mdtest/slots.md | 31 +++++++ .../resources/mdtest/subscript/instance.md | 3 +- .../resources/mdtest/unary/not.md | 4 +- .../resources/mdtest/with/sync.md | 8 +- crates/red_knot_python_semantic/src/symbol.rs | 8 ++ crates/red_knot_python_semantic/src/types.rs | 91 +++++++++++++------ .../src/types/signatures.rs | 10 +- crates/ruff_benchmark/benches/red_knot.rs | 6 +- 23 files changed, 212 insertions(+), 91 deletions(-) diff --git a/crates/red_knot_python_semantic/resources/mdtest/annotations/literal.md b/crates/red_knot_python_semantic/resources/mdtest/annotations/literal.md index 524ad383dc50c..8037211838016 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/annotations/literal.md +++ b/crates/red_knot_python_semantic/resources/mdtest/annotations/literal.md @@ -36,7 +36,7 @@ def f(): reveal_type(a7) # revealed: None reveal_type(a8) # revealed: Literal[1] # TODO: This should be Color.RED - reveal_type(b1) # revealed: Literal[0] + reveal_type(b1) # revealed: Unknown | Literal[0] # error: [invalid-type-form] invalid1: Literal[3 + 4] diff --git a/crates/red_knot_python_semantic/resources/mdtest/attributes.md b/crates/red_knot_python_semantic/resources/mdtest/attributes.md index 49121cf97f9ce..63cc31d0dbc0f 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/attributes.md +++ b/crates/red_knot_python_semantic/resources/mdtest/attributes.md @@ -175,7 +175,7 @@ class C: reveal_type(C.pure_class_variable1) # revealed: str -# TODO: this should be `Literal[1]`, or `Unknown | Literal[1]`. +# TODO: Should be `Unknown | Literal[1]`. reveal_type(C.pure_class_variable2) # revealed: Unknown c_instance = C() @@ -252,8 +252,7 @@ class C: reveal_type(C.variable_with_class_default1) # revealed: str -# TODO: this should be `Unknown | Literal[1]`. -reveal_type(C.variable_with_class_default2) # revealed: Literal[1] +reveal_type(C.variable_with_class_default2) # revealed: Unknown | Literal[1] c_instance = C() @@ -296,8 +295,8 @@ def _(flag: bool): else: x = 4 - reveal_type(C1.x) # revealed: Literal[1, 2] - reveal_type(C2.x) # revealed: Literal[3, 4] + reveal_type(C1.x) # revealed: Unknown | Literal[1, 2] + reveal_type(C2.x) # revealed: Unknown | Literal[3, 4] ``` ## Inherited class attributes @@ -311,7 +310,7 @@ class A: class B(A): ... class C(B): ... -reveal_type(C.X) # revealed: Literal["foo"] +reveal_type(C.X) # revealed: Unknown | Literal["foo"] ``` ### Multiple inheritance @@ -334,7 +333,7 @@ class A(B, C): ... reveal_type(A.__mro__) # `E` is earlier in the MRO than `F`, so we should use the type of `E.X` -reveal_type(A.X) # revealed: Literal[42] +reveal_type(A.X) # revealed: Unknown | Literal[42] ``` ## Unions with possibly unbound paths @@ -356,7 +355,7 @@ def _(flag1: bool, flag2: bool): C = C1 if flag1 else C2 if flag2 else C3 # error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C1, C2, C3]` is possibly unbound" - reveal_type(C.x) # revealed: Literal[1, 3] + reveal_type(C.x) # revealed: Unknown | Literal[1, 3] ``` ### Possibly-unbound within a class @@ -379,7 +378,7 @@ def _(flag: bool, flag1: bool, flag2: bool): C = C1 if flag1 else C2 if flag2 else C3 # error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C1, C2, C3]` is possibly unbound" - reveal_type(C.x) # revealed: Literal[1, 2, 3] + reveal_type(C.x) # revealed: Unknown | Literal[1, 2, 3] ``` ### Unions with all paths unbound diff --git a/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md b/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md index 0f614802f3584..54b4c6c6d10c6 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md +++ b/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md @@ -262,7 +262,8 @@ class A: class B: __add__ = A() -reveal_type(B() + B()) # revealed: int +# TODO: this could be `int` if we declare `B.__add__` using a `Callable` type +reveal_type(B() + B()) # revealed: Unknown | int ``` ## Integration test: numbers from typeshed diff --git a/crates/red_knot_python_semantic/resources/mdtest/boundness_declaredness/public.md b/crates/red_knot_python_semantic/resources/mdtest/boundness_declaredness/public.md index 6604792ccb156..55c4e7e9c7dda 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/boundness_declaredness/public.md +++ b/crates/red_knot_python_semantic/resources/mdtest/boundness_declaredness/public.md @@ -5,6 +5,11 @@ that is, a use of a symbol from another scope. If a symbol has a declared type i (e.g. `int`), we use that as the symbol's "public type" (the type of the symbol from the perspective of other scopes) even if there is a more precise local inferred type for the symbol (`Literal[1]`). +If a symbol has no declared type, we use the union of `Unknown` with the inferred type as the public +type. If there is no declaration, then the symbol can be reassigned to any type from another scope; +the union with `Unknown` reflects that its type must at least be as large as the type of the +assigned value, but could be arbitrarily larger. + We test the whole matrix of possible boundness and declaredness states. The current behavior is summarized in the following table, while the tests below demonstrate each case. Note that some of this behavior is questionable and might change in the future. See the TODOs in `symbol_by_id` @@ -12,11 +17,11 @@ this behavior is questionable and might change in the future. See the TODOs in ` In particular, we should raise errors in the "possibly-undeclared-and-unbound" as well as the "undeclared-and-possibly-unbound" cases (marked with a "?"). -| **Public type** | declared | possibly-undeclared | undeclared | -| ---------------- | ------------ | -------------------------- | ------------ | -| bound | `T_declared` | `T_declared \| T_inferred` | `T_inferred` | -| possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `T_inferred` | -| unbound | `T_declared` | `T_declared` | `Unknown` | +| **Public type** | declared | possibly-undeclared | undeclared | +| ---------------- | ------------ | -------------------------- | ----------------------- | +| bound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` | +| possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` | +| unbound | `T_declared` | `T_declared` | `Unknown` | | **Diagnostic** | declared | possibly-undeclared | undeclared | | ---------------- | -------- | ------------------------- | ------------------- | @@ -97,17 +102,24 @@ def flag() -> bool: ... x = 1 y = 2 +z = 3 if flag(): - x: Any + x: int + y: Any # error: [invalid-declaration] - y: str + z: str ``` ```py -from mod import x, y +from mod import x, y, z -reveal_type(x) # revealed: Literal[1] | Any -reveal_type(y) # revealed: Literal[2] | Unknown +reveal_type(x) # revealed: int +reveal_type(y) # revealed: Literal[2] | Any +reveal_type(z) # revealed: Literal[3] | Unknown + +# External modifications of `x` that violate the declared type are not allowed: +# error: [invalid-assignment] +x = None ``` ### Possibly undeclared and possibly unbound @@ -134,6 +146,10 @@ from mod import x, y reveal_type(x) # revealed: Literal[1] | Any reveal_type(y) # revealed: Literal[2] | str + +# External modifications of `y` that violate the declared type are not allowed: +# error: [invalid-assignment] +y = None ``` ### Possibly undeclared and unbound @@ -154,14 +170,16 @@ if flag(): from mod import x reveal_type(x) # revealed: int + +# External modifications to `x` that violate the declared type are not allowed: +# error: [invalid-assignment] +x = None ``` ## Undeclared ### Undeclared but bound -We use the inferred type as the public type, if a symbol has no declared type. - ```py path=mod.py x = 1 ``` @@ -169,7 +187,10 @@ x = 1 ```py from mod import x -reveal_type(x) # revealed: Literal[1] +reveal_type(x) # revealed: Unknown | Literal[1] + +# All external modifications of `x` are allowed: +x = None ``` ### Undeclared and possibly unbound @@ -189,7 +210,10 @@ if flag: # on top of this document. from mod import x -reveal_type(x) # revealed: Literal[1] +reveal_type(x) # revealed: Unknown | Literal[1] + +# All external modifications of `x` are allowed: +x = None ``` ### Undeclared and unbound @@ -206,4 +230,7 @@ if False: from mod import x reveal_type(x) # revealed: Unknown + +# Modifications allowed in this case: +x = None ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md b/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md index 8f5aab67b5aa4..f98d2cf1fc70e 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md +++ b/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md @@ -52,7 +52,7 @@ class NonCallable: __call__ = 1 a = NonCallable() -# error: "Object of type `NonCallable` is not callable" +# error: "Object of type `Unknown | Literal[1]` is not callable (due to union element `Literal[1]`)" reveal_type(a()) # revealed: Unknown ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/comprehensions/basic.md b/crates/red_knot_python_semantic/resources/mdtest/comprehensions/basic.md index 5236b27cce7e4..caeb5f95b747f 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comprehensions/basic.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comprehensions/basic.md @@ -43,7 +43,8 @@ class IntIterable: def __iter__(self) -> IntIterator: return IntIterator() -# revealed: tuple[int, int] +# TODO: This could be a `tuple[int, int]` if we model that `y` can not be modified in the outer comprehension scope +# revealed: tuple[int, Unknown | int] [[reveal_type((x, y)) for x in IntIterable()] for y in IntIterable()] ``` @@ -66,7 +67,8 @@ class IterableOfIterables: def __iter__(self) -> IteratorOfIterables: return IteratorOfIterables() -# revealed: tuple[int, IntIterable] +# TODO: This could be a `tuple[int, int]` (see above) +# revealed: tuple[int, Unknown | IntIterable] [[reveal_type((x, y)) for x in y] for y in IterableOfIterables()] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md b/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md index b0872c17a930b..1ccf74edd4efe 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md +++ b/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md @@ -5,7 +5,7 @@ ```py def _(flag: bool): class A: - always_bound = 1 + always_bound: int = 1 if flag: union = 1 @@ -13,14 +13,21 @@ def _(flag: bool): union = "abc" if flag: - possibly_unbound = "abc" + union_declared: int = 1 + else: + union_declared: str = "abc" + + if flag: + possibly_unbound: str = "abc" + + reveal_type(A.always_bound) # revealed: int - reveal_type(A.always_bound) # revealed: Literal[1] + reveal_type(A.union) # revealed: Unknown | Literal[1, "abc"] - reveal_type(A.union) # revealed: Literal[1, "abc"] + reveal_type(A.union_declared) # revealed: int | str # error: [possibly-unbound-attribute] "Attribute `possibly_unbound` on type `Literal[A]` is possibly unbound" - reveal_type(A.possibly_unbound) # revealed: Literal["abc"] + reveal_type(A.possibly_unbound) # revealed: str # error: [unresolved-attribute] "Type `Literal[A]` has no attribute `non_existent`" reveal_type(A.non_existent) # revealed: Unknown diff --git a/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md b/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md index ad9075b8f876e..8231f44b06435 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md +++ b/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md @@ -55,7 +55,7 @@ reveal_type("x" or "y" and "") # revealed: Literal["x"] ## Evaluates to builtin ```py path=a.py -redefined_builtin_bool = bool +redefined_builtin_bool: type[bool] = bool def my_bool(x) -> bool: return True diff --git a/crates/red_knot_python_semantic/resources/mdtest/expression/len.md b/crates/red_knot_python_semantic/resources/mdtest/expression/len.md index ca1892c6a38ff..19d965c99b202 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/expression/len.md +++ b/crates/red_knot_python_semantic/resources/mdtest/expression/len.md @@ -172,10 +172,10 @@ class IntUnion: def __len__(self) -> Literal[SomeEnum.INT, SomeEnum.INT_2]: ... reveal_type(len(Auto())) # revealed: int -reveal_type(len(Int())) # revealed: Literal[2] +reveal_type(len(Int())) # revealed: int reveal_type(len(Str())) # revealed: int reveal_type(len(Tuple())) # revealed: int -reveal_type(len(IntUnion())) # revealed: Literal[2, 32] +reveal_type(len(IntUnion())) # revealed: int ``` ### Negative integers diff --git a/crates/red_knot_python_semantic/resources/mdtest/generics.md b/crates/red_knot_python_semantic/resources/mdtest/generics.md index 1f5d1212e86f2..24459f8febf15 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/generics.md +++ b/crates/red_knot_python_semantic/resources/mdtest/generics.md @@ -20,7 +20,7 @@ wrong_innards: MyBox[int] = MyBox("five") # TODO reveal int, do not leak the typevar reveal_type(box.data) # revealed: T -reveal_type(MyBox.box_model_number) # revealed: Literal[695] +reveal_type(MyBox.box_model_number) # revealed: Unknown | Literal[695] ``` ## Subclassing diff --git a/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md b/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md index 79686f8e74676..e4c246064896b 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md +++ b/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md @@ -23,8 +23,8 @@ reveal_type(y) # error: [possibly-unbound-import] "Member `y` of module `maybe_unbound` is possibly unbound" from maybe_unbound import x, y -reveal_type(x) # revealed: Literal[3] -reveal_type(y) # revealed: Literal[3] +reveal_type(x) # revealed: Unknown | Literal[3] +reveal_type(y) # revealed: Unknown | Literal[3] ``` ## Maybe unbound annotated @@ -52,7 +52,7 @@ Importing an annotated name prefers the declared type over the inferred type: # error: [possibly-unbound-import] "Member `y` of module `maybe_unbound_annotated` is possibly unbound" from maybe_unbound_annotated import x, y -reveal_type(x) # revealed: Literal[3] +reveal_type(x) # revealed: Unknown | Literal[3] reveal_type(y) # revealed: int ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/loops/for.md b/crates/red_knot_python_semantic/resources/mdtest/loops/for.md index 791ab7b1bd704..6ad8c2be498ec 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/loops/for.md +++ b/crates/red_knot_python_semantic/resources/mdtest/loops/for.md @@ -109,9 +109,9 @@ reveal_type(x) def _(flag: bool): class NotIterable: if flag: - __iter__ = 1 + __iter__: int = 1 else: - __iter__ = None + __iter__: None = None for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" pass @@ -135,7 +135,7 @@ for x in nonsense: # error: "Object of type `Literal[123]` is not iterable" class NotIterable: def __getitem__(self, key: int) -> int: return 42 - __iter__ = None + __iter__: None = None for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" pass diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/type.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/type.md index 386a508aab3ad..a0fdd5ca1d9ab 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/narrow/type.md +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/type.md @@ -99,9 +99,9 @@ def _(x: str | int): class A: ... class B: ... -alias_for_type = type - def _(x: A | B): + alias_for_type = type + if alias_for_type(x) is A: reveal_type(x) # revealed: A ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/scopes/nonlocal.md b/crates/red_knot_python_semantic/resources/mdtest/scopes/nonlocal.md index 037179e7d5863..7aa16794c3553 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/scopes/nonlocal.md +++ b/crates/red_knot_python_semantic/resources/mdtest/scopes/nonlocal.md @@ -6,7 +6,7 @@ def f(): x = 1 def g(): - reveal_type(x) # revealed: Literal[1] + reveal_type(x) # revealed: Unknown | Literal[1] ``` ## Two levels up @@ -16,7 +16,7 @@ def f(): x = 1 def g(): def h(): - reveal_type(x) # revealed: Literal[1] + reveal_type(x) # revealed: Unknown | Literal[1] ``` ## Skips class scope @@ -28,7 +28,7 @@ def f(): class C: x = 2 def g(): - reveal_type(x) # revealed: Literal[1] + reveal_type(x) # revealed: Unknown | Literal[1] ``` ## Skips annotation-only assignment @@ -41,7 +41,7 @@ def f(): # name is otherwise not defined; maybe should be an error? x: int def h(): - reveal_type(x) # revealed: Literal[1] + reveal_type(x) # revealed: Unknown | Literal[1] ``` ## Implicit global in function @@ -52,5 +52,5 @@ A name reference to a never-defined symbol in a function is implicitly a global x = 1 def f(): - reveal_type(x) # revealed: Literal[1] + reveal_type(x) # revealed: Unknown | Literal[1] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/scopes/unbound.md b/crates/red_knot_python_semantic/resources/mdtest/scopes/unbound.md index 3030865c9d0ff..6e4d57195adf1 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/scopes/unbound.md +++ b/crates/red_knot_python_semantic/resources/mdtest/scopes/unbound.md @@ -17,8 +17,8 @@ class C: x = 2 # error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C]` is possibly unbound" -reveal_type(C.x) # revealed: Literal[2] -reveal_type(C.y) # revealed: Literal[1] +reveal_type(C.x) # revealed: Unknown | Literal[2] +reveal_type(C.y) # revealed: Unknown | Literal[1] ``` ## Possibly unbound in class and global scope @@ -37,7 +37,7 @@ class C: # error: [possibly-unresolved-reference] y = x -reveal_type(C.y) # revealed: Literal[1, "abc"] +reveal_type(C.y) # revealed: Unknown | Literal[1, "abc"] ``` ## Unbound function local diff --git a/crates/red_knot_python_semantic/resources/mdtest/slots.md b/crates/red_knot_python_semantic/resources/mdtest/slots.md index c61c2bf96dee4..6b59c48723794 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/slots.md +++ b/crates/red_knot_python_semantic/resources/mdtest/slots.md @@ -182,3 +182,34 @@ class C(A, B): ... # False negative: [incompatible-slots] class A(int, str): ... ``` + +### Diagnostic if `__slots__` is externally modified + +We special-case type inference for `__slots__` and return the pure inferred type, even if the symbol +is not declared — a case in which we union with `Unknown` for other public symbols. The reason for +this is that `__slots__` has a special handling in the runtime. Modifying it externally is actually +allowed, but those changes do not take effect. If you have a class `C` with `__slots__ = ("foo",)` +and externally set `C.__slots__ = ("bar",)`, you still can't access `C.bar`. And you can still +access `C.foo`. We therefore issue a diagnostic for such assignments: + +```py +class A: + __slots__ = ("a",) + + # Modifying `__slots__` from within the class body is fine: + __slots__ = ("a", "b") + +# No `Unknown` here: +reveal_type(A.__slots__) # revealed: tuple[Literal["a"], Literal["b"]] + +# But modifying it externally is not: + +# error: [invalid-assignment] +A.__slots__ = ("a",) + +# error: [invalid-assignment] +A.__slots__ = ("a", "b_new") + +# error: [invalid-assignment] +A.__slots__ = ("a", "b", "c") +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md b/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md index eeb251dbcbbe7..19a4aac66e509 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md +++ b/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md @@ -14,7 +14,8 @@ a = NotSubscriptable()[0] # error: "Cannot subscript object of type `NotSubscri class NotSubscriptable: __getitem__ = None -a = NotSubscriptable()[0] # error: "Method `__getitem__` of type `None` is not callable on object of type `NotSubscriptable`" +# error: "Method `__getitem__` of type `Unknown | None` is not callable on object of type `NotSubscriptable`" +a = NotSubscriptable()[0] ``` ## Valid getitem diff --git a/crates/red_knot_python_semantic/resources/mdtest/unary/not.md b/crates/red_knot_python_semantic/resources/mdtest/unary/not.md index 0b887ee036458..b35783be799a9 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/unary/not.md +++ b/crates/red_knot_python_semantic/resources/mdtest/unary/not.md @@ -139,7 +139,9 @@ reveal_type(not AlwaysFalse()) # We don't get into a cycle if someone sets their `__bool__` method to the `bool` builtin: class BoolIsBool: - __bool__ = bool + # TODO: The `type[bool]` declaration here is a workaround to avoid running into + # https://github.com/astral-sh/ruff/issues/15672 + __bool__: type[bool] = bool # revealed: bool reveal_type(not BoolIsBool()) diff --git a/crates/red_knot_python_semantic/resources/mdtest/with/sync.md b/crates/red_knot_python_semantic/resources/mdtest/with/sync.md index 408a9f9c232e3..48ea3c2c41a62 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/with/sync.md +++ b/crates/red_knot_python_semantic/resources/mdtest/with/sync.md @@ -76,11 +76,11 @@ with Manager(): ```py class Manager: - __enter__ = 42 + __enter__: int = 42 def __exit__(self, exc_tpe, exc_value, traceback): ... -# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__enter__` of type `Literal[42]` is not callable" +# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__enter__` of type `int` is not callable" with Manager(): ... ``` @@ -91,9 +91,9 @@ with Manager(): class Manager: def __enter__(self) -> Self: ... - __exit__ = 32 + __exit__: int = 32 -# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__exit__` of type `Literal[32]` is not callable" +# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__exit__` of type `int` is not callable" with Manager(): ... ``` diff --git a/crates/red_knot_python_semantic/src/symbol.rs b/crates/red_knot_python_semantic/src/symbol.rs index 5b1852ce7c809..3086ecbbca768 100644 --- a/crates/red_knot_python_semantic/src/symbol.rs +++ b/crates/red_knot_python_semantic/src/symbol.rs @@ -85,6 +85,14 @@ impl<'db> Symbol<'db> { Symbol::Unbound => self, } } + + #[must_use] + pub(crate) fn map_type(self, f: impl FnOnce(Type<'db>) -> Type<'db>) -> Symbol<'db> { + match self { + Symbol::Type(ty, boundness) => Symbol::Type(f(ty), boundness), + Symbol::Unbound => Symbol::Unbound, + } + } } #[cfg(test)] diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 2ace01226993f..69940e4a9d78f 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -80,29 +80,54 @@ pub fn check_types(db: &dyn Db, file: File) -> TypeCheckDiagnostics { diagnostics } +/// Computes a possibly-widened type `Unknown | T_inferred` from the inferred type `T_inferred` +/// of a symbol, unless the type is a known-instance type (e.g. `typing.Any`) or the symbol is +/// considered non-modifiable (e.g. when the symbol is `@Final`). We need this for public uses +/// of symbols that have no declared type. +fn widen_type_for_undeclared_public_symbol<'db>( + db: &'db dyn Db, + inferred: Symbol<'db>, + is_considered_non_modifiable: bool, +) -> Symbol<'db> { + // We special-case known-instance types here since symbols like `typing.Any` are typically + // not declared in the stubs (e.g. `Any = object()`), but we still want to treat them as + // such. + let is_known_instance = inferred + .ignore_possibly_unbound() + .is_some_and(|ty| matches!(ty, Type::KnownInstance(_))); + + if is_considered_non_modifiable || is_known_instance { + inferred + } else { + inferred.map_type(|ty| UnionType::from_elements(db, [Type::unknown(), ty])) + } +} + /// Infer the public type of a symbol (its type as seen from outside its scope). fn symbol<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Symbol<'db> { #[salsa::tracked] fn symbol_by_id<'db>( db: &'db dyn Db, scope: ScopeId<'db>, - symbol: ScopedSymbolId, + is_dunder_slots: bool, + symbol_id: ScopedSymbolId, ) -> Symbol<'db> { let use_def = use_def_map(db, scope); // If the symbol is declared, the public type is based on declarations; otherwise, it's based // on inference from bindings. - let declarations = use_def.public_declarations(symbol); - let declared = - symbol_from_declarations(db, declarations).map(|SymbolAndQualifiers(ty, _)| ty); + let declarations = use_def.public_declarations(symbol_id); + let declared = symbol_from_declarations(db, declarations); + let is_final = declared.as_ref().is_ok_and(SymbolAndQualifiers::is_final); + let declared = declared.map(|SymbolAndQualifiers(symbol, _)| symbol); match declared { // Symbol is declared, trust the declared type Ok(symbol @ Symbol::Type(_, Boundness::Bound)) => symbol, // Symbol is possibly declared Ok(Symbol::Type(declared_ty, Boundness::PossiblyUnbound)) => { - let bindings = use_def.public_bindings(symbol); + let bindings = use_def.public_bindings(symbol_id); let inferred = symbol_from_bindings(db, bindings); match inferred { @@ -120,12 +145,14 @@ fn symbol<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Symbol<'db> ), } } - // Symbol is undeclared, return the inferred type + // Symbol is undeclared, return the union of `Unknown` with the inferred type Ok(Symbol::Unbound) => { - let bindings = use_def.public_bindings(symbol); - symbol_from_bindings(db, bindings) + let bindings = use_def.public_bindings(symbol_id); + let inferred = symbol_from_bindings(db, bindings); + + widen_type_for_undeclared_public_symbol(db, inferred, is_dunder_slots || is_final) } - // Symbol is possibly undeclared + // Symbol has conflicting declared types Err((declared_ty, _)) => { // Intentionally ignore conflicting declared types; that's not our problem, // it's the problem of the module we are importing from. @@ -177,9 +204,15 @@ fn symbol<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Symbol<'db> } let table = symbol_table(db, scope); + // `__slots__` is a symbol with special behavior in Python's runtime. It can be + // modified externally, but those changes do not take effect. We therefore issue + // a diagnostic if we see it being modified externally. In type inference, we + // can assign a "narrow" type to it even if it is not *declared*. This means, we + // do not have to call [`widen_type_for_undeclared_public_symbol`]. + let is_dunder_slots = name == "__slots__"; table .symbol_id_by_name(name) - .map(|symbol| symbol_by_id(db, scope, symbol)) + .map(|symbol| symbol_by_id(db, scope, is_dunder_slots, symbol)) .unwrap_or(Symbol::Unbound) } @@ -378,6 +411,10 @@ impl SymbolAndQualifiers<'_> { fn is_class_var(&self) -> bool { self.1.contains(TypeQualifiers::CLASS_VAR) } + + fn is_final(&self) -> bool { + self.1.contains(TypeQualifiers::FINAL) + } } impl<'db> From> for SymbolAndQualifiers<'db> { @@ -4076,7 +4113,7 @@ impl<'db> Class<'db> { /// this class, not on its superclasses. fn own_instance_member(self, db: &'db dyn Db, name: &str) -> SymbolAndQualifiers<'db> { // TODO: There are many things that are not yet implemented here: - // - `typing.ClassVar` and `typing.Final` + // - `typing.Final` // - Proper diagnostics // - Handling of possibly-undeclared/possibly-unbound attributes // - The descriptor protocol @@ -4084,10 +4121,10 @@ impl<'db> Class<'db> { let body_scope = self.body_scope(db); let table = symbol_table(db, body_scope); - if let Some(symbol) = table.symbol_id_by_name(name) { + if let Some(symbol_id) = table.symbol_id_by_name(name) { let use_def = use_def_map(db, body_scope); - let declarations = use_def.public_declarations(symbol); + let declarations = use_def.public_declarations(symbol_id); match symbol_from_declarations(db, declarations) { Ok(SymbolAndQualifiers(Symbol::Type(declared_ty, _), qualifiers)) => { @@ -4104,20 +4141,14 @@ impl<'db> Class<'db> { SymbolAndQualifiers(Symbol::Type(declared_ty, Boundness::Bound), qualifiers) } } - Ok(SymbolAndQualifiers(Symbol::Unbound, qualifiers)) => { - let bindings = use_def.public_bindings(symbol); + Ok(symbol @ SymbolAndQualifiers(Symbol::Unbound, qualifiers)) => { + let bindings = use_def.public_bindings(symbol_id); let inferred = symbol_from_bindings(db, bindings); - match inferred { - Symbol::Type(ty, _) => SymbolAndQualifiers( - Symbol::Type( - UnionType::from_elements(db, [Type::unknown(), ty]), - Boundness::Bound, - ), - qualifiers, - ), - Symbol::Unbound => SymbolAndQualifiers(Symbol::Unbound, qualifiers), - } + SymbolAndQualifiers( + widen_type_for_undeclared_public_symbol(db, inferred, symbol.is_final()), + qualifiers, + ) } Err((declared_ty, _conflicting_declarations)) => { // Ignore conflicting declarations @@ -4694,7 +4725,10 @@ pub(crate) mod tests { let bar = system_path_to_file(&db, "src/bar.py")?; let a = global_symbol(&db, bar, "a"); - assert_eq!(a.expect_type(), KnownClass::Int.to_instance(&db)); + assert_eq!( + a.expect_type(), + UnionType::from_elements(&db, [Type::unknown(), KnownClass::Int.to_instance(&db)]) + ); // Add a docstring to foo to trigger a re-run. // The bar-call site of foo should not be re-run because of that @@ -4710,7 +4744,10 @@ pub(crate) mod tests { let a = global_symbol(&db, bar, "a"); - assert_eq!(a.expect_type(), KnownClass::Int.to_instance(&db)); + assert_eq!( + a.expect_type(), + UnionType::from_elements(&db, [Type::unknown(), KnownClass::Int.to_instance(&db)]) + ); let events = db.take_salsa_events(); let call = &*parsed_module(&db, bar).syntax().body[1] diff --git a/crates/red_knot_python_semantic/src/types/signatures.rs b/crates/red_knot_python_semantic/src/types/signatures.rs index 6a4e7c4ba1255..3d350300a8797 100644 --- a/crates/red_knot_python_semantic/src/types/signatures.rs +++ b/crates/red_knot_python_semantic/src/types/signatures.rs @@ -543,7 +543,10 @@ mod tests { assert_eq!(a_name, "a"); assert_eq!(b_name, "b"); // TODO resolution should not be deferred; we should see A not B - assert_eq!(a_annotated_ty.unwrap().display(&db).to_string(), "B"); + assert_eq!( + a_annotated_ty.unwrap().display(&db).to_string(), + "Unknown | B" + ); assert_eq!(b_annotated_ty.unwrap().display(&db).to_string(), "T"); } @@ -583,7 +586,10 @@ mod tests { assert_eq!(a_name, "a"); assert_eq!(b_name, "b"); // Parameter resolution deferred; we should see B - assert_eq!(a_annotated_ty.unwrap().display(&db).to_string(), "B"); + assert_eq!( + a_annotated_ty.unwrap().display(&db).to_string(), + "Unknown | B" + ); assert_eq!(b_annotated_ty.unwrap().display(&db).to_string(), "T"); } diff --git a/crates/ruff_benchmark/benches/red_knot.rs b/crates/ruff_benchmark/benches/red_knot.rs index 255e89d92af60..b8ea77920eb19 100644 --- a/crates/ruff_benchmark/benches/red_knot.rs +++ b/crates/ruff_benchmark/benches/red_knot.rs @@ -43,10 +43,10 @@ static EXPECTED_DIAGNOSTICS: &[&str] = &[ "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:579:12 Name `char` used when possibly not defined", "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:580:63 Name `char` used when possibly not defined", // We don't handle intersections in `is_assignable_to` yet - "error[lint:invalid-argument-type] /src/tomllib/_parser.py:626:46 Object of type `@Todo & ~AlwaysFalsy` cannot be assigned to parameter 1 (`match`) of function `match_to_datetime`; expected type `Match`", + "error[lint:invalid-argument-type] /src/tomllib/_parser.py:626:46 Object of type `Unknown & ~AlwaysFalsy | @Todo & ~AlwaysFalsy` cannot be assigned to parameter 1 (`match`) of function `match_to_datetime`; expected type `Match`", "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:629:38 Name `datetime_obj` used when possibly not defined", - "error[lint:invalid-argument-type] /src/tomllib/_parser.py:632:58 Object of type `@Todo & ~AlwaysFalsy` cannot be assigned to parameter 1 (`match`) of function `match_to_localtime`; expected type `Match`", - "error[lint:invalid-argument-type] /src/tomllib/_parser.py:639:52 Object of type `@Todo & ~AlwaysFalsy` cannot be assigned to parameter 1 (`match`) of function `match_to_number`; expected type `Match`", + "error[lint:invalid-argument-type] /src/tomllib/_parser.py:632:58 Object of type `Unknown & ~AlwaysFalsy | @Todo & ~AlwaysFalsy` cannot be assigned to parameter 1 (`match`) of function `match_to_localtime`; expected type `Match`", + "error[lint:invalid-argument-type] /src/tomllib/_parser.py:639:52 Object of type `Unknown & ~AlwaysFalsy | @Todo & ~AlwaysFalsy` cannot be assigned to parameter 1 (`match`) of function `match_to_number`; expected type `Match`", "warning[lint:unused-ignore-comment] /src/tomllib/_parser.py:682:31 Unused blanket `type: ignore` directive", ];