[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.
This commit is contained in:
David Peter 2025-01-24 12:47:48 +01:00 committed by GitHub
parent 7778d1d646
commit 1feb3cf41a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 212 additions and 91 deletions

View file

@ -36,7 +36,7 @@ def f():
reveal_type(a7) # revealed: None reveal_type(a7) # revealed: None
reveal_type(a8) # revealed: Literal[1] reveal_type(a8) # revealed: Literal[1]
# TODO: This should be Color.RED # TODO: This should be Color.RED
reveal_type(b1) # revealed: Literal[0] reveal_type(b1) # revealed: Unknown | Literal[0]
# error: [invalid-type-form] # error: [invalid-type-form]
invalid1: Literal[3 + 4] invalid1: Literal[3 + 4]

View file

@ -175,7 +175,7 @@ class C:
reveal_type(C.pure_class_variable1) # revealed: str 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 reveal_type(C.pure_class_variable2) # revealed: Unknown
c_instance = C() c_instance = C()
@ -252,8 +252,7 @@ class C:
reveal_type(C.variable_with_class_default1) # revealed: str reveal_type(C.variable_with_class_default1) # revealed: str
# TODO: this should be `Unknown | Literal[1]`. reveal_type(C.variable_with_class_default2) # revealed: Unknown | Literal[1]
reveal_type(C.variable_with_class_default2) # revealed: Literal[1]
c_instance = C() c_instance = C()
@ -296,8 +295,8 @@ def _(flag: bool):
else: else:
x = 4 x = 4
reveal_type(C1.x) # revealed: Literal[1, 2] reveal_type(C1.x) # revealed: Unknown | Literal[1, 2]
reveal_type(C2.x) # revealed: Literal[3, 4] reveal_type(C2.x) # revealed: Unknown | Literal[3, 4]
``` ```
## Inherited class attributes ## Inherited class attributes
@ -311,7 +310,7 @@ class A:
class B(A): ... class B(A): ...
class C(B): ... class C(B): ...
reveal_type(C.X) # revealed: Literal["foo"] reveal_type(C.X) # revealed: Unknown | Literal["foo"]
``` ```
### Multiple inheritance ### Multiple inheritance
@ -334,7 +333,7 @@ class A(B, C): ...
reveal_type(A.__mro__) reveal_type(A.__mro__)
# `E` is earlier in the MRO than `F`, so we should use the type of `E.X` # `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 ## Unions with possibly unbound paths
@ -356,7 +355,7 @@ def _(flag1: bool, flag2: bool):
C = C1 if flag1 else C2 if flag2 else C3 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" # 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 ### 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 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" # 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 ### Unions with all paths unbound

View file

@ -262,7 +262,8 @@ class A:
class B: class B:
__add__ = A() __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 ## Integration test: numbers from typeshed

View file

@ -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 (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]`). 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 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 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` 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 In particular, we should raise errors in the "possibly-undeclared-and-unbound" as well as the
"undeclared-and-possibly-unbound" cases (marked with a "?"). "undeclared-and-possibly-unbound" cases (marked with a "?").
| **Public type** | declared | possibly-undeclared | undeclared | | **Public type** | declared | possibly-undeclared | undeclared |
| ---------------- | ------------ | -------------------------- | ------------ | | ---------------- | ------------ | -------------------------- | ----------------------- |
| bound | `T_declared` | `T_declared \| T_inferred` | `T_inferred` | | bound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` |
| possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `T_inferred` | | possibly-unbound | `T_declared` | `T_declared \| T_inferred` | `Unknown \| T_inferred` |
| unbound | `T_declared` | `T_declared` | `Unknown` | | unbound | `T_declared` | `T_declared` | `Unknown` |
| **Diagnostic** | declared | possibly-undeclared | undeclared | | **Diagnostic** | declared | possibly-undeclared | undeclared |
| ---------------- | -------- | ------------------------- | ------------------- | | ---------------- | -------- | ------------------------- | ------------------- |
@ -97,17 +102,24 @@ def flag() -> bool: ...
x = 1 x = 1
y = 2 y = 2
z = 3
if flag(): if flag():
x: Any x: int
y: Any
# error: [invalid-declaration] # error: [invalid-declaration]
y: str z: str
``` ```
```py ```py
from mod import x, y from mod import x, y, z
reveal_type(x) # revealed: Literal[1] | Any reveal_type(x) # revealed: int
reveal_type(y) # revealed: Literal[2] | Unknown 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 ### Possibly undeclared and possibly unbound
@ -134,6 +146,10 @@ from mod import x, y
reveal_type(x) # revealed: Literal[1] | Any reveal_type(x) # revealed: Literal[1] | Any
reveal_type(y) # revealed: Literal[2] | str 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 ### Possibly undeclared and unbound
@ -154,14 +170,16 @@ if flag():
from mod import x from mod import x
reveal_type(x) # revealed: int reveal_type(x) # revealed: int
# External modifications to `x` that violate the declared type are not allowed:
# error: [invalid-assignment]
x = None
``` ```
## Undeclared ## Undeclared
### Undeclared but bound ### Undeclared but bound
We use the inferred type as the public type, if a symbol has no declared type.
```py path=mod.py ```py path=mod.py
x = 1 x = 1
``` ```
@ -169,7 +187,10 @@ x = 1
```py ```py
from mod import x 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 ### Undeclared and possibly unbound
@ -189,7 +210,10 @@ if flag:
# on top of this document. # on top of this document.
from mod import x 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 ### Undeclared and unbound
@ -206,4 +230,7 @@ if False:
from mod import x from mod import x
reveal_type(x) # revealed: Unknown reveal_type(x) # revealed: Unknown
# Modifications allowed in this case:
x = None
``` ```

View file

@ -52,7 +52,7 @@ class NonCallable:
__call__ = 1 __call__ = 1
a = NonCallable() 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 reveal_type(a()) # revealed: Unknown
``` ```

View file

@ -43,7 +43,8 @@ class IntIterable:
def __iter__(self) -> IntIterator: def __iter__(self) -> IntIterator:
return 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()] [[reveal_type((x, y)) for x in IntIterable()] for y in IntIterable()]
``` ```
@ -66,7 +67,8 @@ class IterableOfIterables:
def __iter__(self) -> IteratorOfIterables: def __iter__(self) -> IteratorOfIterables:
return 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()] [[reveal_type((x, y)) for x in y] for y in IterableOfIterables()]
``` ```

View file

@ -5,7 +5,7 @@
```py ```py
def _(flag: bool): def _(flag: bool):
class A: class A:
always_bound = 1 always_bound: int = 1
if flag: if flag:
union = 1 union = 1
@ -13,14 +13,21 @@ def _(flag: bool):
union = "abc" union = "abc"
if flag: if flag:
possibly_unbound = "abc" union_declared: int = 1
else:
union_declared: str = "abc"
reveal_type(A.always_bound) # revealed: Literal[1] if flag:
possibly_unbound: str = "abc"
reveal_type(A.union) # revealed: Literal[1, "abc"] reveal_type(A.always_bound) # revealed: int
reveal_type(A.union) # revealed: Unknown | 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" # 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`" # error: [unresolved-attribute] "Type `Literal[A]` has no attribute `non_existent`"
reveal_type(A.non_existent) # revealed: Unknown reveal_type(A.non_existent) # revealed: Unknown

View file

@ -55,7 +55,7 @@ reveal_type("x" or "y" and "") # revealed: Literal["x"]
## Evaluates to builtin ## Evaluates to builtin
```py path=a.py ```py path=a.py
redefined_builtin_bool = bool redefined_builtin_bool: type[bool] = bool
def my_bool(x) -> bool: def my_bool(x) -> bool:
return True return True

View file

@ -172,10 +172,10 @@ class IntUnion:
def __len__(self) -> Literal[SomeEnum.INT, SomeEnum.INT_2]: ... def __len__(self) -> Literal[SomeEnum.INT, SomeEnum.INT_2]: ...
reveal_type(len(Auto())) # revealed: int 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(Str())) # revealed: int
reveal_type(len(Tuple())) # revealed: int reveal_type(len(Tuple())) # revealed: int
reveal_type(len(IntUnion())) # revealed: Literal[2, 32] reveal_type(len(IntUnion())) # revealed: int
``` ```
### Negative integers ### Negative integers

View file

@ -20,7 +20,7 @@ wrong_innards: MyBox[int] = MyBox("five")
# TODO reveal int, do not leak the typevar # TODO reveal int, do not leak the typevar
reveal_type(box.data) # revealed: T 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 ## Subclassing

View file

@ -23,8 +23,8 @@ reveal_type(y)
# error: [possibly-unbound-import] "Member `y` of module `maybe_unbound` is possibly unbound" # error: [possibly-unbound-import] "Member `y` of module `maybe_unbound` is possibly unbound"
from maybe_unbound import x, y from maybe_unbound import x, y
reveal_type(x) # revealed: Literal[3] reveal_type(x) # revealed: Unknown | Literal[3]
reveal_type(y) # revealed: Literal[3] reveal_type(y) # revealed: Unknown | Literal[3]
``` ```
## Maybe unbound annotated ## 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" # error: [possibly-unbound-import] "Member `y` of module `maybe_unbound_annotated` is possibly unbound"
from maybe_unbound_annotated import x, y 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 reveal_type(y) # revealed: int
``` ```

View file

@ -109,9 +109,9 @@ reveal_type(x)
def _(flag: bool): def _(flag: bool):
class NotIterable: class NotIterable:
if flag: if flag:
__iter__ = 1 __iter__: int = 1
else: else:
__iter__ = None __iter__: None = None
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
pass pass
@ -135,7 +135,7 @@ for x in nonsense: # error: "Object of type `Literal[123]` is not iterable"
class NotIterable: class NotIterable:
def __getitem__(self, key: int) -> int: def __getitem__(self, key: int) -> int:
return 42 return 42
__iter__ = None __iter__: None = None
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
pass pass

View file

@ -99,9 +99,9 @@ def _(x: str | int):
class A: ... class A: ...
class B: ... class B: ...
alias_for_type = type
def _(x: A | B): def _(x: A | B):
alias_for_type = type
if alias_for_type(x) is A: if alias_for_type(x) is A:
reveal_type(x) # revealed: A reveal_type(x) # revealed: A
``` ```

View file

@ -6,7 +6,7 @@
def f(): def f():
x = 1 x = 1
def g(): def g():
reveal_type(x) # revealed: Literal[1] reveal_type(x) # revealed: Unknown | Literal[1]
``` ```
## Two levels up ## Two levels up
@ -16,7 +16,7 @@ def f():
x = 1 x = 1
def g(): def g():
def h(): def h():
reveal_type(x) # revealed: Literal[1] reveal_type(x) # revealed: Unknown | Literal[1]
``` ```
## Skips class scope ## Skips class scope
@ -28,7 +28,7 @@ def f():
class C: class C:
x = 2 x = 2
def g(): def g():
reveal_type(x) # revealed: Literal[1] reveal_type(x) # revealed: Unknown | Literal[1]
``` ```
## Skips annotation-only assignment ## Skips annotation-only assignment
@ -41,7 +41,7 @@ def f():
# name is otherwise not defined; maybe should be an error? # name is otherwise not defined; maybe should be an error?
x: int x: int
def h(): def h():
reveal_type(x) # revealed: Literal[1] reveal_type(x) # revealed: Unknown | Literal[1]
``` ```
## Implicit global in function ## 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 x = 1
def f(): def f():
reveal_type(x) # revealed: Literal[1] reveal_type(x) # revealed: Unknown | Literal[1]
``` ```

View file

@ -17,8 +17,8 @@ class C:
x = 2 x = 2
# error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C]` is possibly unbound" # error: [possibly-unbound-attribute] "Attribute `x` on type `Literal[C]` is possibly unbound"
reveal_type(C.x) # revealed: Literal[2] reveal_type(C.x) # revealed: Unknown | Literal[2]
reveal_type(C.y) # revealed: Literal[1] reveal_type(C.y) # revealed: Unknown | Literal[1]
``` ```
## Possibly unbound in class and global scope ## Possibly unbound in class and global scope
@ -37,7 +37,7 @@ class C:
# error: [possibly-unresolved-reference] # error: [possibly-unresolved-reference]
y = x y = x
reveal_type(C.y) # revealed: Literal[1, "abc"] reveal_type(C.y) # revealed: Unknown | Literal[1, "abc"]
``` ```
## Unbound function local ## Unbound function local

View file

@ -182,3 +182,34 @@ class C(A, B): ...
# False negative: [incompatible-slots] # False negative: [incompatible-slots]
class A(int, str): ... 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")
```

View file

@ -14,7 +14,8 @@ a = NotSubscriptable()[0] # error: "Cannot subscript object of type `NotSubscri
class NotSubscriptable: class NotSubscriptable:
__getitem__ = None __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 ## Valid getitem

View file

@ -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: # We don't get into a cycle if someone sets their `__bool__` method to the `bool` builtin:
class BoolIsBool: 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 # revealed: bool
reveal_type(not BoolIsBool()) reveal_type(not BoolIsBool())

View file

@ -76,11 +76,11 @@ with Manager():
```py ```py
class Manager: class Manager:
__enter__ = 42 __enter__: int = 42
def __exit__(self, exc_tpe, exc_value, traceback): ... 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(): with Manager():
... ...
``` ```
@ -91,9 +91,9 @@ with Manager():
class Manager: class Manager:
def __enter__(self) -> Self: ... 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(): with Manager():
... ...
``` ```

View file

@ -85,6 +85,14 @@ impl<'db> Symbol<'db> {
Symbol::Unbound => self, 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)] #[cfg(test)]

