mirror of
https://github.com/astral-sh/ruff.git
synced 2025-07-31 08:53:47 +00:00
[red-knot] Add support for editable installs to the module resolver (#12307)
Co-authored-by: Micha Reiser <micha@reiser.io> Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
parent
595b1aa4a1
commit
9a2dafb43d
4 changed files with 686 additions and 40 deletions
|
@ -1,7 +1,7 @@
|
|||
use ruff_db::Upcast;
|
||||
|
||||
use crate::resolver::{
|
||||
file_to_module,
|
||||
editable_install_resolution_paths, file_to_module,
|
||||
internal::{ModuleNameIngredient, ModuleResolverSettings},
|
||||
resolve_module_query,
|
||||
};
|
||||
|
@ -11,6 +11,7 @@ use crate::typeshed::parse_typeshed_versions;
|
|||
pub struct Jar(
|
||||
ModuleNameIngredient<'_>,
|
||||
ModuleResolverSettings,
|
||||
editable_install_resolution_paths,
|
||||
resolve_module_query,
|
||||
file_to_module,
|
||||
parse_typeshed_versions,
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
use std::fmt;
|
||||
|
||||
use ruff_db::files::{system_path_to_file, vendored_path_to_file, File, FilePath};
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use ruff_db::system::{System, SystemPath, SystemPathBuf};
|
||||
use ruff_db::vendored::{VendoredPath, VendoredPathBuf};
|
||||
|
||||
use crate::db::Db;
|
||||
|
@ -73,6 +73,7 @@ enum ModuleResolutionPathBufInner {
|
|||
FirstParty(SystemPathBuf),
|
||||
StandardLibrary(FilePath),
|
||||
SitePackages(SystemPathBuf),
|
||||
EditableInstall(SystemPathBuf),
|
||||
}
|
||||
|
||||
impl ModuleResolutionPathBufInner {
|
||||
|
@ -134,6 +135,19 @@ impl ModuleResolutionPathBufInner {
|
|||
);
|
||||
path.push(component);
|
||||
}
|
||||
Self::EditableInstall(ref mut path) => {
|
||||
if let Some(extension) = extension {
|
||||
assert!(
|
||||
matches!(extension, "pyi" | "py"),
|
||||
"Extension must be `py` or `pyi`; got `{extension}`"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
path.extension().is_none(),
|
||||
"Cannot push part {component} to {path}, which already has an extension"
|
||||
);
|
||||
path.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -197,6 +211,18 @@ impl ModuleResolutionPathBuf {
|
|||
.then_some(Self(ModuleResolutionPathBufInner::SitePackages(path)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn editable_installation_root(
|
||||
system: &dyn System,
|
||||
path: impl Into<SystemPathBuf>,
|
||||
) -> Option<Self> {
|
||||
let path = path.into();
|
||||
// TODO: Add Salsa invalidation to this system call:
|
||||
system
|
||||
.is_directory(&path)
|
||||
.then_some(Self(ModuleResolutionPathBufInner::EditableInstall(path)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn is_regular_package(&self, search_path: &Self, resolver: &ResolverState) -> bool {
|
||||
ModuleResolutionPathRef::from(self).is_regular_package(search_path, resolver)
|
||||
|
@ -229,6 +255,16 @@ impl ModuleResolutionPathBuf {
|
|||
pub(crate) fn to_file(&self, search_path: &Self, resolver: &ResolverState) -> Option<File> {
|
||||
ModuleResolutionPathRef::from(self).to_file(search_path, resolver)
|
||||
}
|
||||
|
||||
pub(crate) fn as_system_path(&self) -> Option<&SystemPathBuf> {
|
||||
match &self.0 {
|
||||
ModuleResolutionPathBufInner::Extra(path) => Some(path),
|
||||
ModuleResolutionPathBufInner::FirstParty(path) => Some(path),
|
||||
ModuleResolutionPathBufInner::StandardLibrary(_) => None,
|
||||
ModuleResolutionPathBufInner::SitePackages(path) => Some(path),
|
||||
ModuleResolutionPathBufInner::EditableInstall(path) => Some(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ModuleResolutionPathBuf {
|
||||
|
@ -250,6 +286,10 @@ impl fmt::Debug for ModuleResolutionPathBuf {
|
|||
.debug_tuple("ModuleResolutionPathBuf::StandardLibrary")
|
||||
.field(path)
|
||||
.finish(),
|
||||
ModuleResolutionPathBufInner::EditableInstall(path) => f
|
||||
.debug_tuple("ModuleResolutionPathBuf::EditableInstall")
|
||||
.field(path)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -272,6 +312,7 @@ enum ModuleResolutionPathRefInner<'a> {
|
|||
FirstParty(&'a SystemPath),
|
||||
StandardLibrary(FilePathRef<'a>),
|
||||
SitePackages(&'a SystemPath),
|
||||
EditableInstall(&'a SystemPath),
|
||||
}
|
||||
|
||||
impl<'a> ModuleResolutionPathRefInner<'a> {
|
||||
|
@ -306,6 +347,7 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
(Self::Extra(path), Self::Extra(_)) => resolver.system().is_directory(path),
|
||||
(Self::FirstParty(path), Self::FirstParty(_)) => resolver.system().is_directory(path),
|
||||
(Self::SitePackages(path), Self::SitePackages(_)) => resolver.system().is_directory(path),
|
||||
(Self::EditableInstall(path), Self::EditableInstall(_)) => resolver.system().is_directory(path),
|
||||
(Self::StandardLibrary(path), Self::StandardLibrary(stdlib_root)) => {
|
||||
match Self::query_stdlib_version(path, search_path, &stdlib_root, resolver) {
|
||||
TypeshedVersionsQueryResult::DoesNotExist => false,
|
||||
|
@ -333,6 +375,7 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
(Self::Extra(path), Self::Extra(_)) => is_non_stdlib_pkg(resolver, path),
|
||||
(Self::FirstParty(path), Self::FirstParty(_)) => is_non_stdlib_pkg(resolver, path),
|
||||
(Self::SitePackages(path), Self::SitePackages(_)) => is_non_stdlib_pkg(resolver, path),
|
||||
(Self::EditableInstall(path), Self::EditableInstall(_)) => is_non_stdlib_pkg(resolver, path),
|
||||
// Unlike the other variants:
|
||||
// (1) Account for VERSIONS
|
||||
// (2) Only test for `__init__.pyi`, not `__init__.py`
|
||||
|
@ -358,6 +401,7 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
(Self::SitePackages(path), Self::SitePackages(_)) => {
|
||||
system_path_to_file(resolver.db.upcast(), path)
|
||||
}
|
||||
(Self::EditableInstall(path), Self::EditableInstall(_)) => system_path_to_file(resolver.db.upcast(), path),
|
||||
(Self::StandardLibrary(path), Self::StandardLibrary(stdlib_root)) => {
|
||||
match Self::query_stdlib_version(&path, search_path, &stdlib_root, resolver) {
|
||||
TypeshedVersionsQueryResult::DoesNotExist => None,
|
||||
|
@ -374,7 +418,10 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
#[must_use]
|
||||
fn to_module_name(self) -> Option<ModuleName> {
|
||||
match self {
|
||||
Self::Extra(path) | Self::FirstParty(path) | Self::SitePackages(path) => {
|
||||
Self::Extra(path)
|
||||
| Self::FirstParty(path)
|
||||
| Self::SitePackages(path)
|
||||
| Self::EditableInstall(path) => {
|
||||
let parent = path.parent()?;
|
||||
let parent_components = parent.components().map(|component| component.as_str());
|
||||
let skip_final_part =
|
||||
|
@ -421,6 +468,9 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
Self::SitePackages(path) => {
|
||||
ModuleResolutionPathBufInner::SitePackages(path.with_extension("pyi"))
|
||||
}
|
||||
Self::EditableInstall(path) => {
|
||||
ModuleResolutionPathBufInner::EditableInstall(path.with_extension("pyi"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -437,6 +487,9 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
Self::SitePackages(path) => Some(ModuleResolutionPathBufInner::SitePackages(
|
||||
path.with_extension("py"),
|
||||
)),
|
||||
Self::EditableInstall(path) => Some(ModuleResolutionPathBufInner::EditableInstall(
|
||||
path.with_extension("py"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -474,6 +527,13 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
.then_some(Self::SitePackages(path))
|
||||
})
|
||||
}
|
||||
(Self::EditableInstall(root), FilePathRef::System(absolute_path)) => {
|
||||
absolute_path.strip_prefix(root).ok().and_then(|path| {
|
||||
path.extension()
|
||||
.map_or(true, |ext| matches!(ext, "pyi" | "py"))
|
||||
.then_some(Self::EditableInstall(path))
|
||||
})
|
||||
}
|
||||
(Self::Extra(_), FilePathRef::Vendored(_)) => None,
|
||||
(Self::FirstParty(_), FilePathRef::Vendored(_)) => None,
|
||||
(Self::StandardLibrary(root), FilePathRef::Vendored(absolute_path)) => match root {
|
||||
|
@ -487,6 +547,7 @@ impl<'a> ModuleResolutionPathRefInner<'a> {
|
|||
}
|
||||
},
|
||||
(Self::SitePackages(_), FilePathRef::Vendored(_)) => None,
|
||||
(Self::EditableInstall(_), FilePathRef::Vendored(_)) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -562,6 +623,10 @@ impl fmt::Debug for ModuleResolutionPathRef<'_> {
|
|||
.debug_tuple("ModuleResolutionPathRef::StandardLibrary")
|
||||
.field(path)
|
||||
.finish(),
|
||||
ModuleResolutionPathRefInner::EditableInstall(path) => f
|
||||
.debug_tuple("ModuleResolutionPathRef::EditableInstall")
|
||||
.field(path)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -582,6 +647,9 @@ impl<'a> From<&'a ModuleResolutionPathBuf> for ModuleResolutionPathRef<'a> {
|
|||
ModuleResolutionPathBufInner::SitePackages(path) => {
|
||||
ModuleResolutionPathRefInner::SitePackages(path)
|
||||
}
|
||||
ModuleResolutionPathBufInner::EditableInstall(path) => {
|
||||
ModuleResolutionPathRefInner::EditableInstall(path)
|
||||
}
|
||||
};
|
||||
ModuleResolutionPathRef(inner)
|
||||
}
|
||||
|
@ -593,6 +661,7 @@ impl PartialEq<SystemPath> for ModuleResolutionPathRef<'_> {
|
|||
ModuleResolutionPathRefInner::Extra(path) => path == other,
|
||||
ModuleResolutionPathRefInner::FirstParty(path) => path == other,
|
||||
ModuleResolutionPathRefInner::SitePackages(path) => path == other,
|
||||
ModuleResolutionPathRefInner::EditableInstall(path) => path == other,
|
||||
ModuleResolutionPathRefInner::StandardLibrary(FilePathRef::System(path)) => {
|
||||
path == other
|
||||
}
|
||||
|
@ -625,6 +694,7 @@ impl PartialEq<VendoredPath> for ModuleResolutionPathRef<'_> {
|
|||
ModuleResolutionPathRefInner::Extra(_) => false,
|
||||
ModuleResolutionPathRefInner::FirstParty(_) => false,
|
||||
ModuleResolutionPathRefInner::SitePackages(_) => false,
|
||||
ModuleResolutionPathRefInner::EditableInstall(_) => false,
|
||||
ModuleResolutionPathRefInner::StandardLibrary(FilePathRef::System(_)) => false,
|
||||
ModuleResolutionPathRefInner::StandardLibrary(FilePathRef::Vendored(path)) => {
|
||||
path == other
|
||||
|
@ -707,6 +777,9 @@ mod tests {
|
|||
ModuleResolutionPathRefInner::SitePackages(path) => {
|
||||
ModuleResolutionPathBufInner::SitePackages(path.to_path_buf())
|
||||
}
|
||||
ModuleResolutionPathRefInner::EditableInstall(path) => {
|
||||
ModuleResolutionPathBufInner::EditableInstall(path.to_path_buf())
|
||||
}
|
||||
};
|
||||
ModuleResolutionPathBuf(inner)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
use std::ops::Deref;
|
||||
use std::collections;
|
||||
use std::hash::BuildHasherDefault;
|
||||
use std::iter::FusedIterator;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustc_hash::FxHasher;
|
||||
|
||||
use ruff_db::files::{File, FilePath};
|
||||
use ruff_db::system::SystemPathBuf;
|
||||
use ruff_db::system::{DirectoryEntry, System, SystemPath, SystemPathBuf};
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::module::{Module, ModuleKind};
|
||||
|
@ -12,6 +16,79 @@ use crate::resolver::internal::ModuleResolverSettings;
|
|||
use crate::state::ResolverState;
|
||||
use crate::supported_py_version::TargetVersion;
|
||||
|
||||
type SearchPathRoot = Arc<ModuleResolutionPathBuf>;
|
||||
|
||||
/// An ordered sequence of search paths.
|
||||
///
|
||||
/// The sequence respects the invariant maintained by [`sys.path` at runtime]
|
||||
/// where no two module-resolution paths ever point to the same directory on disk.
|
||||
/// (Paths may, however, *overlap* -- e.g. you could have both `src/` and `src/foo`
|
||||
/// as module resolution paths simultaneously.)
|
||||
///
|
||||
/// [`sys.path` at runtime]: https://docs.python.org/3/library/site.html#module-site
|
||||
#[derive(Debug, PartialEq, Eq, Default, Clone)]
|
||||
pub(crate) struct SearchPathSequence {
|
||||
raw_paths: collections::HashSet<SystemPathBuf, BuildHasherDefault<FxHasher>>,
|
||||
search_paths: Vec<SearchPathRoot>,
|
||||
}
|
||||
|
||||
impl SearchPathSequence {
|
||||
fn insert(&mut self, path: SearchPathRoot) -> bool {
|
||||
// Just assume that all search paths that aren't SystemPaths are unique
|
||||
if let Some(fs_path) = path.as_system_path() {
|
||||
if self.raw_paths.contains(fs_path) {
|
||||
false
|
||||
} else {
|
||||
let raw_path = fs_path.to_owned();
|
||||
self.search_paths.push(path);
|
||||
self.raw_paths.insert(raw_path)
|
||||
}
|
||||
} else {
|
||||
self.search_paths.push(path);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, path: &SearchPathRoot) -> bool {
|
||||
if let Some(fs_path) = path.as_system_path() {
|
||||
self.raw_paths.contains(fs_path)
|
||||
} else {
|
||||
self.search_paths.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn iter(&self) -> std::slice::Iter<SearchPathRoot> {
|
||||
self.search_paths.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a SearchPathSequence {
|
||||
type IntoIter = std::slice::Iter<'a, SearchPathRoot>;
|
||||
type Item = &'a SearchPathRoot;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<SearchPathRoot> for SearchPathSequence {
|
||||
fn from_iter<T: IntoIterator<Item = SearchPathRoot>>(iter: T) -> Self {
|
||||
let mut sequence = Self::default();
|
||||
for item in iter {
|
||||
sequence.insert(item);
|
||||
}
|
||||
sequence
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<SearchPathRoot> for SearchPathSequence {
|
||||
fn extend<T: IntoIterator<Item = SearchPathRoot>>(&mut self, iter: T) {
|
||||
for item in iter {
|
||||
self.insert(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the module resolver settings.
|
||||
///
|
||||
/// Must be called before calling any other module resolution functions.
|
||||
|
@ -19,7 +96,7 @@ pub fn set_module_resolution_settings(db: &mut dyn Db, config: RawModuleResoluti
|
|||
// There's no concurrency issue here because we hold a `&mut dyn Db` reference. No other
|
||||
// thread can mutate the `Db` while we're in this call, so using `try_get` to test if
|
||||
// the settings have already been set is safe.
|
||||
let resolved_settings = config.into_configuration_settings();
|
||||
let resolved_settings = config.into_configuration_settings(db.system().current_directory());
|
||||
if let Some(existing) = ModuleResolverSettings::try_get(db) {
|
||||
existing.set_settings(db).to(resolved_settings);
|
||||
} else {
|
||||
|
@ -82,12 +159,14 @@ pub(crate) fn file_to_module(db: &dyn Db, file: File) -> Option<Module> {
|
|||
|
||||
let resolver_settings = module_resolver_settings(db);
|
||||
|
||||
let relative_path = resolver_settings
|
||||
.search_paths()
|
||||
.iter()
|
||||
.find_map(|root| root.relativize_path(path))?;
|
||||
let mut search_paths = resolver_settings.search_paths(db);
|
||||
|
||||
let module_name = relative_path.to_module_name()?;
|
||||
let module_name = loop {
|
||||
let candidate = search_paths.next()?;
|
||||
if let Some(relative_path) = candidate.relativize_path(path) {
|
||||
break relative_path.to_module_name()?;
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the module name to see if Python would resolve the name to the same path.
|
||||
// If it doesn't, then that means that multiple modules have the same name in different
|
||||
|
@ -133,7 +212,10 @@ pub struct RawModuleResolutionSettings {
|
|||
}
|
||||
|
||||
impl RawModuleResolutionSettings {
|
||||
/// Implementation of the typing spec's [module resolution order]
|
||||
/// Validate and normalize the raw settings given by the user
|
||||
/// into settings we can use for module resolution
|
||||
///
|
||||
/// This method also implements the typing spec's [module resolution order].
|
||||
///
|
||||
/// TODO(Alex): this method does multiple `.unwrap()` calls when it should really return an error.
|
||||
/// Each `.unwrap()` call is a point where we're validating a setting that the user would pass
|
||||
|
@ -143,67 +225,297 @@ impl RawModuleResolutionSettings {
|
|||
/// This validation should probably be done outside of Salsa?
|
||||
///
|
||||
/// [module resolution order]: https://typing.readthedocs.io/en/latest/spec/distributing.html#import-resolution-ordering
|
||||
fn into_configuration_settings(self) -> ModuleResolutionSettings {
|
||||
fn into_configuration_settings(
|
||||
self,
|
||||
current_directory: &SystemPath,
|
||||
) -> ModuleResolutionSettings {
|
||||
let RawModuleResolutionSettings {
|
||||
target_version,
|
||||
extra_paths,
|
||||
workspace_root,
|
||||
site_packages,
|
||||
site_packages: site_packages_setting,
|
||||
custom_typeshed,
|
||||
} = self;
|
||||
|
||||
let mut paths: Vec<ModuleResolutionPathBuf> = extra_paths
|
||||
let mut static_search_paths: SearchPathSequence = extra_paths
|
||||
.into_iter()
|
||||
.map(|fs_path| ModuleResolutionPathBuf::extra(fs_path).unwrap())
|
||||
.map(|fs_path| {
|
||||
Arc::new(
|
||||
ModuleResolutionPathBuf::extra(SystemPath::absolute(
|
||||
fs_path,
|
||||
current_directory,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
paths.push(ModuleResolutionPathBuf::first_party(workspace_root).unwrap());
|
||||
static_search_paths.insert(Arc::new(
|
||||
ModuleResolutionPathBuf::first_party(SystemPath::absolute(
|
||||
workspace_root,
|
||||
current_directory,
|
||||
))
|
||||
.unwrap(),
|
||||
));
|
||||
|
||||
paths.push(
|
||||
custom_typeshed.map_or_else(ModuleResolutionPathBuf::vendored_stdlib, |custom| {
|
||||
ModuleResolutionPathBuf::stdlib_from_custom_typeshed_root(&custom).unwrap()
|
||||
}),
|
||||
);
|
||||
static_search_paths.insert(Arc::new(custom_typeshed.map_or_else(
|
||||
ModuleResolutionPathBuf::vendored_stdlib,
|
||||
|custom| {
|
||||
ModuleResolutionPathBuf::stdlib_from_custom_typeshed_root(&SystemPath::absolute(
|
||||
custom,
|
||||
current_directory,
|
||||
))
|
||||
.unwrap()
|
||||
},
|
||||
)));
|
||||
|
||||
let mut site_packages = None;
|
||||
|
||||
if let Some(path) = site_packages_setting {
|
||||
let site_packages_root = Arc::new(
|
||||
ModuleResolutionPathBuf::site_packages(SystemPath::absolute(
|
||||
path,
|
||||
current_directory,
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
site_packages = Some(site_packages_root.clone());
|
||||
static_search_paths.insert(site_packages_root);
|
||||
}
|
||||
|
||||
// TODO vendor typeshed's third-party stubs as well as the stdlib and fallback to them as a final step
|
||||
if let Some(site_packages) = site_packages {
|
||||
paths.push(ModuleResolutionPathBuf::site_packages(site_packages).unwrap());
|
||||
}
|
||||
|
||||
ModuleResolutionSettings {
|
||||
target_version,
|
||||
search_paths: OrderedSearchPaths(paths.into_iter().map(Arc::new).collect()),
|
||||
search_path_settings: ValidatedSearchPathSettings {
|
||||
static_search_paths,
|
||||
site_packages,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved module resolution order as per the [typing spec]
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
struct ValidatedSearchPathSettings {
|
||||
/// Search paths that have been statically determined purely from reading Ruff's configuration settings.
|
||||
/// These shouldn't ever change unless the config settings themselves change.
|
||||
///
|
||||
/// Note that `site-packages` *is included* as a search path in this sequence,
|
||||
/// but it is also stored separately so that we're able to find editable installs later.
|
||||
static_search_paths: SearchPathSequence,
|
||||
site_packages: Option<SearchPathRoot>,
|
||||
}
|
||||
|
||||
/// Collect all dynamic search paths:
|
||||
/// search paths listed in `.pth` files in the `site-packages` directory
|
||||
/// due to editable installations of third-party packages.
|
||||
#[salsa::tracked(return_ref)]
|
||||
pub(crate) fn editable_install_resolution_paths(db: &dyn Db) -> SearchPathSequence {
|
||||
// This query needs to be re-executed each time a `.pth` file
|
||||
// is added, modified or removed from the `site-packages` directory.
|
||||
// However, we don't use Salsa queries to read the source text of `.pth` files;
|
||||
// we use the APIs on the `System` trait directly. As such, for now we simply ask
|
||||
// Salsa to recompute this query on each new revision.
|
||||
//
|
||||
// TODO: add some kind of watcher for the `site-packages` directory that looks
|
||||
// for `site-packages/*.pth` files being added/modified/removed; get rid of this.
|
||||
// When doing so, also make the test
|
||||
// `deleting_pth_file_on_which_module_resolution_depends_invalidates_cache()`
|
||||
// more principled!
|
||||
db.report_untracked_read();
|
||||
|
||||
let ValidatedSearchPathSettings {
|
||||
static_search_paths,
|
||||
site_packages,
|
||||
} = &module_resolver_settings(db).search_path_settings;
|
||||
|
||||
let mut dynamic_paths = SearchPathSequence::default();
|
||||
|
||||
if let Some(site_packages) = site_packages {
|
||||
let site_packages = site_packages
|
||||
.as_system_path()
|
||||
.expect("Expected site-packages never to be a VendoredPath!");
|
||||
|
||||
// As well as modules installed directly into `site-packages`,
|
||||
// the directory may also contain `.pth` files.
|
||||
// Each `.pth` file in `site-packages` may contain one or more lines
|
||||
// containing a (relative or absolute) path.
|
||||
// Each of these paths may point to an editable install of a package,
|
||||
// so should be considered an additional search path.
|
||||
let Ok(pth_file_iterator) = PthFileIterator::new(db, site_packages) else {
|
||||
return dynamic_paths;
|
||||
};
|
||||
|
||||
// The Python documentation specifies that `.pth` files in `site-packages`
|
||||
// are processed in alphabetical order, so collecting and then sorting is necessary.
|
||||
// https://docs.python.org/3/library/site.html#module-site
|
||||
let mut all_pth_files: Vec<PthFile> = pth_file_iterator.collect();
|
||||
all_pth_files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
for pth_file in &all_pth_files {
|
||||
dynamic_paths.extend(
|
||||
pth_file
|
||||
.editable_installations()
|
||||
.filter_map(|editable_path| {
|
||||
let possible_search_path = Arc::new(editable_path);
|
||||
(!static_search_paths.contains(&possible_search_path))
|
||||
.then_some(possible_search_path)
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
dynamic_paths
|
||||
}
|
||||
|
||||
/// Iterate over the available module-resolution search paths,
|
||||
/// following the invariants maintained by [`sys.path` at runtime]:
|
||||
/// "No item is added to `sys.path` more than once."
|
||||
/// Dynamic search paths (required for editable installs into `site-packages`)
|
||||
/// are only calculated lazily.
|
||||
///
|
||||
/// [typing spec]: https://typing.readthedocs.io/en/latest/spec/distributing.html#import-resolution-ordering
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct OrderedSearchPaths(Vec<Arc<ModuleResolutionPathBuf>>);
|
||||
/// [`sys.path` at runtime]: https://docs.python.org/3/library/site.html#module-site
|
||||
struct SearchPathIterator<'db> {
|
||||
db: &'db dyn Db,
|
||||
static_paths: std::slice::Iter<'db, SearchPathRoot>,
|
||||
dynamic_paths: Option<std::slice::Iter<'db, SearchPathRoot>>,
|
||||
}
|
||||
|
||||
impl Deref for OrderedSearchPaths {
|
||||
type Target = [Arc<ModuleResolutionPathBuf>];
|
||||
impl<'db> Iterator for SearchPathIterator<'db> {
|
||||
type Item = &'db SearchPathRoot;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let SearchPathIterator {
|
||||
db,
|
||||
static_paths,
|
||||
dynamic_paths,
|
||||
} = self;
|
||||
|
||||
static_paths.next().or_else(|| {
|
||||
dynamic_paths
|
||||
.get_or_insert_with(|| editable_install_resolution_paths(*db).into_iter())
|
||||
.next()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'db> FusedIterator for SearchPathIterator<'db> {}
|
||||
|
||||
/// Represents a single `.pth` file in a `site-packages` directory.
|
||||
/// One or more lines in a `.pth` file may be a (relative or absolute)
|
||||
/// path that represents an editable installation of a package.
|
||||
struct PthFile<'db> {
|
||||
system: &'db dyn System,
|
||||
path: SystemPathBuf,
|
||||
contents: String,
|
||||
site_packages: &'db SystemPath,
|
||||
}
|
||||
|
||||
impl<'db> PthFile<'db> {
|
||||
/// Yield paths in this `.pth` file that appear to represent editable installations,
|
||||
/// and should therefore be added as module-resolution search paths.
|
||||
fn editable_installations(&'db self) -> impl Iterator<Item = ModuleResolutionPathBuf> + 'db {
|
||||
let PthFile {
|
||||
system,
|
||||
path: _,
|
||||
contents,
|
||||
site_packages,
|
||||
} = self;
|
||||
|
||||
// Empty lines or lines starting with '#' are ignored by the Python interpreter.
|
||||
// Lines that start with "import " or "import\t" do not represent editable installs at all;
|
||||
// instead, these are lines that are executed by Python at startup.
|
||||
// https://docs.python.org/3/library/site.html#module-site
|
||||
contents.lines().filter_map(move |line| {
|
||||
let line = line.trim_end();
|
||||
if line.is_empty()
|
||||
|| line.starts_with('#')
|
||||
|| line.starts_with("import ")
|
||||
|| line.starts_with("import\t")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let possible_editable_install = SystemPath::absolute(line, site_packages);
|
||||
ModuleResolutionPathBuf::editable_installation_root(*system, possible_editable_install)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator that yields a [`PthFile`] instance for every `.pth` file
|
||||
/// found in a given `site-packages` directory.
|
||||
struct PthFileIterator<'db> {
|
||||
db: &'db dyn Db,
|
||||
directory_iterator: Box<dyn Iterator<Item = std::io::Result<DirectoryEntry>> + 'db>,
|
||||
site_packages: &'db SystemPath,
|
||||
}
|
||||
|
||||
impl<'db> PthFileIterator<'db> {
|
||||
fn new(db: &'db dyn Db, site_packages: &'db SystemPath) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
db,
|
||||
directory_iterator: db.system().read_directory(site_packages)?,
|
||||
site_packages,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'db> Iterator for PthFileIterator<'db> {
|
||||
type Item = PthFile<'db>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let PthFileIterator {
|
||||
db,
|
||||
directory_iterator,
|
||||
site_packages,
|
||||
} = self;
|
||||
|
||||
let system = db.system();
|
||||
|
||||
loop {
|
||||
let entry_result = directory_iterator.next()?;
|
||||
let Ok(entry) = entry_result else {
|
||||
continue;
|
||||
};
|
||||
let file_type = entry.file_type();
|
||||
if file_type.is_directory() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.into_path();
|
||||
if path.extension() != Some("pth") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(contents) = db.system().read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
return Some(PthFile {
|
||||
system,
|
||||
path,
|
||||
contents,
|
||||
site_packages,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated and normalized module-resolution settings.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ModuleResolutionSettings {
|
||||
search_paths: OrderedSearchPaths,
|
||||
search_path_settings: ValidatedSearchPathSettings,
|
||||
target_version: TargetVersion,
|
||||
}
|
||||
|
||||
impl ModuleResolutionSettings {
|
||||
pub(crate) fn search_paths(&self) -> &[Arc<ModuleResolutionPathBuf>] {
|
||||
&self.search_paths
|
||||
fn target_version(&self) -> TargetVersion {
|
||||
self.target_version
|
||||
}
|
||||
|
||||
pub(crate) fn target_version(&self) -> TargetVersion {
|
||||
self.target_version
|
||||
fn search_paths<'db>(&'db self, db: &'db dyn Db) -> SearchPathIterator<'db> {
|
||||
SearchPathIterator {
|
||||
db,
|
||||
static_paths: self.search_path_settings.static_search_paths.iter(),
|
||||
dynamic_paths: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,7 +557,7 @@ fn resolve_name(
|
|||
let resolver_settings = module_resolver_settings(db);
|
||||
let resolver_state = ResolverState::new(db, resolver_settings.target_version());
|
||||
|
||||
for search_path in resolver_settings.search_paths() {
|
||||
for search_path in resolver_settings.search_paths(db) {
|
||||
let mut components = name.components();
|
||||
let module_name = components.next_back()?;
|
||||
|
||||
|
@ -388,6 +700,7 @@ mod tests {
|
|||
use ruff_db::files::{system_path_to_file, File, FilePath};
|
||||
use ruff_db::system::{DbWithTestSystem, OsSystem, SystemPath};
|
||||
use ruff_db::testing::assert_function_query_was_not_run;
|
||||
use ruff_db::Db;
|
||||
|
||||
use crate::db::tests::TestDb;
|
||||
use crate::module::ModuleKind;
|
||||
|
@ -1140,4 +1453,259 @@ mod tests {
|
|||
system_path_to_file(&db, stdlib.join("functools.pyi"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_install_absolute_path() {
|
||||
const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src")];
|
||||
let x_directory = [("/x/src/foo/__init__.py", ""), ("/x/src/foo/bar.py", "")];
|
||||
|
||||
let TestCase { mut db, .. } = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
db.write_files(x_directory).unwrap();
|
||||
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
let foo_bar_module_name = ModuleName::new_static("foo.bar").unwrap();
|
||||
|
||||
let foo_module = resolve_module(&db, foo_module_name.clone()).unwrap();
|
||||
let foo_bar_module = resolve_module(&db, foo_bar_module_name.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
foo_module.file().path(&db),
|
||||
&FilePath::system("/x/src/foo/__init__.py")
|
||||
);
|
||||
assert_eq!(
|
||||
foo_bar_module.file().path(&db),
|
||||
&FilePath::system("/x/src/foo/bar.py")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_install_pth_file_with_whitespace() {
|
||||
const SITE_PACKAGES: &[FileSpec] = &[
|
||||
("_foo.pth", " /x/src"),
|
||||
("_bar.pth", "/y/src "),
|
||||
];
|
||||
let external_files = [("/x/src/foo.py", ""), ("/y/src/bar.py", "")];
|
||||
|
||||
let TestCase { mut db, .. } = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
db.write_files(external_files).unwrap();
|
||||
|
||||
// Lines with leading whitespace in `.pth` files do not parse:
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
assert_eq!(resolve_module(&db, foo_module_name), None);
|
||||
|
||||
// Lines with trailing whitespace in `.pth` files do:
|
||||
let bar_module_name = ModuleName::new_static("bar").unwrap();
|
||||
let bar_module = resolve_module(&db, bar_module_name.clone()).unwrap();
|
||||
assert_eq!(
|
||||
bar_module.file().path(&db),
|
||||
&FilePath::system("/y/src/bar.py")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_install_relative_path() {
|
||||
const SITE_PACKAGES: &[FileSpec] = &[
|
||||
("_foo.pth", "../../x/../x/y/src"),
|
||||
("../x/y/src/foo.pyi", ""),
|
||||
];
|
||||
|
||||
let TestCase { db, .. } = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
let foo_module = resolve_module(&db, foo_module_name.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
foo_module.file().path(&db),
|
||||
&FilePath::system("/x/y/src/foo.pyi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_install_multiple_pth_files_with_multiple_paths() {
|
||||
const COMPLEX_PTH_FILE: &str = "\
|
||||
/
|
||||
|
||||
# a comment
|
||||
/baz
|
||||
|
||||
import not_an_editable_install; do_something_else_crazy_dynamic()
|
||||
|
||||
# another comment
|
||||
spam
|
||||
|
||||
not_a_directory
|
||||
";
|
||||
|
||||
const SITE_PACKAGES: &[FileSpec] = &[
|
||||
("_foo.pth", "../../x/../x/y/src"),
|
||||
("_lots_of_others.pth", COMPLEX_PTH_FILE),
|
||||
("../x/y/src/foo.pyi", ""),
|
||||
("spam/spam.py", ""),
|
||||
];
|
||||
|
||||
let root_files = [("/a.py", ""), ("/baz/b.py", "")];
|
||||
|
||||
let TestCase {
|
||||
mut db,
|
||||
site_packages,
|
||||
..
|
||||
} = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
db.write_files(root_files).unwrap();
|
||||
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
let a_module_name = ModuleName::new_static("a").unwrap();
|
||||
let b_module_name = ModuleName::new_static("b").unwrap();
|
||||
let spam_module_name = ModuleName::new_static("spam").unwrap();
|
||||
|
||||
let foo_module = resolve_module(&db, foo_module_name.clone()).unwrap();
|
||||
let a_module = resolve_module(&db, a_module_name.clone()).unwrap();
|
||||
let b_module = resolve_module(&db, b_module_name.clone()).unwrap();
|
||||
let spam_module = resolve_module(&db, spam_module_name.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
foo_module.file().path(&db),
|
||||
&FilePath::system("/x/y/src/foo.pyi")
|
||||
);
|
||||
assert_eq!(a_module.file().path(&db), &FilePath::system("/a.py"));
|
||||
assert_eq!(b_module.file().path(&db), &FilePath::system("/baz/b.py"));
|
||||
assert_eq!(
|
||||
spam_module.file().path(&db),
|
||||
&FilePath::System(site_packages.join("spam/spam.py"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_resolution_paths_cached_between_different_module_resolutions() {
|
||||
const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src"), ("_bar.pth", "/y/src")];
|
||||
let external_directories = [("/x/src/foo.py", ""), ("/y/src/bar.py", "")];
|
||||
|
||||
let TestCase { mut db, .. } = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
db.write_files(external_directories).unwrap();
|
||||
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
let bar_module_name = ModuleName::new_static("bar").unwrap();
|
||||
|
||||
let foo_module = resolve_module(&db, foo_module_name).unwrap();
|
||||
assert_eq!(
|
||||
foo_module.file().path(&db),
|
||||
&FilePath::system("/x/src/foo.py")
|
||||
);
|
||||
|
||||
db.clear_salsa_events();
|
||||
let bar_module = resolve_module(&db, bar_module_name).unwrap();
|
||||
assert_eq!(
|
||||
bar_module.file().path(&db),
|
||||
&FilePath::system("/y/src/bar.py")
|
||||
);
|
||||
let events = db.take_salsa_events();
|
||||
assert_function_query_was_not_run::<editable_install_resolution_paths, _, _>(
|
||||
&db,
|
||||
|res| &res.function,
|
||||
&(),
|
||||
&events,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_pth_file_on_which_module_resolution_depends_invalidates_cache() {
|
||||
const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src")];
|
||||
let x_directory = [("/x/src/foo.py", "")];
|
||||
|
||||
let TestCase {
|
||||
mut db,
|
||||
site_packages,
|
||||
..
|
||||
} = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
db.write_files(x_directory).unwrap();
|
||||
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
let foo_module = resolve_module(&db, foo_module_name.clone()).unwrap();
|
||||
assert_eq!(
|
||||
foo_module.file().path(&db),
|
||||
&FilePath::system("/x/src/foo.py")
|
||||
);
|
||||
|
||||
db.memory_file_system()
|
||||
.remove_file(site_packages.join("_foo.pth"))
|
||||
.unwrap();
|
||||
|
||||
// Why are we touching a random file in the path that's been editably installed,
|
||||
// rather than the `.pth` file, when the `.pth` file is the one that has been deleted?
|
||||
// It's because the `.pth` file isn't directly tracked as a dependency by Salsa
|
||||
// currently (we don't use `system_path_to_file()` to get the file, and we don't use
|
||||
// `source_text()` to read the source of the file). Instead of using these APIs which
|
||||
// would automatically add the existence and contents of the file as a Salsa-tracked
|
||||
// dependency, we use `.report_untracked_read()` to force Salsa to re-parse all
|
||||
// `.pth` files on each new "revision". Making a random modification to a tracked
|
||||
// Salsa file forces a new revision.
|
||||
//
|
||||
// TODO: get rid of the `.report_untracked_read()` call...
|
||||
File::touch_path(&mut db, SystemPath::new("/x/src/foo.py"));
|
||||
|
||||
assert_eq!(resolve_module(&db, foo_module_name.clone()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_editable_install_on_which_module_resolution_depends_invalidates_cache() {
|
||||
const SITE_PACKAGES: &[FileSpec] = &[("_foo.pth", "/x/src")];
|
||||
let x_directory = [("/x/src/foo.py", "")];
|
||||
|
||||
let TestCase { mut db, .. } = TestCaseBuilder::new()
|
||||
.with_site_packages_files(SITE_PACKAGES)
|
||||
.build();
|
||||
|
||||
db.write_files(x_directory).unwrap();
|
||||
|
||||
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
||||
let foo_module = resolve_module(&db, foo_module_name.clone()).unwrap();
|
||||
let src_path = SystemPathBuf::from("/x/src");
|
||||
assert_eq!(
|
||||
foo_module.file().path(&db),
|
||||
&FilePath::System(src_path.join("foo.py"))
|
||||
);
|
||||
|
||||
db.memory_file_system()
|
||||
.remove_file(src_path.join("foo.py"))
|
||||
.unwrap();
|
||||
db.memory_file_system().remove_directory(&src_path).unwrap();
|
||||
File::touch_path(&mut db, &src_path.join("foo.py"));
|
||||
File::touch_path(&mut db, &src_path);
|
||||
assert_eq!(resolve_module(&db, foo_module_name.clone()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_search_paths_added() {
|
||||
let TestCase { db, .. } = TestCaseBuilder::new()
|
||||
.with_src_files(&[("foo.py", "")])
|
||||
.with_site_packages_files(&[("_foo.pth", "/src")])
|
||||
.build();
|
||||
|
||||
let search_paths: Vec<&SearchPathRoot> =
|
||||
module_resolver_settings(&db).search_paths(&db).collect();
|
||||
|
||||
assert!(search_paths.contains(&&Arc::new(
|
||||
ModuleResolutionPathBuf::first_party("/src").unwrap()
|
||||
)));
|
||||
|
||||
assert!(!search_paths.contains(&&Arc::new(
|
||||
ModuleResolutionPathBuf::editable_installation_root(db.system(), "/src").unwrap()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -150,6 +150,10 @@ impl DirectoryEntry {
|
|||
Self { path, file_type }
|
||||
}
|
||||
|
||||
pub fn into_path(self) -> SystemPathBuf {
|
||||
self.path
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &SystemPath {
|
||||
&self.path
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue