Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions isort/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_ticketed_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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