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
158 changes: 132 additions & 26 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 @@ -283,6 +284,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,
) -> 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,
)


# Ignore DeepSource cyclomatic complexity check for this function. It was
# already complex when this check was enabled.
# skipcq: PY-R1000
Expand Down Expand Up @@ -353,15 +417,29 @@ 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)
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,
Expand All @@ -375,10 +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 = ""
# 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 = 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,
Expand All @@ -389,52 +476,48 @@ 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
):
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,
),
parsed.line_separator,
config,
)
for as_import in as_imports[from_import]
)
Comment on lines +501 to 517
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
Expand Down Expand Up @@ -697,7 +780,30 @@ 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,
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


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 @@ -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()
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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}")
Expand Down
Loading