mirror of
https://github.com/astral-sh/ruff.git
synced 2025-10-26 18:06:43 +00:00
## Summary We use the `System` abstraction in ty to abstract away the host/system on which ty runs. This has a few benefits: * Tests can run in full isolation using a memory system (that uses an in-memory file system) * The LSP has a custom implementation where `read_to_string` returns the content as seen by the editor (e.g. unsaved changes) instead of always returning the content as it is stored on disk * We don't require any file system polyfills for wasm in the browser However, it does require extra care that we don't accidentally use `std::fs` or `std::env` (etc.) methods in ty's code base (which is very easy). This PR sets up Clippy and disallows the most common methods, instead pointing users towards the corresponding `System` methods. The setup is a bit awkward because clippy doesn't support inheriting configurations. That means, a crate can only override the entire workspace configuration or not at all. The approach taken in this PR is: * Configure the disallowed methods at the workspace level * Allow `disallowed_methods` at the workspace level * Enable the lint at the crate level using the warn attribute (in code) The obvious downside is that it won't work if we ever want to disallow other methods, but we can figure that out once we reach that point. What about false positives: Just add an `allow` and move on with your life :) This isn't something that we have to enforce strictly; the goal is to catch accidental misuse. ## Test Plan Clippy found a place where we incorrectly used `std::fs::read_to_string`
2356 lines
85 KiB
Rust
2356 lines
85 KiB
Rust
/*!
|
|
This module principally provides two routines for resolving a particular module
|
|
name to a `Module`: [`resolve_module`] and [`resolve_real_module`]. You'll
|
|
usually want the former, unless you're certain you want to forbid stubs, in
|
|
which case, use the latter.
|
|
|
|
For implementors, see `import-resolution-diagram.svg` for a flow diagram that
|
|
specifies ty's implementation of Python's import resolution algorithm.
|
|
*/
|
|
|
|
use std::borrow::Cow;
|
|
use std::fmt;
|
|
use std::iter::FusedIterator;
|
|
use std::str::Split;
|
|
|
|
use compact_str::format_compact;
|
|
use rustc_hash::{FxBuildHasher, FxHashSet};
|
|
|
|
use ruff_db::files::{File, FilePath, FileRootKind};
|
|
use ruff_db::system::{DirectoryEntry, System, SystemPath, SystemPathBuf};
|
|
use ruff_db::vendored::VendoredFileSystem;
|
|
use ruff_python_ast::PythonVersion;
|
|
|
|
use crate::db::Db;
|
|
use crate::module_name::ModuleName;
|
|
use crate::module_resolver::typeshed::{TypeshedVersions, vendored_typeshed_versions};
|
|
use crate::{Program, SearchPathSettings};
|
|
|
|
use super::module::{Module, ModuleKind};
|
|
use super::path::{ModulePath, SearchPath, SearchPathValidationError, SystemOrVendoredPathRef};
|
|
|
|
/// Resolves a module name to a module.
|
|
pub fn resolve_module<'db>(db: &'db dyn Db, module_name: &ModuleName) -> Option<Module<'db>> {
|
|
let interned_name = ModuleNameIngredient::new(db, module_name, ModuleResolveMode::StubsAllowed);
|
|
|
|
resolve_module_query(db, interned_name)
|
|
}
|
|
|
|
/// Resolves a module name to a module (stubs not allowed).
|
|
pub fn resolve_real_module<'db>(db: &'db dyn Db, module_name: &ModuleName) -> Option<Module<'db>> {
|
|
let interned_name =
|
|
ModuleNameIngredient::new(db, module_name, ModuleResolveMode::StubsNotAllowed);
|
|
|
|
resolve_module_query(db, interned_name)
|
|
}
|
|
|
|
/// Which files should be visible when doing a module query
|
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, get_size2::GetSize)]
|
|
pub(crate) enum ModuleResolveMode {
|
|
/// Stubs are allowed to appear.
|
|
///
|
|
/// This is the "normal" mode almost everything uses, as type checkers are in fact supposed
|
|
/// to *prefer* stubs over the actual implementations.
|
|
StubsAllowed,
|
|
/// Stubs are not allowed to appear.
|
|
///
|
|
/// This is the "goto definition" mode, where we need to ignore the typing spec and find actual
|
|
/// implementations. When querying searchpaths this also notably replaces typeshed with
|
|
/// the "real" stdlib.
|
|
StubsNotAllowed,
|
|
}
|
|
|
|
#[salsa::interned(heap_size=ruff_memory_usage::heap_size)]
|
|
#[derive(Debug)]
|
|
pub(crate) struct ModuleResolveModeIngredient<'db> {
|
|
mode: ModuleResolveMode,
|
|
}
|
|
|
|
impl ModuleResolveMode {
|
|
fn stubs_allowed(self) -> bool {
|
|
matches!(self, Self::StubsAllowed)
|
|
}
|
|
}
|
|
|
|
/// Salsa query that resolves an interned [`ModuleNameIngredient`] to a module.
|
|
///
|
|
/// This query should not be called directly. Instead, use [`resolve_module`]. It only exists
|
|
/// because Salsa requires the module name to be an ingredient.
|
|
#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)]
|
|
fn resolve_module_query<'db>(
|
|
db: &'db dyn Db,
|
|
module_name: ModuleNameIngredient<'db>,
|
|
) -> Option<Module<'db>> {
|
|
let name = module_name.name(db);
|
|
let mode = module_name.mode(db);
|
|
let _span = tracing::trace_span!("resolve_module", %name).entered();
|
|
|
|
let Some(resolved) = resolve_name(db, name, mode) else {
|
|
tracing::debug!("Module `{name}` not found in search paths");
|
|
return None;
|
|
};
|
|
|
|
let module = match resolved {
|
|
ResolvedName::FileModule(module) => {
|
|
tracing::trace!(
|
|
"Resolved module `{name}` to `{path}`",
|
|
path = module.file.path(db)
|
|
);
|
|
Module::file_module(
|
|
db,
|
|
name.clone(),
|
|
module.kind,
|
|
module.search_path,
|
|
module.file,
|
|
)
|
|
}
|
|
ResolvedName::NamespacePackage => {
|
|
tracing::trace!("Module `{name}` is a namespace package");
|
|
Module::namespace_package(db, name.clone())
|
|
}
|
|
};
|
|
|
|
Some(module)
|
|
}
|
|
|
|
/// Resolves the module for the given path.
|
|
///
|
|
/// Returns `None` if the path is not a module locatable via any of the known search paths.
|
|
#[allow(unused)]
|
|
pub(crate) fn path_to_module<'db>(db: &'db dyn Db, path: &FilePath) -> Option<Module<'db>> {
|
|
// It's not entirely clear on first sight why this method calls `file_to_module` instead of
|
|
// it being the other way round, considering that the first thing that `file_to_module` does
|
|
// is to retrieve the file's path.
|
|
//
|
|
// The reason is that `file_to_module` is a tracked Salsa query and salsa queries require that
|
|
// all arguments are Salsa ingredients (something stored in Salsa). `Path`s aren't salsa ingredients but
|
|
// `VfsFile` is. So what we do here is to retrieve the `path`'s `VfsFile` so that we can make
|
|
// use of Salsa's caching and invalidation.
|
|
let file = path.to_file(db)?;
|
|
file_to_module(db, file)
|
|
}
|
|
|
|
/// Resolves the module for the file with the given id.
|
|
///
|
|
/// Returns `None` if the file is not a module locatable via any of the known search paths.
|
|
#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)]
|
|
pub(crate) fn file_to_module(db: &dyn Db, file: File) -> Option<Module<'_>> {
|
|
let _span = tracing::trace_span!("file_to_module", ?file).entered();
|
|
|
|
let path = SystemOrVendoredPathRef::try_from_file(db, file)?;
|
|
|
|
let module_name = search_paths(db, ModuleResolveMode::StubsAllowed).find_map(|candidate| {
|
|
let relative_path = match path {
|
|
SystemOrVendoredPathRef::System(path) => candidate.relativize_system_path(path),
|
|
SystemOrVendoredPathRef::Vendored(path) => candidate.relativize_vendored_path(path),
|
|
}?;
|
|
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
|
|
// root paths, but that the module corresponding to `path` is in a lower priority search path,
|
|
// in which case we ignore it.
|
|
let module = resolve_module(db, &module_name)?;
|
|
let module_file = module.file(db)?;
|
|
|
|
if file.path(db) == module_file.path(db) {
|
|
Some(module)
|
|
} else {
|
|
// This path is for a module with the same name but with a different precedence. For example:
|
|
// ```
|
|
// src/foo.py
|
|
// src/foo/__init__.py
|
|
// ```
|
|
// The module name of `src/foo.py` is `foo`, but the module loaded by Python is `src/foo/__init__.py`.
|
|
// That means we need to ignore `src/foo.py` even though it resolves to the same module name.
|
|
None
|
|
}
|
|
}
|
|
|
|
pub(crate) fn search_paths(db: &dyn Db, resolve_mode: ModuleResolveMode) -> SearchPathIterator<'_> {
|
|
Program::get(db).search_paths(db).iter(db, resolve_mode)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)]
|
|
pub struct SearchPaths {
|
|
/// Search paths that have been statically determined purely from reading
|
|
/// ty's configuration settings. These shouldn't ever change unless the
|
|
/// config settings themselves change.
|
|
static_paths: Vec<SearchPath>,
|
|
|
|
/// Path to typeshed, which should come immediately after static paths.
|
|
///
|
|
/// This can currently only be None if the `SystemPath` this points to is already in `static_paths`.
|
|
stdlib_path: Option<SearchPath>,
|
|
|
|
/// Path to the real stdlib, this replaces typeshed (`stdlib_path`) for goto-definition searches
|
|
/// ([`ModuleResolveMode::StubsNotAllowed`]).
|
|
real_stdlib_path: Option<SearchPath>,
|
|
|
|
/// site-packages paths are not included in the above fields:
|
|
/// if there are multiple site-packages paths, editable installations can appear
|
|
/// *between* the site-packages paths on `sys.path` at runtime.
|
|
/// That means we can't know where a second or third `site-packages` path should sit
|
|
/// in terms of module-resolution priority until we've discovered the editable installs
|
|
/// for the first `site-packages` path
|
|
site_packages: Vec<SearchPath>,
|
|
|
|
typeshed_versions: TypeshedVersions,
|
|
}
|
|
|
|
impl SearchPaths {
|
|
/// 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].
|
|
///
|
|
/// [module resolution order]: https://typing.python.org/en/latest/spec/distributing.html#import-resolution-ordering
|
|
pub(crate) fn from_settings(
|
|
settings: &SearchPathSettings,
|
|
system: &dyn System,
|
|
vendored: &VendoredFileSystem,
|
|
) -> Result<Self, SearchPathValidationError> {
|
|
fn canonicalize(path: &SystemPath, system: &dyn System) -> SystemPathBuf {
|
|
system
|
|
.canonicalize_path(path)
|
|
.unwrap_or_else(|_| path.to_path_buf())
|
|
}
|
|
|
|
let SearchPathSettings {
|
|
extra_paths,
|
|
src_roots,
|
|
custom_typeshed: typeshed,
|
|
site_packages_paths,
|
|
real_stdlib_path,
|
|
} = settings;
|
|
|
|
let mut static_paths = vec![];
|
|
|
|
for path in extra_paths {
|
|
let path = canonicalize(path, system);
|
|
tracing::debug!("Adding extra search-path `{path}`");
|
|
|
|
static_paths.push(SearchPath::extra(system, path)?);
|
|
}
|
|
|
|
for src_root in src_roots {
|
|
tracing::debug!("Adding first-party search path `{src_root}`");
|
|
static_paths.push(SearchPath::first_party(system, src_root.to_path_buf())?);
|
|
}
|
|
|
|
let (typeshed_versions, stdlib_path) = if let Some(typeshed) = typeshed {
|
|
let typeshed = canonicalize(typeshed, system);
|
|
tracing::debug!("Adding custom-stdlib search path `{typeshed}`");
|
|
|
|
let versions_path = typeshed.join("stdlib/VERSIONS");
|
|
|
|
let versions_content = system.read_to_string(&versions_path).map_err(|error| {
|
|
SearchPathValidationError::FailedToReadVersionsFile {
|
|
path: versions_path,
|
|
error,
|
|
}
|
|
})?;
|
|
|
|
let parsed: TypeshedVersions = versions_content.parse()?;
|
|
|
|
let search_path = SearchPath::custom_stdlib(system, &typeshed)?;
|
|
|
|
(parsed, search_path)
|
|
} else {
|
|
tracing::debug!("Using vendored stdlib");
|
|
(
|
|
vendored_typeshed_versions(vendored),
|
|
SearchPath::vendored_stdlib(),
|
|
)
|
|
};
|
|
|
|
let real_stdlib_path = if let Some(path) = real_stdlib_path {
|
|
Some(SearchPath::real_stdlib(system, path.clone())?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut site_packages: Vec<_> = Vec::with_capacity(site_packages_paths.len());
|
|
|
|
for path in site_packages_paths {
|
|
tracing::debug!("Adding site-packages search path `{path}`");
|
|
site_packages.push(SearchPath::site_packages(system, path.clone())?);
|
|
}
|
|
|
|
// TODO vendor typeshed's third-party stubs as well as the stdlib and
|
|
// fallback to them as a final step?
|
|
//
|
|
// See: <https://github.com/astral-sh/ruff/pull/19620#discussion_r2240609135>
|
|
|
|
// Filter out module resolution paths that point to the same directory
|
|
// on disk (the same invariant maintained by [`sys.path` at runtime]).
|
|
// (Paths may, however, *overlap* -- e.g. you could have both `src/`
|
|
// and `src/foo` as module resolution paths simultaneously.)
|
|
//
|
|
// This code doesn't use an `IndexSet` because the key is the system
|
|
// path and not the search root.
|
|
//
|
|
// [`sys.path` at runtime]: https://docs.python.org/3/library/site.html#module-site
|
|
let mut seen_paths = FxHashSet::with_capacity_and_hasher(static_paths.len(), FxBuildHasher);
|
|
|
|
static_paths.retain(|path| {
|
|
if let Some(path) = path.as_system_path() {
|
|
seen_paths.insert(path.to_path_buf())
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
|
|
// Users probably shouldn't do this but... if they've shadowed their stdlib we should deduplicate it away.
|
|
// This notably will mess up anything that checks if a search path "is the standard library" as we won't
|
|
// "remember" that fact for static paths.
|
|
//
|
|
// (We used to shove these into static_paths, so the above retain implicitly did this. I am opting to
|
|
// preserve this behaviour to avoid getting into the weeds of corner cases.)
|
|
let stdlib_path_is_shadowed = stdlib_path
|
|
.as_system_path()
|
|
.map(|path| seen_paths.contains(path))
|
|
.unwrap_or(false);
|
|
let real_stdlib_path_is_shadowed = real_stdlib_path
|
|
.as_ref()
|
|
.and_then(SearchPath::as_system_path)
|
|
.map(|path| seen_paths.contains(path))
|
|
.unwrap_or(false);
|
|
|
|
let stdlib_path = if stdlib_path_is_shadowed {
|
|
None
|
|
} else {
|
|
Some(stdlib_path)
|
|
};
|
|
let real_stdlib_path = if real_stdlib_path_is_shadowed {
|
|
None
|
|
} else {
|
|
real_stdlib_path
|
|
};
|
|
|
|
Ok(SearchPaths {
|
|
static_paths,
|
|
stdlib_path,
|
|
real_stdlib_path,
|
|
site_packages,
|
|
typeshed_versions,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn try_register_static_roots(&self, db: &dyn Db) {
|
|
let files = db.files();
|
|
for path in self.static_paths.iter().chain(self.site_packages.iter()) {
|
|
if let Some(system_path) = path.as_system_path() {
|
|
if !path.is_first_party() {
|
|
files.try_add_root(db, system_path, FileRootKind::LibrarySearchPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn iter<'a>(
|
|
&'a self,
|
|
db: &'a dyn Db,
|
|
mode: ModuleResolveMode,
|
|
) -> SearchPathIterator<'a> {
|
|
let stdlib_path = self.stdlib(mode);
|
|
SearchPathIterator {
|
|
db,
|
|
static_paths: self.static_paths.iter(),
|
|
stdlib_path,
|
|
dynamic_paths: None,
|
|
mode: ModuleResolveModeIngredient::new(db, mode),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn stdlib(&self, mode: ModuleResolveMode) -> Option<&SearchPath> {
|
|
match mode {
|
|
ModuleResolveMode::StubsAllowed => self.stdlib_path.as_ref(),
|
|
ModuleResolveMode::StubsNotAllowed => self.real_stdlib_path.as_ref(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn custom_stdlib(&self) -> Option<&SystemPath> {
|
|
self.stdlib_path
|
|
.as_ref()
|
|
.and_then(SearchPath::as_system_path)
|
|
}
|
|
|
|
pub(crate) fn typeshed_versions(&self) -> &TypeshedVersions {
|
|
&self.typeshed_versions
|
|
}
|
|
}
|
|
|
|
/// Collect all dynamic search paths. For each `site-packages` path:
|
|
/// - Collect that `site-packages` path
|
|
/// - Collect any search paths listed in `.pth` files in that `site-packages` directory
|
|
/// due to editable installations of third-party packages.
|
|
///
|
|
/// The editable-install search paths for the first `site-packages` directory
|
|
/// should come between the two `site-packages` directories when it comes to
|
|
/// module-resolution priority.
|
|
#[salsa::tracked(returns(deref), heap_size=ruff_memory_usage::heap_size)]
|
|
pub(crate) fn dynamic_resolution_paths<'db>(
|
|
db: &'db dyn Db,
|
|
mode: ModuleResolveModeIngredient<'db>,
|
|
) -> Vec<SearchPath> {
|
|
tracing::debug!("Resolving dynamic module resolution paths");
|
|
|
|
let SearchPaths {
|
|
static_paths,
|
|
stdlib_path,
|
|
site_packages,
|
|
typeshed_versions: _,
|
|
real_stdlib_path,
|
|
} = Program::get(db).search_paths(db);
|
|
|
|
let mut dynamic_paths = Vec::new();
|
|
|
|
if site_packages.is_empty() {
|
|
return dynamic_paths;
|
|
}
|
|
|
|
let mut existing_paths: FxHashSet<_> = static_paths
|
|
.iter()
|
|
.filter_map(|path| path.as_system_path())
|
|
.map(Cow::Borrowed)
|
|
.collect();
|
|
|
|
// Use the `ModuleResolveMode` to determine which stdlib (if any) to mark as existing
|
|
let stdlib = match mode.mode(db) {
|
|
ModuleResolveMode::StubsAllowed => stdlib_path,
|
|
ModuleResolveMode::StubsNotAllowed => real_stdlib_path,
|
|
};
|
|
if let Some(path) = stdlib.as_ref().and_then(SearchPath::as_system_path) {
|
|
existing_paths.insert(Cow::Borrowed(path));
|
|
}
|
|
|
|
let files = db.files();
|
|
let system = db.system();
|
|
|
|
for site_packages_search_path in site_packages {
|
|
let site_packages_dir = site_packages_search_path
|
|
.as_system_path()
|
|
.expect("Expected site package path to be a system path");
|
|
let site_packages_dir = system
|
|
.canonicalize_path(site_packages_dir)
|
|
.unwrap_or_else(|_| site_packages_dir.to_path_buf());
|
|
|
|
if !existing_paths.insert(Cow::Owned(site_packages_dir.clone())) {
|
|
continue;
|
|
}
|
|
|
|
let site_packages_root = files
|
|
.root(db, &site_packages_dir)
|
|
.expect("Site-package root to have been created");
|
|
|
|
// 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, add a dependency on the
|
|
// site-package directory's revision.
|
|
site_packages_root.revision(db);
|
|
|
|
dynamic_paths.push(site_packages_search_path.clone());
|
|
|
|
// 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 pth_file_iterator = match PthFileIterator::new(db, &site_packages_dir) {
|
|
Ok(iterator) => iterator,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to search for editable installation in {site_packages_dir}: {error}"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// 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_unstable_by(|a, b| a.path.cmp(&b.path));
|
|
|
|
let installations = all_pth_files.iter().flat_map(PthFile::items);
|
|
|
|
for installation in installations {
|
|
let installation = system
|
|
.canonicalize_path(&installation)
|
|
.unwrap_or(installation);
|
|
|
|
if existing_paths.insert(Cow::Owned(installation.clone())) {
|
|
match SearchPath::editable(system, installation.clone()) {
|
|
Ok(search_path) => {
|
|
tracing::debug!(
|
|
"Adding editable installation to module resolution path {path}",
|
|
path = installation
|
|
);
|
|
dynamic_paths.push(search_path);
|
|
}
|
|
|
|
Err(error) => {
|
|
tracing::debug!("Skipping editable installation: {error}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
///
|
|
/// [`sys.path` at runtime]: https://docs.python.org/3/library/site.html#module-site
|
|
pub(crate) struct SearchPathIterator<'db> {
|
|
db: &'db dyn Db,
|
|
static_paths: std::slice::Iter<'db, SearchPath>,
|
|
stdlib_path: Option<&'db SearchPath>,
|
|
dynamic_paths: Option<std::slice::Iter<'db, SearchPath>>,
|
|
mode: ModuleResolveModeIngredient<'db>,
|
|
}
|
|
|
|
impl<'db> Iterator for SearchPathIterator<'db> {
|
|
type Item = &'db SearchPath;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let SearchPathIterator {
|
|
db,
|
|
static_paths,
|
|
stdlib_path,
|
|
mode,
|
|
dynamic_paths,
|
|
} = self;
|
|
|
|
static_paths
|
|
.next()
|
|
.or_else(|| stdlib_path.take())
|
|
.or_else(|| {
|
|
dynamic_paths
|
|
.get_or_insert_with(|| dynamic_resolution_paths(*db, *mode).iter())
|
|
.next()
|
|
})
|
|
}
|
|
}
|
|
|
|
impl FusedIterator for SearchPathIterator<'_> {}
|
|
|
|
/// 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> {
|
|
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 items(&'db self) -> impl Iterator<Item = SystemPathBuf> + 'db {
|
|
let PthFile {
|
|
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;
|
|
}
|
|
|
|
Some(SystemPath::absolute(line, site_packages))
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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 contents = match system.read_to_string(&path) {
|
|
Ok(contents) => contents,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to read .pth file `{path}`: {error}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
return Some(PthFile {
|
|
path,
|
|
contents,
|
|
site_packages,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A thin wrapper around `ModuleName` to make it a Salsa ingredient.
|
|
///
|
|
/// This is needed because Salsa requires that all query arguments are salsa ingredients.
|
|
#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)]
|
|
struct ModuleNameIngredient<'db> {
|
|
#[returns(ref)]
|
|
pub(super) name: ModuleName,
|
|
pub(super) mode: ModuleResolveMode,
|
|
}
|
|
|
|
/// Returns `true` if the module name refers to a standard library module which can't be shadowed
|
|
/// by a first-party module.
|
|
///
|
|
/// This includes "builtin" modules, which can never be shadowed at runtime either, as well as the
|
|
/// `types` module, which tends to be imported early in Python startup, so can't be consistently
|
|
/// shadowed, and is important to type checking.
|
|
pub(super) fn is_non_shadowable(minor_version: u8, module_name: &str) -> bool {
|
|
module_name == "types" || ruff_python_stdlib::sys::is_builtin_module(minor_version, module_name)
|
|
}
|
|
|
|
/// Given a module name and a list of search paths in which to lookup modules,
|
|
/// attempt to resolve the module name
|
|
fn resolve_name(db: &dyn Db, name: &ModuleName, mode: ModuleResolveMode) -> Option<ResolvedName> {
|
|
let program = Program::get(db);
|
|
let python_version = program.python_version(db);
|
|
let resolver_state = ResolverContext::new(db, python_version, mode);
|
|
let is_non_shadowable = is_non_shadowable(python_version.minor, name.as_str());
|
|
|
|
let name = RelaxedModuleName::new(name);
|
|
let stub_name = name.to_stub_package();
|
|
let mut is_namespace_package = false;
|
|
|
|
for search_path in search_paths(db, mode) {
|
|
// When a builtin module is imported, standard module resolution is bypassed:
|
|
// the module name always resolves to the stdlib module,
|
|
// even if there's a module of the same name in the first-party root
|
|
// (which would normally result in the stdlib module being overridden).
|
|
// TODO: offer a diagnostic if there is a first-party module of the same name
|
|
if is_non_shadowable && !search_path.is_standard_library() {
|
|
continue;
|
|
}
|
|
|
|
if !search_path.is_standard_library() && resolver_state.mode.stubs_allowed() {
|
|
match resolve_name_in_search_path(&resolver_state, &stub_name, search_path) {
|
|
Ok((package_kind, _, ResolvedName::FileModule(module))) => {
|
|
if package_kind.is_root() && module.kind.is_module() {
|
|
tracing::trace!(
|
|
"Search path `{search_path}` contains a module \
|
|
named `{stub_name}` but a standalone module isn't a valid stub."
|
|
);
|
|
} else {
|
|
return Some(ResolvedName::FileModule(module));
|
|
}
|
|
}
|
|
Ok((_, _, ResolvedName::NamespacePackage)) => {
|
|
is_namespace_package = true;
|
|
}
|
|
Err((PackageKind::Root, _)) => {
|
|
tracing::trace!(
|
|
"Search path `{search_path}` contains no stub package named `{stub_name}`."
|
|
);
|
|
}
|
|
Err((PackageKind::Regular, PyTyped::Partial)) => {
|
|
tracing::trace!(
|
|
"Stub-package in `{search_path}` doesn't contain module: \
|
|
`{name}` but it is a partial package, keep going."
|
|
);
|
|
// stub exists, but the module doesn't. But this is a partial package,
|
|
// fall through to looking for a non-stub package
|
|
}
|
|
Err((PackageKind::Regular, _)) => {
|
|
tracing::trace!(
|
|
"Stub-package in `{search_path}` doesn't contain module: `{name}`"
|
|
);
|
|
// stub exists, but the module doesn't.
|
|
return None;
|
|
}
|
|
Err((PackageKind::Namespace, _)) => {
|
|
tracing::trace!(
|
|
"Stub-package in `{search_path}` doesn't contain module: \
|
|
`{name}` but it is a namespace package, keep going."
|
|
);
|
|
// stub exists, but the module doesn't. But this is a namespace package,
|
|
// fall through to looking for a non-stub package
|
|
}
|
|
}
|
|
}
|
|
|
|
match resolve_name_in_search_path(&resolver_state, &name, search_path) {
|
|
Ok((_, _, ResolvedName::FileModule(module))) => {
|
|
return Some(ResolvedName::FileModule(module));
|
|
}
|
|
Ok((_, _, ResolvedName::NamespacePackage)) => {
|
|
is_namespace_package = true;
|
|
}
|
|
Err(kind) => match kind {
|
|
(PackageKind::Root, _) => {
|
|
tracing::trace!(
|
|
"Search path `{search_path}` contains no package named `{name}`."
|
|
);
|
|
}
|
|
(PackageKind::Regular, PyTyped::Partial) => {
|
|
tracing::trace!(
|
|
"Package in `{search_path}` doesn't contain module: \
|
|
`{name}` but it is a partial package, keep going."
|
|
);
|
|
}
|
|
(PackageKind::Regular, _) => {
|
|
// For regular packages, don't search the next search path. All files of that
|
|
// package must be in the same location
|
|
tracing::trace!("Package in `{search_path}` doesn't contain module: `{name}`");
|
|
return None;
|
|
}
|
|
(PackageKind::Namespace, _) => {
|
|
tracing::trace!(
|
|
"Package in `{search_path}` doesn't contain module: \
|
|
`{name}` but it is a namespace package, keep going."
|
|
);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
if is_namespace_package {
|
|
return Some(ResolvedName::NamespacePackage);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ResolvedName {
|
|
/// A module that resolves to a file.
|
|
FileModule(ResolvedFileModule),
|
|
|
|
/// The module name resolved to a namespace package.
|
|
///
|
|
/// For example, `from opentelemetry import trace, metrics` where
|
|
/// `opentelemetry` is a namespace package (and `trace` and `metrics` are
|
|
/// sub packages).
|
|
NamespacePackage,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ResolvedFileModule {
|
|
kind: ModuleKind,
|
|
search_path: SearchPath,
|
|
file: File,
|
|
}
|
|
|
|
/// Attempts to resolve a module name in a particular search path.
|
|
///
|
|
/// `search_path` should be the directory to start looking for the module.
|
|
///
|
|
/// `name` should be a complete non-empty module name, e.g, `foo` or
|
|
/// `foo.bar.baz`.
|
|
///
|
|
/// Upon success, this returns the kind of the parent package (root, regular
|
|
/// package or namespace package) along with the resolved details of the
|
|
/// module: its kind (single-file module or package), the search path in
|
|
/// which it was found (guaranteed to be equal to the one given) and the
|
|
/// corresponding `File`.
|
|
///
|
|
/// Upon error, the kind of the parent package is returned.
|
|
fn resolve_name_in_search_path(
|
|
context: &ResolverContext,
|
|
name: &RelaxedModuleName,
|
|
search_path: &SearchPath,
|
|
) -> Result<(PackageKind, PyTyped, ResolvedName), (PackageKind, PyTyped)> {
|
|
let mut components = name.components();
|
|
let module_name = components.next_back().unwrap();
|
|
|
|
let resolved_package = resolve_package(search_path, components, context)?;
|
|
|
|
let mut package_path = resolved_package.path;
|
|
|
|
package_path.push(module_name);
|
|
|
|
// Check for a regular package first (highest priority)
|
|
package_path.push("__init__");
|
|
if let Some(regular_package) = resolve_file_module(&package_path, context) {
|
|
return Ok((
|
|
resolved_package.kind,
|
|
resolved_package.typed,
|
|
ResolvedName::FileModule(ResolvedFileModule {
|
|
search_path: search_path.clone(),
|
|
kind: ModuleKind::Package,
|
|
file: regular_package,
|
|
}),
|
|
));
|
|
}
|
|
|
|
// Check for a file module next
|
|
package_path.pop();
|
|
|
|
if let Some(file_module) = resolve_file_module(&package_path, context) {
|
|
return Ok((
|
|
resolved_package.kind,
|
|
resolved_package.typed,
|
|
ResolvedName::FileModule(ResolvedFileModule {
|
|
file: file_module,
|
|
kind: ModuleKind::Module,
|
|
search_path: search_path.clone(),
|
|
}),
|
|
));
|
|
}
|
|
|
|
// Last resort, check if a folder with the given name exists. If so,
|
|
// then this is a namespace package. We need to skip this check for
|
|
// typeshed because the `resolve_file_module` can also return `None` if the
|
|
// `__init__.py` exists but isn't available for the current Python version.
|
|
// Let's assume that the `xml` module is only available on Python 3.11+ and
|
|
// we're resolving for Python 3.10:
|
|
//
|
|
// * `resolve_file_module("xml/__init__.pyi")` returns `None` even though
|
|
// the file exists but the module isn't available for the current Python
|
|
// version.
|
|
// * The check here would now return `true` because the `xml` directory
|
|
// exists, resulting in a false positive for a namespace package.
|
|
//
|
|
// Since typeshed doesn't use any namespace packages today (May 2025),
|
|
// simply skip this check which also helps performance. If typeshed
|
|
// ever uses namespace packages, ensure that this check also takes the
|
|
// `VERSIONS` file into consideration.
|
|
if !search_path.is_standard_library() && package_path.is_directory(context) {
|
|
if let Some(path) = package_path.to_system_path() {
|
|
let system = context.db.system();
|
|
if system.case_sensitivity().is_case_sensitive()
|
|
|| system.path_exists_case_sensitive(
|
|
&path,
|
|
package_path.search_path().as_system_path().unwrap(),
|
|
)
|
|
{
|
|
return Ok((
|
|
resolved_package.kind,
|
|
resolved_package.typed,
|
|
ResolvedName::NamespacePackage,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err((resolved_package.kind, resolved_package.typed))
|
|
}
|
|
|
|
/// If `module` exists on disk with either a `.pyi` or `.py` extension,
|
|
/// return the [`File`] corresponding to that path.
|
|
///
|
|
/// `.pyi` files take priority, as they always have priority when
|
|
/// resolving modules.
|
|
pub(super) fn resolve_file_module(
|
|
module: &ModulePath,
|
|
resolver_state: &ResolverContext,
|
|
) -> Option<File> {
|
|
// Stubs have precedence over source files
|
|
let stub_file = if resolver_state.mode.stubs_allowed() {
|
|
module.with_pyi_extension().to_file(resolver_state)
|
|
} else {
|
|
None
|
|
};
|
|
let file = stub_file.or_else(|| {
|
|
module
|
|
.with_py_extension()
|
|
.and_then(|path| path.to_file(resolver_state))
|
|
})?;
|
|
|
|
// For system files, test if the path has the correct casing.
|
|
// We can skip this step for vendored files or virtual files because
|
|
// those file systems are case sensitive (we wouldn't get to this point).
|
|
if let Some(path) = file.path(resolver_state.db).as_system_path() {
|
|
let system = resolver_state.db.system();
|
|
if !system.case_sensitivity().is_case_sensitive()
|
|
&& !system
|
|
.path_exists_case_sensitive(path, module.search_path().as_system_path().unwrap())
|
|
{
|
|
return None;
|
|
}
|
|
}
|
|
|
|
Some(file)
|
|
}
|
|
|
|
/// Attempt to resolve the parent package of a module.
|
|
///
|
|
/// `module_search_path` should be the directory to start looking for the
|
|
/// parent package.
|
|
///
|
|
/// `components` should be the full module name of the parent package. This
|
|
/// specifically should not include the basename of the module. So e.g.,
|
|
/// for `foo.bar.baz`, `components` should be `[foo, bar]`. It follows that
|
|
/// `components` may be empty (in which case, the parent package is the root).
|
|
///
|
|
/// Upon success, the path to the package and its "kind" (root, regular or
|
|
/// namespace) is returned. Upon error, the kind of the package is still
|
|
/// returned based on how many components were found and whether `__init__.py`
|
|
/// is present.
|
|
fn resolve_package<'a, 'db, I>(
|
|
module_search_path: &SearchPath,
|
|
components: I,
|
|
resolver_state: &ResolverContext<'db>,
|
|
) -> Result<ResolvedPackage, (PackageKind, PyTyped)>
|
|
where
|
|
I: Iterator<Item = &'a str>,
|
|
{
|
|
let mut package_path = module_search_path.to_module_path();
|
|
|
|
// `true` if inside a folder that is a namespace package (has no `__init__.py`).
|
|
// Namespace packages are special because they can be spread across multiple search paths.
|
|
// https://peps.python.org/pep-0420/
|
|
let mut in_namespace_package = false;
|
|
|
|
// `true` if resolving a sub-package. For example, `true` when resolving `bar` of `foo.bar`.
|
|
let mut in_sub_package = false;
|
|
|
|
let mut typed = package_path.py_typed(resolver_state);
|
|
|
|
// For `foo.bar.baz`, test that `foo` and `bar` both contain a `__init__.py`.
|
|
for folder in components {
|
|
package_path.push(folder);
|
|
typed = package_path.py_typed(resolver_state).inherit_parent(typed);
|
|
|
|
let is_regular_package = package_path.is_regular_package(resolver_state);
|
|
|
|
if is_regular_package {
|
|
in_namespace_package = false;
|
|
} else if package_path.is_directory(resolver_state)
|
|
// Pure modules hide namespace packages with the same name
|
|
&& resolve_file_module(&package_path, resolver_state).is_none()
|
|
{
|
|
// A directory without an `__init__.py(i)` is a namespace package,
|
|
// continue with the next folder.
|
|
in_namespace_package = true;
|
|
} else if in_namespace_package {
|
|
// Package not found but it is part of a namespace package.
|
|
return Err((PackageKind::Namespace, typed));
|
|
} else if in_sub_package {
|
|
// A regular sub package wasn't found.
|
|
return Err((PackageKind::Regular, typed));
|
|
} else {
|
|
// We couldn't find `foo` for `foo.bar.baz`, search the next search path.
|
|
return Err((PackageKind::Root, typed));
|
|
}
|
|
|
|
in_sub_package = true;
|
|
}
|
|
|
|
let kind = if in_namespace_package {
|
|
PackageKind::Namespace
|
|
} else if in_sub_package {
|
|
PackageKind::Regular
|
|
} else {
|
|
PackageKind::Root
|
|
};
|
|
|
|
Ok(ResolvedPackage {
|
|
kind,
|
|
path: package_path,
|
|
typed,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ResolvedPackage {
|
|
path: ModulePath,
|
|
kind: PackageKind,
|
|
typed: PyTyped,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
|
enum PackageKind {
|
|
/// A root package or module. E.g. `foo` in `foo.bar.baz` or just `foo`.
|
|
Root,
|
|
|
|
/// A regular sub-package where the parent contains an `__init__.py`.
|
|
///
|
|
/// For example, `bar` in `foo.bar` when the `foo` directory contains an `__init__.py`.
|
|
Regular,
|
|
|
|
/// A sub-package in a namespace package. A namespace package is a package
|
|
/// without an `__init__.py`.
|
|
///
|
|
/// For example, `bar` in `foo.bar` if the `foo` directory contains no
|
|
/// `__init__.py`.
|
|
Namespace,
|
|
}
|
|
|
|
impl PackageKind {
|
|
pub(crate) const fn is_root(self) -> bool {
|
|
matches!(self, PackageKind::Root)
|
|
}
|
|
}
|
|
|
|
/// Info about the `py.typed` file for this package
|
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
|
pub(crate) enum PyTyped {
|
|
/// No `py.typed` was found
|
|
Untyped,
|
|
/// A `py.typed` was found containing "partial"
|
|
Partial,
|
|
/// A `py.typed` was found (not partial)
|
|
Full,
|
|
}
|
|
|
|
impl PyTyped {
|
|
/// Inherit py.typed info from the parent package
|
|
///
|
|
/// > This marker applies recursively: if a top-level package includes it,
|
|
/// > all its sub-packages MUST support type checking as well.
|
|
///
|
|
/// This implementation implies that once a `py.typed` is specified
|
|
/// all child packages inherit it, so they can never become Untyped.
|
|
/// However they can override whether that's Full or Partial by
|
|
/// redeclaring a `py.typed` file of their own.
|
|
fn inherit_parent(self, parent: Self) -> Self {
|
|
if self == Self::Untyped { parent } else { self }
|
|
}
|
|
}
|
|
|
|
pub(super) struct ResolverContext<'db> {
|
|
pub(super) db: &'db dyn Db,
|
|
pub(super) python_version: PythonVersion,
|
|
pub(super) mode: ModuleResolveMode,
|
|
}
|
|
|
|
impl<'db> ResolverContext<'db> {
|
|
pub(super) fn new(
|
|
db: &'db dyn Db,
|
|
python_version: PythonVersion,
|
|
mode: ModuleResolveMode,
|
|
) -> Self {
|
|
Self {
|
|
db,
|
|
python_version,
|
|
mode,
|
|
}
|
|
}
|
|
|
|
pub(super) fn vendored(&self) -> &VendoredFileSystem {
|
|
self.db.vendored()
|
|
}
|
|
}
|
|
|
|
/// A [`ModuleName`] but with relaxed semantics to allow `<package>-stubs.path`
|
|
#[derive(Debug)]
|
|
struct RelaxedModuleName(compact_str::CompactString);
|
|
|
|
impl RelaxedModuleName {
|
|
fn new(name: &ModuleName) -> Self {
|
|
Self(name.as_str().into())
|
|
}
|
|
|
|
fn components(&self) -> Split<'_, char> {
|
|
self.0.split('.')
|
|
}
|
|
|
|
fn to_stub_package(&self) -> Self {
|
|
if let Some((package, rest)) = self.0.split_once('.') {
|
|
Self(format_compact!("{package}-stubs.{rest}"))
|
|
} else {
|
|
Self(format_compact!("{package}-stubs", package = self.0))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for RelaxedModuleName {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
self.0.fmt(f)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "These are tests, so it's fine to do I/O by-passing System."
|
|
)]
|
|
use ruff_db::Db;
|
|
use ruff_db::files::{File, FilePath, system_path_to_file};
|
|
use ruff_db::system::{DbWithTestSystem as _, DbWithWritableSystem as _};
|
|
use ruff_db::testing::assert_function_query_was_not_run;
|
|
use ruff_python_ast::PythonVersion;
|
|
|
|
use crate::db::tests::TestDb;
|
|
use crate::module_name::ModuleName;
|
|
use crate::module_resolver::module::ModuleKind;
|
|
use crate::module_resolver::testing::{FileSpec, MockedTypeshed, TestCase, TestCaseBuilder};
|
|
use crate::{ProgramSettings, PythonPlatform, PythonVersionWithSource};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn first_party_module() {
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new()
|
|
.with_src_files(&[("foo.py", "print('Hello, world!')")])
|
|
.build();
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
let foo_module = resolve_module(&db, &foo_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&foo_module),
|
|
resolve_module(&db, &foo_module_name).as_ref()
|
|
);
|
|
|
|
assert_eq!("foo", foo_module.name(&db));
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(ModuleKind::Module, foo_module.kind(&db));
|
|
|
|
let expected_foo_path = src.join("foo.py");
|
|
assert_eq!(&expected_foo_path, foo_module.file(&db).unwrap().path(&db));
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(expected_foo_path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stubs_over_module_source() {
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new()
|
|
.with_src_files(&[("foo.py", ""), ("foo.pyi", "")])
|
|
.build();
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
let foo_module = resolve_module(&db, &foo_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&foo_module),
|
|
resolve_module(&db, &foo_module_name).as_ref()
|
|
);
|
|
|
|
assert_eq!("foo", foo_module.name(&db));
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(ModuleKind::Module, foo_module.kind(&db));
|
|
|
|
let expected_foo_path = src.join("foo.pyi");
|
|
assert_eq!(&expected_foo_path, foo_module.file(&db).unwrap().path(&db));
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(expected_foo_path))
|
|
);
|
|
}
|
|
|
|
/// Tests precedence when there is a package and a sibling stub file.
|
|
///
|
|
/// NOTE: I am unsure if this is correct. I wrote this test to match
|
|
/// behavior while implementing "list modules." Notably, in this case, the
|
|
/// regular source file gets priority. But in `stubs_over_module_source`
|
|
/// above, the stub file gets priority.
|
|
#[test]
|
|
fn stubs_over_package_source() {
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new()
|
|
.with_src_files(&[("foo/__init__.py", ""), ("foo.pyi", "")])
|
|
.build();
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
let foo_module = resolve_module(&db, &foo_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&foo_module),
|
|
resolve_module(&db, &foo_module_name).as_ref()
|
|
);
|
|
|
|
assert_eq!("foo", foo_module.name(&db));
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(ModuleKind::Package, foo_module.kind(&db));
|
|
|
|
let expected_foo_path = src.join("foo/__init__.py");
|
|
assert_eq!(&expected_foo_path, foo_module.file(&db).unwrap().path(&db));
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(expected_foo_path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builtins_vendored() {
|
|
let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
|
|
.with_vendored_typeshed()
|
|
.with_src_files(&[("builtins.py", "FOOOO = 42")])
|
|
.build();
|
|
|
|
let builtins_module_name = ModuleName::new_static("builtins").unwrap();
|
|
let builtins = resolve_module(&db, &builtins_module_name).expect("builtins to resolve");
|
|
|
|
assert_eq!(
|
|
builtins.file(&db).unwrap().path(&db),
|
|
&stdlib.join("builtins.pyi")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builtins_custom() {
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: &[("builtins.pyi", "def min(a, b): ...")],
|
|
versions: "builtins: 3.8-",
|
|
};
|
|
|
|
const SRC: &[FileSpec] = &[("builtins.py", "FOOOO = 42")];
|
|
|
|
let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
|
|
.with_src_files(SRC)
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let builtins_module_name = ModuleName::new_static("builtins").unwrap();
|
|
let builtins = resolve_module(&db, &builtins_module_name).expect("builtins to resolve");
|
|
|
|
assert_eq!(
|
|
builtins.file(&db).unwrap().path(&db),
|
|
&stdlib.join("builtins.pyi")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stdlib() {
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
|
|
versions: "functools: 3.8-",
|
|
};
|
|
|
|
let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&functools_module),
|
|
resolve_module(&db, &functools_module_name).as_ref()
|
|
);
|
|
|
|
assert_eq!(&stdlib, functools_module.search_path(&db).unwrap());
|
|
assert_eq!(ModuleKind::Module, functools_module.kind(&db));
|
|
|
|
let expected_functools_path = stdlib.join("functools.pyi");
|
|
assert_eq!(
|
|
&expected_functools_path,
|
|
functools_module.file(&db).unwrap().path(&db)
|
|
);
|
|
|
|
assert_eq!(
|
|
Some(functools_module),
|
|
path_to_module(&db, &FilePath::System(expected_functools_path))
|
|
);
|
|
}
|
|
|
|
fn create_module_names(raw_names: &[&str]) -> Vec<ModuleName> {
|
|
raw_names
|
|
.iter()
|
|
.map(|raw| ModuleName::new(raw).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn stdlib_resolution_respects_versions_file_py38_existing_modules() {
|
|
const VERSIONS: &str = "\
|
|
asyncio: 3.8- # 'Regular' package on py38+
|
|
asyncio.tasks: 3.9-3.11 # Submodule on py39+ only
|
|
functools: 3.8- # Top-level single-file module
|
|
xml: 3.8-3.8 # Namespace package on py38 only
|
|
";
|
|
|
|
const STDLIB: &[FileSpec] = &[
|
|
("asyncio/__init__.pyi", ""),
|
|
("asyncio/tasks.pyi", ""),
|
|
("functools.pyi", ""),
|
|
("xml/etree.pyi", ""),
|
|
];
|
|
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: STDLIB,
|
|
versions: VERSIONS,
|
|
};
|
|
|
|
let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let existing_modules = create_module_names(&["asyncio", "functools", "xml.etree"]);
|
|
for module_name in existing_modules {
|
|
let resolved_module = resolve_module(&db, &module_name).unwrap_or_else(|| {
|
|
panic!("Expected module {module_name} to exist in the mock stdlib")
|
|
});
|
|
let search_path = resolved_module.search_path(&db).unwrap();
|
|
assert_eq!(
|
|
&stdlib, search_path,
|
|
"Search path for {module_name} was unexpectedly {search_path:?}"
|
|
);
|
|
assert!(
|
|
search_path.is_standard_library(),
|
|
"Expected a stdlib search path, but got {search_path:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stdlib_resolution_respects_versions_file_py38_nonexisting_modules() {
|
|
const VERSIONS: &str = "\
|
|
asyncio: 3.8- # 'Regular' package on py38+
|
|
asyncio.tasks: 3.9-3.11 # Submodule on py39+ only
|
|
collections: 3.9- # 'Regular' package on py39+
|
|
importlib: 3.9- # Namespace package on py39+
|
|
xml: 3.8-3.8 # Namespace package on 3.8 only
|
|
";
|
|
|
|
const STDLIB: &[FileSpec] = &[
|
|
("collections/__init__.pyi", ""),
|
|
("asyncio/__init__.pyi", ""),
|
|
("asyncio/tasks.pyi", ""),
|
|
("importlib/abc.pyi", ""),
|
|
("xml/etree.pyi", ""),
|
|
];
|
|
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: STDLIB,
|
|
versions: VERSIONS,
|
|
};
|
|
|
|
let TestCase { db, .. } = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let nonexisting_modules = create_module_names(&[
|
|
"collections",
|
|
"importlib",
|
|
"importlib.abc",
|
|
"xml",
|
|
"asyncio.tasks",
|
|
]);
|
|
|
|
for module_name in nonexisting_modules {
|
|
assert!(
|
|
resolve_module(&db, &module_name).is_none(),
|
|
"Unexpectedly resolved a module for {module_name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stdlib_resolution_respects_versions_file_py39_existing_modules() {
|
|
const VERSIONS: &str = "\
|
|
asyncio: 3.8- # 'Regular' package on py38+
|
|
asyncio.tasks: 3.9-3.11 # Submodule on py39+ only
|
|
collections: 3.9- # 'Regular' package on py39+
|
|
functools: 3.8- # Top-level single-file module
|
|
importlib: 3.9- # Namespace package on py39+
|
|
";
|
|
|
|
const STDLIB: &[FileSpec] = &[
|
|
("asyncio/__init__.pyi", ""),
|
|
("asyncio/tasks.pyi", ""),
|
|
("collections/__init__.pyi", ""),
|
|
("functools.pyi", ""),
|
|
("importlib/abc.pyi", ""),
|
|
];
|
|
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: STDLIB,
|
|
versions: VERSIONS,
|
|
};
|
|
|
|
let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY39)
|
|
.build();
|
|
|
|
let existing_modules = create_module_names(&[
|
|
"asyncio",
|
|
"functools",
|
|
"importlib.abc",
|
|
"collections",
|
|
"asyncio.tasks",
|
|
]);
|
|
|
|
for module_name in existing_modules {
|
|
let resolved_module = resolve_module(&db, &module_name).unwrap_or_else(|| {
|
|
panic!("Expected module {module_name} to exist in the mock stdlib")
|
|
});
|
|
let search_path = resolved_module.search_path(&db).unwrap();
|
|
assert_eq!(
|
|
&stdlib, search_path,
|
|
"Search path for {module_name} was unexpectedly {search_path:?}"
|
|
);
|
|
assert!(
|
|
search_path.is_standard_library(),
|
|
"Expected a stdlib search path, but got {search_path:?}"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn stdlib_resolution_respects_versions_file_py39_nonexisting_modules() {
|
|
const VERSIONS: &str = "\
|
|
importlib: 3.9- # Namespace package on py39+
|
|
xml: 3.8-3.8 # Namespace package on 3.8 only
|
|
";
|
|
|
|
const STDLIB: &[FileSpec] = &[("importlib/abc.pyi", ""), ("xml/etree.pyi", "")];
|
|
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: STDLIB,
|
|
versions: VERSIONS,
|
|
};
|
|
|
|
let TestCase { db, .. } = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY39)
|
|
.build();
|
|
|
|
let nonexisting_modules = create_module_names(&["importlib", "xml", "xml.etree"]);
|
|
for module_name in nonexisting_modules {
|
|
assert!(
|
|
resolve_module(&db, &module_name).is_none(),
|
|
"Unexpectedly resolved a module for {module_name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn first_party_precedence_over_stdlib() {
|
|
const SRC: &[FileSpec] = &[("functools.py", "def update_wrapper(): ...")];
|
|
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
|
|
versions: "functools: 3.8-",
|
|
};
|
|
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new()
|
|
.with_src_files(SRC)
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&functools_module),
|
|
resolve_module(&db, &functools_module_name).as_ref()
|
|
);
|
|
assert_eq!(&src, functools_module.search_path(&db).unwrap());
|
|
assert_eq!(ModuleKind::Module, functools_module.kind(&db));
|
|
assert_eq!(
|
|
&src.join("functools.py"),
|
|
functools_module.file(&db).unwrap().path(&db)
|
|
);
|
|
|
|
assert_eq!(
|
|
Some(functools_module),
|
|
path_to_module(&db, &FilePath::System(src.join("functools.py")))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stdlib_uses_vendored_typeshed_when_no_custom_typeshed_supplied() {
|
|
let TestCase { db, stdlib, .. } = TestCaseBuilder::new()
|
|
.with_vendored_typeshed()
|
|
.with_python_version(PythonVersion::default())
|
|
.build();
|
|
|
|
let pydoc_data_topics_name = ModuleName::new_static("pydoc_data.topics").unwrap();
|
|
let pydoc_data_topics = resolve_module(&db, &pydoc_data_topics_name).unwrap();
|
|
|
|
assert_eq!("pydoc_data.topics", pydoc_data_topics.name(&db));
|
|
assert_eq!(pydoc_data_topics.search_path(&db).unwrap(), &stdlib);
|
|
assert_eq!(
|
|
pydoc_data_topics.file(&db).unwrap().path(&db),
|
|
&stdlib.join("pydoc_data/topics.pyi")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_package() {
|
|
let TestCase { src, db, .. } = TestCaseBuilder::new()
|
|
.with_src_files(&[("foo/__init__.py", "print('Hello, world!'")])
|
|
.build();
|
|
|
|
let foo_path = src.join("foo/__init__.py");
|
|
let foo_module = resolve_module(&db, &ModuleName::new_static("foo").unwrap()).unwrap();
|
|
|
|
assert_eq!("foo", foo_module.name(&db));
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(&foo_path, foo_module.file(&db).unwrap().path(&db));
|
|
|
|
assert_eq!(
|
|
Some(&foo_module),
|
|
path_to_module(&db, &FilePath::System(foo_path)).as_ref()
|
|
);
|
|
|
|
// Resolving by directory doesn't resolve to the init file.
|
|
assert_eq!(
|
|
None,
|
|
path_to_module(&db, &FilePath::System(src.join("foo")))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn package_priority_over_module() {
|
|
const SRC: &[FileSpec] = &[
|
|
("foo/__init__.py", "print('Hello, world!')"),
|
|
("foo.py", "print('Hello, world!')"),
|
|
];
|
|
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
|
|
|
|
let foo_module = resolve_module(&db, &ModuleName::new_static("foo").unwrap()).unwrap();
|
|
let foo_init_path = src.join("foo/__init__.py");
|
|
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(&foo_init_path, foo_module.file(&db).unwrap().path(&db));
|
|
assert_eq!(ModuleKind::Package, foo_module.kind(&db));
|
|
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(foo_init_path))
|
|
);
|
|
assert_eq!(
|
|
None,
|
|
path_to_module(&db, &FilePath::System(src.join("foo.py")))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typing_stub_over_module() {
|
|
const SRC: &[FileSpec] = &[("foo.py", "print('Hello, world!')"), ("foo.pyi", "x: int")];
|
|
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
|
|
|
|
let foo = resolve_module(&db, &ModuleName::new_static("foo").unwrap()).unwrap();
|
|
let foo_stub = src.join("foo.pyi");
|
|
|
|
assert_eq!(&src, foo.search_path(&db).unwrap());
|
|
assert_eq!(&foo_stub, foo.file(&db).unwrap().path(&db));
|
|
|
|
assert_eq!(Some(foo), path_to_module(&db, &FilePath::System(foo_stub)));
|
|
assert_eq!(
|
|
None,
|
|
path_to_module(&db, &FilePath::System(src.join("foo.py")))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sub_packages() {
|
|
const SRC: &[FileSpec] = &[
|
|
("foo/__init__.py", ""),
|
|
("foo/bar/__init__.py", ""),
|
|
("foo/bar/baz.py", "print('Hello, world!)'"),
|
|
];
|
|
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
|
|
|
|
let baz_module =
|
|
resolve_module(&db, &ModuleName::new_static("foo.bar.baz").unwrap()).unwrap();
|
|
let baz_path = src.join("foo/bar/baz.py");
|
|
|
|
assert_eq!(&src, baz_module.search_path(&db).unwrap());
|
|
assert_eq!(&baz_path, baz_module.file(&db).unwrap().path(&db));
|
|
|
|
assert_eq!(
|
|
Some(baz_module),
|
|
path_to_module(&db, &FilePath::System(baz_path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn module_search_path_priority() {
|
|
let TestCase {
|
|
db,
|
|
src,
|
|
site_packages,
|
|
..
|
|
} = TestCaseBuilder::new()
|
|
.with_src_files(&[("foo.py", "")])
|
|
.with_site_packages_files(&[("foo.py", "")])
|
|
.build();
|
|
|
|
let foo_module = resolve_module(&db, &ModuleName::new_static("foo").unwrap()).unwrap();
|
|
let foo_src_path = src.join("foo.py");
|
|
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(&foo_src_path, foo_module.file(&db).unwrap().path(&db));
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(foo_src_path))
|
|
);
|
|
|
|
assert_eq!(
|
|
None,
|
|
path_to_module(&db, &FilePath::System(site_packages.join("foo.py")))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(target_family = "unix")]
|
|
fn symlink() -> anyhow::Result<()> {
|
|
use anyhow::Context;
|
|
|
|
use crate::{
|
|
PythonPlatform, PythonVersionSource, PythonVersionWithSource, program::Program,
|
|
};
|
|
use ruff_db::system::{OsSystem, SystemPath};
|
|
|
|
use crate::db::tests::TestDb;
|
|
|
|
let mut db = TestDb::new();
|
|
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let root = temp_dir
|
|
.path()
|
|
.canonicalize()
|
|
.context("Failed to canonicalize temp dir")?;
|
|
let root = SystemPath::from_std_path(&root).unwrap();
|
|
db.use_system(OsSystem::new(root));
|
|
|
|
let src = root.join("src");
|
|
let site_packages = root.join("site-packages");
|
|
let custom_typeshed = root.join("typeshed");
|
|
|
|
let foo = src.join("foo.py");
|
|
let bar = src.join("bar.py");
|
|
|
|
std::fs::create_dir_all(src.as_std_path())?;
|
|
std::fs::create_dir_all(site_packages.as_std_path())?;
|
|
std::fs::create_dir_all(custom_typeshed.join("stdlib").as_std_path())?;
|
|
std::fs::File::create(custom_typeshed.join("stdlib/VERSIONS").as_std_path())?;
|
|
|
|
std::fs::write(foo.as_std_path(), "")?;
|
|
std::os::unix::fs::symlink(foo.as_std_path(), bar.as_std_path())?;
|
|
|
|
Program::from_settings(
|
|
&db,
|
|
ProgramSettings {
|
|
python_version: PythonVersionWithSource {
|
|
version: PythonVersion::PY38,
|
|
source: PythonVersionSource::default(),
|
|
},
|
|
python_platform: PythonPlatform::default(),
|
|
search_paths: SearchPathSettings {
|
|
custom_typeshed: Some(custom_typeshed),
|
|
site_packages_paths: vec![site_packages],
|
|
..SearchPathSettings::new(vec![src.clone()])
|
|
}
|
|
.to_search_paths(db.system(), db.vendored())
|
|
.expect("Valid search path settings"),
|
|
},
|
|
);
|
|
|
|
let foo_module = resolve_module(&db, &ModuleName::new_static("foo").unwrap()).unwrap();
|
|
let bar_module = resolve_module(&db, &ModuleName::new_static("bar").unwrap()).unwrap();
|
|
|
|
assert_ne!(foo_module, bar_module);
|
|
|
|
assert_eq!(&src, foo_module.search_path(&db).unwrap());
|
|
assert_eq!(&foo, foo_module.file(&db).unwrap().path(&db));
|
|
|
|
// `foo` and `bar` shouldn't resolve to the same file
|
|
|
|
assert_eq!(&src, bar_module.search_path(&db).unwrap());
|
|
assert_eq!(&bar, bar_module.file(&db).unwrap().path(&db));
|
|
assert_eq!(&foo, foo_module.file(&db).unwrap().path(&db));
|
|
|
|
assert_ne!(&foo_module, &bar_module);
|
|
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(foo))
|
|
);
|
|
assert_eq!(
|
|
Some(bar_module),
|
|
path_to_module(&db, &FilePath::System(bar))
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_an_unrelated_file_doesnt_change_module_resolution() {
|
|
let TestCase { mut db, src, .. } = TestCaseBuilder::new()
|
|
.with_src_files(&[("foo.py", "x = 1"), ("bar.py", "x = 2")])
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
let foo_module = resolve_module(&db, &foo_module_name).unwrap();
|
|
let foo_pieces = (
|
|
foo_module.name(&db).clone(),
|
|
foo_module.file(&db),
|
|
foo_module.known(&db),
|
|
foo_module.search_path(&db).cloned(),
|
|
foo_module.kind(&db),
|
|
);
|
|
|
|
let bar_path = src.join("bar.py");
|
|
let bar = system_path_to_file(&db, &bar_path).expect("bar.py to exist");
|
|
|
|
db.clear_salsa_events();
|
|
|
|
// Delete `bar.py`
|
|
db.memory_file_system().remove_file(&bar_path).unwrap();
|
|
bar.sync(&mut db);
|
|
|
|
// Re-query the foo module. The foo module should still be cached
|
|
// because `bar.py` isn't relevant for resolving `foo`.
|
|
|
|
let foo_module2 = resolve_module(&db, &foo_module_name);
|
|
let foo_pieces2 = foo_module2.map(|foo_module2| {
|
|
(
|
|
foo_module2.name(&db).clone(),
|
|
foo_module2.file(&db),
|
|
foo_module2.known(&db),
|
|
foo_module2.search_path(&db).cloned(),
|
|
foo_module2.kind(&db),
|
|
)
|
|
});
|
|
|
|
assert!(
|
|
!db.take_salsa_events()
|
|
.iter()
|
|
.any(|event| { matches!(event.kind, salsa::EventKind::WillExecute { .. }) })
|
|
);
|
|
|
|
assert_eq!(Some(foo_pieces), foo_pieces2);
|
|
}
|
|
|
|
#[test]
|
|
fn adding_file_on_which_module_resolution_depends_invalidates_previously_failing_query_that_now_succeeds()
|
|
-> anyhow::Result<()> {
|
|
let TestCase { mut db, src, .. } = TestCaseBuilder::new().build();
|
|
let foo_path = src.join("foo.py");
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
assert_eq!(resolve_module(&db, &foo_module_name), None);
|
|
|
|
// Now write the foo file
|
|
db.write_file(&foo_path, "x = 1")?;
|
|
|
|
let foo_file = system_path_to_file(&db, &foo_path).expect("foo.py to exist");
|
|
|
|
let foo_module = resolve_module(&db, &foo_module_name).expect("Foo module to resolve");
|
|
assert_eq!(foo_file, foo_module.file(&db).unwrap());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn removing_file_on_which_module_resolution_depends_invalidates_previously_successful_query_that_now_fails()
|
|
-> anyhow::Result<()> {
|
|
const SRC: &[FileSpec] = &[("foo.py", "x = 1"), ("foo/__init__.py", "x = 2")];
|
|
|
|
let TestCase { mut db, src, .. } = TestCaseBuilder::new().with_src_files(SRC).build();
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
let foo_module = resolve_module(&db, &foo_module_name).expect("foo module to exist");
|
|
let foo_init_path = src.join("foo/__init__.py");
|
|
|
|
assert_eq!(&foo_init_path, foo_module.file(&db).unwrap().path(&db));
|
|
|
|
// Delete `foo/__init__.py` and the `foo` folder. `foo` should now resolve to `foo.py`
|
|
db.memory_file_system().remove_file(&foo_init_path)?;
|
|
db.memory_file_system()
|
|
.remove_directory(foo_init_path.parent().unwrap())?;
|
|
File::sync_path(&mut db, &foo_init_path);
|
|
File::sync_path(&mut db, foo_init_path.parent().unwrap());
|
|
|
|
let foo_module = resolve_module(&db, &foo_module_name).expect("Foo module to resolve");
|
|
assert_eq!(&src.join("foo.py"), foo_module.file(&db).unwrap().path(&db));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn adding_file_to_search_path_with_lower_priority_does_not_invalidate_query() {
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
versions: "functools: 3.8-",
|
|
stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
|
|
};
|
|
|
|
let TestCase {
|
|
mut db,
|
|
stdlib,
|
|
site_packages,
|
|
..
|
|
} = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let stdlib_functools_path = stdlib.join("functools.pyi");
|
|
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
assert_eq!(functools_module.search_path(&db).unwrap(), &stdlib);
|
|
assert_eq!(
|
|
Ok(functools_module.file(&db).unwrap()),
|
|
system_path_to_file(&db, &stdlib_functools_path)
|
|
);
|
|
|
|
// Adding a file to site-packages does not invalidate the query,
|
|
// since site-packages takes lower priority in the module resolution
|
|
db.clear_salsa_events();
|
|
let site_packages_functools_path = site_packages.join("functools.py");
|
|
db.write_file(&site_packages_functools_path, "f: int")
|
|
.unwrap();
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
let functools_file = functools_module.file(&db).unwrap();
|
|
let functools_search_path = functools_module.search_path(&db).unwrap().clone();
|
|
let events = db.take_salsa_events();
|
|
assert_function_query_was_not_run(
|
|
&db,
|
|
resolve_module_query,
|
|
ModuleNameIngredient::new(&db, functools_module_name, ModuleResolveMode::StubsAllowed),
|
|
&events,
|
|
);
|
|
assert_eq!(&functools_search_path, &stdlib);
|
|
assert_eq!(
|
|
Ok(functools_file),
|
|
system_path_to_file(&db, &stdlib_functools_path)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn adding_file_to_search_path_with_higher_priority_invalidates_the_query() {
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
versions: "functools: 3.8-",
|
|
stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
|
|
};
|
|
|
|
let TestCase {
|
|
mut db,
|
|
stdlib,
|
|
src,
|
|
..
|
|
} = TestCaseBuilder::new()
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
assert_eq!(functools_module.search_path(&db).unwrap(), &stdlib);
|
|
assert_eq!(
|
|
Ok(functools_module.file(&db).unwrap()),
|
|
system_path_to_file(&db, stdlib.join("functools.pyi"))
|
|
);
|
|
|
|
// Adding a first-party file invalidates the query,
|
|
// since first-party files take higher priority in module resolution:
|
|
let src_functools_path = src.join("functools.py");
|
|
db.write_file(&src_functools_path, "FOO: int").unwrap();
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
assert_eq!(functools_module.search_path(&db).unwrap(), &src);
|
|
assert_eq!(
|
|
Ok(functools_module.file(&db).unwrap()),
|
|
system_path_to_file(&db, &src_functools_path)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_file_from_higher_priority_search_path_invalidates_the_query() {
|
|
const SRC: &[FileSpec] = &[("functools.py", "FOO: int")];
|
|
|
|
const TYPESHED: MockedTypeshed = MockedTypeshed {
|
|
versions: "functools: 3.8-",
|
|
stdlib_files: &[("functools.pyi", "def update_wrapper(): ...")],
|
|
};
|
|
|
|
let TestCase {
|
|
mut db,
|
|
stdlib,
|
|
src,
|
|
..
|
|
} = TestCaseBuilder::new()
|
|
.with_src_files(SRC)
|
|
.with_mocked_typeshed(TYPESHED)
|
|
.with_python_version(PythonVersion::PY38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let src_functools_path = src.join("functools.py");
|
|
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
assert_eq!(functools_module.search_path(&db).unwrap(), &src);
|
|
assert_eq!(
|
|
Ok(functools_module.file(&db).unwrap()),
|
|
system_path_to_file(&db, &src_functools_path)
|
|
);
|
|
|
|
// If we now delete the first-party file,
|
|
// it should resolve to the stdlib:
|
|
db.memory_file_system()
|
|
.remove_file(&src_functools_path)
|
|
.unwrap();
|
|
File::sync_path(&mut db, &src_functools_path);
|
|
let functools_module = resolve_module(&db, &functools_module_name).unwrap();
|
|
assert_eq!(functools_module.search_path(&db).unwrap(), &stdlib);
|
|
assert_eq!(
|
|
Ok(functools_module.file(&db).unwrap()),
|
|
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).unwrap();
|
|
let foo_bar_module = resolve_module(&db, &foo_bar_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
foo_module.file(&db).unwrap().path(&db),
|
|
&FilePath::system("/x/src/foo/__init__.py")
|
|
);
|
|
assert_eq!(
|
|
foo_bar_module.file(&db).unwrap().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).unwrap();
|
|
assert_eq!(
|
|
bar_module.file(&db).unwrap().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).unwrap();
|
|
|
|
assert_eq!(
|
|
foo_module.file(&db).unwrap().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).unwrap();
|
|
let a_module = resolve_module(&db, &a_module_name).unwrap();
|
|
let b_module = resolve_module(&db, &b_module_name).unwrap();
|
|
let spam_module = resolve_module(&db, &spam_module_name).unwrap();
|
|
|
|
assert_eq!(
|
|
foo_module.file(&db).unwrap().path(&db),
|
|
&FilePath::system("/x/y/src/foo.pyi")
|
|
);
|
|
assert_eq!(
|
|
a_module.file(&db).unwrap().path(&db),
|
|
&FilePath::system("/a.py")
|
|
);
|
|
assert_eq!(
|
|
b_module.file(&db).unwrap().path(&db),
|
|
&FilePath::system("/baz/b.py")
|
|
);
|
|
assert_eq!(
|
|
spam_module.file(&db).unwrap().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(&db).unwrap().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(&db).unwrap().path(&db),
|
|
&FilePath::system("/y/src/bar.py")
|
|
);
|
|
let events = db.take_salsa_events();
|
|
assert_function_query_was_not_run(
|
|
&db,
|
|
dynamic_resolution_paths,
|
|
ModuleResolveModeIngredient::new(&db, ModuleResolveMode::StubsAllowed),
|
|
&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).unwrap();
|
|
assert_eq!(
|
|
foo_module.file(&db).unwrap().path(&db),
|
|
&FilePath::system("/x/src/foo.py")
|
|
);
|
|
|
|
db.memory_file_system()
|
|
.remove_file(site_packages.join("_foo.pth"))
|
|
.unwrap();
|
|
|
|
File::sync_path(&mut db, &site_packages.join("_foo.pth"));
|
|
|
|
assert_eq!(resolve_module(&db, &foo_module_name), 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).unwrap();
|
|
let src_path = SystemPathBuf::from("/x/src");
|
|
assert_eq!(
|
|
foo_module.file(&db).unwrap().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::sync_path(&mut db, &src_path.join("foo.py"));
|
|
File::sync_path(&mut db, &src_path);
|
|
assert_eq!(resolve_module(&db, &foo_module_name), 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<&SearchPath> =
|
|
search_paths(&db, ModuleResolveMode::StubsAllowed).collect();
|
|
|
|
assert!(search_paths.contains(
|
|
&&SearchPath::first_party(db.system(), SystemPathBuf::from("/src")).unwrap()
|
|
));
|
|
assert!(
|
|
!search_paths.contains(
|
|
&&SearchPath::editable(db.system(), SystemPathBuf::from("/src")).unwrap()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_site_packages_with_editables() {
|
|
let mut db = TestDb::new();
|
|
|
|
let venv_site_packages = SystemPathBuf::from("/venv-site-packages");
|
|
let site_packages_pth = venv_site_packages.join("foo.pth");
|
|
let system_site_packages = SystemPathBuf::from("/system-site-packages");
|
|
let editable_install_location = SystemPathBuf::from("/x/y/a.py");
|
|
let system_site_packages_location = system_site_packages.join("a.py");
|
|
|
|
db.memory_file_system()
|
|
.create_directory_all("/src")
|
|
.unwrap();
|
|
db.write_files([
|
|
(&site_packages_pth, "/x/y"),
|
|
(&editable_install_location, ""),
|
|
(&system_site_packages_location, ""),
|
|
])
|
|
.unwrap();
|
|
|
|
Program::from_settings(
|
|
&db,
|
|
ProgramSettings {
|
|
python_version: PythonVersionWithSource::default(),
|
|
python_platform: PythonPlatform::default(),
|
|
search_paths: SearchPathSettings {
|
|
site_packages_paths: vec![venv_site_packages, system_site_packages],
|
|
..SearchPathSettings::new(vec![SystemPathBuf::from("/src")])
|
|
}
|
|
.to_search_paths(db.system(), db.vendored())
|
|
.expect("Valid search path settings"),
|
|
},
|
|
);
|
|
|
|
// The editable installs discovered from the `.pth` file in the first `site-packages` directory
|
|
// take precedence over the second `site-packages` directory...
|
|
let a_module_name = ModuleName::new_static("a").unwrap();
|
|
let a_module = resolve_module(&db, &a_module_name).unwrap();
|
|
assert_eq!(
|
|
a_module.file(&db).unwrap().path(&db),
|
|
&editable_install_location
|
|
);
|
|
|
|
db.memory_file_system()
|
|
.remove_file(&site_packages_pth)
|
|
.unwrap();
|
|
File::sync_path(&mut db, &site_packages_pth);
|
|
|
|
// ...But now that the `.pth` file in the first `site-packages` directory has been deleted,
|
|
// the editable install no longer exists, so the module now resolves to the file in the
|
|
// second `site-packages` directory
|
|
let a_module = resolve_module(&db, &a_module_name).unwrap();
|
|
assert_eq!(
|
|
a_module.file(&db).unwrap().path(&db),
|
|
&system_site_packages_location
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn case_sensitive_resolution_with_symlinked_directory() -> anyhow::Result<()> {
|
|
use anyhow::Context;
|
|
use ruff_db::system::OsSystem;
|
|
|
|
let temp_dir = tempfile::TempDir::new()?;
|
|
let root = SystemPathBuf::from_path_buf(
|
|
temp_dir
|
|
.path()
|
|
.canonicalize()
|
|
.context("Failed to canonicalized path")?,
|
|
)
|
|
.expect("UTF8 path for temp dir");
|
|
|
|
let mut db = TestDb::new();
|
|
|
|
let src = root.join("src");
|
|
let a_package_target = root.join("a-package");
|
|
let a_src = src.join("a");
|
|
|
|
db.use_system(OsSystem::new(&root));
|
|
|
|
db.write_file(
|
|
a_package_target.join("__init__.py"),
|
|
"class Foo: x: int = 4",
|
|
)
|
|
.context("Failed to write `a-package/__init__.py`")?;
|
|
|
|
db.write_file(src.join("main.py"), "print('Hy')")
|
|
.context("Failed to write `main.py`")?;
|
|
|
|
// The symlink triggers the slow-path in the `OsSystem`'s
|
|
// `exists_path_case_sensitive` code because canonicalizing the path
|
|
// for `a/__init__.py` results in `a-package/__init__.py`
|
|
std::os::unix::fs::symlink(a_package_target.as_std_path(), a_src.as_std_path())
|
|
.context("Failed to symlink `src/a` to `a-package`")?;
|
|
|
|
Program::from_settings(
|
|
&db,
|
|
ProgramSettings {
|
|
python_version: PythonVersionWithSource::default(),
|
|
python_platform: PythonPlatform::default(),
|
|
search_paths: SearchPathSettings::new(vec![src])
|
|
.to_search_paths(db.system(), db.vendored())
|
|
.expect("valid search path settings"),
|
|
},
|
|
);
|
|
|
|
// Now try to resolve the module `A` (note the capital `A` instead of `a`).
|
|
let a_module_name = ModuleName::new_static("A").unwrap();
|
|
assert_eq!(resolve_module(&db, &a_module_name), None);
|
|
|
|
// Now lookup the same module using the lowercase `a` and it should
|
|
// resolve to the file in the system site-packages
|
|
let a_module_name = ModuleName::new_static("a").unwrap();
|
|
let a_module = resolve_module(&db, &a_module_name).expect("a.py to resolve");
|
|
assert!(
|
|
a_module
|
|
.file(&db)
|
|
.unwrap()
|
|
.path(&db)
|
|
.as_str()
|
|
.ends_with("src/a/__init__.py"),
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn file_to_module_where_one_search_path_is_subdirectory_of_other() {
|
|
let project_directory = SystemPathBuf::from("/project");
|
|
let site_packages = project_directory.join(".venv/lib/python3.13/site-packages");
|
|
let installed_foo_module = site_packages.join("foo/__init__.py");
|
|
|
|
let mut db = TestDb::new();
|
|
db.write_file(&installed_foo_module, "").unwrap();
|
|
|
|
Program::from_settings(
|
|
&db,
|
|
ProgramSettings {
|
|
python_version: PythonVersionWithSource::default(),
|
|
python_platform: PythonPlatform::default(),
|
|
search_paths: SearchPathSettings {
|
|
site_packages_paths: vec![site_packages.clone()],
|
|
..SearchPathSettings::new(vec![project_directory])
|
|
}
|
|
.to_search_paths(db.system(), db.vendored())
|
|
.unwrap(),
|
|
},
|
|
);
|
|
|
|
let foo_module_file = File::new(&db, FilePath::System(installed_foo_module));
|
|
let module = file_to_module(&db, foo_module_file).unwrap();
|
|
assert_eq!(module.search_path(&db).unwrap(), &site_packages);
|
|
}
|
|
}
|