Skip to content

Commit a1eb834

Browse files
authored
Fix relative import resolution in site-packages packages when the site-packages search path is a subdirectory of the first-party search path (#17178)
## Summary If a package in `site-packages` had this directory structure: ```py # bar/__init__.py from .a import A # bar/a.py class A: ... ``` then we would fail to resolve the `from .a import A` import _if_ (as is usually the case!) the `site-packages` search path was located inside a `.venv` directory that was a subdirectory of the project's first-party search path. The reason for this is a bug in `file_to_module` in the module resolver. In this loop, we would identify that `/project_root/.venv/lib/python3.13/site-packages/foo/__init__.py` can be turned into a path relative to the first-party search path (`/project_root`): https://github.com/astral-sh/ruff/blob/6e2b8f9696e87d786cfed6304dc3baa0f029d341/crates/red_knot_python_semantic/src/module_resolver/resolver.rs#L101-L110 but we'd then try to turn the relative path (.venv/lib/python3.13/site-packages/foo/__init__.py`) into a module path, realise that it wasn't a valid module path... and therefore immediately `break` out of the loop before trying any other search paths (such as the `site-packages` search path). This bug was originally reported on Discord by @MatthewMckee4. ## Test Plan I added a unit test for `file_to_module` in `resolver.rs`, and an integration test that shows we can now resolve the import correctly in `infer.rs`.
1 parent c1f93a7 commit a1eb834

File tree

3 files changed

+76
-20
lines changed

3 files changed

+76
-20
lines changed

crates/red_knot_python_semantic/src/db.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ pub(crate) mod tests {
1919
use std::sync::Arc;
2020

2121
use crate::program::{Program, SearchPathSettings};
22-
use crate::{default_lint_registry, ProgramSettings, PythonPlatform};
22+
use crate::{default_lint_registry, ProgramSettings, PythonPath, PythonPlatform};
2323

2424
use super::Db;
2525
use crate::lint::{LintRegistry, RuleSelection};
2626
use anyhow::Context;
2727
use ruff_db::files::{File, Files};
2828
use ruff_db::system::{
29-
DbWithTestSystem, DbWithWritableSystem as _, System, SystemPathBuf, TestSystem,
29+
DbWithTestSystem, DbWithWritableSystem as _, System, SystemPath, SystemPathBuf, TestSystem,
3030
};
3131
use ruff_db::vendored::VendoredFileSystem;
3232
use ruff_db::{Db as SourceDb, Upcast};
@@ -139,8 +139,8 @@ pub(crate) mod tests {
139139
python_version: PythonVersion,
140140
/// Target Python platform
141141
python_platform: PythonPlatform,
142-
/// Path to a custom typeshed directory
143-
custom_typeshed: Option<SystemPathBuf>,
142+
/// Paths to the directory to use for `site-packages`
143+
site_packages: Vec<SystemPathBuf>,
144144
/// Path and content pairs for files that should be present
145145
files: Vec<(&'a str, &'a str)>,
146146
}
@@ -150,7 +150,7 @@ pub(crate) mod tests {
150150
Self {
151151
python_version: PythonVersion::default(),
152152
python_platform: PythonPlatform::default(),
153-
custom_typeshed: None,
153+
site_packages: vec![],
154154
files: vec![],
155155
}
156156
}
@@ -160,8 +160,20 @@ pub(crate) mod tests {
160160
self
161161
}
162162

163-
pub(crate) fn with_file(mut self, path: &'a str, content: &'a str) -> Self {
164-
self.files.push((path, content));
163+
pub(crate) fn with_file(
164+
mut self,
165+
path: &'a (impl AsRef<SystemPath> + ?Sized),
166+
content: &'a str,
167+
) -> Self {
168+
self.files.push((path.as_ref().as_str(), content));
169+
self
170+
}
171+
172+
pub(crate) fn with_site_packages_search_path(
173+
mut self,
174+
path: &(impl AsRef<SystemPath> + ?Sized),
175+
) -> Self {
176+
self.site_packages.push(path.as_ref().to_path_buf());
165177
self
166178
}
167179

@@ -175,7 +187,7 @@ pub(crate) mod tests {
175187
.context("Failed to write test files")?;
176188

177189
let mut search_paths = SearchPathSettings::new(vec![src_root]);
178-
search_paths.custom_typeshed = self.custom_typeshed;
190+
search_paths.python_path = PythonPath::KnownSitePackages(self.site_packages);
179191

180192
Program::from_settings(
181193
&db,

crates/red_knot_python_semantic/src/module_resolver/resolver.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,26 +96,21 @@ pub(crate) fn file_to_module(db: &dyn Db, file: File) -> Option<Module> {
9696
FilePath::SystemVirtual(_) => return None,
9797
};
9898

99-
let mut search_paths = search_paths(db);
100-
101-
let module_name = loop {
102-
let candidate = search_paths.next()?;
99+
let module_name = search_paths(db).find_map(|candidate| {
103100
let relative_path = match path {
104101
SystemOrVendoredPathRef::System(path) => candidate.relativize_system_path(path),
105102
SystemOrVendoredPathRef::Vendored(path) => candidate.relativize_vendored_path(path),
106-
};
107-
if let Some(relative_path) = relative_path {
108-
break relative_path.to_module_name()?;
109-
}
110-
};
103+
}?;
104+
relative_path.to_module_name()
105+
})?;
111106

112107
// Resolve the module name to see if Python would resolve the name to the same path.
113108
// If it doesn't, then that means that multiple modules have the same name in different
114109
// root paths, but that the module corresponding to `path` is in a lower priority search path,
115110
// in which case we ignore it.
116111
let module = resolve_module(db, &module_name)?;
117112

118-
if file == module.file() {
113+
if file.path(db) == module.file().path(db) {
119114
Some(module)
120115
} else {
121116
// This path is for a module with the same name but with a different precedence. For example:
@@ -1969,4 +1964,33 @@ not_a_directory
19691964

19701965
Ok(())
19711966
}
1967+
1968+
#[test]
1969+
fn file_to_module_where_one_search_path_is_subdirectory_of_other() {
1970+
let project_directory = SystemPathBuf::from("/project");
1971+
let site_packages = project_directory.join(".venv/lib/python3.13/site-packages");
1972+
let installed_foo_module = site_packages.join("foo/__init__.py");
1973+
1974+
let mut db = TestDb::new();
1975+
db.write_file(&installed_foo_module, "").unwrap();
1976+
1977+
Program::from_settings(
1978+
&db,
1979+
ProgramSettings {
1980+
python_version: PythonVersion::default(),
1981+
python_platform: PythonPlatform::default(),
1982+
search_paths: SearchPathSettings {
1983+
extra_paths: vec![],
1984+
src_roots: vec![project_directory],
1985+
custom_typeshed: None,
1986+
python_path: PythonPath::KnownSitePackages(vec![site_packages.clone()]),
1987+
},
1988+
},
1989+
)
1990+
.unwrap();
1991+
1992+
let foo_module_file = File::new(&db, FilePath::System(installed_foo_module));
1993+
let module = file_to_module(&db, foo_module_file).unwrap();
1994+
assert_eq!(module.search_path(), &site_packages);
1995+
}
19721996
}

crates/red_knot_python_semantic/src/types/infer.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7371,15 +7371,15 @@ impl StringPartsCollector {
73717371

73727372
#[cfg(test)]
73737373
mod tests {
7374-
use crate::db::tests::{setup_db, TestDb};
7374+
use crate::db::tests::{setup_db, TestDb, TestDbBuilder};
73757375
use crate::semantic_index::definition::Definition;
73767376
use crate::semantic_index::symbol::FileScopeId;
73777377
use crate::semantic_index::{global_scope, semantic_index, symbol_table, use_def_map};
73787378
use crate::symbol::global_symbol;
73797379
use crate::types::check_types;
73807380
use ruff_db::diagnostic::Diagnostic;
73817381
use ruff_db::files::{system_path_to_file, File};
7382-
use ruff_db::system::DbWithWritableSystem as _;
7382+
use ruff_db::system::{DbWithWritableSystem as _, SystemPath};
73837383
use ruff_db::testing::{assert_function_query_was_not_run, assert_function_query_was_run};
73847384

73857385
use super::*;
@@ -7535,6 +7535,26 @@ mod tests {
75357535
Ok(())
75367536
}
75377537

7538+
#[test]
7539+
fn relative_import_resolution_in_site_packages_when_site_packages_is_subdirectory_of_first_party_search_path(
7540+
) {
7541+
let project_root = SystemPath::new("/src");
7542+
let foo_dot_py = project_root.join("foo.py");
7543+
let site_packages = project_root.join(".venv/lib/python3.13/site-packages");
7544+
7545+
let db = TestDbBuilder::new()
7546+
.with_site_packages_search_path(&site_packages)
7547+
.with_file(&foo_dot_py, "from bar import A")
7548+
.with_file(&site_packages.join("bar/__init__.py"), "from .a import *")
7549+
.with_file(&site_packages.join("bar/a.py"), "class A: ...")
7550+
.build()
7551+
.unwrap();
7552+
7553+
assert_file_diagnostics(&db, foo_dot_py.as_str(), &[]);
7554+
let a_symbol = get_symbol(&db, foo_dot_py.as_str(), &[], "A");
7555+
assert!(a_symbol.expect_type().is_class_literal());
7556+
}
7557+
75387558
#[test]
75397559
fn pep695_type_params() {
75407560
let mut db = setup_db();

0 commit comments

Comments
 (0)