View file

@ -80,29 +80,54 @@ pub fn check_types(db: &dyn Db, file: File) -> TypeCheckDiagnostics {
diagnostics 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). /// 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> { fn symbol<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Symbol<'db> {
#[salsa::tracked] #[salsa::tracked]
fn symbol_by_id<'db>( fn symbol_by_id<'db>(
db: &'db dyn Db, db: &'db dyn Db,
scope: ScopeId<'db>, scope: ScopeId<'db>,
symbol: ScopedSymbolId, is_dunder_slots: bool,
symbol_id: ScopedSymbolId,
) -> Symbol<'db> { ) -> Symbol<'db> {
let use_def = use_def_map(db, scope); let use_def = use_def_map(db, scope);
// If the symbol is declared, the public type is based on declarations; otherwise, it's based // If the symbol is declared, the public type is based on declarations; otherwise, it's based
// on inference from bindings. // on inference from bindings.
let declarations = use_def.public_declarations(symbol); let declarations = use_def.public_declarations(symbol_id);
let declared = let declared = symbol_from_declarations(db, declarations);
symbol_from_declarations(db, declarations).map(|SymbolAndQualifiers(ty, _)| ty); let is_final = declared.as_ref().is_ok_and(SymbolAndQualifiers::is_final);
let declared = declared.map(|SymbolAndQualifiers(symbol, _)| symbol);
match declared { match declared {
// Symbol is declared, trust the declared type // Symbol is declared, trust the declared type
Ok(symbol @ Symbol::Type(_, Boundness::Bound)) => symbol, Ok(symbol @ Symbol::Type(_, Boundness::Bound)) => symbol,
// Symbol is possibly declared // Symbol is possibly declared
Ok(Symbol::Type(declared_ty, Boundness::PossiblyUnbound)) => { 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); let inferred = symbol_from_bindings(db, bindings);
match inferred { 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) => { Ok(Symbol::Unbound) => {
let bindings = use_def.public_bindings(symbol); let bindings = use_def.public_bindings(symbol_id);
symbol_from_bindings(db, bindings) 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, _)) => { Err((declared_ty, _)) => {
// Intentionally ignore conflicting declared types; that's not our problem, // Intentionally ignore conflicting declared types; that's not our problem,
// it's the problem of the module we are importing from. // 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); 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 table
.symbol_id_by_name(name) .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) .unwrap_or(Symbol::Unbound)
} }
@ -378,6 +411,10 @@ impl SymbolAndQualifiers<'_> {
fn is_class_var(&self) -> bool { fn is_class_var(&self) -> bool {
self.1.contains(TypeQualifiers::CLASS_VAR) self.1.contains(TypeQualifiers::CLASS_VAR)
} }
fn is_final(&self) -> bool {
self.1.contains(TypeQualifiers::FINAL)
}
} }
impl<'db> From<Symbol<'db>> for SymbolAndQualifiers<'db> { impl<'db> From<Symbol<'db>> for SymbolAndQualifiers<'db> {
@ -4076,7 +4113,7 @@ impl<'db> Class<'db> {
/// this class, not on its superclasses. /// this class, not on its superclasses.
fn own_instance_member(self, db: &'db dyn Db, name: &str) -> SymbolAndQualifiers<'db> { fn own_instance_member(self, db: &'db dyn Db, name: &str) -> SymbolAndQualifiers<'db> {
// TODO: There are many things that are not yet implemented here: // TODO: There are many things that are not yet implemented here:
// - `typing.ClassVar` and `typing.Final` // - `typing.Final`
// - Proper diagnostics // - Proper diagnostics
// - Handling of possibly-undeclared/possibly-unbound attributes // - Handling of possibly-undeclared/possibly-unbound attributes
// - The descriptor protocol // - The descriptor protocol
@ -4084,10 +4121,10 @@ impl<'db> Class<'db> {
let body_scope = self.body_scope(db); let body_scope = self.body_scope(db);
let table = symbol_table(db, body_scope); 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 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) { match symbol_from_declarations(db, declarations) {
Ok(SymbolAndQualifiers(Symbol::Type(declared_ty, _), qualifiers)) => { Ok(SymbolAndQualifiers(Symbol::Type(declared_ty, _), qualifiers)) => {
@ -4104,20 +4141,14 @@ impl<'db> Class<'db> {
SymbolAndQualifiers(Symbol::Type(declared_ty, Boundness::Bound), qualifiers) SymbolAndQualifiers(Symbol::Type(declared_ty, Boundness::Bound), qualifiers)
} }
} }
Ok(SymbolAndQualifiers(Symbol::Unbound, qualifiers)) => { Ok(symbol @ SymbolAndQualifiers(Symbol::Unbound, qualifiers)) => {
let bindings = use_def.public_bindings(symbol); let bindings = use_def.public_bindings(symbol_id);
let inferred = symbol_from_bindings(db, bindings); let inferred = symbol_from_bindings(db, bindings);
match inferred { SymbolAndQualifiers(
Symbol::Type(ty, _) => SymbolAndQualifiers( widen_type_for_undeclared_public_symbol(db, inferred, symbol.is_final()),
Symbol::Type( qualifiers,
UnionType::from_elements(db, [Type::unknown(), ty]), )
Boundness::Bound,
),
qualifiers,
),
Symbol::Unbound => SymbolAndQualifiers(Symbol::Unbound, qualifiers),
}
} }
Err((declared_ty, _conflicting_declarations)) => { Err((declared_ty, _conflicting_declarations)) => {
// Ignore conflicting declarations // Ignore conflicting declarations
@ -4694,7 +4725,10 @@ pub(crate) mod tests {
let bar = system_path_to_file(&db, "src/bar.py")?; let bar = system_path_to_file(&db, "src/bar.py")?;
let a = global_symbol(&db, bar, "a"); 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. // Add a docstring to foo to trigger a re-run.
// The bar-call site of foo should not be re-run because of that // 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"); 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 events = db.take_salsa_events();
let call = &*parsed_module(&db, bar).syntax().body[1] let call = &*parsed_module(&db, bar).syntax().body[1]

View file

@ -543,7 +543,10 @@ mod tests {
assert_eq!(a_name, "a"); assert_eq!(a_name, "a");
assert_eq!(b_name, "b"); assert_eq!(b_name, "b");
// TODO resolution should not be deferred; we should see A not 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"); assert_eq!(b_annotated_ty.unwrap().display(&db).to_string(), "T");
} }
@ -583,7 +586,10 @@ mod tests {
assert_eq!(a_name, "a"); assert_eq!(a_name, "a");
assert_eq!(b_name, "b"); assert_eq!(b_name, "b");
// Parameter resolution deferred; we should see 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"); assert_eq!(b_annotated_ty.unwrap().display(&db).to_string(), "T");
} }

View file

@ -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: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", "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 // 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", "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: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 `@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: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", "warning[lint:unused-ignore-comment] /src/tomllib/_parser.py:682:31 Unused blanket `type: ignore` directive",
]; ];