Skip to content

fix: keep the trailing comment of a single import in wrap modes 9 and 10 - #2733

Merged
DanielNoord merged 1 commit into
PyCQA:mainfrom
simpleqt:fix/wrap-modes-trailing-comment
Oct 21, 2026
Merged

DanielNoord merged 1 commit into
PyCQA:mainfrom
simpleqt:fix/wrap-modes-trailing-comment

Conversation

@simpleqt

Copy link
Copy Markdown
Contributor

Summary

multi_line_output=9 (VERTICAL_PREFIX_FROM_MODULE_IMPORT) and multi_line_output=10
(HANGING_INDENT_WITH_PARENTHESES) delete a trailing comment from a from import
when 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 was
suppressing. For mode 10 the output was also not idempotent: it produced
from a import (b) for a single import, which the next run rewrote to from a import b.

>>> import isort
>>> src = "from a import b  # noqa: F401\n"
>>> first = isort.code(src, multi_line_output=10, line_length=20)
>>> first                      # before the fix: comment gone
'from a import (b)\n'
>>> isort.code(first, multi_line_output=10, line_length=20)   # not idempotent
'from a import b\n'
>>> isort.code(src, multi_line_output=9, line_length=20)      # before the fix: comment gone
'from a import b\n'

Root cause

Both modes attach the collected trailing comment while wrapping the next import:

  • hanging_indent_with_parentheses (isort/wrap_modes.py) only reaches
    comments.add_to_line() inside the while imports: loop, and the first-import
    length check ignores the comment, so a lone import that fits under the limit left
    comments unconsumed and the final _add_syntax(statement, ")") never used them.
  • vertical_prefix_from_module_import used
    if comments and statement_with_comments: after the loop; statement_with_comments
    is only set by a loop iteration, so with one import the comment was discarded.

isort/output.py deliberately routes a single import that carries a noqa comment
through the wrap-mode formatter (_build_grouped_from_imports, the
has_noqa_comment guard) to keep the comment on the statement line — which is exactly
the 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 of
all 12 modes shows 0 losses in the other 10).

Fix

  • Mode 10: when the first import is also the last one, attach the comment before the
    fit check. It is hoisted onto the ( line when it does not fit — the shape the mode
    already produces when the import itself is too long — and kept after the closing
    parenthesis when it does fit.
  • Mode 9: emit any comment the wrapping loop did not consume, instead of only the one
    carried by the last loop iteration. For statements with several imports this is
    byte-for-byte the previous behaviour (the removed statement_with_comments value was
    comments.add_to_line(comments, output_statement)), so only the single-import case
    changes.

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_import
  • tests/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_import
  • tests/unit/test_regressions.py::test_wrap_modes_9_and_10_keep_a_trailing_noqa_comment_on_a_single_import
    (public API: exact output, noqa survives, code(code(x)) == code(x) and ast.parse
    over modes 9/10, line_length 12–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.py reverted to main

$ pytest tests/unit/test_wrap_modes.py::test_hanging_indent_with_parentheses_preserves_comment_of_a_single_import \
         tests/unit/test_wrap_modes.py::test_hanging_indent_with_parentheses_comment_position_unchanged_for_many_imports \
         tests/unit/test_wrap_modes.py::test_vertical_prefix_from_module_import_preserves_comment_of_a_single_import \
         tests/unit/test_regressions.py::test_wrap_modes_9_and_10_keep_a_trailing_noqa_comment_on_a_single_import -q
E       AssertionError: assert 'from a import b' == 'from a impor... # noqa: F401'
E         - from a import b  # noqa: F401
E         + from a import b
...
E       AssertionError: assert 'from a import (b)\n' == 'from a impor...401\n    b)\n'
E         + from a import (b)
E         - from a import (  # noqa: F401
E         -     b)
=========================== short test summary info ===========================
FAILED tests/unit/test_wrap_modes.py::test_hanging_indent_with_parentheses_preserves_comment_of_a_single_import
FAILED tests/unit/test_wrap_modes.py::test_vertical_prefix_from_module_import_preserves_comment_of_a_single_import
FAILED tests/unit/test_regressions.py::test_wrap_modes_9_and_10_keep_a_trailing_noqa_comment_on_a_single_import
3 failed, 1 passed in 0.32s

(the 4th, ..._comment_position_unchanged_for_many_imports, passes on main by
design — it pins behaviour that must not change.)

Full transcription: G:\pr\work\out\red.txt

GREEN — same tests with the fix

$ pytest <same four node ids> -q
....                                                                     [100%]
4 passed in 0.66s

Full transcription: G:\pr\work\out\green.txt

Verification

  • pytest tests/unit/test_wrap_modes.py tests/unit/test_regressions.py -q → 158 passed

  • pytest tests/unit -q → 1 failed, 704 passed, 5 skipped; main (same venv, same
    command) → 1 failed, 700 passed, 5 skipped. The single failure is identical on
    both and is pre-existing: test_windows_diff_too_large_misrepresentative_issue_1348
    rewrites tests/unit/example_crlf_file.py in place, so it fails after an earlier test
    in 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 the dev dependency group were installed
    locally, otherwise test_sort_configurable_sort_issue_1732 and
    test_isort_supports_shared_profiles_issue_970 fail on base as well.)

  • pytest tests/integration/test_hypothesmith.py -q (property test:
    code(code(x)) == code(x) over hypothesmith-generated sources and randomised
    isort.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 formatted
    • flake8 isort/ tests/ → clean (exit 0)
    • mypy → 1 pre-existing error, identical on main:
      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 fixture
      tests/unit/example_crlf_file.py as unsorted — identical output with the change
      stashed, 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:

    marker comment lost unparsable output non-idempotent
    before 1,782 0 720
    after 0 0 190

    The remaining 190 non-idempotent cases are unrelated pre-existing cases in other
    modes (e.g. mode 0 with profile=black at very short line_length); a targeted
    base/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

  • Mode 10 emits from a import (b) # noqa: F401 (parens around a single import) when
    the comment fits; that shape already existed, only the comment is new. The ) is
    always placed before the comment by the existing _add_syntax helper, so the output
    stays valid Python (asserted in the regression test with ast.parse).
  • remove_comments/ignore_comments behaviour is unchanged: comments.add_to_line is
    called with removed=remove_comments, so with ignore_comments=True no comment is
    emitted (only the single-import path now adds a call that is a no-op in that case).
  • Mode 9 gains a comment on the continuation line of a single over-long import
    (from a import \ / b # noqa), which lengthens that line; it is the same
    fallback shape used for a long single import without a comment.

Artifacts

  • Patch: G:\pr\work\out\is-wrap-modes-trailing-comment.patch
  • Repro script (uncommitted, kept in the clone): G:\pr\work\is\scratch_repro.py;
    unpatched transcript G:\pr\work\out\scratch_repro_base.txt (4/4 cases lose the
    comment
    , 2 of them also non-idempotent), patched run → 0/4.

``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>
Copilot AI balanced review requested due to automatic review settings October 20, 2026 11:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@DanielNoord DanielNoord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
DanielNoord enabled auto-merge October 21, 2026 19:33
@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.38%. Comparing base (f97a190) to head (b22f624).
⚠️ Report is 2 commits behind head on main.

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:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@DanielNoord
DanielNoord added this pull request to the merge queue Oct 21, 2026
Merged via the queue into PyCQA:main with commit cf295be Oct 21, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants