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