Stop swallowing a statement that immediately follows a sorted literal - #2627
Stop swallowing a statement that immediately follows a sorted literal#2627VXNCXNX wants to merge 4 commits into
Conversation
53da7c1 to
af7bef4
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2627 +/- ##
=======================================
Coverage 99.37% 99.37%
=======================================
Files 41 41
Lines 3181 3198 +17
Branches 686 690 +4
=======================================
+ Hits 3161 3178 +17
Misses 12 12
Partials 8 8 🚀 New features to boost your workflow:
|
DanielNoord
left a comment
There was a problem hiding this comment.
Ci fails.
Also, this feels really really complex for such a small bug. Is there a solution that does not involve such a complex function?
|
Both fair. CI was Lint ( Dropped the bracket scanner for |
Manny7717
left a comment
There was a problem hiding this comment.
Verified locally (head ecb32a2, isort from source): all 5 new issue_2286 tests pass, and the original crash reproduces on main for both the --sort-reexports and # isort: list variants and is fixed here. Full CI matrix on this head is green.
However, I found a related case this PR silently corrupts, which I think is worth resolving before merge (inline comment below):
__all__ = ["b", "a"]
# note
x = 1isort.code(..., sort_reexports=True) on main raises ValueError: too many values to unpack; on this head it returns:
__all____all__ = ["a", "b"]
x = 1i.e. the standalone comment is silently dropped, the identifier is duplicated, and the real __all__ no longer exists — so from module import * semantics silently change. Also reproduces with the comment at EOF (__all__ = ["b", "a"]\n# note\n -> __all____all__ = ["a", "b"]\n). isort.check_code(..., sort_reexports=True) does return False for it, so --check/CI catches the change — but anyone running isort file.py gets a silently corrupted file.
Two related, milder observations from the same family:
__all__ = ["b", "a"] # exports\nx = 1\n-> inline comment silently dropped (no corruption).# isort: list\n__all__ = ["b", "a"]\n# note\nx = 1\n-> sorted correctly, but the comment is relocated above the literal.
Suggested direction (happy to help): when a section contains comment lines, either bail out and leave the section unsorted, or exclude comments from both the literal section and the rollback accounting — with regression tests for the comment-in-section cases. This also speaks to the complexity concern raised on the PR: the AST-split needs to mirror exactly what was already emitted to the stream.
Out of scope / pre-existing (unchanged by this PR): a literal containing = (e.g. __all__ = [f"{x}={y}" for x in ...]) still crashes at isort/core.py:291 (stripped_line.split("=")) on both main and this head.
| line_separator = "\n" | ||
|
|
||
| if code_sorting and code_sorting_section: | ||
| literal_section, trailing_section = _split_code_sorting_section( |
There was a problem hiding this comment.
The split assumes everything in code_sorting_section was not yet written, but a standalone comment line inside the section was already emitted to the output stream earlier (the in_top_comment path), while reexport_rollback only covers the literal line. The reexport seek(tell() - reexport_rollback) therefore lands mid-line and the sorted rewrite overlaps a stale prefix.
Repro: __all__ = ["b", "a"]\n# note\nx = 1\n with --sort-reexports -> __all____all__ = ["a", "b"]\nx = 1\n (real __all__ deleted, comment dropped). Pre-fix this input crashed loudly; post-fix it corrupts silently.
Suggestion: if the section contains comment lines (standalone or trailing), skip sorting and pass the section through unchanged; or strip comments from literal_section and include them in the rollback. Either way a regression test for the comment-in-section case would pin it down.
Replace hand-written quote-aware bracket scanner with ast.parse-based approach to find statement boundaries. Merge output_stream writes to reduce cyclomatic complexity of process() below C901 limit of 91. Add regression test for # isort: assignments, achieving 100% coverage for core.py.
ecb32a2 to
8dd645f
Compare
|
@Manny7717 thank you, this was a real defect and I reproduced all of it. Fixed, with one correction to the diagnosis. The corruption was not introduced by this PR. What this PR did was widen the set of inputs that reach it, since inputs that used to crash on Root cause. A comment line under the literal is matched by the top-of-file comment heuristic ( I first tried the obvious repair, recording an absolute stream position instead of a relative rollback. That is wrong, and the test suite said so: isort buffers the import section, so the position when the So: guard the top-comment heuristic with
Four regression tests added, including one asserting Full suite: 630 passed. The only failures are the 4 that already fail on Still dropped, and unchanged by this PR because Your last point stands too: a literal containing @DanielNoord on both of your points: CI is green now, that was a stale run from before a rebase. On complexity, AI-assisted: written with Claude Code, reviewed and tested by me before sending. |
A standalone comment under a sorted literal was matched by the top-of-file comment heuristic, so it was written to the output stream before the sorted code. In the reexport path the rollback only accounts for the __all__ line, so the seek then landed mid-line and produced __all____all__, deleting the real __all__ and dropping the comment. Guard the heuristic with 'not code_sorting' so such a comment stays in its section, and end the literal at the comment so the sorter, which rebuilds the value from its parsed form, writes it through untouched instead of discarding it.
8dd645f to
70f7eac
Compare
Manny7717
left a comment
There was a problem hiding this comment.
Verified your fix locally on head 70f7eac — and yes, your correction to my diagnosis is right: on main (131f4ad), the EOF shape already corrupts (__all____all__ = ["a", "b"]\n), while the statement shape crashed with ValueError: too many values to unpack. So this PR widened reachability of a pre-existing corruption and your fix closes it properly rather than just avoiding it. That's a better fix than my suggestion, and the relative-rollback-vs-absolute-position reasoning in the commit message is correct.
What I re-ran:
- All three shapes from my original finding now produce sorted output with the comment preserved in place: reexports+comment+stmt, reexports+comment at EOF, and
# isort: list+comment. - The new check_code-agreement test: verified by hand that
check_codereturns True iffcode()is a no-op on all three inputs (the ERROR lines it prints for unsorted inputs are the expected check failure output). - Full unit suite: 630 passed / 4 failed on head; the identical 4 fail on main (issue_909/938/970/1732, FileNotFoundError env noise) — zero new failures introduced.
- ruff clean on changed files.
Both remaining gaps are confirmed pre-existing and correctly scoped out: the trailing inline comment (__all__ = [...] # exports) is dropped identically on main, and a literal containing = still raises LiteralParsingFailure on both. Happy to look at the trailing-comment case separately if you open it as an issue.
|
Thanks for re-running it, and for catching the original defect. Our numbers match: 630 / 4 on this head, the same 4 on Filed the trailing-comment case as #2646 since you offered to look at it. Two symptoms on that seam, both pre-existing and both untouched by this PR:
The second is the same @DanielNoord one thing that is easy to miss from the checks list here: the CI run you saw failing was from before a rebase, and the current run is sitting at |
Manny7717
left a comment
There was a problem hiding this comment.
The #2286 fix works (verified: __all__ = ["b", "a"]\nx = 1 and the # isort: list variant now sort instead of raising ValueError; all 9 new tests pass; full suite head 632 passed / 2 pre-existing env failures vs base 623 / 2 — zero regressions on the covered shapes). However, the new _split_code_sorting_section introduces a crash on input that base completed: any standalone comment line INSIDE a multi-line literal truncates the section at that comment, leaving an unclosed bracket → LiteralParsingFailure. Comments inside a sorted literal are exactly the shapes isort previously accepted (it rebuilds the literal from its parsed value, dropping the comment — pre-existing #2646-family behavior).
Repros (base @ 131f4ad OK → head @ 70f7eac CRASH):
# isort: list
NAMES = [
"b",
"a",
# note
]__all__ = [
"b",
"a",
# note
]
# with sort_reexports=TrueBase: NAMES = ["a", "b"] / __all__ = ["a", "b"] (sorted, comment dropped as before). Head: LiteralParsingFailure: isort failed to parse the given literal NAMES = [\n "b",\n "a",\n. — the section is cut at the comment line, so ast.literal_eval gets an unclosed [. CLI impact: isort --check on such a file goes from "Imports are incorrectly sorted" (actionable, exit 1) to "ERROR: isort failed to parse the given literal" — a hard failure blaming the user's code for something isort used to process.
Root cause: core.py:84-86. When the section has a single statement (len(body) == 1), literal_end = len(lines) and the comment scan runs over the WHOLE section including bracket-nested comment lines. The scan should only treat a comment as a terminator when it starts AFTER the first statement's end line (body[0].end_lineno - 1); comment lines inside the brackets belong to the literal, not to the trailing section.
Verified fix (one-loop-scope change, full suite still 632/2, all 9 new tests pass, all crash cases resolve):
literal_end = body[1].lineno - 1 if len(body) > 1 else len(lines)
stmt_end = body[0].end_lineno - 1
for index, line in enumerate(lines[stmt_end:literal_end], start=stmt_end):
if line.lstrip().startswith("#"):
literal_end = index
breakWorth adding a regression test for an interior/trailing comment inside a multi-line literal (both the # isort: list and sort_reexports paths).
| return section, "" | ||
|
|
||
| literal_end = body[1].lineno - 1 if len(body) > 1 else len(lines) | ||
| for index, line in enumerate(lines[:literal_end]): |
There was a problem hiding this comment.
This loop scans from line 0 of the section, so a comment nested inside a multi-line literal's brackets (e.g. NAMES = [\n "b",\n # note\n "a",\n]) is mistaken for the end of the literal → the section is cut mid-bracket and literal.assignment raises LiteralParsingFailure. Base completed on such input (sorted + dropped the comment, the known #2646 limitation); head now hard-crashes, including via isort --check. Suggest starting the scan after the first statement's end line (body[0].end_lineno - 1) so only comments that follow the closed literal can terminate the section.
The comment scan in _split_code_sorting_section started at the top of the
section, so a standalone comment nested inside a multi-line literal ended
the section mid-bracket and ast.literal_eval received an unclosed bracket:
# isort: list
NAMES = [
"b",
"a",
# note
]
main sorts this (dropping the comment, the pre-existing PyCQA#2646 behaviour);
the previous head raised LiteralParsingFailure, turning "imports are
incorrectly sorted" into a hard parse error blaming the user's file.
Only a comment that follows the closed literal can end the section, so the
scan now starts at the first statement's end line. Two regression tests
cover the # isort: list and sort_reexports paths.
|
Reproduced both shapes and confirmed it against base: Took your scan bounds as written, with one addition: |
Manny7717
left a comment
There was a problem hiding this comment.
Verified the follow-up fix on head 9217b1c — it resolves the regression I reported in my last review, and does so exactly along the lines I suggested.
What I verified
- The two new regression tests pass:
test_isort_list_comment_inside_multiline_literal_issue_2286andtest_sort_reexports_comment_inside_multiline_all_issue_2286. All 11issue_2286tests in test_regressions.py pass; test_regressions.py + test_literal.py = 130 passed. - The scan now starts at
body[0].end_lineno - 1(after the literal's closing bracket) with an empty-body guard, so comments nested inside the brackets belong to the literal instead of terminating the section mid-bracket. - Manual probes of the reported crash shapes, both via
# isort: listandsort_reexports=True: comments nested inside multi-line literals (head-position, middle, and inline-tail) now sort without raisingLiteralParsingFailure; a standalone comment or statement after the closed literal still terminates the section correctly (original #2286 behavior preserved); the EOF shape stays intact. - Full unit suite on head: 634 passed, 2 failed — both failures (
test_settings_path_skip_issue_909,test_skip_paths_issue_938) are the known pre-existing FileNotFoundError env noise that also fails identically on main. Zero new failures.
Nice catch shipping the regression test alongside the fix — the previous head traded "wrongly sorted" for a user-blame parse error, and this restores graceful sorting for all the nested-comment shapes.
Fixes #2286.
What's broken
A statement that immediately follows a sorted literal, with no blank line, makes isort raise.
The second needs no
--sort-reexports, so this is not specific to reexports. The issue reports it as a reexport bug, but the same accumulator serves the action comments.The fix
The
elif code_sorting:branch incore.pyaccumulates lines and only stops at a blank line, so a trailing statement gets folded into the section.literal.assignmentthen runscode.split("=")over the combined text and unpacks two names from it.At flush time the section is now split into the literal and whatever followed it, and the remainder is written verbatim after the sorted code. The flush point itself does not move, which matters: accumulated lines set
line = ""and never reachoutput_stream, so the reexport rollback and truncate accounting is untouched.I tried the more obvious fix first, flushing as soon as the brackets balance, and it corrupts output. That happens before the current line's bytes are written, so the rollback relocates content above
__all__.test_reexport_not_first_linecatches it. There is a regression test here for that case specifically.# isort: assignmentsis returned whole, since it deliberately spans several bracket-free statements until a blank line.literal.pynow usessplit("=", 1). That alone does not fix anything, it just turns the crash into a parse failure, but it seemed worth having.Verification
Four tests in
tests/unit/test_regressions.py: both repros above, a case where__all__is not the first line, and a multi-line__all__. All four fail without thecore.pychange.Also checked by hand, all sorting correctly, parsing, and idempotent on a second pass: a literal indented inside a class, a bracket inside a string value, a trailing comment, a nested
# isort: dict, and the existing blank-line-separated form.pytest tests/unitgives 588 passed against 584 before, the difference being these four. Two failures are the same on both trees,FileNotFoundErrorfromgitmissing in my environment.One case I did not fix:
# isort: splitinside a literal produces mangled output. That is pre-existing, byte for byte identical before and after this change on a blank-line-terminated literal, so I left it alone rather than widen the diff. Happy to open a separate issue for it.