Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e70d297
Add reference project
PeterJCLaw Nov 1, 2025
3328deb
Update block output
PeterJCLaw Oct 30, 2025
bb28d91
More demo
PeterJCLaw Dec 13, 2025
cb4619d
More demo details
PeterJCLaw Dec 13, 2025
c075114
WIP E402 fix
PeterJCLaw Dec 13, 2025
1bc2431
First attempt to preserve comments
PeterJCLaw Dec 13, 2025
126a95c
Enable more fixes in the test project
PeterJCLaw Dec 20, 2025
30d94e2
Some more imports to make it visible when editor import organisation …
PeterJCLaw Dec 24, 2025
2062d76
Include E402 fixing in editor import organising
PeterJCLaw Dec 24, 2025
3a74298
Remove unused import
PeterJCLaw Dec 24, 2025
9b82555
Remove comment-out code
PeterJCLaw Dec 24, 2025
2a40051
Improve handling of lines around moved imports
PeterJCLaw Dec 26, 2025
1a7dee2
Remove debug files from previous approach
PeterJCLaw Dec 26, 2025
e161ff9
First pass at supporting fixing E402 in notebooks too
PeterJCLaw Dec 26, 2025
fd07fd5
Add fix title for E402 fixer
PeterJCLaw Mar 11, 2026
b633b7f
Update snapshot tests for E402 fixer
PeterJCLaw Mar 11, 2026
913a236
Comment about unsafe fixes in the organise imports action
PeterJCLaw Mar 11, 2026
e623668
Drop demo project now it's no longer needed
PeterJCLaw Mar 11, 2026
60e386f
Add tests for e402 fixer
PeterJCLaw Mar 11, 2026
47df7c1
Ensure fixes for E402 are only moved to after future imports
PeterJCLaw Mar 11, 2026
f99f1cb
Merge branch 'main' into e402-fixer
PeterJCLaw Apr 19, 2026
7ff8f51
Only enable E402 fixer in preview mode
PeterJCLaw Apr 19, 2026
25e4fde
Reinstate non-preview files
PeterJCLaw Apr 21, 2026
f4f8196
Merge branch 'main' into e402-fixer
PeterJCLaw May 24, 2026
1a4b053
Rebuild snapshots for newer cargo-insta
PeterJCLaw May 24, 2026
0a34521
Group imports
PeterJCLaw May 24, 2026
4d73f92
Avoid allocation we don't need
PeterJCLaw May 24, 2026
8582e57
Clarify helper function name
PeterJCLaw May 24, 2026
6271fc9
Add notes on fix safety
PeterJCLaw May 24, 2026
671fbf2
Remove redundant code from snapshot tests
PeterJCLaw May 25, 2026
66a1fe8
Improve comment
PeterJCLaw May 25, 2026
33b5efb
Drop attempted support for notebooks
PeterJCLaw May 25, 2026
53e2818
No need to run these new fixer tests non-preview
PeterJCLaw May 25, 2026
a5a1ebf
Merge branch 'main' into e402-fixer
PeterJCLaw Jul 1, 2026
0a70b5c
Update snapshots following #26161
PeterJCLaw Jul 1, 2026
31b7cf5
Merge branch 'main' into e402-fixer
PeterJCLaw Jul 12, 2026
a13c73f
Simplify import insertion logic dropping partial notebook support
ntBre Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import os

print("something")

import last

import late # comment-late

from late_paren1 import ( # comment-late_paren1
value
)

from late_paren2 import (
value # comment-late_paren2
)

from late_paren3 import (
value
) # comment-late_paren3

from late_paren4 import (
value1,
value2, # comment-late_paren4
value3,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""module docstring"""

print("something")

import os
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from __future__ import annotations

print("something")

import os
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/python3

print("something")

import os
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/python3

"""docstring"""

from __future__ import annotations

print("something")

import os
32 changes: 20 additions & 12 deletions crates/ruff_linter/src/importer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ use ruff_python_parser::Parsed;
use ruff_python_semantic::{
ImportedName, MemberNameImport, ModuleNameImport, NameImport, SemanticModel,
};
use ruff_python_trivia::textwrap::indent;
use ruff_text_size::{Ranged, TextSize};
use ruff_python_trivia::{PythonWhitespace, textwrap::indent};
use ruff_source_file::LineRanges;
use ruff_text_size::{Ranged, TextRange, TextSize};

use crate::cst::matchers::{match_aliases, match_import_from, match_statement};
use crate::fix;
Expand Down Expand Up @@ -77,16 +78,23 @@ impl<'a> Importer<'a> {
// Insert after the last top-level import.
Insertion::end_of_statement(stmt, self.source, self.stylist).into_edit(&required_import)
} else {
// Check if there are any future imports that we need to respect
if let Some(last_future_import) = self.find_last_future_import() {
// Insert after the last future import
Insertion::end_of_statement(last_future_import, self.source, self.stylist)
.into_edit(&required_import)
} else {
// Insert at the start of the file.
Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
.into_edit(&required_import)
}
self.add_at_start(&required_import)
}
}

/// Add an existing import statement to the start of the file.
pub(crate) fn add_import_at_start(&self, import: &Stmt) -> Edit {
let range = TextRange::new(import.start(), self.source.line_end(import.end()));
self.add_at_start(self.source[range].trim_whitespace())
}

fn add_at_start(&self, text: &str) -> Edit {
if let Some(last_future_import) = self.find_last_future_import() {
Insertion::end_of_statement(last_future_import, self.source, self.stylist)
.into_edit(text)
} else {
Insertion::start_of_file(self.python_ast, self.source, self.stylist, None)
.into_edit(text)
}
}

Expand Down
5 changes: 5 additions & 0 deletions crates/ruff_linter/src/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,11 @@ pub(crate) const fn is_incorrect_dict_iterator_comprehension_enabled(
settings.preview.is_enabled()
}

// https://github.com/astral-sh/ruff/pull/22212
pub(crate) const fn is_e402_fix_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}

// https://github.com/astral-sh/ruff/pull/23260
pub(crate) const fn is_up006_future_annotations_fix_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
Expand Down
16 changes: 16 additions & 0 deletions crates/ruff_linter/src/rules/pycodestyle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ mod tests {
}

#[test_case(Rule::LineTooLong, Path::new("E501_5.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E40.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_0.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_1.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_2.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_3.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_4.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_5.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_docstring.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_comments.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_future.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_shebang.py"))]
#[test_case(
Rule::ModuleImportNotAtTopOfFile,
Path::new("E402_shebang_docstring_and_future.py")
)]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402.ipynb"))]
#[test_case(Rule::RedundantBackslash, Path::new("E502.py"))]
#[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_0.py"))]
#[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_1.py"))]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{PySourceType, Stmt};
use ruff_text_size::Ranged;
use ruff_source_file::LineRanges;
use ruff_text_size::{Ranged, TextRange};

use crate::Violation;
use crate::checkers::ast::Checker;
Comment thread
PeterJCLaw marked this conversation as resolved.
use crate::preview::is_e402_fix_enabled;
use crate::{Edit, Fix, FixAvailability, Violation};

/// ## What it does
/// Checks for imports that are not at the top of the file.
Expand Down Expand Up @@ -38,6 +40,12 @@ use crate::checkers::ast::Checker;
/// ## Notebook behavior
/// For Jupyter notebooks, this rule checks for imports that are not at the top of a *cell*.
///
/// ## Fix safety
/// This rule's fix is marked as unsafe as imports moved to the top of the file
/// are placed above existing imports, in reverse order than they were in the
/// file. Re-ordering imports is unsafe as it can change the execution order of
/// the imported code.
///
/// [PEP 8]: https://peps.python.org/pep-0008/#imports
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "v0.0.28")]
Expand All @@ -46,6 +54,8 @@ pub(crate) struct ModuleImportNotAtTopOfFile {
}

impl Violation for ModuleImportNotAtTopOfFile {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;

#[derive_message_formats]
fn message(&self) -> String {
if self.source_type.is_ipynb() {
Expand All @@ -54,16 +64,54 @@ impl Violation for ModuleImportNotAtTopOfFile {
"Module level import not at top of file".to_string()
}
}

fn fix_title(&self) -> Option<String> {
if self.source_type.is_ipynb() {
Some("Move module level imports to top of cell".to_string())
} else {
Some("Move module level imports to top of file".to_string())
}
}
}

/// E402
pub(crate) fn module_import_not_at_top_of_file(checker: &Checker, stmt: &Stmt) {
if checker.semantic().seen_import_boundary() && checker.semantic().at_top_level() {
checker.report_diagnostic(
let mut diagnostic = checker.report_diagnostic(
ModuleImportNotAtTopOfFile {
source_type: checker.source_type,
},
stmt.range(),
);

if !is_e402_fix_enabled(checker.settings()) {
return;
}

// Support for fixing notebooks is not yet implemented.
if checker.cell_offsets().is_some() {
return;
}

let indexer = checker.indexer();
let locator = checker.locator();

// Special-cases: there's leading or trailing content in the import block. These
// are too hard to get right, and relatively rare, so flag but don't fix.
if indexer.preceded_by_multi_statement_line(stmt, locator.contents())
|| indexer.followed_by_multi_statement_line(stmt, locator.contents())
{
return;
}

let edit = checker.importer().add_import_at_start(stmt);

// Include trailing comments and the newline in the removal.
let removal_range = TextRange::new(stmt.start(), locator.full_line_end(stmt.end()));

diagnostic.set_fix(Fix::unsafe_edits(
Edit::range_deletion(removal_range),
[edit],
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ E402 Module level import not at top of file
57 | #: E402
58 | import foo
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E40.py:58:1
Expand All @@ -22,6 +23,7 @@ E402 Module level import not at top of file
59 |
60 | a = 1
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E40.py:62:1
Expand All @@ -33,6 +35,7 @@ E402 Module level import not at top of file
63 |
64 | #: E401
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E40.py:65:1
Expand All @@ -42,6 +45,7 @@ E402 Module level import not at top of file
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
66 | import re as regex, string; x = 1
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E40.py:66:1
Expand All @@ -53,6 +57,7 @@ E402 Module level import not at top of file
67 |
68 | x = 1; import re as regex, string
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E40.py:68:8
Expand All @@ -62,3 +67,4 @@ E402 Module level import not at top of file
68 | x = 1; import re as regex, string
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Move module level imports to top of file
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ E402 Module level import not at top of cell
10 |
11 | import a
|
help: Move module level imports to top of cell

E402 Module level import not at top of cell
--> E402.ipynb:22:1
Expand All @@ -21,6 +22,7 @@ E402 Module level import not at top of cell
| ^^^^^^^^
23 | import ok
|
help: Move module level imports to top of cell

E402 Module level import not at top of cell
--> E402.ipynb:30:1
Expand All @@ -30,3 +32,4 @@ E402 Module level import not at top of cell
31 |
32 | %%time
|
help: Move module level imports to top of cell
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ E402 Module level import not at top of file
35 | import h
| ^^^^^^^^
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E402_0.py:45:1
Expand All @@ -18,6 +19,7 @@ E402 Module level import not at top of file
45 | import k; import l
| ^^^^^^^^
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E402_0.py:45:11
Expand All @@ -27,3 +29,4 @@ E402 Module level import not at top of file
45 | import k; import l
| ^^^^^^^^
|
help: Move module level imports to top of file
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ E402 Module level import not at top of file
6 |
7 | """Some other docstring."""
|
help: Move module level imports to top of file

E402 Module level import not at top of file
--> E402_1.py:9:1
Expand All @@ -20,3 +21,4 @@ E402 Module level import not at top of file
9 | import c
| ^^^^^^^^
|
help: Move module level imports to top of file
Loading
Loading