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 2a27866325..4d98329ca6 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md +++ b/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md @@ -35,6 +35,7 @@ class C: if flag: 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] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md b/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md new file mode 100644 index 0000000000..06e3cb9e50 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/expression/attribute.md @@ -0,0 +1,28 @@ +# Attribute access + +## Boundness + +```py +def flag() -> bool: ... + +class A: + always_bound = 1 + + if flag(): + union = 1 + else: + union = "abc" + + if flag(): + possibly_unbound = "abc" + +reveal_type(A.always_bound) # revealed: Literal[1] + +reveal_type(A.union) # revealed: Literal[1] | Literal["abc"] + +# error: [possibly-unbound-attribute] "Attribute `possibly_unbound` on type `Literal[A]` is possibly unbound" +reveal_type(A.possibly_unbound) # revealed: Literal["abc"] + +# 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/scopes/moduletype_attrs.md b/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md index a55eaba44d..652d796825 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md +++ b/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md @@ -74,6 +74,7 @@ we're dealing with: ```py path=__getattr__.py import typing +# error: [unresolved-attribute] reveal_type(typing.__getattr__) # revealed: Unknown ``` diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index def4f99a21..23f2cd8674 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -31,7 +31,6 @@ use std::num::NonZeroU32; use itertools::Itertools; use ruff_db::files::File; use ruff_db::parsed::parsed_module; -use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, AnyNodeRef, Expr, ExprContext, UnaryOp}; use rustc_hash::FxHashMap; use salsa; @@ -2796,10 +2795,35 @@ impl<'db> TypeInferenceBuilder<'db> { } = attribute; let value_ty = self.infer_expression(value); - value_ty - .member(self.db, &Name::new(&attr.id)) - .ignore_possibly_unbound() - .unwrap_or(Type::Unknown) + match value_ty.member(self.db, &attr.id) { + Symbol::Type(member_ty, boundness) => { + if boundness == Boundness::PossiblyUnbound { + self.diagnostics.add( + attribute.into(), + "possibly-unbound-attribute", + format_args!( + "Attribute `{}` on type `{}` is possibly unbound", + attr.id, + value_ty.display(self.db), + ), + ); + } + + member_ty + } + Symbol::Unbound => { + self.diagnostics.add( + attribute.into(), + "unresolved-attribute", + format_args!( + "Type `{}` has no attribute `{}`", + value_ty.display(self.db), + attr.id + ), + ); + Type::Unknown + } + } } fn infer_attribute_expression(&mut self, attribute: &ast::ExprAttribute) -> Type<'db> { @@ -4734,13 +4758,12 @@ mod tests { } #[track_caller] - fn assert_scope_ty( - db: &TestDb, + fn get_symbol<'db>( + db: &'db TestDb, file_name: &str, scopes: &[&str], symbol_name: &str, - expected: &str, - ) { + ) -> Symbol<'db> { let file = system_path_to_file(db, file_name).expect("file to exist"); let index = semantic_index(db, file); let mut file_scope_id = FileScopeId::global(); @@ -4755,9 +4778,18 @@ mod tests { assert_eq!(scope.name(db), *expected_scope_name); } - let ty = symbol(db, scope, symbol_name) - .ignore_possibly_unbound() - .unwrap_or(Type::Unknown); + symbol(db, scope, symbol_name) + } + + #[track_caller] + fn assert_scope_ty( + db: &TestDb, + file_name: &str, + scopes: &[&str], + symbol_name: &str, + expected: &str, + ) { + let ty = get_symbol(db, file_name, scopes, symbol_name).expect_type(); assert_eq!(ty.display(db).to_string(), expected); } @@ -5437,9 +5469,10 @@ mod tests { db.write_dedented("src/a.py", "[z for z in x]")?; - assert_scope_ty(&db, "src/a.py", &[""], "x", "Unknown"); + let x = get_symbol(&db, "src/a.py", &[""], "x"); + assert!(x.is_unbound()); - // Iterating over an `Unbound` yields `Unknown`: + // Iterating over an unbound iterable yields `Unknown`: assert_scope_ty(&db, "src/a.py", &[""], "z", "Unknown"); assert_file_diagnostics(&db, "src/a.py", &["Name `x` used when not defined"]); @@ -5573,7 +5606,8 @@ mod tests { ", )?; - assert_scope_ty(&db, "src/a.py", &["foo", ""], "z", "Unknown"); + let z = get_symbol(&db, "src/a.py", &["foo", ""], "z"); + assert!(z.is_unbound()); // (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", &["Name `z` used when not defined"]);