Skip to content

Commit fbab04f

Browse files
authored
[red-knot] Allow multiple site-packages search paths (#12609)
1 parent 9aa43d5 commit fbab04f

File tree

11 files changed

+143
-80
lines changed

11 files changed

+143
-80
lines changed

crates/red_knot/src/main.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub fn main() -> anyhow::Result<()> {
104104
extra_paths,
105105
workspace_root: workspace_metadata.root().to_path_buf(),
106106
custom_typeshed: custom_typeshed_dir,
107-
site_packages: None,
107+
site_packages: vec![],
108108
},
109109
};
110110

crates/red_knot/tests/file_watching.rs

+6-6
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ where
181181
extra_paths: vec![],
182182
workspace_root: workspace_path.to_path_buf(),
183183
custom_typeshed: None,
184-
site_packages: None,
184+
site_packages: vec![],
185185
})
186186
}
187187

@@ -697,7 +697,7 @@ fn search_path() -> anyhow::Result<()> {
697697
extra_paths: vec![],
698698
workspace_root: workspace_path.to_path_buf(),
699699
custom_typeshed: None,
700-
site_packages: Some(root_path.join("site_packages")),
700+
site_packages: vec![root_path.join("site_packages")],
701701
}
702702
})?;
703703

@@ -734,7 +734,7 @@ fn add_search_path() -> anyhow::Result<()> {
734734

735735
// Register site-packages as a search path.
736736
case.update_search_path_settings(|settings| SearchPathSettings {
737-
site_packages: Some(site_packages.clone()),
737+
site_packages: vec![site_packages.clone()],
738738
..settings.clone()
739739
});
740740

@@ -757,14 +757,14 @@ fn remove_search_path() -> anyhow::Result<()> {
757757
extra_paths: vec![],
758758
workspace_root: workspace_path.to_path_buf(),
759759
custom_typeshed: None,
760-
site_packages: Some(root_path.join("site_packages")),
760+
site_packages: vec![root_path.join("site_packages")],
761761
}
762762
})?;
763763

764764
// Remove site packages from the search path settings.
765765
let site_packages = case.root_path().join("site_packages");
766766
case.update_search_path_settings(|settings| SearchPathSettings {
767-
site_packages: None,
767+
site_packages: vec![],
768768
..settings.clone()
769769
});
770770

@@ -1175,7 +1175,7 @@ mod unix {
11751175
extra_paths: vec![],
11761176
workspace_root: workspace.to_path_buf(),
11771177
custom_typeshed: None,
1178-
site_packages: Some(workspace.join(".venv/lib/python3.12/site-packages")),
1178+
site_packages: vec![workspace.join(".venv/lib/python3.12/site-packages")],
11791179
},
11801180
)?;
11811181

crates/red_knot_module_resolver/src/path.rs

-6
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,6 @@ impl SearchPath {
477477
)
478478
}
479479

480-
/// Does this search path point to the `site-packages` directory?
481-
#[must_use]
482-
pub(crate) fn is_site_packages(&self) -> bool {
483-
matches!(&*self.0, SearchPathInner::SitePackages(_))
484-
}
485-
486480
fn is_valid_extension(&self, extension: &str) -> bool {
487481
if self.is_standard_library() {
488482
extension == "pyi"

crates/red_knot_module_resolver/src/resolver.rs

+124-58
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,6 @@ fn try_resolve_module_resolution_settings(
160160
SearchPath::vendored_stdlib()
161161
});
162162

163-
if let Some(site_packages) = site_packages {
164-
files.try_add_root(db.upcast(), site_packages, FileRootKind::LibrarySearchPath);
165-
166-
static_search_paths.push(SearchPath::site_packages(system, site_packages.clone())?);
167-
};
168-
169163
// TODO vendor typeshed's third-party stubs as well as the stdlib and fallback to them as a final step
170164

171165
let target_version = program.target_version(db.upcast());
@@ -191,6 +185,7 @@ fn try_resolve_module_resolution_settings(
191185
Ok(ModuleResolutionSettings {
192186
target_version,
193187
static_search_paths,
188+
site_packages_paths: site_packages.to_owned(),
194189
})
195190
}
196191

@@ -200,67 +195,79 @@ pub(crate) fn module_resolution_settings(db: &dyn Db) -> ModuleResolutionSetting
200195
try_resolve_module_resolution_settings(db).unwrap()
201196
}
202197

