From 656eed1e158e9c2f466052709a79cf1234d5edc1 Mon Sep 17 00:00:00 2001 From: Eryx-Z Date: Sat, 17 Oct 2026 07:55:43 +0800 Subject: [PATCH] Fix non-idempotent black-profile output for name-level import comments When a from-import name carries an inline comment that does not fit on one line, keep the name in the grouped import and hoist the comment to the opening parenthesis for vertical wrap modes. Previously the name was split onto its own statement and the next run regrouped differently, so isort --check rejected isort's own first-pass output. Fixes #2694. --- isort/output.py | 34 ++++++++++++++++++++++------ tests/unit/test_ticketed_features.py | 33 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/isort/output.py b/isort/output.py index 27ae505b7..d75993216 100644 --- a/isort/output.py +++ b/isort/output.py @@ -723,15 +723,35 @@ def _with_from_imports_for_module( parsed.categorized_comments["nested"].setdefault(module, {})[from_import] = comment continue if comment is not None: - # If the comment is a noqa and hanging indent wrapping is used, - # keep the name in the main list and hoist the comment to the statement. - if ( + # If the comment would land on the opening-paren line after wrapping + # (hanging-indent noqa, or any comment in the vertical modes that + # hoist via wrap._hoist_comment_to_paren), keep the name in the main + # list and hoist the comment to the statement. Otherwise the name is + # split onto its own statement, wrap.line may hoist the comment to a + # statement-level position, and the next run regroups — non-idempotent + # under black profile (see #2694). + vertical_modes = ( + wrap_modes.WrapModes.VERTICAL_HANGING_INDENT, + wrap_modes.WrapModes.VERTICAL_GRID_GROUPED, + ) + hoist_to_statement = ( comment.lower().startswith("noqa") and config.multi_line_output == wrap_modes.WrapModes.HANGING_INDENT - ): - comments = list(comments) if comments else [] - comments.append(comment) - continue + ) or config.multi_line_output in vertical_modes + if hoist_to_statement: + # Only hoist when a standalone name+comment line would not fit; + # short lines can keep the name-level comment without churn. + standalone = ( + f"{import_start}{from_import}" + f"{config.comment_prefix} {comment}" + ) + if ( + config.multi_line_output == wrap_modes.WrapModes.HANGING_INDENT + or len(standalone) > config.line_length + ): + comments = list(comments) if comments else [] + comments.append(comment) + continue from_imports.remove(from_import) if from_imports: diff --git a/tests/unit/test_ticketed_features.py b/tests/unit/test_ticketed_features.py index 0cc8c6e5e..67b489be1 100644 --- a/tests/unit/test_ticketed_features.py +++ b/tests/unit/test_ticketed_features.py @@ -1264,3 +1264,36 @@ def test_comment_not_duplicated_across_from_imports_2282() -> None: from foo import e """ assert isort.code(code) == code + + +def test_nested_import_comment_idempotent_under_black_profile_2694() -> None: + """A name-level inline comment must not make black-profile output non-idempotent. + + When a deeply-dotted from-import carries a per-name comment that does not fit + on one line, isort used to split that name onto its own statement. The next + run then regrouped remaining names differently (or hoisted the comment), so + ``isort --check`` rejected isort's own first-pass output. See #2694. + """ + source = ( + "from pkg.submodule.submodule.submodule.submodule import (\n" + " alpha, # type: ignore[import-untyped]\n" + " beta, gamma\n" + ")\n" + ) + first = isort.code(source, profile="black") + second = isort.code(first, profile="black") + assert first == second + assert isort.check_code(first, profile="black") + assert first == ( + "from pkg.submodule.submodule.submodule.submodule import (" + " # type: ignore[import-untyped]\n" + " alpha,\n" + " beta,\n" + " gamma,\n" + ")\n" + ) + + # Short module paths keep the name-level comment (no hoist needed). + short = "from pkg import alpha # type: ignore\n" + assert isort.code(short, profile="black") == short + assert isort.code(isort.code(short, profile="black"), profile="black") == short