From b99a6099a1986e12f98dd5f7c259582e26a5b00a Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 18 Aug 2025 10:16:52 -0500 Subject: [PATCH 01/33] factor out getting candidate unused imports --- .../src/rules/pyflakes/rules/unused_import.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 9f73bb3b9707f..2b45f86e09981 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -8,7 +8,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::name::QualifiedName; use ruff_python_ast::{self as ast, Stmt}; use ruff_python_semantic::{ - AnyImport, BindingKind, Exceptions, Imported, NodeId, Scope, ScopeId, SemanticModel, + AnyImport, Binding, BindingKind, Exceptions, Imported, NodeId, Scope, ScopeId, SemanticModel, SubmoduleImport, }; use ruff_text_size::{Ranged, TextRange}; @@ -284,9 +284,7 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope) { let mut unused: BTreeMap<(NodeId, Exceptions), Vec> = BTreeMap::default(); let mut ignored: BTreeMap<(NodeId, Exceptions), Vec> = BTreeMap::default(); - for binding_id in scope.binding_ids() { - let binding = checker.semantic().binding(binding_id); - + for binding in unused_imports_in_scope(checker.semantic(), scope) { if binding.is_used() || binding.is_explicit_export() || binding.is_nonlocal() @@ -582,3 +580,10 @@ fn fix_by_reexporting<'a>( let isolation = Checker::isolation(checker.semantic().parent_statement_id(node_id)); Ok(Fix::safe_edits(head, tail).isolate(isolation)) } + +fn unused_imports_in_scope<'a, 'b>( + semantic: &'a SemanticModel<'b>, + scope: &'a Scope, +) -> impl Iterator> { + scope.binding_ids().map(|id| semantic.binding(id)) +} From b906a21fc38767f32b16aa0afdb073955970ef6c Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 18 Aug 2025 15:16:15 -0500 Subject: [PATCH 02/33] first complete attempt --- .../src/rules/pyflakes/rules/unused_import.rs | 131 ++++++++++++++++-- 1 file changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 2b45f86e09981..8a0c53995cc5e 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -5,11 +5,11 @@ use anyhow::{Result, anyhow, bail}; use std::collections::BTreeMap; use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_ast::name::QualifiedName; +use ruff_python_ast::name::{QualifiedName, QualifiedNameBuilder}; use ruff_python_ast::{self as ast, Stmt}; use ruff_python_semantic::{ - AnyImport, Binding, BindingKind, Exceptions, Imported, NodeId, Scope, ScopeId, SemanticModel, - SubmoduleImport, + AnyImport, Binding, BindingId, BindingKind, Exceptions, Imported, NodeId, Scope, ScopeId, + SemanticModel, SubmoduleImport, }; use ruff_text_size::{Ranged, TextRange}; @@ -285,11 +285,7 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope) { let mut ignored: BTreeMap<(NodeId, Exceptions), Vec> = BTreeMap::default(); for binding in unused_imports_in_scope(checker.semantic(), scope) { - if binding.is_used() - || binding.is_explicit_export() - || binding.is_nonlocal() - || binding.is_global() - { + if binding.is_used() { continue; } @@ -585,5 +581,122 @@ fn unused_imports_in_scope<'a, 'b>( semantic: &'a SemanticModel<'b>, scope: &'a Scope, ) -> impl Iterator> { - scope.binding_ids().map(|id| semantic.binding(id)) + scope + .binding_ids() + .map(|id| (id, semantic.binding(id))) + .filter(|(_, bdg)| { + matches!( + bdg.kind, + BindingKind::Import(_) + | BindingKind::FromImport(_) + | BindingKind::SubmoduleImport(_) + ) + }) + .filter(|(_, bdg)| !bdg.is_global() && !bdg.is_nonlocal() && !bdg.is_explicit_export()) + .flat_map(|(id, bdg)| { + if scope.shadowed_bindings(id).all(|shadow| { + matches!( + semantic.binding(shadow).kind, + BindingKind::Import(_) | BindingKind::SubmoduleImport(_) + ) + }) { + unused_imports_from_binding(semantic, id, scope) + } else { + vec![bdg] + } + }) +} + +fn unused_imports_from_binding<'a, 'b>( + semantic: &'a SemanticModel<'b>, + id: BindingId, + scope: &'a Scope, +) -> Vec<&'a Binding<'b>> { + let binding = semantic.binding(id); + let mut unused: Vec<_> = scope + .shadowed_bindings(id) + .map(|id| semantic.binding(id)) + .collect(); + + for ref_id in binding.references() { + let Some(expr_id) = semantic.reference(ref_id).expression_id() else { + continue; + }; + remove_uses_of_ref(semantic, &mut unused, expr_id); + } + + unused +} + +fn expand_to_qualified_name_attribute<'b>( + semantic: &SemanticModel<'b>, + expr_id: NodeId, +) -> Option> { + let mut builder = QualifiedNameBuilder::with_capacity(16); + + let mut expr_id = expr_id; + + let expr = semantic.expression(expr_id)?; + + let name = expr.as_name_expr()?; + + builder.push(&name.id); + + while let Some(node_id) = semantic.parent_expression_id(expr_id) { + let Some(expr) = semantic.expression(node_id) else { + break; + }; + let Some(expr_attr) = expr.as_attribute_expr() else { + break; + }; + builder.push(expr_attr.attr.as_str()); + expr_id = node_id; + } + Some(builder.build()) +} + +fn remove_uses_of_ref(semantic: &SemanticModel, unused: &mut Vec<&Binding>, expr_id: NodeId) { + let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { + return; + }; + + let Some(best) = best_match(unused, &prototype) else { + return; + }; + + let Some(bimp) = best.as_any_import() else { + return; + }; + + let bname = bimp.qualified_name(); + + unused.retain(|binding| { + binding + .as_any_import() + .is_some_and(|imp| imp.qualified_name() != bname) + }); +} + +fn rank_match(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cmp::Reverse) { + let Some(import) = binding.as_any_import() else { + unreachable!() + }; + let qname = import.qualified_name(); + let left = qname + .segments() + .iter() + .zip(prototype.segments()) + .take_while(|(x, y)| x == y) + .count(); + (left, std::cmp::Reverse(qname.segments().len())) +} + +fn best_match<'a, 'b, 'c>( + unused: &'a Vec<&'b Binding<'c>>, + prototype: &'a QualifiedName, +) -> Option<&'b Binding<'c>> { + unused + .iter() + .max_by_key(|binding| rank_match(binding, prototype)) + .map(|v| &**v) } From e8bb57527540f41946d08bfc494f5d6c65ed1384 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 18 Aug 2025 15:23:14 -0500 Subject: [PATCH 03/33] update snapshot --- ...ules__pyflakes__tests__F401_F401_0.py.snap | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap index 6f4ab6f43d155..2b55906c277a9 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap @@ -37,6 +37,27 @@ help: Remove unused import: `collections.OrderedDict` 7 | ) 8 | import multiprocessing.pool +F401 [*] `logging.config` imported but unused + --> F401_0.py:11:8 + | + 9 | import multiprocessing.pool +10 | import multiprocessing.process +11 | import logging.config + | ^^^^^^^^^^^^^^ +12 | import logging.handlers +13 | from typing import ( + | +help: Remove unused import: `logging.config` + +ℹ Safe fix +8 8 | ) +9 9 | import multiprocessing.pool +10 10 | import multiprocessing.process +11 |-import logging.config +12 11 | import logging.handlers +13 12 | from typing import ( +14 13 | TYPE_CHECKING, + F401 [*] `logging.handlers` imported but unused --> F401_0.py:12:8 | From 3462567f71adf4d861a06d497aa919eaa39050e4 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 25 Aug 2025 11:27:53 -0500 Subject: [PATCH 04/33] fallback when shadowed have aliases --- .../src/rules/pyflakes/rules/unused_import.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 8a0c53995cc5e..2c28a48902b79 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -8,8 +8,8 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::name::{QualifiedName, QualifiedNameBuilder}; use ruff_python_ast::{self as ast, Stmt}; use ruff_python_semantic::{ - AnyImport, Binding, BindingId, BindingKind, Exceptions, Imported, NodeId, Scope, ScopeId, - SemanticModel, SubmoduleImport, + AnyImport, Binding, BindingFlags, BindingId, BindingKind, Exceptions, Imported, NodeId, Scope, + ScopeId, SemanticModel, SubmoduleImport, }; use ruff_text_size::{Ranged, TextRange}; @@ -285,10 +285,6 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope) { let mut ignored: BTreeMap<(NodeId, Exceptions), Vec> = BTreeMap::default(); for binding in unused_imports_in_scope(checker.semantic(), scope) { - if binding.is_used() { - continue; - } - let Some(import) = binding.as_any_import() else { continue; }; @@ -595,12 +591,15 @@ fn unused_imports_in_scope<'a, 'b>( .filter(|(_, bdg)| !bdg.is_global() && !bdg.is_nonlocal() && !bdg.is_explicit_export()) .flat_map(|(id, bdg)| { if scope.shadowed_bindings(id).all(|shadow| { + let shadowed_binding = semantic.binding(shadow); matches!( - semantic.binding(shadow).kind, + shadowed_binding.kind, BindingKind::Import(_) | BindingKind::SubmoduleImport(_) - ) + ) && !shadowed_binding.flags.contains(BindingFlags::ALIAS) }) { unused_imports_from_binding(semantic, id, scope) + } else if bdg.is_used() { + vec![] } else { vec![bdg] } From 4111a0464e5be75b68b0f18262236e535e6101dd Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 29 Aug 2025 16:35:15 -0500 Subject: [PATCH 05/33] handle dunder all exports --- crates/ruff_linter/src/rules/pyflakes/mod.rs | 6 +- .../src/rules/pyflakes/rules/unused_import.rs | 88 +++++++++++++++---- ...ules__pyflakes__tests__F401_F401_0.py.snap | 35 ++++++-- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index b645198b494b8..0397479da8995 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -2760,7 +2760,7 @@ lambda: fu import fu.bar fu.x ", - &[], + &[Rule::UnusedImport], ); flakes( @@ -2769,7 +2769,7 @@ lambda: fu import fu fu.x ", - &[], + &[Rule::UnusedImport], ); } @@ -2803,7 +2803,7 @@ lambda: fu import fu import fu.bar ", - &[Rule::UnusedImport], + &[Rule::UnusedImport, Rule::UnusedImport], ); } diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 2c28a48902b79..9c130c5c4b1df 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -606,25 +606,69 @@ fn unused_imports_in_scope<'a, 'b>( }) } +#[derive(Debug)] +struct MarkedBindings<'a, 'b> { + bindings: Vec<&'a Binding<'b>>, + used: Vec, +} + +impl<'a, 'b> MarkedBindings<'a, 'b> { + fn from_binding_id(semantic: &'a SemanticModel<'b>, id: BindingId, scope: &'a Scope) -> Self { + let unused: Vec<_> = scope + .shadowed_bindings(id) + .map(|id| semantic.binding(id)) + .collect(); + + let num_unused = &unused.len(); + + Self { + bindings: unused, + used: vec![false; *num_unused], + } + } + + fn to_unused(self) -> Vec<&'a Binding<'b>> { + self.bindings + .into_iter() + .zip(self.used.into_iter()) + .filter_map(|(bdg, is_used)| (!is_used).then_some(bdg)) + .collect() + } + + fn iter_mut(&mut self) -> impl Iterator, &mut bool)> { + self.bindings.iter().copied().zip(self.used.iter_mut()) + } +} + fn unused_imports_from_binding<'a, 'b>( semantic: &'a SemanticModel<'b>, id: BindingId, scope: &'a Scope, ) -> Vec<&'a Binding<'b>> { + let mut marked = MarkedBindings::from_binding_id(semantic, id, scope); + let binding = semantic.binding(id); - let mut unused: Vec<_> = scope - .shadowed_bindings(id) - .map(|id| semantic.binding(id)) - .collect(); + + // ensure we only do this once since it involves an allocation + let mut marked_dunder_all = false; for ref_id in binding.references() { - let Some(expr_id) = semantic.reference(ref_id).expression_id() else { + let resolved_reference = semantic.reference(ref_id); + if !marked_dunder_all && resolved_reference.in_dunder_all_definition() { + let first = binding.as_any_import().unwrap().qualified_name().segments()[0]; + mark_uses_of_qualified_name(&mut marked, &QualifiedName::user_defined(first)); + marked_dunder_all = true; continue; + } + let Some(expr_id) = resolved_reference.expression_id() else { + // bail if there is some other kind of reference + dbg!("isn't this unreachable???"); + return vec![binding]; }; - remove_uses_of_ref(semantic, &mut unused, expr_id); + mark_uses_of_ref(semantic, &mut marked, expr_id); } - unused + marked.to_unused() } fn expand_to_qualified_name_attribute<'b>( @@ -654,12 +698,8 @@ fn expand_to_qualified_name_attribute<'b>( Some(builder.build()) } -fn remove_uses_of_ref(semantic: &SemanticModel, unused: &mut Vec<&Binding>, expr_id: NodeId) { - let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { - return; - }; - - let Some(best) = best_match(unused, &prototype) else { +fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &QualifiedName) { + let Some(best) = best_match(&marked.bindings, &prototype) else { return; }; @@ -669,11 +709,25 @@ fn remove_uses_of_ref(semantic: &SemanticModel, unused: &mut Vec<&Binding>, expr let bname = bimp.qualified_name(); - unused.retain(|binding| { - binding + for (binding, is_used) in marked.iter_mut() { + if *is_used { + continue; + } + + if binding .as_any_import() - .is_some_and(|imp| imp.qualified_name() != bname) - }); + .is_some_and(|imp| imp.qualified_name() == bname) + { + *is_used = true; + } + } +} +fn mark_uses_of_ref(semantic: &SemanticModel, marked: &mut MarkedBindings, expr_id: NodeId) { + let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { + return; + }; + + mark_uses_of_qualified_name(marked, &prototype); } fn rank_match(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cmp::Reverse) { diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap index 2b55906c277a9..12f31379782f0 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap @@ -37,6 +37,25 @@ help: Remove unused import: `collections.OrderedDict` 7 | ) 8 | import multiprocessing.pool +F401 [*] `multiprocessing.process` imported but unused + --> F401_0.py:10:8 + | + 8 | ) + 9 | import multiprocessing.pool +10 | import multiprocessing.process + | ^^^^^^^^^^^^^^^^^^^^^^^ +11 | import logging.config +12 | import logging.handlers + | +help: Remove unused import: `multiprocessing.process` +7 | namedtuple, +8 | ) +9 | import multiprocessing.pool + - import multiprocessing.process +10 | import logging.config +11 | import logging.handlers +12 | from typing import ( + F401 [*] `logging.config` imported but unused --> F401_0.py:11:8 | @@ -48,15 +67,13 @@ F401 [*] `logging.config` imported but unused 13 | from typing import ( | help: Remove unused import: `logging.config` - -ℹ Safe fix -8 8 | ) -9 9 | import multiprocessing.pool -10 10 | import multiprocessing.process -11 |-import logging.config -12 11 | import logging.handlers -13 12 | from typing import ( -14 13 | TYPE_CHECKING, +8 | ) +9 | import multiprocessing.pool +10 | import multiprocessing.process + - import logging.config +11 | import logging.handlers +12 | from typing import ( +13 | TYPE_CHECKING, F401 [*] `logging.handlers` imported but unused --> F401_0.py:12:8 From f5474082c4a6fc30b191f68b9a9fa405331719a6 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 14:37:23 -0500 Subject: [PATCH 06/33] gate under preview --- crates/ruff_linter/src/preview.rs | 5 +++ .../src/rules/pyflakes/rules/unused_import.rs | 35 ++++++++++++------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index be0a925fc7c21..ed7dec537c622 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -235,3 +235,8 @@ pub(crate) const fn is_a003_class_scope_shadowing_expansion_enabled( ) -> bool { settings.preview.is_enabled() } + +// TODO +pub(crate) const fn is_refined_submodule_import_match_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 9c130c5c4b1df..3c4847741a4a6 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -15,9 +15,12 @@ use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; use crate::fix; -use crate::preview::is_dunder_init_fix_unused_import_enabled; +use crate::preview::{ + is_dunder_init_fix_unused_import_enabled, is_refined_submodule_import_match_enabled, +}; use crate::registry::Rule; use crate::rules::{isort, isort::ImportSection, isort::ImportType}; +use crate::settings::LinterSettings; use crate::{Applicability, Fix, FixAvailability, Violation}; /// ## What it does @@ -284,7 +287,7 @@ pub(crate) fn unused_import(checker: &Checker, scope: &Scope) { let mut unused: BTreeMap<(NodeId, Exceptions), Vec> = BTreeMap::default(); let mut ignored: BTreeMap<(NodeId, Exceptions), Vec> = BTreeMap::default(); - for binding in unused_imports_in_scope(checker.semantic(), scope) { + for binding in unused_imports_in_scope(checker.semantic(), scope, checker.settings()) { let Some(import) = binding.as_any_import() else { continue; }; @@ -576,6 +579,7 @@ fn fix_by_reexporting<'a>( fn unused_imports_in_scope<'a, 'b>( semantic: &'a SemanticModel<'b>, scope: &'a Scope, + settings: &'a LinterSettings, ) -> impl Iterator> { scope .binding_ids() @@ -590,13 +594,19 @@ fn unused_imports_in_scope<'a, 'b>( }) .filter(|(_, bdg)| !bdg.is_global() && !bdg.is_nonlocal() && !bdg.is_explicit_export()) .flat_map(|(id, bdg)| { - if scope.shadowed_bindings(id).all(|shadow| { - let shadowed_binding = semantic.binding(shadow); - matches!( - shadowed_binding.kind, - BindingKind::Import(_) | BindingKind::SubmoduleImport(_) - ) && !shadowed_binding.flags.contains(BindingFlags::ALIAS) - }) { + if is_refined_submodule_import_match_enabled(settings) + // Only apply the new logic when all shadowed bindings + // are un-aliased imports and submodule imports to avoid + // complexity, false positives, and intersection with + // `redefined-while-unused` (`F811`). + && scope.shadowed_bindings(id).all(|shadow| { + let shadowed_binding = semantic.binding(shadow); + matches!( + shadowed_binding.kind, + BindingKind::Import(_) | BindingKind::SubmoduleImport(_) + ) && !shadowed_binding.flags.contains(BindingFlags::ALIAS) + }) + { unused_imports_from_binding(semantic, id, scope) } else if bdg.is_used() { vec![] @@ -649,7 +659,7 @@ fn unused_imports_from_binding<'a, 'b>( let binding = semantic.binding(id); - // ensure we only do this once since it involves an allocation + // ensure we only do this once let mut marked_dunder_all = false; for ref_id in binding.references() { @@ -661,8 +671,8 @@ fn unused_imports_from_binding<'a, 'b>( continue; } let Some(expr_id) = resolved_reference.expression_id() else { - // bail if there is some other kind of reference - dbg!("isn't this unreachable???"); + // If there is some other kind of reference, abandon + // the refined approach for the usual one return vec![binding]; }; mark_uses_of_ref(semantic, &mut marked, expr_id); @@ -722,6 +732,7 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie } } } + fn mark_uses_of_ref(semantic: &SemanticModel, marked: &mut MarkedBindings, expr_id: NodeId) { let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { return; From 54c444cf004f18f73658d1b17ff241dbf1dd6f8f Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 14:39:18 -0500 Subject: [PATCH 07/33] clippy --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 3c4847741a4a6..e7a83f39170f6 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -640,7 +640,7 @@ impl<'a, 'b> MarkedBindings<'a, 'b> { fn to_unused(self) -> Vec<&'a Binding<'b>> { self.bindings .into_iter() - .zip(self.used.into_iter()) + .zip(self.used) .filter_map(|(bdg, is_used)| (!is_used).then_some(bdg)) .collect() } @@ -709,7 +709,7 @@ fn expand_to_qualified_name_attribute<'b>( } fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &QualifiedName) { - let Some(best) = best_match(&marked.bindings, &prototype) else { + let Some(best) = best_match(&marked.bindings, prototype) else { return; }; From 8b2c986a365f17185cef41f7c19c43af7d756db5 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 15:07:06 -0500 Subject: [PATCH 08/33] add snapshots --- ...ules__pyflakes__tests__F401_F401_0.py.snap | 38 -------------- ...1_import_submodules_but_use_top_level.snap | 16 ++++++ ...s_different_lengths_but_use_top_level.snap | 16 ++++++ ...flakes__tests__f401_use_in_dunder_all.snap | 16 ++++++ ..._pyflakes__tests__f401_use_top_member.snap | 16 ++++++ ...ber_and_redefine_before_second_import.snap | 4 ++ ...1_use_top_member_before_second_import.snap | 16 ++++++ ..._top_member_then_import_then_redefine.snap | 4 ++ ...kes__tests__f401_use_top_member_twice.snap | 18 +++++++ ...lakes__tests__preview_diff__F401_0.py.snap | 50 +++++++++++++++++++ ...lakes__tests__preview_diff__F401_1.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_10.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_11.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_12.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_13.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_14.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_15.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_16.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_17.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_18.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_19.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_2.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_20.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_21.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_22.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_23.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_3.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_32.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_34.py.snap | 10 ++++ ...akes__tests__preview_diff__F401_35.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_4.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_5.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_6.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_7.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_8.py.snap | 10 ++++ ...lakes__tests__preview_diff__F401_9.py.snap | 10 ++++ 36 files changed, 416 insertions(+), 38 deletions(-) create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_but_use_top_level.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_different_lengths_but_use_top_level.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_dunder_all.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_and_redefine_before_second_import.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_then_import_then_redefine.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_twice.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_0.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_1.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_10.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_11.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_12.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_13.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_14.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_15.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_16.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_17.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_18.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_19.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_2.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_20.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_21.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_22.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_23.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_3.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_32.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_34.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_35.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_4.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_5.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_6.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_7.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_8.py.snap create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_9.py.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap index 12f31379782f0..6f4ab6f43d155 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_0.py.snap @@ -37,44 +37,6 @@ help: Remove unused import: `collections.OrderedDict` 7 | ) 8 | import multiprocessing.pool -F401 [*] `multiprocessing.process` imported but unused - --> F401_0.py:10:8 - | - 8 | ) - 9 | import multiprocessing.pool -10 | import multiprocessing.process - | ^^^^^^^^^^^^^^^^^^^^^^^ -11 | import logging.config -12 | import logging.handlers - | -help: Remove unused import: `multiprocessing.process` -7 | namedtuple, -8 | ) -9 | import multiprocessing.pool - - import multiprocessing.process -10 | import logging.config -11 | import logging.handlers -12 | from typing import ( - -F401 [*] `logging.config` imported but unused - --> F401_0.py:11:8 - | - 9 | import multiprocessing.pool -10 | import multiprocessing.process -11 | import logging.config - | ^^^^^^^^^^^^^^ -12 | import logging.handlers -13 | from typing import ( - | -help: Remove unused import: `logging.config` -8 | ) -9 | import multiprocessing.pool -10 | import multiprocessing.process - - import logging.config -11 | import logging.handlers -12 | from typing import ( -13 | TYPE_CHECKING, - F401 [*] `logging.handlers` imported but unused --> F401_0.py:12:8 | diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_but_use_top_level.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_but_use_top_level.snap new file mode 100644 index 0000000000000..c4ad92dca672e --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_but_use_top_level.snap @@ -0,0 +1,16 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a.b` imported but unused + --> f401_preview_submodule.py:3:8 + | +2 | import a.c +3 | import a.b + | ^^^ +4 | a.foo() + | +help: Remove unused import: `a.b` +1 | +2 | import a.c + - import a.b +3 | a.foo() diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_different_lengths_but_use_top_level.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_different_lengths_but_use_top_level.snap new file mode 100644 index 0000000000000..12df1553b875c --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_different_lengths_but_use_top_level.snap @@ -0,0 +1,16 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a.b.d` imported but unused + --> f401_preview_submodule.py:3:8 + | +2 | import a.c +3 | import a.b.d + | ^^^^^ +4 | a.foo() + | +help: Remove unused import: `a.b.d` +1 | +2 | import a.c + - import a.b.d +3 | a.foo() diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_dunder_all.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_dunder_all.snap new file mode 100644 index 0000000000000..58ec910de06a5 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_dunder_all.snap @@ -0,0 +1,16 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a.b` imported but unused + --> f401_preview_submodule.py:3:8 + | +2 | import a +3 | import a.b + | ^^^ +4 | __all__ = ["a"] + | +help: Remove unused import: `a.b` +1 | +2 | import a + - import a.b +3 | __all__ = ["a"] diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member.snap new file mode 100644 index 0000000000000..de8d07c090370 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member.snap @@ -0,0 +1,16 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a.b` imported but unused + --> f401_preview_submodule.py:3:8 + | +2 | import a +3 | import a.b + | ^^^ +4 | a.foo() + | +help: Remove unused import: `a.b` +1 | +2 | import a + - import a.b +3 | a.foo() diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_and_redefine_before_second_import.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_and_redefine_before_second_import.snap new file mode 100644 index 0000000000000..d0b409f39ee0b --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_and_redefine_before_second_import.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap new file mode 100644 index 0000000000000..97c803b424899 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap @@ -0,0 +1,16 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a.b` imported but unused + --> f401_preview_submodule.py:4:8 + | +2 | import a +3 | a.foo() +4 | import a.b + | ^^^ + | +help: Remove unused import: `a.b` +1 | +2 | import a +3 | a.foo() + - import a.b diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_then_import_then_redefine.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_then_import_then_redefine.snap new file mode 100644 index 0000000000000..d0b409f39ee0b --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_then_import_then_redefine.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_twice.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_twice.snap new file mode 100644 index 0000000000000..a46036a45f445 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_twice.snap @@ -0,0 +1,18 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a.b` imported but unused + --> f401_preview_submodule.py:3:8 + | +2 | import a +3 | import a.b + | ^^^ +4 | a.foo() +5 | a.bar() + | +help: Remove unused import: `a.b` +1 | +2 | import a + - import a.b +3 | a.foo() +4 | a.bar() diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_0.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_0.py.snap new file mode 100644 index 0000000000000..64cb811dd4763 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_0.py.snap @@ -0,0 +1,50 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 2 + +--- Added --- +F401 [*] `multiprocessing.process` imported but unused + --> F401_0.py:10:8 + | + 8 | ) + 9 | import multiprocessing.pool +10 | import multiprocessing.process + | ^^^^^^^^^^^^^^^^^^^^^^^ +11 | import logging.config +12 | import logging.handlers + | +help: Remove unused import: `multiprocessing.process` +7 | namedtuple, +8 | ) +9 | import multiprocessing.pool + - import multiprocessing.process +10 | import logging.config +11 | import logging.handlers +12 | from typing import ( + + +F401 [*] `logging.config` imported but unused + --> F401_0.py:11:8 + | + 9 | import multiprocessing.pool +10 | import multiprocessing.process +11 | import logging.config + | ^^^^^^^^^^^^^^ +12 | import logging.handlers +13 | from typing import ( + | +help: Remove unused import: `logging.config` +8 | ) +9 | import multiprocessing.pool +10 | import multiprocessing.process + - import logging.config +11 | import logging.handlers +12 | from typing import ( +13 | TYPE_CHECKING, diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_1.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_1.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_10.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_10.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_10.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_11.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_11.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_11.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_12.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_12.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_12.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_13.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_13.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_13.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_14.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_14.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_14.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_15.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_15.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_15.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_16.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_16.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_16.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_17.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_17.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_17.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_18.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_18.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_18.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_19.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_19.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_19.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_2.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_2.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_2.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_20.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_20.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_20.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_21.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_21.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_21.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_22.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_22.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_22.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_23.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_23.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_23.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_3.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_3.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_3.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_32.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_32.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_32.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_34.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_34.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_34.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_35.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_35.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_35.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_4.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_4.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_4.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_5.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_5.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_5.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_6.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_6.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_6.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_7.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_7.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_7.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_8.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_8.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_8.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_9.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_9.py.snap new file mode 100644 index 0000000000000..4da451424dc7a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__preview_diff__F401_9.py.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 0 From 56356724a1d607b0afb3ec74a8b192d3df43b34e Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 15:07:44 -0500 Subject: [PATCH 09/33] add more tests --- crates/ruff_linter/src/rules/pyflakes/mod.rs | 115 ++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index 0397479da8995..1187f44b8e96d 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -29,7 +29,7 @@ mod tests { use crate::settings::{LinterSettings, flags}; use crate::source_kind::SourceKind; use crate::test::{test_contents, test_path, test_snippet}; - use crate::{Locator, assert_diagnostics, directives}; + use crate::{Locator, assert_diagnostics, assert_diagnostics_diff, directives}; #[test_case(Rule::UnusedImport, Path::new("F401_0.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_1.py"))] @@ -392,6 +392,119 @@ mod tests { Ok(()) } + #[test_case(Rule::UnusedImport, Path::new("F401_0.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_1.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_2.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_3.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_4.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_5.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_6.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_7.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_8.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_9.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_10.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_11.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_12.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_13.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_14.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_15.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_16.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_17.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_18.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_19.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_20.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_21.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_22.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_23.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_32.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_34.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_35.py"))] + fn f401_preview_refined_submodule_handling_diffs(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("preview_diff__{}", path.to_string_lossy()); + assert_diagnostics_diff!( + snapshot, + Path::new("pyflakes").join(path).as_path(), + &LinterSettings::for_rule(rule_code), + &LinterSettings { + preview: PreviewMode::Enabled, + ..LinterSettings::for_rule(rule_code) + } + ); + Ok(()) + } + + #[test_case( + r" + import a + import a.b + a.foo()", + "f401_use_top_member" + )] + #[test_case( + r" + import a + import a.b + a.foo() + a.bar()", + "f401_use_top_member_twice" + )] + #[test_case( + r" + import a + a.foo() + import a.b", + "f401_use_top_member_before_second_import" + )] + #[test_case( + r" + import a + a.foo() + a = 1 + import a.b", + "f401_use_top_member_and_redefine_before_second_import" + )] + #[test_case( + r" + import a + a.foo() + import a.b + a = 1", + "f401_use_top_member_then_import_then_redefine" + )] + #[test_case( + r#" + import a + import a.b + __all__ = ["a"]"#, + "f401_use_in_dunder_all" + )] + #[test_case( + r" + import a.c + import a.b + a.foo()", + "f401_import_submodules_but_use_top_level" + )] + #[test_case( + r" + import a.c + import a.b.d + a.foo()", + "f401_import_submodules_different_lengths_but_use_top_level" + )] + fn f401_preview_refined_submodule_handling(contents: &str, snapshot: &str) { + let diagnostics = test_contents( + &SourceKind::Python(dedent(contents).to_string()), + Path::new("f401_preview_submodule.py"), + &LinterSettings { + preview: PreviewMode::Enabled, + ..LinterSettings::for_rule(Rule::UnusedImport) + }, + ) + .0; + assert_diagnostics!(snapshot, diagnostics); + } + #[test] fn f841_dummy_variable_rgx() -> Result<()> { let diagnostics = test_path( From 18ac76ce90039d2175b590feeaf632af40d0bc5f Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 15:13:43 -0500 Subject: [PATCH 10/33] clippy --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index e7a83f39170f6..763b8e137badc 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -637,7 +637,7 @@ impl<'a, 'b> MarkedBindings<'a, 'b> { } } - fn to_unused(self) -> Vec<&'a Binding<'b>> { + fn into_unused(self) -> Vec<&'a Binding<'b>> { self.bindings .into_iter() .zip(self.used) @@ -678,7 +678,7 @@ fn unused_imports_from_binding<'a, 'b>( mark_uses_of_ref(semantic, &mut marked, expr_id); } - marked.to_unused() + marked.into_unused() } fn expand_to_qualified_name_attribute<'b>( From 0cfbe4cbe71ddb4f4f684da43820a6ff4647c6be Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 15:18:59 -0500 Subject: [PATCH 11/33] adjust tests --- crates/ruff_linter/src/rules/pyflakes/mod.rs | 13 ++++-- ...ests__f401_multiple_unused_submodules.snap | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_multiple_unused_submodules.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index 1187f44b8e96d..cda32ef9ec433 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -433,6 +433,13 @@ mod tests { Ok(()) } + #[test_case( + r" + import a + import a.b + import a.c", + "f401_multiple_unused_submodules" + )] #[test_case( r" import a @@ -2873,7 +2880,7 @@ lambda: fu import fu.bar fu.x ", - &[Rule::UnusedImport], + &[], ); flakes( @@ -2882,7 +2889,7 @@ lambda: fu import fu fu.x ", - &[Rule::UnusedImport], + &[], ); } @@ -2916,7 +2923,7 @@ lambda: fu import fu import fu.bar ", - &[Rule::UnusedImport, Rule::UnusedImport], + &[Rule::UnusedImport], ); } diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_multiple_unused_submodules.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_multiple_unused_submodules.snap new file mode 100644 index 0000000000000..4eed530d8f787 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_multiple_unused_submodules.snap @@ -0,0 +1,44 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a` imported but unused + --> f401_preview_submodule.py:2:8 + | +2 | import a + | ^ +3 | import a.b +4 | import a.c + | +help: Remove unused import: `a` +1 | + - import a +2 | import a.b +3 | import a.c + +F401 [*] `a.b` imported but unused + --> f401_preview_submodule.py:3:8 + | +2 | import a +3 | import a.b + | ^^^ +4 | import a.c + | +help: Remove unused import: `a.b` +1 | +2 | import a + - import a.b +3 | import a.c + +F401 [*] `a.c` imported but unused + --> f401_preview_submodule.py:4:8 + | +2 | import a +3 | import a.b +4 | import a.c + | ^^^ + | +help: Remove unused import: `a.c` +1 | +2 | import a +3 | import a.b + - import a.c From f68462269761501b3404dbc286a3f64eced40a7c Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 16:02:57 -0500 Subject: [PATCH 12/33] inline marking ref uses --- .../src/rules/pyflakes/rules/unused_import.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 763b8e137badc..f90c2c3429de8 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -675,7 +675,11 @@ fn unused_imports_from_binding<'a, 'b>( // the refined approach for the usual one return vec![binding]; }; - mark_uses_of_ref(semantic, &mut marked, expr_id); + let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { + continue; + }; + + mark_uses_of_qualified_name(&mut marked, &prototype); } marked.into_unused() @@ -733,14 +737,6 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie } } -fn mark_uses_of_ref(semantic: &SemanticModel, marked: &mut MarkedBindings, expr_id: NodeId) { - let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { - return; - }; - - mark_uses_of_qualified_name(marked, &prototype); -} - fn rank_match(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cmp::Reverse) { let Some(import) = binding.as_any_import() else { unreachable!() From 7eb0b9499e81eb3b71734ed8718ed95e461d0162 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 16:25:13 -0500 Subject: [PATCH 13/33] add test for function scope --- crates/ruff_linter/src/rules/pyflakes/mod.rs | 8 ++++++++ ...401_import_submodules_in_function_scope.snap | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index cda32ef9ec433..3b7f8dce82de5 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -499,6 +499,14 @@ mod tests { a.foo()", "f401_import_submodules_different_lengths_but_use_top_level" )] + #[test_case( + r" + import a + def foo(): + import a.b + a.foo()", + "f401_import_submodules_in_function_scope" + )] fn f401_preview_refined_submodule_handling(contents: &str, snapshot: &str) { let diagnostics = test_contents( &SourceKind::Python(dedent(contents).to_string()), diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap new file mode 100644 index 0000000000000..445556e6d8a3a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap @@ -0,0 +1,17 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F401 [*] `a` imported but unused + --> f401_preview_submodule.py:2:8 + | +2 | import a + | ^ +3 | def foo(): +4 | import a.b + | +help: Remove unused import: `a` +1 | + - import a +2 | def foo(): +3 | import a.b +4 | a.foo() From 3eb83b56198a373e2bd2d03d5e544736d0b9ab2a Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Mon, 1 Sep 2025 17:04:25 -0500 Subject: [PATCH 14/33] a little bit of documentation --- .../src/rules/pyflakes/rules/unused_import.rs | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index f90c2c3429de8..739998489d5d2 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -576,6 +576,22 @@ fn fix_by_reexporting<'a>( Ok(Fix::safe_edits(head, tail).isolate(isolation)) } +/// Returns an iterator over bindings to import statements that appear unused. +/// +/// The stable behavior is to return those bindings to imports +/// satisfying the following properties: +/// +/// - they are not shadowed +/// - they are not `global`, not `nonlocal`, and not explicit exports (i.e. `import foo as foo`) +/// - they have no references, according to the semantic model +/// +/// Under preview, there is a more refined analysis performed +/// in the case where all bindings shadowed by a given import +/// binding (including the binding itself) are of a simple form: +/// they are required to be un-aliased imports or submodule imports. +/// +/// This alternative analysis is described in the documentation for +/// [`unused_imports_from_binding`]. fn unused_imports_in_scope<'a, 'b>( semantic: &'a SemanticModel<'b>, scope: &'a Scope, @@ -650,6 +666,45 @@ impl<'a, 'b> MarkedBindings<'a, 'b> { } } +/// Returns a `Vec` of bindings to unused import statements that +/// are shadowed by a given binding. +/// +/// Beginning with the collection of all bindings shadowed by +/// the given one, we iterate over references to the module. +/// Associated to each reference, we attempt to build a [`QualifiedName`] +/// corresponding to an iterated attribute access (e.g. `a.b.foo`). +/// We then determine the closest matching import statement to that +/// qualified name, and mark it as used. +/// +/// For example, given the following module: +/// +/// ```python +/// import a +/// import a.b +/// import a.b.c +/// +/// __all__ = ["a"] +/// +/// a.b.foo() +/// ``` +/// +/// The function below expects to receive the binding to +/// `import a.b` and will return the vector with +/// a single member corresponding to the binding created by +/// `import a.b.c`. +/// +/// The qualified name associated to the reference from the +/// dunder all export is `"a"` and the qualified name associated +/// to the reference in the last line is `"a.b.foo"`. The closest +/// matches are `import a` and `import a.b`, respectively, leaving +/// `import a.b.c` unused. +/// +/// For a precise definition of "closest match" see [`best_match`] +/// and [`rank_matches`]. +/// +/// Note: if any reference comes from something other than +/// a `Name` or a dunder all expression, then we return just +/// the original binding, thus reverting the stable behavior. fn unused_imports_from_binding<'a, 'b>( semantic: &'a SemanticModel<'b>, id: BindingId, @@ -665,7 +720,11 @@ fn unused_imports_from_binding<'a, 'b>( for ref_id in binding.references() { let resolved_reference = semantic.reference(ref_id); if !marked_dunder_all && resolved_reference.in_dunder_all_definition() { - let first = binding.as_any_import().unwrap().qualified_name().segments()[0]; + let first = binding + .as_any_import() + .expect("Binding to be an import binding") + .qualified_name() + .segments()[0]; mark_uses_of_qualified_name(&mut marked, &QualifiedName::user_defined(first)); marked_dunder_all = true; continue; @@ -676,7 +735,7 @@ fn unused_imports_from_binding<'a, 'b>( return vec![binding]; }; let Some(prototype) = expand_to_qualified_name_attribute(semantic, expr_id) else { - continue; + return vec![binding]; }; mark_uses_of_qualified_name(&mut marked, &prototype); @@ -737,7 +796,7 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie } } -fn rank_match(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cmp::Reverse) { +fn rank_matches(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cmp::Reverse) { let Some(import) = binding.as_any_import() else { unreachable!() }; @@ -757,6 +816,6 @@ fn best_match<'a, 'b, 'c>( ) -> Option<&'b Binding<'c>> { unused .iter() - .max_by_key(|binding| rank_match(binding, prototype)) + .max_by_key(|binding| rank_matches(binding, prototype)) .map(|v| &**v) } From d91bb5d1402a06ec5725c8a931d628248c192216 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 12 Sep 2025 13:33:44 -0500 Subject: [PATCH 15/33] add link to PR in preview module --- crates/ruff_linter/src/preview.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index ed7dec537c622..3cfca8379f749 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -236,7 +236,7 @@ pub(crate) const fn is_a003_class_scope_shadowing_expansion_enabled( settings.preview.is_enabled() } -// TODO +// https://github.com/astral-sh/ruff/pull/20200 pub(crate) const fn is_refined_submodule_import_match_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() } From 67151d679fbbeddf7b4bb166629602bcf2bae70e Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 12 Sep 2025 14:39:09 -0500 Subject: [PATCH 16/33] update rule documentation --- .../integration_test__rule_f401.snap | 39 +++++++++++++++++++ .../snapshots/lint__output_format_sarif.snap | 2 +- ...inter__message__sarif__tests__results.snap | 2 +- .../src/rules/pyflakes/rules/unused_import.rs | 39 +++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/ruff/tests/snapshots/integration_test__rule_f401.snap b/crates/ruff/tests/snapshots/integration_test__rule_f401.snap index 55c35568cd999..9213ccb0d725f 100644 --- a/crates/ruff/tests/snapshots/integration_test__rule_f401.snap +++ b/crates/ruff/tests/snapshots/integration_test__rule_f401.snap @@ -44,6 +44,43 @@ import some_module __all__ = ["some_module"] ``` +## Preview +When [preview] is enabled (and certain simplifying assumptions +are met), we analyze all import statements for a given module +when determining whether an import is used, rather than simply +the last of these statements. This can result in both different and +more import statements being marked as unused. + +For example, if a module consists of + +```python +import a +import a.b +``` + +then both statements are marked as unused under [preview], whereas +only the second is marked as unused under stable behavior. + +As another example, if a module consists of + +```python +import a.b +import a + +a.b.foo() +``` + +then a diagnostic will only be emitted for the first line under [preview], +whereas a diagnostic would only be emitted for the second line under +stable behavior. + +Note that this behavior is somewhat subjective and is designed +to conform to the developer's intuition rather than Python's actual +execution. To wit, the statement `import a.b` automatically executes +`import a`, so in some sense `import a` is _always_ redundant +in the presence of `import a.b`. + + ## Fix safety Fixes to remove unused imports are safe, except in `__init__.py` files. @@ -96,4 +133,6 @@ else: - [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec) - [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols) +[preview]: https://docs.astral.sh/ruff/preview/ + ----- stderr ----- diff --git a/crates/ruff/tests/snapshots/lint__output_format_sarif.snap b/crates/ruff/tests/snapshots/lint__output_format_sarif.snap index e5dba49431437..1744357e80343 100644 --- a/crates/ruff/tests/snapshots/lint__output_format_sarif.snap +++ b/crates/ruff/tests/snapshots/lint__output_format_sarif.snap @@ -119,7 +119,7 @@ exit_code: 1 "rules": [ { "fullDescription": { - "text": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\nSee [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc)\nfor more details on how Ruff\ndetermines whether an import is first or third-party.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols)\n" + "text": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Preview\nWhen [preview] is enabled (and certain simplifying assumptions\nare met), we analyze all import statements for a given module\nwhen determining whether an import is used, rather than simply\nthe last of these statements. This can result in both different and\nmore import statements being marked as unused.\n\nFor example, if a module consists of\n\n```python\nimport a\nimport a.b\n```\n\nthen both statements are marked as unused under [preview], whereas\nonly the second is marked as unused under stable behavior.\n\nAs another example, if a module consists of\n\n```python\nimport a.b\nimport a\n\na.b.foo()\n```\n\nthen a diagnostic will only be emitted for the first line under [preview],\nwhereas a diagnostic would only be emitted for the second line under\nstable behavior.\n\nNote that this behavior is somewhat subjective and is designed\nto conform to the developer's intuition rather than Python's actual\nexecution. To wit, the statement `import a.b` automatically executes\n`import a`, so in some sense `import a` is _always_ redundant\nin the presence of `import a.b`.\n\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\nSee [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc)\nfor more details on how Ruff\ndetermines whether an import is first or third-party.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols)\n\n[preview]: https://docs.astral.sh/ruff/preview/\n" }, "help": { "text": "`{name}` imported but unused; consider using `importlib.util.find_spec` to test for availability" diff --git a/crates/ruff_linter/src/message/snapshots/ruff_linter__message__sarif__tests__results.snap b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__sarif__tests__results.snap index bafe793739635..c8c125c6a5b36 100644 --- a/crates/ruff_linter/src/message/snapshots/ruff_linter__message__sarif__tests__results.snap +++ b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__sarif__tests__results.snap @@ -129,7 +129,7 @@ expression: value "rules": [ { "fullDescription": { - "text": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\nSee [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc)\nfor more details on how Ruff\ndetermines whether an import is first or third-party.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols)\n" + "text": "## What it does\nChecks for unused imports.\n\n## Why is this bad?\nUnused imports add a performance overhead at runtime, and risk creating\nimport cycles. They also increase the cognitive load of reading the code.\n\nIf an import statement is used to check for the availability or existence\nof a module, consider using `importlib.util.find_spec` instead.\n\nIf an import statement is used to re-export a symbol as part of a module's\npublic interface, consider using a \"redundant\" import alias, which\ninstructs Ruff (and other tools) to respect the re-export, and avoid\nmarking it as unused, as in:\n\n```python\nfrom module import member as member\n```\n\nAlternatively, you can use `__all__` to declare a symbol as part of the module's\ninterface, as in:\n\n```python\n# __init__.py\nimport some_module\n\n__all__ = [\"some_module\"]\n```\n\n## Preview\nWhen [preview] is enabled (and certain simplifying assumptions\nare met), we analyze all import statements for a given module\nwhen determining whether an import is used, rather than simply\nthe last of these statements. This can result in both different and\nmore import statements being marked as unused.\n\nFor example, if a module consists of\n\n```python\nimport a\nimport a.b\n```\n\nthen both statements are marked as unused under [preview], whereas\nonly the second is marked as unused under stable behavior.\n\nAs another example, if a module consists of\n\n```python\nimport a.b\nimport a\n\na.b.foo()\n```\n\nthen a diagnostic will only be emitted for the first line under [preview],\nwhereas a diagnostic would only be emitted for the second line under\nstable behavior.\n\nNote that this behavior is somewhat subjective and is designed\nto conform to the developer's intuition rather than Python's actual\nexecution. To wit, the statement `import a.b` automatically executes\n`import a`, so in some sense `import a` is _always_ redundant\nin the presence of `import a.b`.\n\n\n## Fix safety\n\nFixes to remove unused imports are safe, except in `__init__.py` files.\n\nApplying fixes to `__init__.py` files is currently in preview. The fix offered depends on the\ntype of the unused import. Ruff will suggest a safe fix to export first-party imports with\neither a redundant alias or, if already present in the file, an `__all__` entry. If multiple\n`__all__` declarations are present, Ruff will not offer a fix. Ruff will suggest an unsafe fix\nto remove third-party and standard library imports -- the fix is unsafe because the module's\ninterface changes.\n\nSee [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc)\nfor more details on how Ruff\ndetermines whether an import is first or third-party.\n\n## Example\n\n```python\nimport numpy as np # unused import\n\n\ndef area(radius):\n return 3.14 * radius**2\n```\n\nUse instead:\n\n```python\ndef area(radius):\n return 3.14 * radius**2\n```\n\nTo check the availability of a module, use `importlib.util.find_spec`:\n\n```python\nfrom importlib.util import find_spec\n\nif find_spec(\"numpy\") is not None:\n print(\"numpy is installed\")\nelse:\n print(\"numpy is not installed\")\n```\n\n## Options\n- `lint.ignore-init-module-imports`\n- `lint.pyflakes.allowed-unused-imports`\n\n## References\n- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)\n- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)\n- [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols)\n\n[preview]: https://docs.astral.sh/ruff/preview/\n" }, "help": { "text": "`{name}` imported but unused; consider using `importlib.util.find_spec` to test for availability" diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 739998489d5d2..be77d1f32eca3 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -52,6 +52,43 @@ use crate::{Applicability, Fix, FixAvailability, Violation}; /// __all__ = ["some_module"] /// ``` /// +/// ## Preview +/// When [preview] is enabled (and certain simplifying assumptions +/// are met), we analyze all import statements for a given module +/// when determining whether an import is used, rather than simply +/// the last of these statements. This can result in both different and +/// more import statements being marked as unused. +/// +/// For example, if a module consists of +/// +/// ```python +/// import a +/// import a.b +/// ``` +/// +/// then both statements are marked as unused under [preview], whereas +/// only the second is marked as unused under stable behavior. +/// +/// As another example, if a module consists of +/// +/// ```python +/// import a.b +/// import a +/// +/// a.b.foo() +/// ``` +/// +/// then a diagnostic will only be emitted for the first line under [preview], +/// whereas a diagnostic would only be emitted for the second line under +/// stable behavior. +/// +/// Note that this behavior is somewhat subjective and is designed +/// to conform to the developer's intuition rather than Python's actual +/// execution. To wit, the statement `import a.b` automatically executes +/// `import a`, so in some sense `import a` is _always_ redundant +/// in the presence of `import a.b`. +/// +/// /// ## Fix safety /// /// Fixes to remove unused imports are safe, except in `__init__.py` files. @@ -103,6 +140,8 @@ use crate::{Applicability, Fix, FixAvailability, Violation}; /// - [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement) /// - [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec) /// - [Typing documentation: interface conventions](https://typing.python.org/en/latest/spec/distributing.html#library-interface-public-and-private-symbols) +/// +/// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] pub(crate) struct UnusedImport { /// Qualified name of the import From c82576cf3324ea203e49c4c1b2281d7644773d1f Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 18 Sep 2025 14:46:17 -0500 Subject: [PATCH 17/33] omit lifetime --- .../ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index be77d1f32eca3..2b891ebc4c4f0 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -849,9 +849,9 @@ fn rank_matches(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cm (left, std::cmp::Reverse(qname.segments().len())) } -fn best_match<'a, 'b, 'c>( - unused: &'a Vec<&'b Binding<'c>>, - prototype: &'a QualifiedName, +fn best_match<'b, 'c>( + unused: &Vec<&'b Binding<'c>>, + prototype: &QualifiedName, ) -> Option<&'b Binding<'c>> { unused .iter() From 868997da29afc4ce6756c8a317ed6edc31c3ce7e Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 18 Sep 2025 14:47:55 -0500 Subject: [PATCH 18/33] avoid temporary num_unused variable --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 2b891ebc4c4f0..a9f859f1a3278 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -684,11 +684,9 @@ impl<'a, 'b> MarkedBindings<'a, 'b> { .map(|id| semantic.binding(id)) .collect(); - let num_unused = &unused.len(); - Self { + used: vec![false; unused.len()], bindings: unused, - used: vec![false; *num_unused], } } From e1cbcf80e62e9f0f31dfb5f62e6309ada236cb22 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 18 Sep 2025 14:51:24 -0500 Subject: [PATCH 19/33] nit: unused -> bindings --- .../ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index a9f859f1a3278..7fb31c3b310ef 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -679,14 +679,14 @@ struct MarkedBindings<'a, 'b> { impl<'a, 'b> MarkedBindings<'a, 'b> { fn from_binding_id(semantic: &'a SemanticModel<'b>, id: BindingId, scope: &'a Scope) -> Self { - let unused: Vec<_> = scope + let bindings: Vec<_> = scope .shadowed_bindings(id) .map(|id| semantic.binding(id)) .collect(); Self { - used: vec![false; unused.len()], - bindings: unused, + used: vec![false; bindings.len()], + bindings, } } From 40d4d200659877ae263b83ac958a2279c38510f5 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 18 Sep 2025 14:52:34 -0500 Subject: [PATCH 20/33] move MarkedBindings struct to after function --- .../src/rules/pyflakes/rules/unused_import.rs | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 7fb31c3b310ef..f2ea2a7b5f332 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -671,38 +671,6 @@ fn unused_imports_in_scope<'a, 'b>( }) } -#[derive(Debug)] -struct MarkedBindings<'a, 'b> { - bindings: Vec<&'a Binding<'b>>, - used: Vec, -} - -impl<'a, 'b> MarkedBindings<'a, 'b> { - fn from_binding_id(semantic: &'a SemanticModel<'b>, id: BindingId, scope: &'a Scope) -> Self { - let bindings: Vec<_> = scope - .shadowed_bindings(id) - .map(|id| semantic.binding(id)) - .collect(); - - Self { - used: vec![false; bindings.len()], - bindings, - } - } - - fn into_unused(self) -> Vec<&'a Binding<'b>> { - self.bindings - .into_iter() - .zip(self.used) - .filter_map(|(bdg, is_used)| (!is_used).then_some(bdg)) - .collect() - } - - fn iter_mut(&mut self) -> impl Iterator, &mut bool)> { - self.bindings.iter().copied().zip(self.used.iter_mut()) - } -} - /// Returns a `Vec` of bindings to unused import statements that /// are shadowed by a given binding. /// @@ -781,6 +749,38 @@ fn unused_imports_from_binding<'a, 'b>( marked.into_unused() } +#[derive(Debug)] +struct MarkedBindings<'a, 'b> { + bindings: Vec<&'a Binding<'b>>, + used: Vec, +} + +impl<'a, 'b> MarkedBindings<'a, 'b> { + fn from_binding_id(semantic: &'a SemanticModel<'b>, id: BindingId, scope: &'a Scope) -> Self { + let bindings: Vec<_> = scope + .shadowed_bindings(id) + .map(|id| semantic.binding(id)) + .collect(); + + Self { + used: vec![false; bindings.len()], + bindings, + } + } + + fn into_unused(self) -> Vec<&'a Binding<'b>> { + self.bindings + .into_iter() + .zip(self.used) + .filter_map(|(bdg, is_used)| (!is_used).then_some(bdg)) + .collect() + } + + fn iter_mut(&mut self) -> impl Iterator, &mut bool)> { + self.bindings.iter().copied().zip(self.used.iter_mut()) + } +} + fn expand_to_qualified_name_attribute<'b>( semantic: &SemanticModel<'b>, expr_id: NodeId, From 6ca20f913512440addbdefd8f83ebd2800e3c7ed Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 18 Sep 2025 14:53:40 -0500 Subject: [PATCH 21/33] nit: better variable name --- .../ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index f2ea2a7b5f332..fbd85c37cde60 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -813,11 +813,11 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie return; }; - let Some(bimp) = best.as_any_import() else { + let Some(best_import) = best.as_any_import() else { return; }; - let bname = bimp.qualified_name(); + let best_name = best_import.qualified_name(); for (binding, is_used) in marked.iter_mut() { if *is_used { @@ -826,7 +826,7 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie if binding .as_any_import() - .is_some_and(|imp| imp.qualified_name() == bname) + .is_some_and(|imp| imp.qualified_name() == best_name) { *is_used = true; } From 1a5fc528806db9e4d25c89f3588bf187853fcbda Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 19 Sep 2025 09:09:59 -0500 Subject: [PATCH 22/33] reword expect message --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index fbd85c37cde60..5c0bb3ac7eb86 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -727,7 +727,7 @@ fn unused_imports_from_binding<'a, 'b>( if !marked_dunder_all && resolved_reference.in_dunder_all_definition() { let first = binding .as_any_import() - .expect("Binding to be an import binding") + .expect("binding to be import binding since current function called after restricting to these in `unused_imports_in_scope`") .qualified_name() .segments()[0]; mark_uses_of_qualified_name(&mut marked, &QualifiedName::user_defined(first)); From ad57a0a43294046dd180750cc99e69b5cafe3897 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 19 Sep 2025 14:24:54 -0500 Subject: [PATCH 23/33] use .copied --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 5c0bb3ac7eb86..2feca38e432d8 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -853,6 +853,6 @@ fn best_match<'b, 'c>( ) -> Option<&'b Binding<'c>> { unused .iter() + .copied() .max_by_key(|binding| rank_matches(binding, prototype)) - .map(|v| &**v) } From bb68bf4e8b4985f0d79b910619fcf693fa9792b6 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 19 Sep 2025 14:26:33 -0500 Subject: [PATCH 24/33] unused --> bindings var name --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 2feca38e432d8..d4feafa4b2310 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -848,10 +848,10 @@ fn rank_matches(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cm } fn best_match<'b, 'c>( - unused: &Vec<&'b Binding<'c>>, + bindings: &Vec<&'b Binding<'c>>, prototype: &QualifiedName, ) -> Option<&'b Binding<'c>> { - unused + bindings .iter() .copied() .max_by_key(|binding| rank_matches(binding, prototype)) From a06886d40a2dc1b25417dfd7595690029ec8c922 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 19 Sep 2025 14:33:38 -0500 Subject: [PATCH 25/33] skip new logic if no shadowed bindings --- crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index d4feafa4b2310..8362d4fc9b6db 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -650,6 +650,8 @@ fn unused_imports_in_scope<'a, 'b>( .filter(|(_, bdg)| !bdg.is_global() && !bdg.is_nonlocal() && !bdg.is_explicit_export()) .flat_map(|(id, bdg)| { if is_refined_submodule_import_match_enabled(settings) + // No need to apply refined logic if there is only a single binding + && scope.shadowed_bindings(id).nth(1).is_some() // Only apply the new logic when all shadowed bindings // are un-aliased imports and submodule imports to avoid // complexity, false positives, and intersection with From a2f498beb44b1782fcb5caa05034a9ca56f93a74 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 25 Sep 2025 15:14:28 -0500 Subject: [PATCH 26/33] test uses in between imports --- crates/ruff_linter/src/rules/pyflakes/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index 3b7f8dce82de5..67d0298205197 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -507,6 +507,20 @@ mod tests { a.foo()", "f401_import_submodules_in_function_scope" )] + #[test_case( + r" + import a + a.b + import a.b", + "f401_use_in_between_imports" + )] + #[test_case( + r" + import a.b + a + import a", + "f401_use_in_between_imports" + )] fn f401_preview_refined_submodule_handling(contents: &str, snapshot: &str) { let diagnostics = test_contents( &SourceKind::Python(dedent(contents).to_string()), From be2edc1fcb69ae7f33ad64e65d52af004912eec8 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 25 Sep 2025 15:26:28 -0500 Subject: [PATCH 27/33] bail if used in betweem imports --- .../src/rules/pyflakes/rules/unused_import.rs | 17 ++++++++++++++--- ...kes__tests__f401_use_in_between_imports.snap | 4 ++++ ...401_use_top_member_before_second_import.snap | 14 +------------- 3 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_between_imports.snap diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 8362d4fc9b6db..28f918205a2d7 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -652,12 +652,23 @@ fn unused_imports_in_scope<'a, 'b>( if is_refined_submodule_import_match_enabled(settings) // No need to apply refined logic if there is only a single binding && scope.shadowed_bindings(id).nth(1).is_some() - // Only apply the new logic when all shadowed bindings - // are un-aliased imports and submodule imports to avoid + // Only apply the new logic in certain situations to avoid // complexity, false positives, and intersection with // `redefined-while-unused` (`F811`). - && scope.shadowed_bindings(id).all(|shadow| { + && scope.shadowed_bindings(id).enumerate().all(|(i,shadow)| { let shadowed_binding = semantic.binding(shadow); + // Bail if one of the shadowed bindings is + // used before the last live binding. This is + // to avoid situations like this: + // + // ``` + // import a + // a.b + // import a.b + // ``` + if i>0 && shadowed_binding.is_used() { + return false + } matches!( shadowed_binding.kind, BindingKind::Import(_) | BindingKind::SubmoduleImport(_) diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_between_imports.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_between_imports.snap new file mode 100644 index 0000000000000..d0b409f39ee0b --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_in_between_imports.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap index 97c803b424899..d0b409f39ee0b 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_use_top_member_before_second_import.snap @@ -1,16 +1,4 @@ --- source: crates/ruff_linter/src/rules/pyflakes/mod.rs --- -F401 [*] `a.b` imported but unused - --> f401_preview_submodule.py:4:8 - | -2 | import a -3 | a.foo() -4 | import a.b - | ^^^ - | -help: Remove unused import: `a.b` -1 | -2 | import a -3 | a.foo() - - import a.b + From 87907ecd00344bdb2dc15856027c7c29addbefdf Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Thu, 25 Sep 2025 15:55:57 -0500 Subject: [PATCH 28/33] add more documentation to functions --- .../src/rules/pyflakes/rules/unused_import.rs | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 28f918205a2d7..d286a27115478 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -687,14 +687,7 @@ fn unused_imports_in_scope<'a, 'b>( /// Returns a `Vec` of bindings to unused import statements that /// are shadowed by a given binding. /// -/// Beginning with the collection of all bindings shadowed by -/// the given one, we iterate over references to the module. -/// Associated to each reference, we attempt to build a [`QualifiedName`] -/// corresponding to an iterated attribute access (e.g. `a.b.foo`). -/// We then determine the closest matching import statement to that -/// qualified name, and mark it as used. -/// -/// For example, given the following module: +/// This is best explained by example. So suppose we have: /// /// ```python /// import a @@ -706,16 +699,29 @@ fn unused_imports_in_scope<'a, 'b>( /// a.b.foo() /// ``` /// -/// The function below expects to receive the binding to -/// `import a.b` and will return the vector with -/// a single member corresponding to the binding created by -/// `import a.b.c`. +/// As of 2025-09-25, Ruff's semantic model, upon visiting +/// the whole module, will have a single live binding for +/// the symbol `a` that points to the line `import a.b.c`, +/// and the remaining two import bindings are considered shadowed +/// by the last. +/// +/// This function expects to receive the `id` +/// for the live binding and will begin by collecting +/// all bindings shadowed by the given one - i.e. all +/// the different import statements binding the symbol `a`. +/// We iterate over references to this +/// module and decide (somewhat subjectively) which +/// import statement the user "intends" to reference. To that end, +/// to each reference we attempt to build a [`QualifiedName`] +/// corresponding to an iterated attribute access (e.g. `a.b.foo`). +/// We then determine the closest matching import statement to that +/// qualified name, and mark it as used. /// -/// The qualified name associated to the reference from the -/// dunder all export is `"a"` and the qualified name associated -/// to the reference in the last line is `"a.b.foo"`. The closest -/// matches are `import a` and `import a.b`, respectively, leaving -/// `import a.b.c` unused. +/// In the present example, the qualified name associated to the +/// reference from the dunder all export is `"a"` and the qualified +/// name associated to the reference in the last line is `"a.b.foo"`. +/// The closest matches are `import a` and `import a.b`, respectively, +/// leaving `import a.b.c` unused. /// /// For a precise definition of "closest match" see [`best_match`] /// and [`rank_matches`]. @@ -794,6 +800,12 @@ impl<'a, 'b> MarkedBindings<'a, 'b> { } } +/// Returns `Some` [`QualifiedName`] delineating the path for the +/// maximal [`ExprName`] or [`ExprAttribute`] containing the expression +/// associated to the given [`NodeId`], or `None` otherwise. +/// +/// For example, if the `expr_id` points to `a` in `a.b.c.foo()` +/// then the qualified name would have segments [`a`, `b`, `c`, `foo`]. fn expand_to_qualified_name_attribute<'b>( semantic: &SemanticModel<'b>, expr_id: NodeId, @@ -846,6 +858,16 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie } } +/// Returns a pair with first component the length of the largest +/// shared prefix between the qualified name of the import binding +/// and the `prototype` and second component the length of the +/// qualified name of the import binding (i.e. the number of path +/// segments). Moreover, we regard the second component as ordered +/// in reverse. +/// +/// For example, if the binding corresponds to `import a.b.c` +/// and the prototype to `a.b.foo()`, then the function returns +/// `(2,std::cmp::Reverse(3))`. fn rank_matches(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cmp::Reverse) { let Some(import) = binding.as_any_import() else { unreachable!() @@ -860,6 +882,10 @@ fn rank_matches(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cm (left, std::cmp::Reverse(qname.segments().len())) } +/// Returns the import binding that shares the longest prefix +/// with the `prototype` and is of minimal length amongst these. +/// +/// See also [`rank_matches`]. fn best_match<'b, 'c>( bindings: &Vec<&'b Binding<'c>>, prototype: &QualifiedName, From f0527e08b4646439192e1f123d56a9bca32a97b3 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 26 Sep 2025 07:30:53 -0500 Subject: [PATCH 29/33] rename lifetimes --- .../ruff_linter/src/rules/pyflakes/rules/unused_import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index d286a27115478..988f5ea7532bf 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -886,10 +886,10 @@ fn rank_matches(binding: &Binding, prototype: &QualifiedName) -> (usize, std::cm /// with the `prototype` and is of minimal length amongst these. /// /// See also [`rank_matches`]. -fn best_match<'b, 'c>( - bindings: &Vec<&'b Binding<'c>>, +fn best_match<'a, 'b>( + bindings: &Vec<&'a Binding<'b>>, prototype: &QualifiedName, -) -> Option<&'b Binding<'c>> { +) -> Option<&'a Binding<'b>> { bindings .iter() .copied() From c4c46f204af92f010ebf16c27f219f807c681330 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 26 Sep 2025 07:41:24 -0500 Subject: [PATCH 30/33] extract condition to function to unbreak rustfmt --- .../src/rules/pyflakes/rules/unused_import.rs | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 988f5ea7532bf..d0ccf087315ee 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -655,25 +655,7 @@ fn unused_imports_in_scope<'a, 'b>( // Only apply the new logic in certain situations to avoid // complexity, false positives, and intersection with // `redefined-while-unused` (`F811`). - && scope.shadowed_bindings(id).enumerate().all(|(i,shadow)| { - let shadowed_binding = semantic.binding(shadow); - // Bail if one of the shadowed bindings is - // used before the last live binding. This is - // to avoid situations like this: - // - // ``` - // import a - // a.b - // import a.b - // ``` - if i>0 && shadowed_binding.is_used() { - return false - } - matches!( - shadowed_binding.kind, - BindingKind::Import(_) | BindingKind::SubmoduleImport(_) - ) && !shadowed_binding.flags.contains(BindingFlags::ALIAS) - }) + && has_simple_shadowed_bindings(scope, id, semantic) { unused_imports_from_binding(semantic, id, scope) } else if bdg.is_used() { @@ -895,3 +877,26 @@ fn best_match<'a, 'b>( .copied() .max_by_key(|binding| rank_matches(binding, prototype)) } + +#[inline] +fn has_simple_shadowed_bindings(scope: &Scope, id: BindingId, semantic: &SemanticModel) -> bool { + scope.shadowed_bindings(id).enumerate().all(|(i, shadow)| { + let shadowed_binding = semantic.binding(shadow); + // Bail if one of the shadowed bindings is + // used before the last live binding. This is + // to avoid situations like this: + // + // ``` + // import a + // a.b + // import a.b + // ``` + if i > 0 && shadowed_binding.is_used() { + return false; + } + matches!( + shadowed_binding.kind, + BindingKind::Import(_) | BindingKind::SubmoduleImport(_) + ) && !shadowed_binding.flags.contains(BindingFlags::ALIAS) + }) +} From f61287c8c595996795c2fed8cfc038ffae8d620c Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 26 Sep 2025 07:41:44 -0500 Subject: [PATCH 31/33] comment why we loop through all bindings again --- .../src/rules/pyflakes/rules/unused_import.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index d0ccf087315ee..3488254a9eb98 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -826,6 +826,19 @@ fn mark_uses_of_qualified_name(marked: &mut MarkedBindings, prototype: &Qualifie let best_name = best_import.qualified_name(); + // We loop through all bindings in case there are repeated instances + // of the `best_name`. For example, if we have + // + // ```python + // import a + // import a + // + // a.foo() + // ``` + // + // then we want to mark both import statements as used. It + // is the job of `redefined-while-unused` (`F811`) to catch + // the repeated binding in this case. for (binding, is_used) in marked.iter_mut() { if *is_used { continue; From 9d1b665fcdcde8371048e4d600916becc1ca7a14 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 26 Sep 2025 07:50:42 -0500 Subject: [PATCH 32/33] comment on current behavior for snapshots --- crates/ruff_linter/src/rules/pyflakes/mod.rs | 6 ++++++ ...f401_import_submodules_in_function_scope.snap | 16 +++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index 67d0298205197..6f3e7bbafe22f 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -457,6 +457,7 @@ mod tests { )] #[test_case( r" + # reverts to stable behavior - used between imports import a a.foo() import a.b", @@ -464,6 +465,7 @@ mod tests { )] #[test_case( r" + # reverts to stable behavior - used between imports import a a.foo() a = 1 @@ -472,6 +474,7 @@ mod tests { )] #[test_case( r" + # reverts to stable behavior - used between imports import a a.foo() import a.b @@ -501,6 +504,7 @@ mod tests { )] #[test_case( r" + # refined logic only applied _within_ scope import a def foo(): import a.b @@ -509,6 +513,7 @@ mod tests { )] #[test_case( r" + # reverts to stable behavior - used between bindings import a a.b import a.b", @@ -516,6 +521,7 @@ mod tests { )] #[test_case( r" + # reverts to stable behavior - used between bindings import a.b a import a", diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap index 445556e6d8a3a..f99502873a4fa 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__f401_import_submodules_in_function_scope.snap @@ -2,16 +2,18 @@ source: crates/ruff_linter/src/rules/pyflakes/mod.rs --- F401 [*] `a` imported but unused - --> f401_preview_submodule.py:2:8 + --> f401_preview_submodule.py:3:8 | -2 | import a +2 | # refined logic only applied _within_ scope +3 | import a | ^ -3 | def foo(): -4 | import a.b +4 | def foo(): +5 | import a.b | help: Remove unused import: `a` 1 | +2 | # refined logic only applied _within_ scope - import a -2 | def foo(): -3 | import a.b -4 | a.foo() +3 | def foo(): +4 | import a.b +5 | a.foo() From ae9ad8aa00e7c791d96e5af248692398abed0030 Mon Sep 17 00:00:00 2001 From: dylwil3 Date: Fri, 26 Sep 2025 08:05:53 -0500 Subject: [PATCH 33/33] make explicit assumption that qualified name is nonempty --- .../src/rules/pyflakes/rules/unused_import.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs index 3488254a9eb98..45860a252c18e 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs @@ -726,11 +726,11 @@ fn unused_imports_from_binding<'a, 'b>( for ref_id in binding.references() { let resolved_reference = semantic.reference(ref_id); if !marked_dunder_all && resolved_reference.in_dunder_all_definition() { - let first = binding - .as_any_import() - .expect("binding to be import binding since current function called after restricting to these in `unused_imports_in_scope`") - .qualified_name() - .segments()[0]; + let first = *binding + .as_any_import() + .expect("binding to be import binding since current function called after restricting to these in `unused_imports_in_scope`") + .qualified_name() + .segments().first().expect("import binding to have nonempty qualified name"); mark_uses_of_qualified_name(&mut marked, &QualifiedName::user_defined(first)); marked_dunder_all = true; continue;