Skip to content

Commit 5967714

Browse files
committed
[flake8-builtins] Match upstream module name comparison (A005)
See #15951 for the original discussion and reviews. This is just the first half of that PR (reaching parity with `flake8-builtins` without adding any new configuration options) split out for nicer changelog entries.
1 parent 10d3e64 commit 5967714

10 files changed

Lines changed: 250 additions & 31 deletions

crates/ruff/tests/lint.rs

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
#![cfg(not(target_family = "wasm"))]
44

55
use regex::escape;
6-
use std::fs;
76
use std::process::Command;
87
use std::str;
8+
use std::{fs, path::Path};
99

1010
use anyhow::Result;
1111
use assert_fs::fixture::{ChildPath, FileTouch, PathChild};
@@ -2236,3 +2236,114 @@ def func(t: _T) -> _T:
22362236
"
22372237
);
22382238
}
2239+
2240+
#[test]
2241+
fn a005_module_shadowing_strict() -> Result<()> {
2242+
fn create_module(path: &Path) -> Result<()> {
2243+
fs::create_dir(path)?;
2244+
fs::File::create(path.join("__init__.py"))?;
2245+
Ok(())
2246+
}
2247+
// construct a directory tree with this structure:
2248+
// .
2249+
// ├── abc
2250+
// │   └── __init__.py
2251+
// ├── collections
2252+
// │   ├── __init__.py
2253+
// │   ├── abc
2254+
// │   │   └── __init__.py
2255+
// │   └── foobar
2256+
// │   └── __init__.py
2257+
// ├── foobar
2258+
// │   ├── __init__.py
2259+
// │   ├── abc
2260+
// │   │   └── __init__.py
2261+
// │   └── collections
2262+
// │   ├── __init__.py
2263+
// │   ├── abc
2264+
// │   │   └── __init__.py
2265+
// │   └── foobar
2266+
// │   └── __init__.py
2267+
// ├── ruff.toml
2268+
// └── urlparse
2269+
// └── __init__.py
2270+
2271+
let tempdir = TempDir::new()?;
2272+
let foobar = tempdir.path().join("foobar");
2273+
create_module(&foobar)?;
2274+
for base in [&tempdir.path().into(), &foobar] {
2275+
for dir in ["abc", "collections"] {
2276+
create_module(&base.join(dir))?;
2277+
}
2278+
create_module(&base.join("collections").join("abc"))?;
2279+
create_module(&base.join("collections").join("foobar"))?;
2280+
}
2281+
create_module(&tempdir.path().join("urlparse"))?;
2282+
// also create a ruff.toml to mark the project root
2283+
fs::File::create(tempdir.path().join("ruff.toml"))?;
2284+
2285+
insta::with_settings!({
2286+
filters => vec![(r"\\", "/")]
2287+
}, {
2288+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2289+
.args(STDIN_BASE_OPTIONS)
2290+
.args(["--select", "A005"])
2291+
.current_dir(tempdir.path()),
2292+
@r"
2293+
success: false
2294+
exit_code: 1
2295+
----- stdout -----
2296+
abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2297+
collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
2298+
collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2299+
foobar/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2300+
foobar/collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
2301+
foobar/collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2302+
Found 6 errors.
2303+
2304+
----- stderr -----
2305+
");
2306+
2307+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2308+
.args(STDIN_BASE_OPTIONS)
2309+
.args(["--select", "A005"])
2310+
.current_dir(tempdir.path()),
2311+
@r"
2312+
success: false
2313+
exit_code: 1
2314+
----- stdout -----
2315+
abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2316+
collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
2317+
collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2318+
foobar/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2319+
foobar/collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
2320+
foobar/collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2321+
Found 6 errors.
2322+
2323+
----- stderr -----
2324+
");
2325+
2326+
// TODO(brent) Default should currently match the strict version, but after the next minor
2327+
// release it will match the non-strict version directly above
2328+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2329+
.args(STDIN_BASE_OPTIONS)
2330+
.args(["--select", "A005"])
2331+
.current_dir(tempdir.path()),
2332+
@r"
2333+
success: false
2334+
exit_code: 1
2335+
----- stdout -----
2336+
abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2337+
collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
2338+
collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2339+
foobar/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2340+
foobar/collections/__init__.py:1:1: A005 Module `collections` shadows a Python standard-library module
2341+
foobar/collections/abc/__init__.py:1:1: A005 Module `abc` shadows a Python standard-library module
2342+
Found 6 errors.
2343+
2344+
----- stderr -----
2345+
");
2346+
});
2347+
2348+
Ok(())
2349+
}

crates/ruff_linter/resources/test/fixtures/flake8_builtins/A005/modules/utils/__init__.py

