Skip to content

Commit ecd9e6a

Browse files
[red-knot] Improve the unresolved-import check (#13007)
Co-authored-by: Micha Reiser <[email protected]>
1 parent 785c399 commit ecd9e6a

File tree

3 files changed

+184
-41
lines changed

3 files changed

+184
-41
lines changed

crates/red_knot_python_semantic/src/types.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,19 @@ impl<'db> Type<'db> {
222222
}
223223
}
224224

225+
/// Resolve a member access of a type.
226+
///
227+
/// For example, if `foo` is `Type::Instance(<Bar>)`,
228+
/// `foo.member(&db, "baz")` returns the type of `baz` attributes
229+
/// as accessed from instances of the `Bar` class.
230+
///
231+
/// TODO: use of this method currently requires manually checking
232+
/// whether the returned type is `Unknown`/`Unbound`
233+
/// (or a union with `Unknown`/`Unbound`) in many places.
234+
/// Ideally we'd use a more type-safe pattern, such as returning
235+
/// an `Option` or a `Result` from this method, which would force
236+
/// us to explicitly consider whether to handle an error or propagate
237+
/// it up the call stack.
225238
#[must_use]
226239
pub fn member(&self, db: &'db dyn Db, name: &Name) -> Type<'db> {
227240
match self {
@@ -369,12 +382,13 @@ mod tests {
369382
use crate::db::tests::TestDb;
370383
use crate::{Program, ProgramSettings, PythonVersion, SearchPathSettings};
371384

372-
#[test]
373-
fn check_types() -> anyhow::Result<()> {
374-
let mut db = TestDb::new();
385+
use super::TypeCheckDiagnostics;
375386

376-
db.write_file("src/foo.py", "import bar\n")
377-
.context("Failed to write foo.py")?;
387+
fn setup_db() -> TestDb {
388+
let db = TestDb::new();
389+
db.memory_file_system()
390+
.create_directory_all("/src")
391+
.unwrap();
378392

379393
Program::from_settings(
380394
&db,
@@ -390,16 +404,82 @@ mod tests {
390404
)
391405
.expect("Valid search path settings");
392406

407+
db
408+
}
409+
410+
fn assert_diagnostic_messages(diagnostics: &TypeCheckDiagnostics, expected: &[&str]) {
411+
let messages: Vec<&str> = diagnostics
412+
.iter()
413+
.map(|diagnostic| diagnostic.message())
414+
.collect();
415+
assert_eq!(&messages, expected);
416+
}
417+
418+
#[test]
419+
fn unresolved_import_statement() -> anyhow::Result<()> {
420+
let mut db = setup_db();
421+
422+
db.write_file("src/foo.py", "import bar\n")
423+
.context("Failed to write foo.py")?;
424+
393425
let foo = system_path_to_file(&db, "src/foo.py").context("Failed to resolve foo.py")?;
394426

395427
let diagnostics = super::check_types(&db, foo);
428+
assert_diagnostic_messages(&diagnostics, &["Import 'bar' could not be resolved."]);
429+
430+
Ok(())
431+
}
432+
433+
#[test]
434+
fn unresolved_import_from_statement() {
435+
let mut db = setup_db();
436+
437+
db.write_file("src/foo.py", "from bar import baz\n")
438+
.unwrap();
439+
let foo = system_path_to_file(&db, "src/foo.py").unwrap();
440+
let diagnostics = super::check_types(&db, foo);
441+
assert_diagnostic_messages(&diagnostics, &["Import 'bar' could not be resolved."]);
442+
}
396443

397-
assert_eq!(diagnostics.len(), 1);
398-
assert_eq!(
399-
diagnostics[0].message(),
400-
"Import 'bar' could not be resolved."
444+
#[test]
445+
fn unresolved_import_from_resolved_module() {
446+
let mut db = setup_db();
447+
448+
db.write_files([("/src/a.py", ""), ("/src/b.py", "from a import thing")])
449+
.unwrap();
450+
451+
let b_file = system_path_to_file(&db, "/src/b.py").unwrap();
452+
let b_file_diagnostics = super::check_types(&db, b_file);
453+
assert_diagnostic_messages(
454+
&b_file_diagnostics,
455+
&["Could not resolve import of 'thing' from 'a'"],
401456
);
457+
}
402458

403-
Ok(())
459+
#[ignore = "\
460+
A spurious second 'Unresolved import' diagnostic message is emitted on `b.py`, \
461+
despite the symbol existing in the symbol table for `a.py`"]
462+
#[test]
463+
fn resolved_import_of_symbol_from_unresolved_import() {
464+
let mut db = setup_db();
465+
466+
db.write_files([
467+
("/src/a.py", "import foo as foo"),
468+
("/src/b.py", "from a import foo"),
469+
])
470+
.unwrap();
471+
472+
let a_file = system_path_to_file(&db, "/src/a.py").unwrap();
473+
let a_file_diagnostics = super::check_types(&db, a_file);
474+
assert_diagnostic_messages(
475+
&a_file_diagnostics,
476+
&["Import 'foo' could not be resolved."],
477+
);
478+
479+
// Importing the unresolved import into a second first-party file should not trigger
480+
// an additional "unresolved import" violation
481+
let b_file = system_path_to_file(&db, "/src/b.py").unwrap();
482+
let b_file_diagnostics = super::check_types(&db, b_file);
483+
assert_eq!(&*b_file_diagnostics, &[]);
404484
}
405485
}

crates/red_knot_python_semantic/src/types/infer.rs

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,26 @@ impl<'db> TypeInferenceBuilder<'db> {
866866
asname: _,
867867
} = alias;
868868

869-
let module_ty = self.module_ty_from_name(ModuleName::new(name), alias.into());
869+
let module_ty = ModuleName::new(name)
870+
.ok_or(ModuleResolutionError::InvalidSyntax)
871+
.and_then(|module_name| self.module_ty_from_name(module_name));
872+
873+
let module_ty = match module_ty {
874+
Ok(ty) => ty,
875+
Err(ModuleResolutionError::InvalidSyntax) => {
876+
tracing::debug!("Failed to resolve import due to invalid syntax");
877+
Type::Unknown
878+
}
879+
Err(ModuleResolutionError::UnresolvedModule) => {
880+
self.add_diagnostic(
881+
AnyNodeRef::Alias(alias),
882+
"unresolved-import",
883+
format_args!("Import '{name}' could not be resolved."),
884+
);
885+
Type::Unknown
886+
}
887+
};
888+
870889
self.types.definitions.insert(definition, module_ty);
871890
}
872891

@@ -914,32 +933,38 @@ impl<'db> TypeInferenceBuilder<'db> {
914933
/// - `tail` is the relative module name stripped of all leading dots:
915934
/// - `from .foo import bar` => `tail == "foo"`
916935
/// - `from ..foo.bar import baz` => `tail == "foo.bar"`
917-
fn relative_module_name(&self, tail: Option<&str>, level: NonZeroU32) -> Option<ModuleName> {
936+
fn relative_module_name(
937+
&self,
938+
tail: Option<&str>,
939+
level: NonZeroU32,
940+
) -> Result<ModuleName, ModuleResolutionError> {
918941
let Some(module) = file_to_module(self.db, self.file) else {
919942
tracing::debug!(
920943
"Relative module resolution '{}' failed; could not resolve file '{}' to a module",
921944
format_import_from_module(level.get(), tail),
922945
self.file.path(self.db)
923946
);
924-
return None;
947+
return Err(ModuleResolutionError::UnresolvedModule);
925948
};
926949
let mut level = level.get();
927950
if module.kind().is_package() {
928951
level -= 1;
929952
}
930953
let mut module_name = module.name().to_owned();
931954
for _ in 0..level {
932-
module_name = module_name.parent()?;
955+
module_name = module_name
956+
.parent()
957+
.ok_or(ModuleResolutionError::UnresolvedModule)?;
933958
}
934959
if let Some(tail) = tail {
935960
if let Some(valid_tail) = ModuleName::new(tail) {
936961
module_name.extend(&valid_tail);
937962
} else {
938963
tracing::debug!("Relative module resolution failed: invalid syntax");
939-
return None;
964+
return Err(ModuleResolutionError::InvalidSyntax);
940965
}
941966
}
942-
Some(module_name)
967+
Ok(module_name)
943968
}
944969

945970
fn infer_import_from_definition(
@@ -974,12 +999,12 @@ impl<'db> TypeInferenceBuilder<'db> {
974999
alias.name,
9751000
format_import_from_module(*level, module),
9761001
);
977-
let module_name =
978-
module.expect("Non-relative import should always have a non-None `module`!");
979-
ModuleName::new(module_name)
1002+
module
1003+
.and_then(ModuleName::new)
1004+
.ok_or(ModuleResolutionError::InvalidSyntax)
9801005
};
9811006

982-
let module_ty = self.module_ty_from_name(module_name, import_from.into());
1007+
let module_ty = module_name.and_then(|module_name| self.module_ty_from_name(module_name));
9831008

9841009
let ast::Alias {
9851010
range: _,
@@ -992,11 +1017,34 @@ impl<'db> TypeInferenceBuilder<'db> {
9921017
// the runtime error will occur immediately (rather than when the symbol is *used*,
9931018
// as would be the case for a symbol with type `Unbound`), so it's appropriate to
9941019
// think of the type of the imported symbol as `Unknown` rather than `Unbound`
995-
let ty = module_ty
1020+
let member_ty = module_ty
1021+
.unwrap_or(Type::Unbound)
9961022
.member(self.db, &Name::new(&name.id))
9971023
.replace_unbound_with(self.db, Type::Unknown);
9981024

999-
self.types.definitions.insert(definition, ty);
1025+
if matches!(module_ty, Err(ModuleResolutionError::UnresolvedModule)) {
1026+
self.add_diagnostic(
1027+
AnyNodeRef::StmtImportFrom(import_from),
1028+
"unresolved-import",
1029+
format_args!(
1030+
"Import '{}{}' could not be resolved.",
1031+
".".repeat(*level as usize),
1032+
module.unwrap_or_default()
1033+
),
1034+
);
1035+
} else if module_ty.is_ok() && member_ty.is_unknown() {
1036+
self.add_diagnostic(
1037+
AnyNodeRef::Alias(alias),
1038+
"unresolved-import",
1039+
format_args!(
1040+
"Could not resolve import of '{name}' from '{}{}'",
1041+
".".repeat(*level as usize),
1042+
module.unwrap_or_default()
1043+
),
1044+
);
1045+
}
1046+
1047+
self.types.definitions.insert(definition, member_ty);
10001048
}
10011049

10021050
fn infer_return_statement(&mut self, ret: &ast::StmtReturn) {
@@ -1011,25 +1059,12 @@ impl<'db> TypeInferenceBuilder<'db> {
10111059
}
10121060

10131061
fn module_ty_from_name(
1014-
&mut self,
1015-
module_name: Option<ModuleName>,
1016-
node: AnyNodeRef,
1017-
) -> Type<'db> {
1018-
let Some(module_name) = module_name else {
1019-
return Type::Unknown;
1020-
};
1021-
1022-
if let Some(module) = resolve_module(self.db, module_name.clone()) {
1023-
Type::Module(module.file())
1024-
} else {
1025-
self.add_diagnostic(
1026-
node,
1027-
"unresolved-import",
1028-
format_args!("Import '{module_name}' could not be resolved."),
1029-
);
1030-
1031-
Type::Unknown
1032-
}
1062+
&self,
1063+
module_name: ModuleName,
1064+
) -> Result<Type<'db>, ModuleResolutionError> {
1065+
resolve_module(self.db, module_name)
1066+
.map(|module| Type::Module(module.file()))
1067+
.ok_or(ModuleResolutionError::UnresolvedModule)
10331068
}
10341069

10351070
fn infer_decorator(&mut self, decorator: &ast::Decorator) -> Type<'db> {
@@ -1795,6 +1830,12 @@ fn format_import_from_module(level: u32, module: Option<&str>) -> String {
17951830
)
17961831
}
17971832

1833+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
1834+
enum ModuleResolutionError {
1835+
InvalidSyntax,
1836+
UnresolvedModule,
1837+
}
1838+
17981839
#[cfg(test)]
17991840
mod tests {
18001841
use anyhow::Context;
@@ -2048,6 +2089,16 @@ mod tests {
20482089
Ok(())
20492090
}
20502091

2092+
#[test]
2093+
fn from_import_with_no_module_name() -> anyhow::Result<()> {
2094+
// This test checks that invalid syntax in a `StmtImportFrom` node
2095+
// leads to the type being inferred as `Unknown`
2096+
let mut db = setup_db();
2097+
db.write_file("src/foo.py", "from import bar")?;
2098+
assert_public_ty(&db, "src/foo.py", "bar", "Unknown");
2099+
Ok(())
2100+
}
2101+
20512102
#[test]
20522103
fn resolve_base_class_by_name() -> anyhow::Result<()> {
20532104
let mut db = setup_db();

crates/ruff_benchmark/benches/red_knot.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ struct Case {
1818
}
1919

2020
const TOMLLIB_312_URL: &str = "https://raw.githubusercontent.com/python/cpython/8e8a4baf652f6e1cee7acde9d78c4b6154539748/Lib/tomllib";
21+
22+
// This first "unresolved import" is because we don't understand `*` imports yet.
23+
// The following "unresolved import" violations are because we can't distinguish currently from
24+
// "Symbol exists in the module but its type is unknown" and
25+
// "Symbol does not exist in the module"
2126
static EXPECTED_DIAGNOSTICS: &[&str] = &[
27+
"/src/tomllib/_parser.py:7:29: Could not resolve import of 'Iterable' from 'collections.abc'",
28+
"/src/tomllib/_parser.py:10:20: Could not resolve import of 'Any' from 'typing'",
29+
"/src/tomllib/_parser.py:13:5: Could not resolve import of 'RE_DATETIME' from '._re'",
30+
"/src/tomllib/_parser.py:14:5: Could not resolve import of 'RE_LOCALTIME' from '._re'",
31+
"/src/tomllib/_parser.py:15:5: Could not resolve import of 'RE_NUMBER' from '._re'",
32+
"/src/tomllib/_parser.py:20:21: Could not resolve import of 'Key' from '._types'",
33+
"/src/tomllib/_parser.py:20:26: Could not resolve import of 'ParseFloat' from '._types'",
2234
"Line 69 is too long (89 characters)",
2335
"Use double quotes for strings",
2436
"Use double quotes for strings",

0 commit comments

Comments
 (0)