diff --git a/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md b/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md index b875f677ff751..088d1ccade161 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md +++ b/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md @@ -2,16 +2,39 @@ ## Maybe unbound -```py +```py path=package/maybe_unbound.py if flag: y = 3 x = y reveal_type(x) # revealed: Unbound | Literal[3] +reveal_type(y) # revealed: Unbound | Literal[3] +``` + +```py path=package/public.py +from .maybe_unbound import x, y # error: [possibly-unresolved-import] +reveal_type(x) # revealed: Literal[3] +reveal_type(y) # revealed: Literal[3] +``` + +## Maybe unbound annotated + +```py path=package/maybe_unbound_annotated.py +if flag: + y: int = 3 +x = y +reveal_type(x) # revealed: Unbound | Literal[3] +reveal_type(y) # revealed: Unbound | int +``` + +```py path=package/public.py +from .maybe_unbound_annotated import x, y # error: [possibly-unresolved-import] +reveal_type(x) # revealed: Literal[3] +reveal_type(y) # revealed: int ``` ## Unbound -```py +```py path=unbound/ x = foo; foo = 1 reveal_type(x) # revealed: Unbound ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md b/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md index 6edf0fb855ae4..433c1719b0334 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md +++ b/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md @@ -16,7 +16,7 @@ reveal_type(x) # revealed: Literal[2, 3] ## Simple if-elif-else -```py +```py path=package/simple_if_elif_else.py y = 1 y = 2 if flag: @@ -28,11 +28,19 @@ else: y = 5 s = y x = y + reveal_type(x) # revealed: Literal[3, 4, 5] reveal_type(r) # revealed: Unbound | Literal[2] reveal_type(s) # revealed: Unbound | Literal[5] ``` +```py path=package/public.py +from .simple_if_elif_else import r, s # error: [unresolved-import] + +reveal_type(r) # revealed: Literal[2] +reveal_type(s) # revealed: Literal[5] +``` + ## Single symbol across if-elif-else ```py diff --git a/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md b/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md index 2ce7c9462d90d..73a3cecda2d14 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md +++ b/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md @@ -14,7 +14,7 @@ reveal_type(y) # revealed: Literal[2, 3] ## Without wildcard -```py +```py path=package/without_wildcard.py match 0: case 1: y = 2 @@ -24,6 +24,12 @@ match 0: reveal_type(y) # revealed: Unbound | Literal[2, 3] ``` +```py path=package/public.py +from .without_wildcard import y # error: [unresolved-import] + +reveal_type(y) # revealed: Literal[2, 3] +``` + ## Basic match ```py diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md b/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md index e9b3dfbca4e29..a733c79e8a6ef 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md @@ -1,14 +1,12 @@ # Except star -TODO(Alex): Once we support `sys.version_info` branches, we can set `--target-version=py311` in these tests and the inferred type will just be `BaseExceptionGroup` - ## Except\* with BaseException ```py try: x except* BaseException as e: - reveal_type(e) # revealed: Unknown | BaseExceptionGroup + reveal_type(e) # revealed: BaseExceptionGroup ``` ## Except\* with specific exception @@ -18,7 +16,7 @@ try: x except* OSError as e: # TODO(Alex): more precise would be `ExceptionGroup[OSError]` - reveal_type(e) # revealed: Unknown | BaseExceptionGroup + reveal_type(e) # revealed: BaseExceptionGroup ``` ## Except\* with multiple exceptions @@ -28,5 +26,5 @@ try: x except* (TypeError, AttributeError) as e: #TODO(Alex): more precise would be `ExceptionGroup[TypeError | AttributeError]`. - reveal_type(e) # revealed: Unknown | BaseExceptionGroup + reveal_type(e) # revealed: BaseExceptionGroup ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/import/relative.md b/crates/red_knot_python_semantic/resources/mdtest/import/relative.md index c7d618cfcaf6b..248eafadeab6c 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/import/relative.md +++ b/crates/red_knot_python_semantic/resources/mdtest/import/relative.md @@ -99,7 +99,7 @@ x ```py path=package/bar.py from .foo import x # error: [unresolved-import] -reveal_type(x) # revealed: Unknown +reveal_type(x) # revealed: Never ``` ## Bare to module @@ -129,5 +129,5 @@ reveal_type(y) # revealed: Unknown # TODO: submodule imports possibly not supported right now? from . import foo # error: [unresolved-import] -reveal_type(foo) # revealed: Unknown +reveal_type(foo) # revealed: Never ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md b/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md index dc12ca30afa7a..b617572032cc2 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md +++ b/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md @@ -2,7 +2,7 @@ ## Basic `for` loop -```py +```py path=package/basic_for_loop.py class IntIterator: def __next__(self) -> int: return 42 @@ -17,6 +17,12 @@ for x in IntIterable(): reveal_type(x) # revealed: Unbound | int ``` +```py path=package/public.py +from .basic_for_loop import x # error: [unresolved-import] + +reveal_type(x) # revealed: int +``` + ## With previous definition ```py @@ -77,7 +83,7 @@ reveal_type(x) # revealed: int | Literal["foo"] ## With old-style iteration protocol -```py +```py path=package/without_oldstyle_iteration_protocol.py class OldStyleIterable: def __getitem__(self, key: int) -> int: return 42 @@ -88,18 +94,30 @@ for x in OldStyleIterable(): reveal_type(x) # revealed: Unbound | int ``` +```py path=package/public.py +from .without_oldstyle_iteration_protocol import x # error: [unresolved-import] + +reveal_type(x) # revealed: int +``` + ## With heterogeneous tuple -```py +```py path=package/with_heterogeneous_tuple.py for x in (1, 'a', b'foo'): pass reveal_type(x) # revealed: Unbound | Literal[1] | Literal["a"] | Literal[b"foo"] ``` +```py path=package/public.py +from .with_heterogeneous_tuple import x # error: [unresolved-import] + +reveal_type(x) # revealed: Literal[1] | Literal["a"] | Literal[b"foo"] +``` + ## With non-callable iterator -```py +```py path=with_noncallable_iterator/with_noncallable_iterator.py class NotIterable: if flag: __iter__ = 1 @@ -112,6 +130,12 @@ for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" reveal_type(x) # revealed: Unbound | Unknown ``` +```py path=with_noncallable_iterator/with_noncallable_iterator.py +from .with_noncallable_iterator import x + +reveal_type(x) # revealed: Unknown | int +``` + ## Invalid iterable ```py diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 447212d384f9e..2cd9cb1f0b11e 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -48,6 +48,7 @@ fn symbol_ty_by_id<'db>(db: &'db dyn Db, scope: ScopeId<'db>, symbol: ScopedSymb let _span = tracing::trace_span!("symbol_ty_by_id", ?symbol).entered(); let use_def = use_def_map(db, scope); + let unbound_ty = || use_def.public_may_be_unbound(symbol).then_some(Type::Never); // If the symbol is declared, the public type is based on declarations; otherwise, it's based // on inference from bindings. @@ -58,9 +59,7 @@ fn symbol_ty_by_id<'db>(db: &'db dyn Db, scope: ScopeId<'db>, symbol: ScopedSymb Some(bindings_ty( db, use_def.public_bindings(symbol), - use_def - .public_may_be_unbound(symbol) - .then_some(Type::Unknown), + unbound_ty(), )) } else { None @@ -69,17 +68,11 @@ fn symbol_ty_by_id<'db>(db: &'db dyn Db, scope: ScopeId<'db>, symbol: ScopedSymb // problem of the module we are importing from. declarations_ty(db, declarations, undeclared_ty).unwrap_or_else(|(ty, _)| ty) } else { - bindings_ty( - db, - use_def.public_bindings(symbol), - use_def - .public_may_be_unbound(symbol) - .then_some(Type::Unbound), - ) + bindings_ty(db, use_def.public_bindings(symbol), unbound_ty()) } } -/// Shorthand for `symbol_ty` that takes a symbol name instead of an ID. +/// Shorthand for `symbol_ty_by_id` that takes a symbol name instead of an ID. fn symbol_ty<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Type<'db> { let table = symbol_table(db, scope); table @@ -288,6 +281,14 @@ impl<'db> Type<'db> { matches!(self, Type::Unbound) } + fn contains_unbound(&self, db: &'db dyn Db) -> bool { + return self.is_equivalent_to(db, Type::Unbound) + || match self { + Type::Union(union) => union.elements(db).iter().any(|ty| ty.contains_unbound(db)), + _ => false, + }; + } + pub const fn is_never(&self) -> bool { matches!(self, Type::Never) } @@ -376,12 +377,15 @@ impl<'db> Type<'db> { #[must_use] pub fn replace_unbound_with(&self, db: &'db dyn Db, replacement: Type<'db>) -> Type<'db> { + if self.is_equivalent_to(db, Type::Unbound) { + return replacement; + } + match self { - Type::Unbound => replacement, Type::Union(union) => { union.map(db, |element| element.replace_unbound_with(db, replacement)) } - ty => *ty, + _ => *self, } } diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index d83b5a8ba6198..0c47deb22ede0 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -1725,7 +1725,6 @@ impl<'db> TypeInferenceBuilder<'db> { let member_ty = module_ty.member(self.db, &ast::name::Name::new(&name.id)); - // TODO: What if it's a union where one of the elements is `Unbound`? if member_ty.is_unbound() { self.add_diagnostic( AnyNodeRef::Alias(alias), @@ -1736,14 +1735,24 @@ impl<'db> TypeInferenceBuilder<'db> { module.unwrap_or_default() ), ); + } else if member_ty.contains_unbound(self.db) { + self.add_diagnostic( + AnyNodeRef::Alias(alias), + "possibly-unresolved-import", + format_args!( + "Module `{}{}` may not have member `{name}`", + ".".repeat(*level as usize), + module.unwrap_or_default() + ), + ); } // If a symbol is unbound in the module the symbol was originally defined in, // when we're trying to import the symbol from that module into "our" module, // the runtime error will occur immediately (rather than when the symbol is *used*, // as would be the case for a symbol with type `Unbound`), so it's appropriate to - // think of the type of the imported symbol as `Unknown` rather than `Unbound` - let ty = member_ty.replace_unbound_with(self.db, Type::Unknown); + // think of the type of the imported symbol as `Never` rather than `Unbound` + let ty = member_ty.replace_unbound_with(self.db, Type::Never); self.add_declaration_with_binding(alias.into(), definition, ty, ty); } @@ -2353,6 +2362,7 @@ impl<'db> TypeInferenceBuilder<'db> { return symbol_ty(self.db, enclosing_scope_id, name); } } + // No nonlocal binding, check module globals. Avoid infinite recursion if `self.scope` // already is module globals. let ty = if file_scope_id.is_global() { @@ -2360,6 +2370,7 @@ impl<'db> TypeInferenceBuilder<'db> { } else { global_symbol_ty(self.db, self.file, name) }; + // Fallback to builtins (without infinite recursion if we're already in builtins.) if ty.may_be_unbound(self.db) && Some(self.scope) != builtins_module_scope(self.db) { let mut builtin_ty = builtins_symbol_ty(self.db, name); @@ -3424,6 +3435,7 @@ mod tests { use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; use ruff_db::testing::assert_function_query_was_not_run; use ruff_python_ast::name::Name; + use test_case::test_case; use super::TypeInferenceBuilder; @@ -3523,6 +3535,24 @@ mod tests { assert_diagnostic_messages(&diagnostics, expected); } + #[test] + fn imported_unbound_symbol_is_unknown() -> anyhow::Result<()> { + let mut db = setup_db(); + + db.write_files([ + ("src/package/__init__.py", ""), + ("src/package/foo.py", "x"), + ("src/package/bar.py", "from package.foo import x"), + ])?; + + // the type as seen from external modules (`Unknown`) + // is different from the type inside the module itself (`Never`): + assert_public_ty(&db, "src/package/foo.py", "x", "Never"); + assert_public_ty(&db, "src/package/bar.py", "x", "Unknown"); + + Ok(()) + } + #[test] fn from_import_with_no_module_name() -> anyhow::Result<()> { // This test checks that invalid syntax in a `StmtImportFrom` node @@ -3745,7 +3775,7 @@ mod tests { )?; // TODO: sys.version_info, and need to understand @final and @type_check_only - assert_public_ty(&db, "src/a.py", "x", "Unknown | EllipsisType"); + assert_public_ty(&db, "src/a.py", "x", "EllipsisType | Unknown"); Ok(()) } @@ -3856,24 +3886,32 @@ mod tests { let y_ty = symbol_ty(&db, function_scope, "y"); let x_ty = symbol_ty(&db, function_scope, "x"); - assert_eq!(x_ty.display(&db).to_string(), "Unbound"); + assert_eq!(x_ty.display(&db).to_string(), "Never"); assert_eq!(y_ty.display(&db).to_string(), "Literal[1]"); Ok(()) } - #[test] - fn conditionally_global_or_builtin() -> anyhow::Result<()> { + #[test_case("", "Literal[1]"; "unannotated")] + // Tests that we only use the definition of a symbol instead of its declaration when we are + // checking module globals without a nonlocal binding. + #[test_case(": int", "int"; "annotated")] + fn conditionally_global_or_builtin( + annotation: &'static str, + expected: &str, + ) -> anyhow::Result<()> { let mut db = setup_db(); db.write_dedented( "/src/a.py", - " - if flag: - copyright = 1 - def f(): - y = copyright + &format!( + " + if flag: + copyright{annotation} = 1 + def f(): + y = copyright ", + ), )?; let file = system_path_to_file(&db, "src/a.py").expect("file to exist"); @@ -3888,7 +3926,7 @@ mod tests { assert_eq!( y_ty.display(&db).to_string(), - "Literal[copyright] | Literal[1]" + format!("Literal[copyright] | {expected}") ); Ok(()) @@ -4404,7 +4442,7 @@ mod tests { ", )?; - assert_scope_ty(&db, "src/a.py", &["foo", ""], "z", "Unbound"); + assert_scope_ty(&db, "src/a.py", &["foo", ""], "z", "Never"); // (There is a diagnostic for invalid syntax that's emitted, but it's not listed by `assert_file_diagnostics`) assert_file_diagnostics(&db, "src/a.py", &[]);