Whitespace-only changes.

crates/ruff_linter/resources/test/fixtures/flake8_builtins/A005/modules/utils/logging.py

Whitespace-only changes.

crates/ruff_linter/src/checkers/filesystem.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,7 @@ pub(crate) fn check_file_path(
4646

4747
// flake8-builtins
4848
if settings.rules.enabled(Rule::StdlibModuleShadowing) {
49-
if let Some(diagnostic) = stdlib_module_shadowing(
50-
path,
51-
package,
52-
&settings.flake8_builtins.builtins_allowed_modules,
53-
settings.target_version,
54-
) {
49+
if let Some(diagnostic) = stdlib_module_shadowing(path, settings) {
5550
diagnostics.push(diagnostic);
5651
}
5752
}

crates/ruff_linter/src/rules/flake8_builtins/mod.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ mod tests {
1414
use crate::registry::Rule;
1515
use crate::settings::types::PythonVersion;
1616
use crate::settings::LinterSettings;
17-
use crate::test::test_path;
17+
use crate::test::{test_path, test_resource_path};
1818

1919
#[test_case(Rule::BuiltinVariableShadowing, Path::new("A001.py"))]
2020
#[test_case(Rule::BuiltinArgumentShadowing, Path::new("A002.py"))]
@@ -56,6 +56,69 @@ mod tests {
5656
Ok(())
5757
}
5858

59+
#[test_case(
60+
Rule::StdlibModuleShadowing,
61+
Path::new("A005/modules/utils/logging.py"),
62+
true
63+
)]
64+
#[test_case(
65+
Rule::StdlibModuleShadowing,
66+
Path::new("A005/modules/utils/logging.py"),
67+
false
68+
)]
69+
fn non_strict_checking(rule_code: Rule, path: &Path, strict: bool) -> Result<()> {
70+
let snapshot = format!(
71+
"{}_{}_{strict}",
72+
rule_code.noqa_code(),
73+
path.to_string_lossy()
74+
);
75+
let diagnostics = test_path(
76+
Path::new("flake8_builtins").join(path).as_path(),
77+
&LinterSettings::for_rule(rule_code),
78+
)?;
79+
assert_messages!(snapshot, diagnostics);
80+
Ok(())
81+
}
82+
83+
/// Test that even with strict checking disabled, a module in `src` will trigger A005
84+
#[test_case(
85+
Rule::StdlibModuleShadowing,
86+
Path::new("A005/modules/utils/logging.py")
87+
)]
88+
fn non_strict_checking_src(rule_code: Rule, path: &Path) -> Result<()> {
89+
let snapshot = format!("{}_{}_src", rule_code.noqa_code(), path.to_string_lossy());
90+
let src = Path::new("fixtures/flake8_builtins");
91+
let diagnostics = test_path(
92+
Path::new("flake8_builtins").join(path).as_path(),
93+
&LinterSettings {
94+
src: vec![test_resource_path(src.join(path.parent().unwrap()))],
95+
..LinterSettings::for_rule(rule_code)
96+
},
97+
)?;
98+
assert_messages!(snapshot, diagnostics);
99+
Ok(())
100+
}
101+
102+
/// Test that even with strict checking disabled, a module in the `project_root` will trigger
103+
/// A005
104+
#[test_case(
105+
Rule::StdlibModuleShadowing,
106+
Path::new("A005/modules/utils/logging.py")
107+
)]
108+
fn non_strict_checking_root(rule_code: Rule, path: &Path) -> Result<()> {
109+
let snapshot = format!("{}_{}_root", rule_code.noqa_code(), path.to_string_lossy());
110+
let src = Path::new("fixtures/flake8_builtins");
111+
let diagnostics = test_path(
112+
Path::new("flake8_builtins").join(path).as_path(),
113+
&LinterSettings {
114+
project_root: test_resource_path(src.join(path.parent().unwrap())),
115+
..LinterSettings::for_rule(rule_code)
116+
},
117+
)?;
118+
assert_messages!(snapshot, diagnostics);
119+
Ok(())
120+
}
121+
59122
#[test_case(Rule::BuiltinVariableShadowing, Path::new("A001.py"))]
60123
#[test_case(Rule::BuiltinArgumentShadowing, Path::new("A002.py"))]
61124
#[test_case(Rule::BuiltinAttributeShadowing, Path::new("A003.py"))]

crates/ruff_linter/src/rules/flake8_builtins/rules/stdlib_module_shadowing.rs

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use std::path::Path;
1+
use std::borrow::Cow;
2+
use std::path::{Component, Path, PathBuf};
23