203-
/// Collect all dynamic search paths:
204-
/// search paths listed in `.pth` files in the `site-packages` directory
205-
/// due to editable installations of third-party packages.
198+
/// Collect all dynamic search paths. For each `site-packages` path:
199+
/// - Collect that `site-packages` path
200+
/// - Collect any search paths listed in `.pth` files in that `site-packages` directory
201+
/// due to editable installations of third-party packages.
202+
///
203+
/// The editable-install search paths for the first `site-packages` directory
204+
/// should come between the two `site-packages` directories when it comes to
205+
/// module-resolution priority.
206206
#[salsa::tracked(return_ref)]
207-
pub(crate) fn editable_install_resolution_paths(db: &dyn Db) -> Vec<SearchPath> {
208-
let settings = module_resolution_settings(db);
209-
let static_search_paths = &settings.static_search_paths;
210-
211-
let site_packages = static_search_paths
212-
.iter()
213-
.find(|path| path.is_site_packages());
214-
215-
let Some(site_packages) = site_packages else {
216-
return Vec::new();
217-
};
218-
219-
let site_packages = site_packages
220-
.as_system_path()
221-
.expect("Expected site-packages never to be a VendoredPath!");
222-
223-
let mut dynamic_paths = Vec::default();
207+
pub(crate) fn dynamic_resolution_paths(db: &dyn Db) -> Vec<SearchPath> {
208+
let ModuleResolutionSettings {
209+
target_version: _,
210+
static_search_paths,
211+
site_packages_paths,
212+
} = module_resolution_settings(db);
224213

225-
// This query needs to be re-executed each time a `.pth` file
226-
// is added, modified or removed from the `site-packages` directory.
227-
// However, we don't use Salsa queries to read the source text of `.pth` files;
228-
// we use the APIs on the `System` trait directly. As such, add a dependency on the
229-
// site-package directory's revision.
230-
if let Some(site_packages_root) = db.files().root(db.upcast(), site_packages) {
231-
let _ = site_packages_root.revision(db.upcast());
232-
}
214+
let mut dynamic_paths = Vec::new();
233215

234-
// As well as modules installed directly into `site-packages`,
235-
// the directory may also contain `.pth` files.
236-
// Each `.pth` file in `site-packages` may contain one or more lines
237-
// containing a (relative or absolute) path.
238-
// Each of these paths may point to an editable install of a package,
239-
// so should be considered an additional search path.
240-
let Ok(pth_file_iterator) = PthFileIterator::new(db, site_packages) else {
216+
if site_packages_paths.is_empty() {
241217
return dynamic_paths;
242-
};
243-
244-
// The Python documentation specifies that `.pth` files in `site-packages`
245-
// are processed in alphabetical order, so collecting and then sorting is necessary.
246-
// https://docs.python.org/3/library/site.html#module-site
247-
let mut all_pth_files: Vec<PthFile> = pth_file_iterator.collect();
248-
all_pth_files.sort_by(|a, b| a.path.cmp(&b.path));
218+
}
249219

250220
let mut existing_paths: FxHashSet<_> = static_search_paths
251221
.iter()
252222
.filter_map(|path| path.as_system_path())
253223
.map(Cow::Borrowed)
254224
.collect();
255225

256-
dynamic_paths.reserve(all_pth_files.len());
226+
let files = db.files();
227+
let system = db.system();
257228

258-
for pth_file in &all_pth_files {
259-
for installation in pth_file.editable_installations() {
260-
if existing_paths.insert(Cow::Owned(
261-
installation.as_system_path().unwrap().to_path_buf(),
262-
)) {
263-
dynamic_paths.push(installation);
229+
for site_packages_dir in site_packages_paths {
230+
if !existing_paths.insert(Cow::Borrowed(site_packages_dir)) {
231+
continue;
232+
}
233+
let site_packages_root = files.try_add_root(
234+
db.upcast(),
235+
site_packages_dir,
236+
FileRootKind::LibrarySearchPath,
237+
);
238+
// This query needs to be re-executed each time a `.pth` file
239+
// is added, modified or removed from the `site-packages` directory.
240+
// However, we don't use Salsa queries to read the source text of `.pth` files;
241+
// we use the APIs on the `System` trait directly. As such, add a dependency on the
242+
// site-package directory's revision.
243+
site_packages_root.revision(db.upcast());
244+
245+
dynamic_paths
246+
.push(SearchPath::site_packages(system, site_packages_dir.to_owned()).unwrap());
247+
248+
// As well as modules installed directly into `site-packages`,
249+
// the directory may also contain `.pth` files.
250+
// Each `.pth` file in `site-packages` may contain one or more lines
251+
// containing a (relative or absolute) path.
252+
// Each of these paths may point to an editable install of a package,
253+
// so should be considered an additional search path.
254+
let Ok(pth_file_iterator) = PthFileIterator::new(db, site_packages_dir) else {
255+
continue;
256+
};
257+
258+
// The Python documentation specifies that `.pth` files in `site-packages`
259+
// are processed in alphabetical order, so collecting and then sorting is necessary.
260+
// https://docs.python.org/3/library/site.html#module-site
261+
let mut all_pth_files: Vec<PthFile> = pth_file_iterator.collect();
262+
all_pth_files.sort_by(|a, b| a.path.cmp(&b.path));
263+
264+
for pth_file in &all_pth_files {
265+
for installation in pth_file.editable_installations() {
266+
if existing_paths.insert(Cow::Owned(
267+
installation.as_system_path().unwrap().to_path_buf(),
268+
)) {
269+
dynamic_paths.push(installation);
270+
}
264271
}
265272
}
266273
}
@@ -293,7 +300,7 @@ impl<'db> Iterator for SearchPathIterator<'db> {
293300

294301
static_paths.next().or_else(|| {
295302
dynamic_paths
296-
.get_or_insert_with(|| editable_install_resolution_paths(*db).iter())
303+
.get_or_insert_with(|| dynamic_resolution_paths(*db).iter())
297304
.next()
298305
})
299306
}
@@ -403,9 +410,18 @@ impl<'db> Iterator for PthFileIterator<'db> {
403410
#[derive(Clone, Debug, PartialEq, Eq)]
404411
pub(crate) struct ModuleResolutionSettings {
405412
target_version: TargetVersion,
413+
406414
/// Search paths that have been statically determined purely from reading Ruff's configuration settings.
407415
/// These shouldn't ever change unless the config settings themselves change.
408416
static_search_paths: Vec<SearchPath>,
417+
418+
/// site-packages paths are not included in the above field:
419+
/// if there are multiple site-packages paths, editable installations can appear
420+
/// *between* the site-packages paths on `sys.path` at runtime.
421+
/// That means we can't know where a second or third `site-packages` path should sit
422+
/// in terms of module-resolution priority until we've discovered the editable installs
423+
/// for the first `site-packages` path
424+
site_packages_paths: Vec<SystemPathBuf>,
409425
}
410426

411427
impl ModuleResolutionSettings {
@@ -630,6 +646,7 @@ mod tests {
630646
};
631647
use ruff_db::Db;
632648

649+
use crate::db::tests::TestDb;
633650
use crate::module::ModuleKind;
634651
use crate::module_name::ModuleName;
635652
use crate::testing::{FileSpec, MockedTypeshed, TestCase, TestCaseBuilder};
@@ -1180,7 +1197,7 @@ mod tests {
11801197
extra_paths: vec![],
11811198
workspace_root: src.clone(),
11821199
custom_typeshed: Some(custom_typeshed.clone()),
1183-
site_packages: Some(site_packages.clone()),
1200+
site_packages: vec![site_packages],
11841201
};
11851202

11861203
Program::new(&db, TargetVersion::Py38, search_paths);
@@ -1578,7 +1595,7 @@ not_a_directory
15781595
&FilePath::system("/y/src/bar.py")
15791596
);
15801597
let events = db.take_salsa_events();
1581-
assert_const_function_query_was_not_run(&db, editable_install_resolution_paths, &events);
1598+
assert_const_function_query_was_not_run(&db, dynamic_resolution_paths, &events);
15821599
}
15831600

15841601
#[test]
@@ -1656,4 +1673,53 @@ not_a_directory
16561673
assert!(!search_paths
16571674
.contains(&&SearchPath::editable(db.system(), SystemPathBuf::from("/src")).unwrap()));
16581675
}
1676+
1677+
#[test]
1678+
fn multiple_site_packages_with_editables() {
1679+
let mut db = TestDb::new();
1680+
1681+
let venv_site_packages = SystemPathBuf::from("/venv-site-packages");
1682+
let site_packages_pth = venv_site_packages.join("foo.pth");
1683+
let system_site_packages = SystemPathBuf::from("/system-site-packages");
1684+
let editable_install_location = SystemPathBuf::from("/x/y/a.py");
1685+
let system_site_packages_location = system_site_packages.join("a.py");
1686+
1687+
db.memory_file_system()
1688+
.create_directory_all("/src")
1689+
.unwrap();
1690+
db.write_files([
1691+
(&site_packages_pth, "/x/y"),
1692+
(&editable_install_location, ""),
1693+
(&system_site_packages_location, ""),
1694+
])
1695+
.unwrap();
1696+
1697+
Program::new(
1698+
&db,
1699+
TargetVersion::default(),
1700+
SearchPathSettings {
1701+
extra_paths: vec![],
1702+
workspace_root: SystemPathBuf::from("/src"),
1703+
custom_typeshed: None,
1704+
site_packages: vec![venv_site_packages, system_site_packages],
1705+
},
1706+
);
1707+
1708+
// The editable installs discovered from the `.pth` file in the first `site-packages` directory
1709+
// take precedence over the second `site-packages` directory...
1710+
let a_module_name = ModuleName::new_static("a").unwrap();
1711+
let a_module = resolve_module(&db, a_module_name.clone()).unwrap();
1712+
assert_eq!(a_module.file().path(&db), &editable_install_location);
1713+
1714+
db.memory_file_system()
1715+
.remove_file(&site_packages_pth)
1716+
.unwrap();
1717+
File::sync_path(&mut db, &site_packages_pth);
1718+
1719+
// ...But now that the `.pth` file in the first `site-packages` directory has been deleted,
1720+
// the editable install no longer exists, so the module now resolves to the file in the
1721+
// second `site-packages` directory
1722+
let a_module = resolve_module(&db, a_module_name).unwrap();
1723+
assert_eq!(a_module.file().path(&db), &system_site_packages_location);
1724+
}
16591725
}

crates/red_knot_module_resolver/src/testing.rs

+5-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ pub(crate) struct TestCase<T> {
1212
pub(crate) db: TestDb,
1313
pub(crate) src: SystemPathBuf,
1414
pub(crate) stdlib: T,
15+
// Most test cases only ever need a single `site-packages` directory,
16+
// so this is a single directory instead of a `Vec` of directories,
17+
// like it is in `ruff_db::Program`.
1518
pub(crate) site_packages: SystemPathBuf,
1619
pub(crate) target_version: TargetVersion,
1720
}
@@ -223,7 +226,7 @@ impl TestCaseBuilder<MockedTypeshed> {
223226
extra_paths: vec![],
224227
workspace_root: src.clone(),
225228
custom_typeshed: Some(typeshed.clone()),
226-
site_packages: Some(site_packages.clone()),
229+
site_packages: vec![site_packages.clone()],
227230
},
228231
);
229232

@@ -276,7 +279,7 @@ impl TestCaseBuilder<VendoredTypeshed> {
276279
extra_paths: vec![],
277280
workspace_root: src.clone(),
278281
custom_typeshed: None,
279-
site_packages: Some(site_packages.clone()),
282+
site_packages: vec![site_packages.clone()],
280283
},
281284
);
282285

crates/red_knot_python_semantic/src/semantic_model.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ mod tests {
179179
SearchPathSettings {
180180
extra_paths: vec![],
181181
workspace_root: SystemPathBuf::from("/src"),
182-
site_packages: None,
182+
site_packages: vec![],
183183
custom_typeshed: None,
184184
},
185185
);

crates/red_knot_python_semantic/src/types/infer.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1515,7 +1515,7 @@ mod tests {
15151515
SearchPathSettings {
15161516
extra_paths: Vec::new(),
15171517
workspace_root: SystemPathBuf::from("/src"),
1518-
site_packages: None,
1518+
site_packages: vec![],
15191519
custom_typeshed: None,
15201520
},
15211521
);
@@ -1532,7 +1532,7 @@ mod tests {
15321532
SearchPathSettings {
15331533
extra_paths: Vec::new(),
15341534
workspace_root: SystemPathBuf::from("/src"),
1535-
site_packages: None,
1535+
site_packages: vec![],
15361536
custom_typeshed: Some(SystemPathBuf::from(typeshed)),
15371537
},
15381538
);

crates/red_knot_workspace/src/lint.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ mod tests {
326326
SearchPathSettings {
327327
extra_paths: Vec::new(),
328328
workspace_root,
329-
site_packages: None,
329+
site_packages: vec![],
330330
custom_typeshed: None,
331331
},
332332
);

0 commit comments

Comments
 (0)