Skip to content

Commit 0a74bc8

Browse files
committed
do the better fix
1 parent f041004 commit 0a74bc8

14 files changed

+186
-105
lines changed

crates/ruff_linter/src/fix/edits.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
//! Interface for generating fix edits from higher-level actions (e.g., "remove an argument").
22
3-
use std::borrow::Cow;
4-
53
use anyhow::{Context, Result};
64

75
use ruff_diagnostics::Edit;
@@ -126,7 +124,7 @@ pub(crate) fn remove_unused_imports<'a>(
126124

127125
/// Edits to make the specified imports explicit, e.g. change `import x` to `import x as x`.
128126
pub(crate) fn make_redundant_alias<'a>(
129-
member_names: impl Iterator<Item = Cow<'a, str>>,
127+
member_names: impl Iterator<Item = &'a str>,
130128
stmt: &Stmt,
131129
) -> Vec<Edit> {
132130
let aliases = match stmt {
@@ -527,7 +525,6 @@ fn all_lines_fit(
527525
#[cfg(test)]
528526
mod tests {
529527
use anyhow::{anyhow, Result};
530-
use std::borrow::Cow;
531528
use test_case::test_case;
532529

533530
use ruff_diagnostics::{Diagnostic, Edit, Fix};
@@ -619,23 +616,23 @@ x = 1 \
619616
let contents = "import x, y as y, z as bees";
620617
let stmt = parse_first_stmt(contents)?;
621618
assert_eq!(
622-
make_redundant_alias(["x"].into_iter().map(Cow::from), &stmt),
619+
make_redundant_alias(["x"].into_iter(), &stmt),
623620
vec![Edit::range_replacement(
624621
String::from("x as x"),
625622
TextRange::new(TextSize::new(7), TextSize::new(8)),
626623
)],
627624
"make just one item redundant"
628625
);
629626
assert_eq!(
630-
make_redundant_alias(vec!["x", "y"].into_iter().map(Cow::from), &stmt),
627+
make_redundant_alias(vec!["x", "y"].into_iter(), &stmt),
631628
vec![Edit::range_replacement(
632629
String::from("x as x"),
633630
TextRange::new(TextSize::new(7), TextSize::new(8)),
634631
)],
635632
"the second item is already a redundant alias"
636633
);
637634
assert_eq!(
638-
make_redundant_alias(vec!["x", "z"].into_iter().map(Cow::from), &stmt),
635+
make_redundant_alias(vec!["x", "z"].into_iter(), &stmt),
639636
vec![Edit::range_replacement(
640637
String::from("x as x"),
641638
TextRange::new(TextSize::new(7), TextSize::new(8)),

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,16 @@ mod tests {
234234
Ok(())
235235
}
236236

237+
#[test_case(
238+
r"import submodule.a",
239+
"f401_preview_first_party_submodule_no_dunder_all"
240+
)]
237241
#[test_case(
238242
r"
239243
import submodule.a
240244
__all__ = ['FOO']
241245
FOO = 42",
242-
"f401_preview_first_party_submodule"
246+
"f401_preview_first_party_submodule_dunder_all"
243247
)]
244248
fn f401_preview_first_party_submodule(contents: &str, snapshot: &str) {
245249
let diagnostics = test_contents(

crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs

Lines changed: 142 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ruff_macros::{derive_message_formats, violation};
99
use ruff_python_ast as ast;
1010
use ruff_python_ast::{Stmt, StmtImportFrom};
1111
use ruff_python_semantic::{
12-
AnyImport, BindingKind, Exceptions, Imported, NodeId, Scope, SemanticModel,
12+
AnyImport, BindingKind, Exceptions, Imported, NodeId, Scope, SemanticModel, SubmoduleImport,
1313
};
1414
use ruff_text_size::{Ranged, TextRange};
1515

@@ -18,16 +18,6 @@ use crate::fix;
1818
use crate::registry::Rule;
1919
use crate::rules::{isort, isort::ImportSection, isort::ImportType};
2020

21-
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
22-
enum UnusedImportContext {
23-
ExceptHandler,
24-
Init {
25-
first_party: bool,
26-
dunder_all_count: usize,
27-
ignore_init_module_imports: bool,
28-
},
29-
}
30-
3121
/// ## What it does
3222
/// Checks for unused imports.
3323
///
@@ -111,8 +101,9 @@ pub struct UnusedImport {
111101
module: String,
112102
/// Name of the import binding
113103
binding: String,
114-
context: Option<UnusedImportContext>,
104+
context: UnusedImportContext,
115105
multiple: bool,
106+
ignore_init_module_imports: bool,
116107
}
117108

118109
impl Violation for UnusedImport {
@@ -122,17 +113,17 @@ impl Violation for UnusedImport {
122113
fn message(&self) -> String {
123114
let UnusedImport { name, context, .. } = self;
124115
match context {
125-
Some(UnusedImportContext::ExceptHandler) => {
116+
UnusedImportContext::ExceptHandler => {
126117
format!(
127118
"`{name}` imported but unused; consider using `importlib.util.find_spec` to test for availability"
128119
)
129120
}
130-
Some(UnusedImportContext::Init { .. }) => {
121+
UnusedImportContext::DunderInitFirstParty { .. } => {
131122
format!(
132123
"`{name}` imported but unused; consider removing, adding to `__all__`, or using a redundant alias"
133124
)
134125
}
135-
None => format!("`{name}` imported but unused"),
126+
UnusedImportContext::Other => format!("`{name}` imported but unused"),
136127
}
137128
}
138129

@@ -142,30 +133,91 @@ impl Violation for UnusedImport {
142133
module,
143134
binding,
144135
multiple,
145-
..
136+
ignore_init_module_imports,
137+
context,
146138
} = self;
147-
match self.context {
148-
Some(UnusedImportContext::Init {
149-
first_party: true,
150-
dunder_all_count: 1,
151-
ignore_init_module_imports: true,
152-
}) => Some(format!("Add unused import `{binding}` to __all__")),
153-
154-
Some(UnusedImportContext::Init {
155-
first_party: true,
156-
dunder_all_count: 0,
157-
ignore_init_module_imports: true,
158-
}) => Some(format!("Use an explicit re-export: `{module} as {module}`")),
159-
160-
_ => Some(if *multiple {
161-
"Remove unused import".to_string()
162-
} else {
163-
format!("Remove unused import: `{name}`")
164-
}),
139+
if *ignore_init_module_imports {
140+
match context {
141+
UnusedImportContext::DunderInitFirstParty {
142+
dunder_all_count: DunderAllCount::Zero,
143+
submodule_import: false,
144+
} => return Some(format!("Use an explicit re-export: `{module} as {module}`")),
145+
UnusedImportContext::DunderInitFirstParty {
146+
dunder_all_count: DunderAllCount::Zero,
147+
submodule_import: true,
148+
} => {
149+
return Some(format!(
150+
"Use an explicit re-export: `import {parent} as {parent}; import {binding}`",
151+
parent = binding
152+
.split('.')
153+
.next()
154+
.expect("Expected all submodule imports to contain a '.'")
155+
))
156+
}
157+
UnusedImportContext::DunderInitFirstParty {
158+
dunder_all_count: DunderAllCount::One,
159+
submodule_import: false,
160+
} => return Some(format!("Add unused import `{binding}` to __all__")),
161+
UnusedImportContext::DunderInitFirstParty {
162+
dunder_all_count: DunderAllCount::One,
163+
submodule_import: true,
164+
} => {
165+
return Some(format!(
166+
"Add `{}` to __all__",
167+
binding
168+
.split('.')
169+
.next()
170+
.expect("Expected all submodule imports to contain a '.'")
171+
))
172+
}
173+
UnusedImportContext::DunderInitFirstParty {
174+
dunder_all_count: DunderAllCount::Many,
175+
submodule_import: _,
176+
}
177+
| UnusedImportContext::ExceptHandler
178+
| UnusedImportContext::Other => {}
179+
}
180+
}
181+
Some(if *multiple {
182+
"Remove unused import".to_string()
183+
} else {
184+
format!("Remove unused import: `{name}`")
185+
})
186+
}
187+
}
188+
189+
/// Enumeration providing three possible answers to the question:
190+
/// "How many `__all__` definitions are there in this file?"
191+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192+
enum DunderAllCount {
193+
Zero,
194+
One,
195+
Many,
196+
}
197+
198+
impl From<usize> for DunderAllCount {
199+
fn from(value: usize) -> Self {
200+
match value {
201+
0 => Self::Zero,
202+
1 => Self::One,
203+
_ => Self::Many,
165204
}
166205
}
167206
}
168207

208+
#[derive(Debug, Copy, Clone, Eq, PartialEq, is_macro::Is)]
209+
enum UnusedImportContext {
210+
/// The unused import occurs inside an except handler
211+
ExceptHandler,
212+
/// The unused import is a first-party import in an `__init__.py` file
213+
DunderInitFirstParty {
214+
dunder_all_count: DunderAllCount,
215+
submodule_import: bool,
216+
},
217+
/// The unused import is something else
218+
Other,
219+
}
220+
169221
fn is_first_party(qualified_name: &str, level: u32, checker: &Checker) -> bool {
170222
let category = isort::categorize(
171223
qualified_name,
@@ -304,31 +356,20 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope, diagnostics: &mut
304356
.into_iter()
305357
.map(|binding| {
306358
let context = if in_except_handler {
307-
Some(UnusedImportContext::ExceptHandler)
308-
} else if in_init && !binding.import.is_submodule_import() {
309-
Some(UnusedImportContext::Init {
310-
first_party: is_first_party(
311-
&binding.import.qualified_name().to_string(),
312-
level,
313-
checker,
314-
),
315-
dunder_all_count: dunder_all_exprs.len(),
316-
ignore_init_module_imports: !fix_init,
317-
})
359+
UnusedImportContext::ExceptHandler
360+
} else if in_init
361+
&& is_first_party(&binding.import.qualified_name().to_string(), level, checker)
362+
{
363+
UnusedImportContext::DunderInitFirstParty {
364+
dunder_all_count: DunderAllCount::from(dunder_all_exprs.len()),
365+
submodule_import: binding.import.is_submodule_import(),
366+
}
318367
} else {
319-
None
368+
UnusedImportContext::Other
320369
};
321370
(binding, context)
322371
})
323-
.partition(|(_, context)| {
324-
matches!(
325-
context,
326-
Some(UnusedImportContext::Init {
327-
first_party: true,
328-
..
329-
})
330-
) && preview_mode
331-
});
372+
.partition(|(_, context)| context.is_dunder_init_first_party() && preview_mode);
332373

333374
// generate fixes that are shared across bindings in the statement
334375
let (fix_remove, fix_reexport) =
@@ -344,7 +385,7 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope, diagnostics: &mut
344385
fix_by_reexporting(
345386
checker,
346387
import_statement,
347-
to_reexport.iter().map(|(b, _)| b).collect::<Vec<_>>(),
388+
to_reexport.iter().map(|(b, _)| b),
348389
&dunder_all_exprs,
349390
)
350391
.ok(),
@@ -364,6 +405,7 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope, diagnostics: &mut
364405
binding: binding.name.to_string(),
365406
context,
366407
multiple,
408+
ignore_init_module_imports: !fix_init,
367409
},
368410
binding.range,
369411
);
@@ -387,8 +429,9 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope, diagnostics: &mut
387429
name: binding.import.qualified_name().to_string(),
388430
module: binding.import.member_name().to_string(),
389431
binding: binding.name.to_string(),
390-
context: None,
432+
context: UnusedImportContext::Other,
391433
multiple: false,
434+
ignore_init_module_imports: !fix_init,
392435
},
393436
binding.range,
394437
);
@@ -412,6 +455,31 @@ struct ImportBinding<'a> {
412455
parent_range: Option<TextRange>,
413456
}
414457

458+
impl<'a> ImportBinding<'a> {
459+
/// The symbol that is stored in the outer scope as a result of this import.
460+
///
461+
/// For example:
462+
/// - `import foo` => `foo` symbol stored in outer scope
463+
/// - `import foo as bar` => `bar` symbol stored in outer scope
464+
/// - `from foo import bar` => `bar` symbol stored in outer scope
465+
/// - `from foo import bar as baz` => `baz` symbol stored in outer scope
466+
/// - `import foo.bar` => `foo` symbol stored in outer scope
467+
fn symbol_stored_in_outer_scope(&self) -> &str {
468+
match &self.import {
469+
AnyImport::FromImport(_) => self.name,
470+
AnyImport::Import(_) => self.name,
471+
AnyImport::SubmoduleImport(SubmoduleImport { qualified_name }) => {
472+
qualified_name.segments().first().unwrap_or_else(|| {
473+
panic!(
474+
"Expected an import binding to have a non-empty qualified name;
475+
got {qualified_name}"
476+
)
477+
})
478+
}
479+
}
480+
}
481+
}
482+
415483
impl Ranged for ImportBinding<'_> {
416484
fn range(&self) -> TextRange {
417485
self.range
@@ -461,29 +529,31 @@ fn fix_by_removing_imports<'a>(
461529

462530
/// Generate a [`Fix`] to make bindings in a statement explicit, either by adding them to `__all__`
463531
/// or changing them from `import a` to `import a as a`.
464-
fn fix_by_reexporting(
532+
fn fix_by_reexporting<'a>(
465533
checker: &Checker,
466534
node_id: NodeId,
467-
mut imports: Vec<&ImportBinding>,
535+
imports: impl IntoIterator<Item = &'a ImportBinding<'a>>,
468536
dunder_all_exprs: &[&ast::Expr],
469537
) -> Result<Fix> {
470538
let statement = checker.semantic().statement(node_id);
471-
if imports.is_empty() {
472-
bail!("Expected import bindings");
473-
}
474539

475-
imports.sort_by_key(|b| b.name);
540+
let imports = {
541+
let mut imports: Vec<&str> = imports
542+
.into_iter()
543+
.map(ImportBinding::symbol_stored_in_outer_scope)
544+
.collect();
545+
if imports.is_empty() {
546+
bail!("Expected import bindings");
547+
}
548+
imports.sort_unstable();
549+
imports
550+
};
476551

477552
let edits = match dunder_all_exprs {
478-
[] => fix::edits::make_redundant_alias(
479-
imports.iter().map(|b| b.import.member_name()),
480-
statement,
481-
),
482-
[dunder_all] => fix::edits::add_to_dunder_all(
483-
imports.iter().map(|b| b.name),
484-
dunder_all,
485-
checker.stylist(),
486-
),
553+
[] => fix::edits::make_redundant_alias(imports.into_iter(), statement),
554+
[dunder_all] => {
555+
fix::edits::add_to_dunder_all(imports.into_iter(), dunder_all, checker.stylist())
556+
}
487557
_ => bail!("Cannot offer a fix when there are multiple __all__ definitions"),
488558
};
489559

crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_deprecated_option_F401_24____init__.py.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
33
---
4-
__init__.py:19:8: F401 [*] `sys` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
4+
__init__.py:19:8: F401 [*] `sys` imported but unused
55
|
66
19 | import sys # F401: remove unused
77
| ^^^ F401

0 commit comments

Comments
 (0)