From 6e8b881dcb51ce08f088ef76b4bdc344affe33cb Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Thu, 16 Jul 2026 21:55:15 +0000 Subject: [PATCH 01/12] fix: keep comment-only lines inside from-import groups Comment-only members of a parenthesised from-import were collapsed onto the opening import line as a single semicolon-joined comment. With the black profile that rewrote intentional WIP comments and produced lines that fail line-length checkers. Track those body comments separately and re-emit them as indented lines inside parenthesised wrapping; preserve the historical collapse for non-parenthesised wrap modes. Fixes #1852 Signed-off-by: Alex Chen --- isort/output.py | 51 +++++++++++++++++++++++++++++++ isort/parse.py | 25 ++++++++++++++++ tests/unit/test_regressions.py | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/isort/output.py b/isort/output.py index cc4fb2b2..8250422a 100644 --- a/isort/output.py +++ b/isort/output.py @@ -286,6 +286,40 @@ def _build_import_group( # Ignore DeepSource cyclomatic complexity check for this function. It was # already complex when this check was enabled. # skipcq: PY-R1000 +def _inject_from_body_comments( + import_statement: str, + body_comments: list[str], + line_separator: str, + indent: str, +) -> str: + """Re-insert comment-only lines inside a multi-line from-import statement. + + Comment-only members of a parenthesised import group must stay as their own + indented lines. Collapsing them onto the opening ``import (`` line produces + a single long ``# a,; b,; c`` comment that breaks line-length checkers. + See issue #1852. + """ + if not body_comments: + return import_statement + + comment_lines = [ + f"{indent}# {comment_text}".rstrip() if comment_text else f"{indent}#" + for comment_text in body_comments + ] + lines = import_statement.split(line_separator) + + # Preferred placement: immediately before the closing ``)`` of a multi-line + # parenthesised import so the comments stay inside the group. + for index in range(len(lines) - 1, -1, -1): + if lines[index].lstrip().startswith(")"): + lines[index:index] = comment_lines + return line_separator.join(lines) + + # Fallback for single-line / non-parenthesised output: keep comments after + # the import statement rather than dropping them. + return line_separator.join([import_statement, *comment_lines]) + + def _with_from_imports( parsed: parse.ParsedContent, config: Config, @@ -353,6 +387,15 @@ def _with_from_imports( only_show_as_imports = False comments: list[str] | None = parsed.categorized_comments["from"].pop(module, None) above_comments = parsed.categorized_comments["above"]["from"].pop(module, None) + body_comments: list[str] = list( + parsed.categorized_comments.get("from_body", {}).pop(module, []) + ) + # Parenthesised black-style wrapping can keep comment-only import members as + # their own lines (issue #1852). Other wrap modes historically collapsed those + # comments onto the import statement (issue #1396); preserve that behaviour. + if body_comments and not config.use_parentheses: + comments = list(comments or []) + body_comments + body_comments = [] while from_imports: if above_comments: output.extend(above_comments) @@ -697,6 +740,14 @@ def _with_from_imports( comments = None if import_statement: + if body_comments and not config.ignore_comments: + import_statement = _inject_from_body_comments( + import_statement, + body_comments, + parsed.line_separator, + config.indent, + ) + body_comments = [] output.append(import_statement) return output diff --git a/isort/parse.py b/isort/parse.py index 03678397..beeae9ad 100644 --- a/isort/parse.py +++ b/isort/parse.py @@ -31,6 +31,11 @@ "straight": dict[str, list[str]], "nested": dict[str, dict[str, str]], "above": CommentsAboveDict, + # Comment-only lines that appeared inside a parenthesised from-import + # (e.g. `` # PasswordChangeView,``). Kept separate from opening-line + # ``from`` comments so they can be re-emitted as their own indented lines + # instead of being collapsed onto the ``import (`` line. See issue #1852. + "from_body": dict[str, list[str]], }, ) @@ -108,6 +113,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte "straight": {}, "nested": {}, "above": {"straight": {}, "from": {}}, + "from_body": {}, } trailing_commas: set[str] = set() @@ -234,6 +240,13 @@ def _get_next_line() -> tuple[str, str | None]: raw_lines.append(extra_line.line) # If during parsing of the continuation lines we encounter a comment, we record it. if extra_line.comment is not None: + code_part = extra_line.line.split("#", 1)[0].strip().rstrip(",") + # A continuation line that is only a comment (no import name before ``#``) + # is not an attribute comment and must not be attached to the opening + # ``from ... import (`` line. Keep it as a body comment so output can + # re-emit it as its own indented line. See issue #1852. + if type_of_import == "from" and not code_part: + continue comments.append(extra_line.comment) stripped_line = strip_syntax(extra_line.line).strip() if ( @@ -312,6 +325,18 @@ def _get_next_line() -> tuple[str, str | None]: if type_of_import == "from": import_from = just_imports.pop(0) + # Preserve comment-only lines from inside the parenthesised import group. + # They were skipped above so they would not collapse onto the opening line. + body_comments = [ + extra_line.comment + for extra_line in extra_lines + if extra_line.comment is not None + and not extra_line.line.split("#", 1)[0].strip().rstrip(",") + ] + if body_comments: + categorized_comments["from_body"].setdefault(import_from, []).extend( + body_comments + ) placed_module = finder(import_from) if config.verbose and not config.only_modified: print(f"from-type place_module for {import_from} returned {placed_module}") diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 09043352..80b488cc 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2406,3 +2406,58 @@ def test_isort_does_not_drop_aliased_import_when_plain_name_has_a_comment(): expected = "from . import bar\nfrom . import one # NOQA\nfrom . import one as zzz\n" assert relative_sorted == expected assert isort.code(relative_sorted) == relative_sorted + +def test_comment_only_lines_in_from_import_group_issue_1852(): + """Comment-only lines inside a parenthesised from-import must stay as lines. + + With the black profile, isort used to collapse commented-out import members + onto the opening ``import (`` line as a single ``# a,; b,; c`` comment. That + rewrites intentional WIP comments and commonly trips line-length checkers. + See issue #1852. + """ + to_sort = ( + "from dj_rest_auth.views import (\n" + " LoginView,\n" + " LogoutView,\n" + " # PasswordChangeView,\n" + " # PasswordResetConfirmView,\n" + " # PasswordResetView,\n" + " # UserDetailsView,\n" + ")\n" + ) + expected = ( + "from dj_rest_auth.views import (\n" + " LoginView,\n" + " LogoutView,\n" + " # PasswordChangeView,\n" + " # PasswordResetConfirmView,\n" + " # PasswordResetView,\n" + " # UserDetailsView,\n" + ")\n" + ) + + first_pass = isort.code(to_sort, profile="black", line_length=100) + assert first_pass == expected + assert "# PasswordChangeView,; PasswordResetConfirmView," not in first_pass + assert isort.code(first_pass, profile="black", line_length=100) == first_pass + assert isort.check_code(first_pass, profile="black", line_length=100, show_diff=True) + + # Mixed live imports and comment-only lines should keep the comments as + # separate lines after the sorted live names. + mixed = ( + "from foo import (\n" + " zeta,\n" + " # disabled\n" + " alpha,\n" + ")\n" + ) + mixed_expected = ( + "from foo import (\n" + " alpha,\n" + " zeta,\n" + " # disabled\n" + ")\n" + ) + mixed_out = isort.code(mixed, profile="black") + assert mixed_out == mixed_expected + assert isort.code(mixed_out, profile="black") == mixed_out From a609e6793f8490c34ee32b1553409d2e48b5ebc4 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 17 Jul 2026 07:46:51 +0000 Subject: [PATCH 02/12] style: keep DeepSource skipcq on complex _with_from_imports Signed-off-by: Alex Chen --- isort/output.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/isort/output.py b/isort/output.py index 8250422a..22b44925 100644 --- a/isort/output.py +++ b/isort/output.py @@ -283,9 +283,6 @@ def _build_import_group( return group_output -# Ignore DeepSource cyclomatic complexity check for this function. It was -# already complex when this check was enabled. -# skipcq: PY-R1000 def _inject_from_body_comments( import_statement: str, body_comments: list[str], @@ -320,6 +317,9 @@ def _inject_from_body_comments( return line_separator.join([import_statement, *comment_lines]) +# Ignore DeepSource cyclomatic complexity check for this function. It was +# already complex when this check was enabled. +# skipcq: PY-R1000 def _with_from_imports( parsed: parse.ParsedContent, config: Config, From bdb6d7cdcf178a9a0a89ff9683b573af6b585221 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 18 Jul 2026 16:57:05 +0000 Subject: [PATCH 03/12] style: ruff-format issue 1852 regression test CI lint fails on `ruff format --check` for the new regression test strings (and blank-line spacing). Apply ruff format only; product behavior unchanged. Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 80b488cc..923e7f3e 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2444,20 +2444,8 @@ def test_comment_only_lines_in_from_import_group_issue_1852(): # Mixed live imports and comment-only lines should keep the comments as # separate lines after the sorted live names. - mixed = ( - "from foo import (\n" - " zeta,\n" - " # disabled\n" - " alpha,\n" - ")\n" - ) - mixed_expected = ( - "from foo import (\n" - " alpha,\n" - " zeta,\n" - " # disabled\n" - ")\n" - ) + mixed = "from foo import (\n zeta,\n # disabled\n alpha,\n)\n" + mixed_expected = "from foo import (\n alpha,\n zeta,\n # disabled\n)\n" mixed_out = isort.code(mixed, profile="black") assert mixed_out == mixed_expected assert isort.code(mixed_out, profile="black") == mixed_out From d218174f89278e664b31210ad15db96a94228454 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sun, 19 Jul 2026 08:13:16 +0000 Subject: [PATCH 04/12] fix: preserve from_body comments on force_single_line and combine_star Route comment-only members onto the first single-line import and fold them into combine_star star comments so #1852 does not silently drop body comments on secondary emit paths. Fixes residual findings from multi-stage review on #2588. Signed-off-by: Alex Chen --- isort/output.py | 12 +++++++++++- tests/unit/test_regressions.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/isort/output.py b/isort/output.py index 22b44925..8ded86ee 100644 --- a/isort/output.py +++ b/isort/output.py @@ -402,9 +402,14 @@ def _with_from_imports( above_comments = None if "*" in from_imports and config.combine_star: + # Fold from_body comments onto the star statement (main-compatible). + star_comments = list(comments or ()) + if body_comments and not config.ignore_comments: + star_comments = star_comments + body_comments + body_comments = [] import_statement = wrap.line( with_comments( - _with_star_comments(parsed, module, list(comments or ())), + _with_star_comments(parsed, module, star_comments), f"{import_start}*", removed=config.ignore_comments, comment_prefix=config.comment_prefix, @@ -418,6 +423,11 @@ def _with_from_imports( only_show_as_imports = True elif config.force_single_line and module not in config.single_line_exclusions: import_statement = "" + # Preserve comment-only members on the first single-line import + # (matches pre-#1852 main behaviour for force_single_line). + if body_comments and not config.ignore_comments: + comments = list(comments or []) + body_comments + body_comments = [] while from_imports: from_import = from_imports.pop(0) single_import_line = with_comments( diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 923e7f3e..2764b628 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2449,3 +2449,26 @@ def test_comment_only_lines_in_from_import_group_issue_1852(): mixed_out = isort.code(mixed, profile="black") assert mixed_out == mixed_expected assert isort.code(mixed_out, profile="black") == mixed_out + + +def test_force_single_line_preserves_body_comments_issue_1852(): + """force_single_line must not drop comment-only from-import members. + + Regression against silent loss after from_body routing (issue #1852). + """ + mixed = "from foo import (\n zeta,\n # disabled\n alpha,\n)\n" + out = isort.code(mixed, profile="black", force_single_line=True) + assert "# disabled" in out + assert "from foo import alpha" in out + assert "from foo import zeta" in out + # main-compatible: comment attached to first emitted line + assert "from foo import alpha # disabled" in out + assert isort.code(out, profile="black", force_single_line=True) == out + + +def test_combine_star_folds_body_comments_issue_1852(): + """combine_star should fold body comments onto the star statement.""" + src = "from foo import (\n # disabled\n *\n)\n" + out = isort.code(src, combine_star=True) + assert out == "from foo import * # disabled\n" + assert isort.code(out, combine_star=True) == out From bac1128569f69dcf4b8cafad674f37984fdb6db7 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Wed, 22 Jul 2026 15:53:50 +0000 Subject: [PATCH 05/12] test: cover from body comment preservation paths Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 98 +++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 13 deletions(-) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 2764b628..a65a93a0 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -6,7 +6,10 @@ import isort import isort.sections +from isort import parse from isort.main import main +from isort.output import _inject_from_body_comments, _with_from_imports, with_comments +from isort.settings import Config def test_isort_duplicating_comments_issue_1264(): @@ -2438,7 +2441,6 @@ def test_comment_only_lines_in_from_import_group_issue_1852(): first_pass = isort.code(to_sort, profile="black", line_length=100) assert first_pass == expected - assert "# PasswordChangeView,; PasswordResetConfirmView," not in first_pass assert isort.code(first_pass, profile="black", line_length=100) == first_pass assert isort.check_code(first_pass, profile="black", line_length=100, show_diff=True) @@ -2451,24 +2453,94 @@ def test_comment_only_lines_in_from_import_group_issue_1852(): assert isort.code(mixed_out, profile="black") == mixed_out +def test_comment_only_empty_from_import_line_issue_1852(): + """A bare comment-only member must stay a bare indented comment.""" + to_sort = "from foo import (\n zeta,\n #\n alpha,\n)\n" + expected = "from foo import (\n alpha,\n zeta,\n #\n)\n" + + assert isort.code(to_sort, profile="black") == expected + + +def test_from_import_body_comments_without_parentheses_issue_1852(): + """Non-parenthesised wrapping keeps body comments on the opening import line.""" + to_sort = "from foo import (\n zeta,\n # disabled\n alpha,\n)\n" + expected = "from foo import ( # disabled\n alpha,\n zeta,\n)\n" + + assert isort.code(to_sort, profile="black", use_parentheses=False) == expected + + def test_force_single_line_preserves_body_comments_issue_1852(): """force_single_line must not drop comment-only from-import members. Regression against silent loss after from_body routing (issue #1852). """ - mixed = "from foo import (\n zeta,\n # disabled\n alpha,\n)\n" - out = isort.code(mixed, profile="black", force_single_line=True) - assert "# disabled" in out - assert "from foo import alpha" in out - assert "from foo import zeta" in out - # main-compatible: comment attached to first emitted line - assert "from foo import alpha # disabled" in out - assert isort.code(out, profile="black", force_single_line=True) == out + to_sort = "from foo import (\n zeta,\n # disabled\n alpha,\n)\n" + expected = "from foo import alpha # disabled\nfrom foo import zeta\n" + + first_pass = isort.code(to_sort, profile="black", force_single_line=True) + assert first_pass == expected + assert isort.code(first_pass, profile="black", force_single_line=True) == first_pass def test_combine_star_folds_body_comments_issue_1852(): """combine_star should fold body comments onto the star statement.""" - src = "from foo import (\n # disabled\n *\n)\n" - out = isort.code(src, combine_star=True) - assert out == "from foo import * # disabled\n" - assert isort.code(out, combine_star=True) == out + to_sort = "from foo import (\n # disabled\n *\n)\n" + expected = "from foo import * # disabled\n" + + first_pass = isort.code(to_sort, combine_star=True) + assert first_pass == expected + assert isort.code(first_pass, combine_star=True) == first_pass + + +def test_inject_from_body_comments_branches_issue_1852(): + """The helper retains empty comments and has a lossless single-line fallback.""" + assert _inject_from_body_comments("from foo import alpha", [], "\n", " ") == ( + "from foo import alpha" + ) + assert _inject_from_body_comments("from foo import alpha", ["disabled", ""], "\n", " ") == ( + "from foo import alpha\n # disabled\n #" + ) + assert _inject_from_body_comments( + "from foo import (\n alpha,\n)", ["disabled", ""], "\n", " " + ) == ("from foo import (\n alpha,\n # disabled\n #\n)") + + +def test_combine_star_folds_body_comments_when_comments_are_enabled_issue_1852(monkeypatch): + """combine_star folds body comments only when comments are retained.""" + parsed = parse.file_contents( + "from foo import (\n # disabled\n *\n)\n", + Config(combine_star=True, use_parentheses=True), + ) + config = Config(combine_star=True, use_parentheses=True) + section = "THIRDPARTY" + module = "foo" + captured: list[list[str]] = [] + original_with_comments = with_comments + + def with_comments_probe(comments, *args, **kwargs): + captured.append(list(comments or [])) + return original_with_comments(comments, *args, **kwargs) + + monkeypatch.setattr("isort.output.with_comments", with_comments_probe) + + assert parsed.categorized_comments["from_body"] == {module: ["disabled"]} + assert _with_from_imports( + parsed, + config, + parsed.imports[section]["from"], + section, + [], + "import", + is_lazy=False, + ) == ["from foo import * # disabled"] + assert captured == [["disabled"]] + + +def test_parse_from_body_comments_issue_1852(): + """The parser stores only comment-only continuation members in from_body.""" + parsed = parse.file_contents( + "from foo import (\n alpha,\n # disabled\n beta, # nested\n)\n", Config() + ) + + assert parsed.categorized_comments["from_body"] == {"foo": ["disabled"]} + assert parsed.categorized_comments["nested"] == {"foo": {"beta": "nested"}} From fbf80901309c14f60045a1cf8981164cc4993966 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Wed, 22 Jul 2026 21:42:54 +0000 Subject: [PATCH 06/12] test: import with_comments from isort.comments for mypy mypy attr-defined failed because isort.output only aliases add_to_line as with_comments and does not re-export it. Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index a65a93a0..514aacad 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -8,7 +8,8 @@ import isort.sections from isort import parse from isort.main import main -from isort.output import _inject_from_body_comments, _with_from_imports, with_comments +from isort.comments import add_to_line as with_comments +from isort.output import _inject_from_body_comments, _with_from_imports from isort.settings import Config From 45f7fd5e99f41926c27c27241434577d4470bb45 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 25 Jul 2026 06:11:37 +0000 Subject: [PATCH 07/12] fix: hug-sort with_comments import in regression tests isort --profile hug --check failed on tests/unit/test_regressions.py because isort.comments must sort before isort.main. Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 514aacad..2ef8a7d0 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -7,8 +7,8 @@ import isort import isort.sections from isort import parse -from isort.main import main from isort.comments import add_to_line as with_comments +from isort.main import main from isort.output import _inject_from_body_comments, _with_from_imports from isort.settings import Config From ef4d567ac2589a7a18a9cfcf647b97bcb0ae66e0 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 25 Jul 2026 11:01:02 +0000 Subject: [PATCH 08/12] style: blank line after main tip alias-comment regression Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 2ef8a7d0..3d51e7da 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2411,6 +2411,7 @@ def test_isort_does_not_drop_aliased_import_when_plain_name_has_a_comment(): assert relative_sorted == expected assert isort.code(relative_sorted) == relative_sorted + def test_comment_only_lines_in_from_import_group_issue_1852(): """Comment-only lines inside a parenthesised from-import must stay as lines. From 0377e894f955d88971b3c83c8664991fd7d24571 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 25 Jul 2026 11:12:27 +0000 Subject: [PATCH 09/12] fix: preserve from_body comments on as-only emit paths Fold single-line body comments via with_comments instead of orphan indented lines, and drain remaining body comments onto the last emitted statement when no grouped import_statement is built. Signed-off-by: Alex Chen --- isort/output.py | 32 ++++++++++++++++++++++--- tests/unit/test_regressions.py | 43 +++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/isort/output.py b/isort/output.py index 8ded86ee..26951d52 100644 --- a/isort/output.py +++ b/isort/output.py @@ -288,6 +288,9 @@ def _inject_from_body_comments( body_comments: list[str], line_separator: str, indent: str, + *, + comment_prefix: str, + ignore_comments: bool = False, ) -> str: """Re-insert comment-only lines inside a multi-line from-import statement. @@ -295,6 +298,10 @@ def _inject_from_body_comments( indented lines. Collapsing them onto the opening ``import (`` line produces a single long ``# a,; b,; c`` comment that breaks line-length checkers. See issue #1852. + + When the statement is single-line (no closing ``)``), fold the body comments + onto that statement with ``with_comments`` instead of emitting orphan + indented comment lines. Orphan lines are non-idempotent under a second sort. """ if not body_comments: return import_statement @@ -312,9 +319,13 @@ def _inject_from_body_comments( lines[index:index] = comment_lines return line_separator.join(lines) - # Fallback for single-line / non-parenthesised output: keep comments after - # the import statement rather than dropping them. - return line_separator.join([import_statement, *comment_lines]) + # Single-line / no-paren fallback: main-compatible fold onto the statement. + return with_comments( + body_comments, + import_statement, + removed=ignore_comments, + comment_prefix=comment_prefix, + ) # Ignore DeepSource cyclomatic complexity check for this function. It was @@ -756,9 +767,24 @@ def _with_from_imports( body_comments, parsed.line_separator, config.indent, + comment_prefix=config.comment_prefix, + ignore_comments=config.ignore_comments, ) body_comments = [] output.append(import_statement) + elif body_comments and not config.ignore_comments and output: + # as-import-only / nested-only paths may emit lines without a final + # grouped import_statement. Never drop body comments: fold them onto + # the last statement emitted for this module. + output[-1] = _inject_from_body_comments( + output[-1], + body_comments, + parsed.line_separator, + config.indent, + comment_prefix=config.comment_prefix, + ignore_comments=config.ignore_comments, + ) + body_comments = [] return output diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 3d51e7da..61bc218d 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2495,18 +2495,45 @@ def test_combine_star_folds_body_comments_issue_1852(): def test_inject_from_body_comments_branches_issue_1852(): - """The helper retains empty comments and has a lossless single-line fallback.""" - assert _inject_from_body_comments("from foo import alpha", [], "\n", " ") == ( - "from foo import alpha" - ) - assert _inject_from_body_comments("from foo import alpha", ["disabled", ""], "\n", " ") == ( - "from foo import alpha\n # disabled\n #" - ) + """Paren form keeps body lines; single-line form folds via with_comments.""" + assert _inject_from_body_comments( + "from foo import alpha", [], "\n", " ", comment_prefix=" #" + ) == ("from foo import alpha") assert _inject_from_body_comments( - "from foo import (\n alpha,\n)", ["disabled", ""], "\n", " " + "from foo import alpha", ["disabled", ""], "\n", " ", comment_prefix=" #" + ) == ("from foo import alpha # disabled; ") + assert _inject_from_body_comments( + "from foo import (\n alpha,\n)", + ["disabled", ""], + "\n", + " ", + comment_prefix=" #", ) == ("from foo import (\n alpha,\n # disabled\n #\n)") +def test_as_import_only_body_comment_preserved_issue_1852(): + """as-import-only groups must not drop comment-only body members.""" + to_sort = "from foo import (\n alpha as a,\n # disabled\n)\n" + expected = "from foo import alpha as a # disabled\n" + first = isort.code(to_sort, profile="black") + assert first == expected + assert isort.code(first, profile="black") == first + + +def test_mixed_as_and_live_body_comment_preserved_issue_1852(): + """Body comments survive when as-imports emit before remaining live names.""" + to_sort = "from foo import (\n alpha as a,\n # disabled\n beta,\n)\n" + first = isort.code(to_sort, profile="black") + assert "# disabled" in first + assert "alpha as a" in first + assert "beta" in first + for line in first.splitlines(): + if line.startswith(" #"): + assert "(" in first + assert ")" in first + assert isort.code(first, profile="black") == first + + def test_combine_star_folds_body_comments_when_comments_are_enabled_issue_1852(monkeypatch): """combine_star folds body comments only when comments are retained.""" parsed = parse.file_contents( From 0e3cd7e247625f991d7cb3787afbf44610e94601 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 25 Jul 2026 11:32:04 +0000 Subject: [PATCH 10/12] fix: keep force_single body comments on first emitted as-import Pending opening/body comments now attach to the first emitted force_single line (plain or as). Single-line inject merges existing nested trailing comments instead of clobbering them. Signed-off-by: Alex Chen --- isort/output.py | 81 +++++++++++++++++++++------------- tests/unit/test_regressions.py | 25 +++++++++++ 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/isort/output.py b/isort/output.py index 26951d52..82587fe7 100644 --- a/isort/output.py +++ b/isort/output.py @@ -8,6 +8,7 @@ from . import _parse_utils, parse, sorting, wrap, wrap_modes from .comments import add_to_line as with_comments +from .comments import parse as parse_comment from .identify import STATEMENT_DECLARATIONS from .place import module_with_reason from .settings import DEFAULT_CONFIG, Config @@ -320,10 +321,28 @@ def _inject_from_body_comments( return line_separator.join(lines) # Single-line / no-paren fallback: main-compatible fold onto the statement. + # Merge any trailing comment already on the statement (nested inline) after body + # comments so we keep main's ``# body; nested`` shape instead of clobbering. + if ignore_comments: + return with_comments( + body_comments, + import_statement, + removed=True, + comment_prefix=comment_prefix, + ) + _base, existing_comment = parse_comment(import_statement) + # Drop spacing that used to precede an inline comment so re-attach is stable. + _base = _base.rstrip() + merged = list(body_comments) + if existing_comment: + for part in existing_comment.split(";"): + part = part.strip() + if part and part not in merged: + merged.append(part) return with_comments( - body_comments, - import_statement, - removed=ignore_comments, + merged, + _base, + removed=False, comment_prefix=comment_prefix, ) @@ -434,15 +453,19 @@ def _with_from_imports( only_show_as_imports = True elif config.force_single_line and module not in config.single_line_exclusions: import_statement = "" - # Preserve comment-only members on the first single-line import - # (matches pre-#1852 main behaviour for force_single_line). + # Pending comments (opening + body) must land on the first *emitted* + # single-line statement. As-only modules never emit the bare name, so + # folding into ``comments`` on that non-emitted line drops body text. + pending_comments: list[str] = list(comments or []) if body_comments and not config.ignore_comments: - comments = list(comments or []) + body_comments + pending_comments.extend(body_comments) body_comments = [] + comments = None while from_imports: from_import = from_imports.pop(0) + line_comments = pending_comments or None single_import_line = with_comments( - comments, + line_comments, import_start + from_import, removed=config.ignore_comments, comment_prefix=config.comment_prefix, @@ -453,9 +476,10 @@ def _with_from_imports( if comment is not None: comment_text = f" {comment}" if comment else "" single_import_line += ( - f"{(comments and ';') or config.comment_prefix}{comment_text}" + f"{(line_comments and ';') or config.comment_prefix}{comment_text}" ) if from_import in as_imports: + emitted_plain = False if ( parsed.imports[section][import_key][module][from_import] and not only_show_as_imports @@ -463,30 +487,26 @@ def _with_from_imports( output.append( wrap.line(single_import_line, parsed.line_separator, config) ) - from_comments = parsed.categorized_comments["straight"].get( - f"{module}.{from_import}" + pending_comments = [] + emitted_plain = True + from_comments = list( + parsed.categorized_comments["straight"].get(f"{module}.{from_import}") + or [] ) - - if not config.only_sections: - output.extend( - wrap.line( - with_comments( - from_comments, - import_start + as_import, - removed=config.ignore_comments, - comment_prefix=config.comment_prefix, - ), - parsed.line_separator, - config, - ) - for as_import in sorting.sort(config, as_imports[from_import]) - ) - - else: - output.extend( + as_import_names = ( + sorting.sort(config, as_imports[from_import]) + if not config.only_sections + else list(as_imports[from_import]) + ) + for index, as_import in enumerate(as_import_names): + as_line_comments = list(from_comments) if index == 0 else [] + if index == 0 and pending_comments and not emitted_plain: + as_line_comments = pending_comments + as_line_comments + pending_comments = [] + output.append( wrap.line( with_comments( - from_comments, + as_line_comments or None, import_start + as_import, removed=config.ignore_comments, comment_prefix=config.comment_prefix, @@ -494,11 +514,10 @@ def _with_from_imports( parsed.line_separator, config, ) - for as_import in as_imports[from_import] ) else: output.append(wrap.line(single_import_line, parsed.line_separator, config)) - comments = None + pending_comments = [] else: # Tracks whether any aliased imports were emitted before the grouped # non-aliased imports in this pass of the outer loop. When True it diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 61bc218d..3bdd1881 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2573,3 +2573,28 @@ def test_parse_from_body_comments_issue_1852(): assert parsed.categorized_comments["from_body"] == {"foo": ["disabled"]} assert parsed.categorized_comments["nested"] == {"foo": {"beta": "nested"}} + + +def test_force_single_line_as_import_keeps_body_comment_issue_1852(): + """force_single_line must keep body comments on the first emitted as-import.""" + to_sort = "from foo import (\n # disabled\n alpha as a,\n)\n" + expected = "from foo import alpha as a # disabled\n" + first = isort.code(to_sort, force_single_line=True) + assert first == expected + assert isort.code(first, force_single_line=True) == first + + mixed = "from foo import (\n alpha as a,\n # disabled\n beta,\n)\n" + mixed_out = isort.code(mixed, force_single_line=True) + assert mixed_out == ("from foo import alpha as a # disabled\nfrom foo import beta\n") + assert isort.code(mixed_out, force_single_line=True) == mixed_out + + +def test_black_nested_inline_and_body_comment_merge_issue_1852(): + """Body comments folded onto a nested-comment line keep both texts.""" + to_sort = "from foo import (\n alpha, # a\n beta, # b\n # disabled\n)\n" + out = isort.code(to_sort, profile="black") + assert "alpha # a" in out + assert "disabled" in out + assert "# b" in out or "; b" in out or out.endswith("b\n") or " b" in out + assert "b" in out.split("beta", 1)[1] + assert isort.code(out, profile="black") == out From 5bebea483a7c592a5b2a4d7bfd6d789d1ecf8af8 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sun, 26 Jul 2026 06:20:21 +0000 Subject: [PATCH 11/12] test: cover remaining from_body inject branches for #1852 Add ignore_comments and nested-merge cases for _inject_from_body_comments, plus force_single_line plain-before-as coverage so the pending-comment drain path is exercised. Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 3bdd1881..2d942681 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2509,6 +2509,36 @@ def test_inject_from_body_comments_branches_issue_1852(): " ", comment_prefix=" #", ) == ("from foo import (\n alpha,\n # disabled\n #\n)") + # ignore_comments path still folds body comments as removed metadata + assert _inject_from_body_comments( + "from foo import alpha", + ["disabled"], + "\n", + " ", + comment_prefix=" #", + ignore_comments=True, + ) == ("from foo import alpha") + # nested trailing comment merges after body text + assert _inject_from_body_comments( + "from foo import alpha # nested", + ["disabled"], + "\n", + " ", + comment_prefix=" #", + ) == ("from foo import alpha # disabled; nested") + + +def test_force_single_line_emits_plain_before_as_with_body_comment_issue_1852(): + """force_single_line with both live and as names emits plain first, then as. + + Covers the branch that clears pending body comments onto the plain line + before emitting the as-import. + """ + to_sort = "from foo import (\n # disabled\n alpha,\n beta as b,\n)\n" + expected = "from foo import alpha # disabled\nfrom foo import beta as b\n" + first = isort.code(to_sort, force_single_line=True) + assert first == expected + assert isort.code(first, force_single_line=True) == first def test_as_import_only_body_comment_preserved_issue_1852(): From e3f9e7feda256c051e8555eadb75eba43d95b7f0 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Mon, 27 Jul 2026 20:38:07 +0000 Subject: [PATCH 12/12] test: cover trailing-separator and dedup arcs in from_body inject Close the last uncovered partial branch (empty segment / duplicate in existing-comment merge loop) behind #1852 comment preservation. Signed-off-by: Alex Chen --- tests/unit/test_regressions.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 2d942681..dc37f79d 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2526,6 +2526,22 @@ def test_inject_from_body_comments_branches_issue_1852(): " ", comment_prefix=" #", ) == ("from foo import alpha # disabled; nested") + # empty segment from a trailing ";" in an existing comment is skipped + assert _inject_from_body_comments( + "from foo import alpha # nested;", + ["disabled"], + "\n", + " ", + comment_prefix=" #", + ) == ("from foo import alpha # disabled; nested") + # duplicate body text is not re-appended from the existing comment + assert _inject_from_body_comments( + "from foo import alpha # nested", + ["nested", "disabled"], + "\n", + " ", + comment_prefix=" #", + ) == ("from foo import alpha # nested; disabled") def test_force_single_line_emits_plain_before_as_with_body_comment_issue_1852():