mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-28 12:55:05 +00:00
Detect empty implicit namespace packages (#14236)
## Summary The implicit namespace package rule currently fails to detect cases like the following: ```text foo/ ├── __init__.py └── bar/ └── baz/ └── __init__.py ``` The problem is that we detect a root at `foo`, and then an independent root at `baz`. We _would_ detect that `bar` is an implicit namespace package, but it doesn't contain any files! So we never check it, and have no place to raise the diagnostic. This PR adds detection for these kinds of nested packages, and augments the `INP` rule to flag the `__init__.py` file above with a specialized message. As a side effect, I've introduced a dedicated `PackageRoot` struct which we can pass around in lieu of Yet Another `Path`. For now, I'm only enabling this in preview (and the approach doesn't affect any other rules). It's a bug fix, but it may end up expanding the rule. Closes https://github.com/astral-sh/ruff/issues/13519.
This commit is contained in:
parent
94dee2a36d
commit
c7d48e10e6
26 changed files with 282 additions and 83 deletions
|
@ -2,6 +2,7 @@
|
|||
//! filesystem.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
@ -18,6 +19,7 @@ use path_slash::PathExt;
|
|||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use ruff_linter::fs;
|
||||
use ruff_linter::package::PackageRoot;
|
||||
use ruff_linter::packaging::is_package;
|
||||
|
||||
use crate::configuration::Configuration;
|
||||
|
@ -147,8 +149,8 @@ impl<'a> Resolver<'a> {
|
|||
fn add(&mut self, path: &Path, settings: Settings) {
|
||||
self.settings.push(settings);
|
||||
|
||||
// normalize the path to use `/` separators and escape the '{' and '}' characters,
|
||||
// which matchit uses for routing parameters
|
||||
// Normalize the path to use `/` separators and escape the '{' and '}' characters,
|
||||
// which matchit uses for routing parameters.
|
||||
let path = path.to_slash_lossy().replace('{', "{{").replace('}', "}}");
|
||||
|
||||
match self
|
||||
|
@ -181,7 +183,10 @@ impl<'a> Resolver<'a> {
|
|||
}
|
||||
|
||||
/// Return a mapping from Python package to its package root.
|
||||
pub fn package_roots(&'a self, files: &[&'a Path]) -> FxHashMap<&'a Path, Option<&'a Path>> {
|
||||
pub fn package_roots(
|
||||
&'a self,
|
||||
files: &[&'a Path],
|
||||
) -> FxHashMap<&'a Path, Option<PackageRoot<'_>>> {
|
||||
// Pre-populate the module cache, since the list of files could (but isn't
|
||||
// required to) contain some `__init__.py` files.
|
||||
let mut package_cache: FxHashMap<&Path, bool> = FxHashMap::default();
|
||||
|
@ -200,7 +205,7 @@ impl<'a> Resolver<'a> {
|
|||
.any(|settings| !settings.linter.namespace_packages.is_empty());
|
||||
|
||||
// Search for the package root for each file.
|
||||
let mut package_roots: FxHashMap<&Path, Option<&Path>> = FxHashMap::default();
|
||||
let mut package_roots: FxHashMap<&Path, Option<PackageRoot<'_>>> = FxHashMap::default();
|
||||
for file in files {
|
||||
if let Some(package) = file.parent() {
|
||||
package_roots.entry(package).or_insert_with(|| {
|
||||
|
@ -210,10 +215,41 @@ impl<'a> Resolver<'a> {
|
|||
&[]
|
||||
};
|
||||
detect_package_root_with_cache(package, namespace_packages, &mut package_cache)
|
||||
.map(|path| PackageRoot::Root { path })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discard any nested roots.
|
||||
//
|
||||
// For example, if `./foo/__init__.py` is a root, and then `./foo/bar` is empty, and
|
||||
// `./foo/bar/baz/__init__.py` was detected as a root, we should only consider
|
||||
// `./foo/__init__.py`.
|
||||
let mut non_roots = FxHashSet::default();
|
||||
let mut router: Router<&Path> = Router::new();
|
||||
for root in package_roots
|
||||
.values()
|
||||
.flatten()
|
||||
.copied()
|
||||
.map(PackageRoot::path)
|
||||
.collect::<BTreeSet<_>>()
|
||||
{
|
||||
// Normalize the path to use `/` separators and escape the '{' and '}' characters,
|
||||
// which matchit uses for routing parameters.
|
||||
let path = root.to_slash_lossy().replace('{', "{{").replace('}', "}}");
|
||||
if let Ok(matched) = router.at_mut(&path) {
|
||||
debug!(
|
||||
"Ignoring nested package root: {} (under {})",
|
||||
root.display(),
|
||||
matched.value.display()
|
||||
);
|
||||
package_roots.insert(root, Some(PackageRoot::nested(root)));
|
||||
non_roots.insert(root);
|
||||
} else {
|
||||
let _ = router.insert(format!("{path}/{{*filepath}}"), root);
|
||||
}
|
||||
}
|
||||
|
||||
package_roots
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue