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 524ad383dc50cf..8037211838016f 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 49121cf97f9ce2..63cc31d0dbc0f2 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 0f614802f35842..54b4c6c6d10c68 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 6604792ccb1566..55c4e7e9c7dda8 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 8f5aab67b5aa4e..f98d2cf1fc70e9 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 5236b27cce7e41..caeb5f95b747f6 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 b0872c17a930bc..1ccf74edd4efe8 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 ad9075b8f876e7..8231f44b064359 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 ca1892c6a38ff1..19d965c99b202b 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 1f5d1212e86f22..24459f8febf15f 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 79686f8e74676c..e4c246064896b5 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 791ab7b1bd704b..6ad8c2be498ecd 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 386a508aab3adb..a0fdd5ca1d9ab6 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 037179e7d5863b..7aa16794c3553d 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 3030865c9d0ff9..6e4d57195adf10 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 c61c2bf96dee4f..6b59c487237949 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 eeb251dbcbbe7b..19a4aac66e509c 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 0b887ee036458e..b35783be799a9f 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 408a9f9c232e3c..48ea3c2c41a62b 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 5b1852ce7c8093..3086ecbbca768b 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 2ace01226993f1..69940e4a9d78f9 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 6a4e7c4ba12558..3d350300a8797d 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 255e89d92af60d..b8ea77920eb19d 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", ];