mirror of https://github.com/astral-sh/ruff
1013 lines
36 KiB
Rust
1013 lines
36 KiB
Rust
use std::ops::Deref;
|
|
use std::sync::Arc;
|
|
|
|
use ruff_db::files::{File, FilePath};
|
|
use ruff_db::system::SystemPathBuf;
|
|
|
|
use crate::db::Db;
|
|
use crate::module::{Module, ModuleKind};
|
|
use crate::module_name::ModuleName;
|
|
use crate::path::ModuleResolutionPathBuf;
|
|
use crate::resolver::internal::ModuleResolverSettings;
|
|
use crate::state::ResolverState;
|
|
use crate::supported_py_version::TargetVersion;
|
|
|
|
/// Configures the module resolver settings.
|
|
///
|
|
/// Must be called before calling any other module resolution functions.
|
|
pub fn set_module_resolution_settings(db: &mut dyn Db, config: RawModuleResolutionSettings) {
|
|
// 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();
|
|
if let Some(existing) = ModuleResolverSettings::try_get(db) {
|
|
existing.set_settings(db).to(resolved_settings);
|
|
} else {
|
|
ModuleResolverSettings::new(db, resolved_settings);
|
|
}
|
|
}
|
|
|
|
/// Resolves a module name to a module.
|
|
pub fn resolve_module(db: &dyn Db, module_name: ModuleName) -> Option<Module> {
|
|
let interned_name = internal::ModuleNameIngredient::new(db, module_name);
|
|
|
|
resolve_module_query(db, interned_name)
|
|
}
|
|
|
|
/// 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]
|
|
pub(crate) fn resolve_module_query<'db>(
|
|
db: &'db dyn Db,
|
|
module_name: internal::ModuleNameIngredient<'db>,
|
|
) -> Option<Module> {
|
|
let _span = tracing::trace_span!("resolve_module", ?module_name).entered();
|
|
|
|
let name = module_name.name(db);
|
|
|
|
let (search_path, module_file, kind) = resolve_name(db, name)?;
|
|
|
|
let module = Module::new(name.clone(), kind, search_path, module_file);
|
|
|
|
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: &dyn Db, path: &FilePath) -> Option<Module> {
|
|
// 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.upcast())?;
|
|
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]
|
|
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 = file.path(db.upcast());
|
|
|
|
let resolver_settings = module_resolver_settings(db);
|
|
|
|
let relative_path = resolver_settings
|
|
.search_paths()
|
|
.iter()
|
|
.find_map(|root| root.relativize_path(path))?;
|
|
|
|
let module_name = 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)?;
|
|
|
|
if file == module.file() {
|
|
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
|
|
}
|
|
}
|
|
|
|
/// "Raw" configuration settings for module resolution: unvalidated, unnormalized
|
|
#[derive(Eq, PartialEq, Debug)]
|
|
pub struct RawModuleResolutionSettings {
|
|
/// The target Python version the user has specified
|
|
pub target_version: TargetVersion,
|
|
|
|
/// List of user-provided paths that should take first priority in the module resolution.
|
|
/// Examples in other type checkers are mypy's MYPYPATH environment variable,
|
|
/// or pyright's stubPath configuration setting.
|
|
pub extra_paths: Vec<SystemPathBuf>,
|
|
|
|
/// The root of the workspace, used for finding first-party modules.
|
|
pub workspace_root: SystemPathBuf,
|
|
|
|
/// Optional (already validated) path to standard-library typeshed stubs.
|
|
/// If this is not provided, we will fallback to our vendored typeshed stubs
|
|
/// bundled as a zip file in the binary
|
|
pub custom_typeshed: Option<SystemPathBuf>,
|
|
|
|
/// The path to the user's `site-packages` directory, where third-party packages from ``PyPI`` are installed.
|
|
pub site_packages: Option<SystemPathBuf>,
|
|
}
|
|
|
|
impl RawModuleResolutionSettings {
|
|
/// Implementation of 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
|
|
/// and transforming it into an internal representation for a validated path.
|
|
/// Rather than panicking if a path fails to validate, we should display an error message to the user
|
|
/// and exit the process with a nonzero exit code.
|
|
/// 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 {
|
|
let RawModuleResolutionSettings {
|
|
target_version,
|
|
extra_paths,
|
|
workspace_root,
|
|
site_packages,
|
|
custom_typeshed,
|
|
} = self;
|
|
|
|
let mut paths: Vec<ModuleResolutionPathBuf> = extra_paths
|
|
.into_iter()
|
|
.map(|fs_path| ModuleResolutionPathBuf::extra(fs_path).unwrap())
|
|
.collect();
|
|
|
|
paths.push(ModuleResolutionPathBuf::first_party(workspace_root).unwrap());
|
|
|
|
paths.push(
|
|
custom_typeshed.map_or_else(ModuleResolutionPathBuf::vendored_stdlib, |custom| {
|
|
ModuleResolutionPathBuf::stdlib_from_custom_typeshed_root(&custom).unwrap()
|
|
}),
|
|
);
|
|
|
|
// 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()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A resolved module resolution order as per the [typing spec]
|
|
///
|
|
/// [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>>);
|
|
|
|
impl Deref for OrderedSearchPaths {
|
|
type Target = [Arc<ModuleResolutionPathBuf>];
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct ModuleResolutionSettings {
|
|
search_paths: OrderedSearchPaths,
|
|
target_version: TargetVersion,
|
|
}
|
|
|
|
impl ModuleResolutionSettings {
|
|
pub(crate) fn search_paths(&self) -> &[Arc<ModuleResolutionPathBuf>] {
|
|
&self.search_paths
|
|
}
|
|
|
|
pub(crate) fn target_version(&self) -> TargetVersion {
|
|
self.target_version
|
|
}
|
|
}
|
|
|
|
// The singleton methods generated by salsa are all `pub` instead of `pub(crate)` which triggers
|
|
// `unreachable_pub`. Work around this by creating a module and allow `unreachable_pub` for it.
|
|
// Salsa also generates uses to `_db` variables for `interned` which triggers `clippy::used_underscore_binding`. Suppress that too
|
|
// TODO(micha): Contribute a fix for this upstream where the singleton methods have the same visibility as the struct.
|
|
#[allow(unreachable_pub, clippy::used_underscore_binding)]
|
|
pub(crate) mod internal {
|
|
use crate::module_name::ModuleName;
|
|
use crate::resolver::ModuleResolutionSettings;
|
|
|
|
#[salsa::input(singleton)]
|
|
pub(crate) struct ModuleResolverSettings {
|
|
#[return_ref]
|
|
pub(super) settings: ModuleResolutionSettings,
|
|
}
|
|
|
|
/// 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]
|
|
pub(crate) struct ModuleNameIngredient<'db> {
|
|
#[return_ref]
|
|
pub(super) name: ModuleName,
|
|
}
|
|
}
|
|
|
|
fn module_resolver_settings(db: &dyn Db) -> &ModuleResolutionSettings {
|
|
ModuleResolverSettings::get(db).settings(db)
|
|
}
|
|
|
|
/// 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,
|
|
) -> Option<(Arc<ModuleResolutionPathBuf>, File, ModuleKind)> {
|
|
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() {
|
|
let mut components = name.components();
|
|
let module_name = components.next_back()?;
|
|
|
|
match resolve_package(search_path, components, &resolver_state) {
|
|
Ok(resolved_package) => {
|
|
let mut package_path = resolved_package.path;
|
|
|
|
package_path.push(module_name);
|
|
|
|
// Must be a `__init__.pyi` or `__init__.py` or it isn't a package.
|
|
let kind = if package_path.is_directory(search_path, &resolver_state) {
|
|
package_path.push("__init__");
|
|
ModuleKind::Package
|
|
} else {
|
|
ModuleKind::Module
|
|
};
|
|
|
|
// TODO Implement full https://peps.python.org/pep-0561/#type-checker-module-resolution-order resolution
|
|
if let Some(stub) = package_path
|
|
.with_pyi_extension()
|
|
.to_file(search_path, &resolver_state)
|
|
{
|
|
return Some((search_path.clone(), stub, kind));
|
|
}
|
|
|
|
if let Some(module) = package_path
|
|
.with_py_extension()
|
|
.and_then(|path| path.to_file(search_path, &resolver_state))
|
|
{
|
|
return Some((search_path.clone(), module, kind));
|
|
}
|
|
|
|
// For regular packages, don't search the next search path. All files of that
|
|
// package must be in the same location
|
|
if resolved_package.kind.is_regular_package() {
|
|
return None;
|
|
}
|
|
}
|
|
Err(parent_kind) => {
|
|
if parent_kind.is_regular_package() {
|
|
// For regular packages, don't search the next search path.
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn resolve_package<'a, 'db, I>(
|
|
module_search_path: &ModuleResolutionPathBuf,
|
|
components: I,
|
|
resolver_state: &ResolverState<'db>,
|
|
) -> Result<ResolvedPackage, PackageKind>
|
|
where
|
|
I: Iterator<Item = &'a str>,
|
|
{
|
|
let mut package_path = module_search_path.clone();
|
|
|
|
// `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;
|
|
|
|
// For `foo.bar.baz`, test that `foo` and `baz` both contain a `__init__.py`.
|
|
for folder in components {
|
|
package_path.push(folder);
|
|
|
|
let is_regular_package =
|
|
package_path.is_regular_package(module_search_path, resolver_state);
|
|
|
|
if is_regular_package {
|
|
in_namespace_package = false;
|
|
} else if package_path.is_directory(module_search_path, resolver_state) {
|
|
// A directory without an `__init__.py` 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);
|
|
} else if in_sub_package {
|
|
// A regular sub package wasn't found.
|
|
return Err(PackageKind::Regular);
|
|
} else {
|
|
// We couldn't find `foo` for `foo.bar.baz`, search the next search path.
|
|
return Err(PackageKind::Root);
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ResolvedPackage {
|
|
path: ModuleResolutionPathBuf,
|
|
kind: PackageKind,
|
|
}
|
|
|
|
#[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 {
|
|
const fn is_regular_package(self) -> bool {
|
|
matches!(self, PackageKind::Regular)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use ruff_db::files::{system_path_to_file, File, FilePath};
|
|
use ruff_db::system::{DbWithTestSystem, OsSystem, SystemPath};
|
|
|
|
use crate::db::tests::TestDb;
|
|
use crate::module::ModuleKind;
|
|
use crate::module_name::ModuleName;
|
|
use crate::testing::{FileSpec, MockedTypeshed, TestCase, TestCaseBuilder};
|
|
|
|
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.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&foo_module),
|
|
resolve_module(&db, foo_module_name.clone()).as_ref()
|
|
);
|
|
|
|
assert_eq!("foo", foo_module.name());
|
|
assert_eq!(&src, &foo_module.search_path());
|
|
assert_eq!(ModuleKind::Module, foo_module.kind());
|
|
|
|
let expected_foo_path = src.join("foo.py");
|
|
assert_eq!(&expected_foo_path, foo_module.file().path(&db));
|
|
assert_eq!(
|
|
Some(foo_module),
|
|
path_to_module(&db, &FilePath::System(expected_foo_path))
|
|
);
|
|
}
|
|
|
|
#[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_custom_typeshed(TYPESHED)
|
|
.with_target_version(TargetVersion::Py38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let functools_module = resolve_module(&db, functools_module_name.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&functools_module),
|
|
resolve_module(&db, functools_module_name).as_ref()
|
|
);
|
|
|
|
assert_eq!(&stdlib, &functools_module.search_path().to_path_buf());
|
|
assert_eq!(ModuleKind::Module, functools_module.kind());
|
|
|
|
let expected_functools_path = stdlib.join("functools.pyi");
|
|
assert_eq!(&expected_functools_path, functools_module.file().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_custom_typeshed(TYPESHED)
|
|
.with_target_version(TargetVersion::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.clone()).unwrap_or_else(|| {
|
|
panic!("Expected module {module_name} to exist in the mock stdlib")
|
|
});
|
|
let search_path = resolved_module.search_path();
|
|
assert_eq!(
|
|
&stdlib, &search_path,
|
|
"Search path for {module_name} was unexpectedly {search_path:?}"
|
|
);
|
|
assert!(
|
|
search_path.is_stdlib_search_path(),
|
|
"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_custom_typeshed(TYPESHED)
|
|
.with_target_version(TargetVersion::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.clone()).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_custom_typeshed(TYPESHED)
|
|
.with_target_version(TargetVersion::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.clone()).unwrap_or_else(|| {
|
|
panic!("Expected module {module_name} to exist in the mock stdlib")
|
|
});
|
|
let search_path = resolved_module.search_path();
|
|
assert_eq!(
|
|
&stdlib, &search_path,
|
|
"Search path for {module_name} was unexpectedly {search_path:?}"
|
|
);
|
|
assert!(
|
|
search_path.is_stdlib_search_path(),
|
|
"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_custom_typeshed(TYPESHED)
|
|
.with_target_version(TargetVersion::Py39)
|
|
.build();
|
|
|
|
let nonexisting_modules = create_module_names(&["importlib", "xml", "xml.etree"]);
|
|
for module_name in nonexisting_modules {
|
|
assert!(
|
|
resolve_module(&db, module_name.clone()).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_custom_typeshed(TYPESHED)
|
|
.with_target_version(TargetVersion::Py38)
|
|
.build();
|
|
|
|
let functools_module_name = ModuleName::new_static("functools").unwrap();
|
|
let functools_module = resolve_module(&db, functools_module_name.clone()).unwrap();
|
|
|
|
assert_eq!(
|
|
Some(&functools_module),
|
|
resolve_module(&db, functools_module_name).as_ref()
|
|
);
|
|
assert_eq!(&src, &functools_module.search_path());
|
|
assert_eq!(ModuleKind::Module, functools_module.kind());
|
|
assert_eq!(&src.join("functools.py"), functools_module.file().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_target_version(TargetVersion::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());
|
|
assert_eq!(pydoc_data_topics.search_path(), stdlib);
|
|
assert_eq!(
|
|
pydoc_data_topics.file().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());
|
|
assert_eq!(&src, &foo_module.search_path());
|
|
assert_eq!(&foo_path, foo_module.file().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());
|
|
assert_eq!(&foo_init_path, foo_module.file().path(&db));
|
|
assert_eq!(ModuleKind::Package, foo_module.kind());
|
|
|
|
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());
|
|
assert_eq!(&foo_stub, foo.file().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());
|
|
assert_eq!(&baz_path, baz_module.file().path(&db));
|
|
|
|
assert_eq!(
|
|
Some(baz_module),
|
|
path_to_module(&db, &FilePath::System(baz_path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn namespace_package() {
|
|
// From [PEP420](https://peps.python.org/pep-0420/#nested-namespace-packages).
|
|
// But uses `src` for `project1` and `site-packages` for `project2`.
|
|
// ```
|
|
// src
|
|
// parent
|
|
// child
|
|
// one.py
|
|
// site_packages
|
|
// parent
|
|
// child
|
|
// two.py
|
|
// ```
|
|
let TestCase {
|
|
db,
|
|
src,
|
|
site_packages,
|
|
..
|
|
} = TestCaseBuilder::new()
|
|
.with_src_files(&[("parent/child/one.py", "print('Hello, world!')")])
|
|
.with_site_packages_files(&[("parent/child/two.py", "print('Hello, world!')")])
|
|
.build();
|
|
|
|
let one_module_name = ModuleName::new_static("parent.child.one").unwrap();
|
|
let one_module_path = FilePath::System(src.join("parent/child/one.py"));
|
|
assert_eq!(
|
|
resolve_module(&db, one_module_name),
|
|
path_to_module(&db, &one_module_path)
|
|
);
|
|
|
|
let two_module_name = ModuleName::new_static("parent.child.two").unwrap();
|
|
let two_module_path = FilePath::System(site_packages.join("parent/child/two.py"));
|
|
assert_eq!(
|
|
resolve_module(&db, two_module_name),
|
|
path_to_module(&db, &two_module_path)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn regular_package_in_namespace_package() {
|
|
// Adopted test case from the [PEP420 examples](https://peps.python.org/pep-0420/#nested-namespace-packages).
|
|
// The `src/parent/child` package is a regular package. Therefore, `site_packages/parent/child/two.py` should not be resolved.
|
|
// ```
|
|
// src
|
|
// parent
|
|
// child
|
|
// one.py
|
|
// site_packages
|
|
// parent
|
|
// child
|
|
// two.py
|
|
// ```
|
|
const SRC: &[FileSpec] = &[
|
|
("parent/child/__init__.py", "print('Hello, world!')"),
|
|
("parent/child/one.py", "print('Hello, world!')"),
|
|
];
|
|
|
|
const SITE_PACKAGES: &[FileSpec] = &[("parent/child/two.py", "print('Hello, world!')")];
|
|
|
|
let TestCase { db, src, .. } = TestCaseBuilder::new()
|
|
.with_src_files(SRC)
|
|
.with_site_packages_files(SITE_PACKAGES)
|
|
.build();
|
|
|
|
let one_module_path = FilePath::System(src.join("parent/child/one.py"));
|
|
let one_module_name =
|
|
resolve_module(&db, ModuleName::new_static("parent.child.one").unwrap());
|
|
assert_eq!(one_module_name, path_to_module(&db, &one_module_path));
|
|
|
|
assert_eq!(
|
|
None,
|
|
resolve_module(&db, ModuleName::new_static("parent.child.two").unwrap())
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
assert_eq!(&foo_src_path, foo_module.file().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<()> {
|
|
let mut db = TestDb::new();
|
|
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let root = SystemPath::from_std_path(temp_dir.path()).unwrap();
|
|
db.use_os_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.as_std_path())?;
|
|
|
|
std::fs::write(foo.as_std_path(), "")?;
|
|
std::os::unix::fs::symlink(foo.as_std_path(), bar.as_std_path())?;
|
|
|
|
let settings = RawModuleResolutionSettings {
|
|
target_version: TargetVersion::Py38,
|
|
extra_paths: vec![],
|
|
workspace_root: src.clone(),
|
|
site_packages: Some(site_packages.clone()),
|
|
custom_typeshed: Some(custom_typeshed.clone()),
|
|
};
|
|
|
|
set_module_resolution_settings(&mut db, 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());
|
|
assert_eq!(&foo, foo_module.file().path(&db));
|
|
|
|
// `foo` and `bar` shouldn't resolve to the same file
|
|
|
|
assert_eq!(&src, &bar_module.search_path());
|
|
assert_eq!(&bar, bar_module.file().path(&db));
|
|
assert_eq!(&foo, foo_module.file().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_target_version(TargetVersion::Py38)
|
|
.build();
|
|
|
|
let foo_module_name = ModuleName::new_static("foo").unwrap();
|
|
let foo_module = resolve_module(&db, foo_module_name.clone()).unwrap();
|
|
|
|
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.touch(&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);
|
|
|
|
assert!(!db
|
|
.take_salsa_events()
|
|
.iter()
|
|
.any(|event| { matches!(event.kind, salsa::EventKind::WillExecute { .. }) }));
|
|
|
|
assert_eq!(Some(foo_module), foo_module2);
|
|
}
|
|
|
|
#[test]
|
|
fn adding_a_file_on_which_the_module_resolution_depends_on_invalidates_the_query(
|
|
) -> 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.clone()), 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());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn removing_a_file_that_the_module_resolution_depends_on_invalidates_the_query(
|
|
) -> 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.clone()).expect("foo module to exist");
|
|
let foo_init_path = src.join("foo/__init__.py");
|
|
|
|
assert_eq!(&foo_init_path, foo_module.file().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::touch_path(&mut db, &foo_init_path);
|
|
|
|
let foo_module = resolve_module(&db, foo_module_name).expect("Foo module to resolve");
|
|
assert_eq!(&src.join("foo.py"), foo_module.file().path(&db));
|
|
|
|
Ok(())
|
|
}
|
|
}
|