Repository navigation
fix: keep the trailing comment of a single import in wrap modes 9 and 10 - #2733
Merged
Merged
Conversation
``multi_line_output=9`` (VERTICAL_PREFIX_FROM_MODULE_IMPORT) and
``multi_line_output=10`` (HANGING_INDENT_WITH_PARENTHESES) attach the collected
trailing comment of a ``from`` import while wrapping the *next* import. A statement
that has nothing left to wrap therefore never emitted it, and the comment was
dropped from the output without any warning:
from a import b # noqa: F401 # multi_line_output=10, line_length=20
from a import (b) # before, pass 1: comment gone
from a import b # before, pass 2: rewritten again
The dropped comment is usually a ``noqa``, whose removal silently re-activates the
lint error it was suppressing. Mode 10 additionally emitted ``from a import (b)``
for a single import, which the next run rewrote to ``from a import b``, so
``isort --check`` rejected isort's own output; mode 9 kept ``from a import b``, which
loses only the comment.
Both modes now emit the comment: mode 10 hoists it onto the ``(`` line when it does
not fit (the shape the mode already produces when the import itself is too long) and
keeps it after the closing parenthesis when it does fit, while mode 9 attaches it to
the statement it could not break. The multi-import paths are untouched, so the
comment keeps the position the wrapping loop already gives it.
A sweep of 20 source shapes x 30 line lengths x 11 option sets over all 12 wrap
modes (79,200 runs) drops the marker comment 1,782 times before this change and
never after it, and the number of non-idempotent results falls from 720 to 190 (the
remaining ones are unrelated pre-existing cases in other modes). Every difference
against the previous behaviour is a comment that used to be deleted, which the
4000-case differential corpus confirms.
This is the same class of bug as the one fixed for the ``grid`` mode, which the
sibling modes 9 and 10 still had.
Signed-off-by: simpleqt <2015900818@qq.com>
DanielNoord
approved these changes
Oct 21, 2026
DanielNoord
left a comment
Member
There was a problem hiding this comment.
Tests are a bit verbose but oh well.
Please for the sanity of open source maintainers, turn down the verbosity of your PR descriptions. This is really poor.
DanielNoord
enabled auto-merge
October 21, 2026 19:33
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2733 +/- ##
=======================================
Coverage 99.38% 99.38%
=======================================
Files 40 40
Lines 3253 3255 +2
Branches 700 701 +1
=======================================
+ Hits 3233 3235 +2
Misses 12 12
Partials 8 8 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
multi_line_output=9(VERTICAL_PREFIX_FROM_MODULE_IMPORT) andmulti_line_output=10(
HANGING_INDENT_WITH_PARENTHESES) delete a trailing comment from afromimportwhen the statement has only one import left to emit. The comment that sits there is
almost always a
noqa, so the output silently re-activates the lint error it wassuppressing. For mode 10 the output was also not idempotent: it produced
from a import (b)for a single import, which the next run rewrote tofrom a import b.Root cause
Both modes attach the collected trailing comment while wrapping the next import:
hanging_indent_with_parentheses(isort/wrap_modes.py) only reachescomments.add_to_line()inside thewhile imports:loop, and the first-importlength check ignores the comment, so a lone import that fits under the limit left
commentsunconsumed and the final_add_syntax(statement, ")")never used them.vertical_prefix_from_module_importusedif comments and statement_with_comments:after the loop;statement_with_commentsis only set by a loop iteration, so with one import the comment was discarded.
isort/output.pydeliberately routes a single import that carries anoqacommentthrough the wrap-mode formatter (
_build_grouped_from_imports, thehas_noqa_commentguard) to keep the comment on the statement line — which is exactlythe path that dropped it. Any other comment on a single import goes through
wrap.line(), which preserves it, so the loss was specific to modes 9/10 (a sweep ofall 12 modes shows 0 losses in the other 10).
Fix
fit check. It is hoisted onto the
(line when it does not fit — the shape the modealready produces when the import itself is too long — and kept after the closing
parenthesis when it does fit.
carried by the last loop iteration. For statements with several imports this is
byte-for-byte the previous behaviour (the removed
statement_with_commentsvalue wascomments.add_to_line(comments, output_statement)), so only the single-import casechanges.
The multi-import comment positions are pinned by a new test, and the 4000-case
differential corpus below confirms that every behavioural difference is a comment that
used to be deleted.
Regression tests
tests/unit/test_wrap_modes.py::test_hanging_indent_with_parentheses_preserves_comment_of_a_single_importtests/unit/test_wrap_modes.py::test_hanging_indent_with_parentheses_comment_position_unchanged_for_many_imports(pin: multi-import comment position is unchanged)
tests/unit/test_wrap_modes.py::test_vertical_prefix_from_module_import_preserves_comment_of_a_single_importtests/unit/test_regressions.py::test_wrap_modes_9_and_10_keep_a_trailing_noqa_comment_on_a_single_import(public API: exact output,
noqasurvives,code(code(x)) == code(x)andast.parseover modes 9/10,
line_length12–40,include_trailing_comma,force_grid_wrap=1)They mirror the neighbouring tests added for mode 0 in PR #2675.
RED — new tests with only
isort/wrap_modes.pyreverted tomain(the 4th,
..._comment_position_unchanged_for_many_imports, passes onmainbydesign — it pins behaviour that must not change.)
Full transcription:
G:\pr\work\out\red.txtGREEN — same tests with the fix
Full transcription:
G:\pr\work\out\green.txtVerification
pytest tests/unit/test_wrap_modes.py tests/unit/test_regressions.py -q→ 158 passedpytest tests/unit -q→ 1 failed, 704 passed, 5 skipped;main(same venv, samecommand) → 1 failed, 700 passed, 5 skipped. The single failure is identical on
both and is pre-existing:
test_windows_diff_too_large_misrepresentative_issue_1348rewrites
tests/unit/example_crlf_file.pyin place, so it fails after an earlier testin the same session corrupts that fixture (it passes in isolation, on both revisions).
The 4 extra passing tests are the new ones.
(The two
example_*plugin packages from thedevdependency group were installedlocally, otherwise
test_sort_configurable_sort_issue_1732andtest_isort_supports_shared_profiles_issue_970fail on base as well.)pytest tests/integration/test_hypothesmith.py -q(property test:code(code(x)) == code(x)over hypothesmith-generated sources and randomisedisort.Config) → 1 passed.Lint/type checks, run as in
tox -e lint:ruff check isort/ tests/ scripts/→ All checks passed!ruff format --check(all project dirs) → 105 files already formattedflake8 isort/ tests/→ clean (exit 0)mypy→ 1 pre-existing error, identical onmain:tests/unit/test_settings.py:101: error: Module has no attribute "mkfifo"(Windows-only)bandit -r isort/→ clean (exit 0)isort --profile hug --check --diff isort/wrap_modes.py tests/unit/test_wrap_modes.py tests/unit/test_regressions.py→ clean. The repo-wide variant (
isort/ tests/ scripts/) reports the CRLF fixturetests/unit/example_crlf_file.pyas unsorted — identical output with the changestashed, a Windows/CRLF artefact of this checkout, not caused by the change.
Sweep (scratch harness, not committed): 20 source shapes × 30 line lengths × 11
option sets × all 12 modes = 79,200 runs, checking that a marker comment survives
the round trip and that the output parses and is stable:
The remaining 190 non-idempotent cases are unrelated pre-existing cases in other
modes (e.g. mode 0 with
profile=blackat very shortline_length); a targetedbase/fixed comparison of that class shows 48 → 36 and no case that is non-idempotent
only after the fix.
Differential corpus (4,000 randomised source/config pairs, before vs after): 30
outputs differ, and every one of them is a previously deleted comment now kept —
no case changes in any other way.
Risks
from a import (b) # noqa: F401(parens around a single import) whenthe comment fits; that shape already existed, only the comment is new. The
)isalways placed before the comment by the existing
_add_syntaxhelper, so the outputstays valid Python (asserted in the regression test with
ast.parse).remove_comments/ignore_commentsbehaviour is unchanged:comments.add_to_lineiscalled with
removed=remove_comments, so withignore_comments=Trueno comment isemitted (only the single-import path now adds a call that is a no-op in that case).
(
from a import \/b # noqa), which lengthens that line; it is the samefallback shape used for a long single import without a comment.
Artifacts
G:\pr\work\out\is-wrap-modes-trailing-comment.patchG:\pr\work\is\scratch_repro.py;unpatched transcript
G:\pr\work\out\scratch_repro_base.txt(4/4 cases lose thecomment, 2 of them also non-idempotent), patched run → 0/4.