34
use ruff_diagnostics::{Diagnostic, Violation};
45
use ruff_macros::{derive_message_formats, ViolationMetadata};
@@ -7,8 +8,7 @@ use ruff_python_stdlib::path::is_module_file;
78
use ruff_python_stdlib::sys::is_known_standard_library;
89
use ruff_text_size::TextRange;
910

10-
use crate::package::PackageRoot;
11-
use crate::settings::types::PythonVersion;
11+
use crate::settings::LinterSettings;
1212

1313
/// ## What it does
1414
/// Checks for modules that use the same names as Python standard-library
@@ -58,37 +58,38 @@ impl Violation for StdlibModuleShadowing {
5858

5959
/// A005
6060
pub(crate) fn stdlib_module_shadowing(
61-
path: &Path,
62-
package: Option<PackageRoot<'_>>,
63-
allowed_modules: &[String],
64-
target_version: PythonVersion,
61+
mut path: &Path,
62+
settings: &LinterSettings,
6563
) -> Option<Diagnostic> {
6664
if !PySourceType::try_from_path(path).is_some_and(PySourceType::is_py_file) {
6765
return None;
6866
}
6967

70-
let package = package?;
68+
// strip src and root prefixes before converting to a fully-qualified module path
69+
let prefix = get_prefix(settings, path);
70+
if let Some(Ok(new_path)) = prefix.map(|p| path.strip_prefix(p)) {
71+
path = new_path;
72+
}
7173

72-
let module_name = if is_module_file(path) {
73-
package.path().file_name().unwrap().to_string_lossy()
74+
// for modules like `modname/__init__.py`, use the parent directory name, otherwise just trim
75+
// the `.py` extension
76+
let path = if is_module_file(path) {
77+
Cow::from(path.parent()?)
7478
} else {
75-
path.file_stem().unwrap().to_string_lossy()
79+
Cow::from(path.with_extension(""))
7680
};
7781

78-
if !is_known_standard_library(target_version.minor(), &module_name) {
79-
return None;
80-
}
82+
// convert a filesystem path like `foobar/collections/abc` to a reversed sequence of modules
83+
// like `["abc", "collections", "foobar"]`, stripping anything that's not a normal component
84+
let mut components = path
85+
.components()
86+
.filter(|c| matches!(c, Component::Normal(_)))
87+
.map(|c| c.as_os_str().to_string_lossy())
88+
.rev();
8189

82-
// Shadowing private stdlib modules is okay.
83-
// https://github.com/astral-sh/ruff/issues/12949
84-
if module_name.starts_with('_') && !module_name.starts_with("__") {
85-
return None;
86-
}
90+
let module_name = components.next()?;
8791

88-
if allowed_modules
89-
.iter()
90-
.any(|allowed_module| allowed_module == &module_name)
91-
{
92+
if is_allowed_module(settings, &module_name) {
9293
return None;
9394
}
9495

@@ -99,3 +100,36 @@ pub(crate) fn stdlib_module_shadowing(
99100
TextRange::default(),
100101
))
101102
}
103+
104+
/// Return the longest prefix of `path` between `settings.src` and `settings.project_root`.
105+
fn get_prefix<'a>(settings: &'a LinterSettings, path: &Path) -> Option<&'a PathBuf> {
106+
let mut prefix = None;
107+
let mut prefix_len = 0;
108+
for dir in settings.src.iter().chain([&settings.project_root]) {
109+
let len = dir.as_os_str().len();
110+
if len > prefix_len && path.starts_with(dir) {
111+
prefix = Some(dir);
112+
prefix_len = len;
113+
}
114+
}
115+
prefix
116+
}
117+
118+
fn is_allowed_module(settings: &LinterSettings, module: &str) -> bool {
119+
// Shadowing private stdlib modules is okay.
120+
// https://github.com/astral-sh/ruff/issues/12949
121+
if module.starts_with('_') && !module.starts_with("__") {
122+
return true;
123+
}
124+
125+
if settings
126+
.flake8_builtins
127+
.builtins_allowed_modules
128+
.iter()
129+
.any(|allowed_module| allowed_module == module)
130+
{
131+
return true;
132+
}
133+
134+
!is_known_standard_library(settings.target_version.minor(), module)
135+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_builtins/mod.rs
3+
---
4+
logging.py:1:1: A005 Module `logging` shadows a Python standard-library module
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_builtins/mod.rs
3+
---
4+
logging.py:1:1: A005 Module `logging` shadows a Python standard-library module
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_builtins/mod.rs
3+
---
4+
logging.py:1:1: A005 Module `logging` shadows a Python standard-library module
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_builtins/mod.rs
3+
---
4+
logging.py:1:1: A005 Module `logging` shadows a Python standard-library module

0 commit comments

Comments
 (0)