Skip to content
Open
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
120 changes: 114 additions & 6 deletions isort/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -297,6 +298,69 @@ def _build_import_group(
return group_output


def _inject_from_body_comments(
import_statement: str,
body_comments: list[str],
line_separator: str,
indent: str,
*,
comment_prefix: str,
ignore_comments: bool = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: both call sites invoke this with ignore_comments=False (each is already inside a not config.ignore_comments guard), so this parameter is currently dead — fine to keep if you expect a future caller, otherwise removable.

) -> 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.

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

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)

# 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(
merged,
_base,
removed=False,
comment_prefix=comment_prefix,
)


def _build_as_imports(
*,
from_import: str, # Y in `from X import Y as Z`
Expand Down Expand Up @@ -592,22 +656,34 @@ def _with_from_imports_for_module(

comments: list[str] = parsed.categorized_comments["from"].pop(module, [])
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 = []
if above_comments:
output.extend(above_comments)

only_show_as_imports = False
if "*" in from_imports:
from_imports.remove("*")

# Fold from_body comments onto the star statement (main-compatible).
star_comments = list(comments if config.combine_star else [])
if body_comments and not config.ignore_comments:
star_comments = star_comments + body_comments
body_comments = []
output.append(
wrap.line(
with_comments(
_with_star_comments(
parsed,
module,
# If we are combining the star imports we want to include all from-import
# comments we found above.
comments if config.combine_star else [],
star_comments,
),
f"{import_start}*",
removed=config.ignore_comments,
Expand All @@ -626,6 +702,14 @@ def _with_from_imports_for_module(

# Handle force_single_line
if config.force_single_line and module not in config.single_line_exclusions:
# 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:
pending_comments.extend(body_comments)
body_comments = []
comments = []
for from_import in from_imports:
if from_import in as_imports:
output.extend(
Expand All @@ -636,7 +720,7 @@ def _with_from_imports_for_module(
line_separator=parsed.line_separator,
config=config,
straight_comments=[
*comments,
*pending_comments,
*parsed.categorized_comments["straight"].get(
f"{module}.{from_import}", []
),
Expand All @@ -648,12 +732,13 @@ def _with_from_imports_for_module(
),
)
)
pending_comments = []
else:
single_import_line = with_comments(
[
c
for c in (
*comments,
*pending_comments,
parsed.categorized_comments["nested"]
.get(module, {})
.pop(from_import, None),
Expand All @@ -666,7 +751,7 @@ def _with_from_imports_for_module(
)

output.append(wrap.line(single_import_line, parsed.line_separator, config))
comments = []
pending_comments = []
return output

while from_imports:
Expand Down Expand Up @@ -771,7 +856,30 @@ def _with_from_imports_for_module(
processed_as_imports_this_iteration=processed_as_imports_this_iteration,
)
if grouped_from_import_statement:
if body_comments and not config.ignore_comments:
grouped_from_import_statement = _inject_from_body_comments(
grouped_from_import_statement,
body_comments,
parsed.line_separator,
config.indent,
comment_prefix=config.comment_prefix,
ignore_comments=config.ignore_comments,
)
body_comments = []
output.append(grouped_from_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 = []

# Reset comments as we have just parsed them.
comments = []
Expand Down
25 changes: 25 additions & 0 deletions isort/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
},
)

Expand Down Expand Up @@ -112,6 +117,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
"straight": {},
"nested": {},
"above": {"straight": {}, "from": {}},
"from_body": {},
}

trailing_commas: set[str] = set()
Expand Down Expand Up @@ -238,6 +244,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 (
Expand Down Expand Up @@ -316,6 +329,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}")
Expand Down
Loading