diff --git a/isort/wrap_modes.py b/isort/wrap_modes.py index ea69dff0..961a5f72 100644 --- a/isort/wrap_modes.py +++ b/isort/wrap_modes.py @@ -110,6 +110,16 @@ def _hanging_indent_end_line(line: str) -> str: return line + "\\" +def _add_syntax(statement: str, suffix: str, line_separator: str) -> str: + # Appended after a trailing comment, punctuation is commented out instead of emitted. + head, separator, last_line = statement.rpartition(line_separator) + code, comment_start, comment = last_line.partition("#") + if not comment_start: + return statement + suffix + spacing = code[len(code.rstrip()) :] or " " + return f"{head}{separator}{code.rstrip()}{suffix}{spacing}{comment_start}{comment}" + + @_wrap_mode def hanging_indent(**interface: Any) -> str: if not interface["imports"]: @@ -347,17 +357,25 @@ def hanging_indent_with_parentheses(**interface: Any) -> str: current_line = next_statement.split(interface["line_separator"])[-1] if len(current_line) > line_length_limit: next_statement = ( - isort.comments.add_to_line( - interface["comments"], - interface["statement"] + ",", - removed=interface["remove_comments"], - comment_prefix=interface["comment_prefix"], + _add_syntax( + isort.comments.add_to_line( + interface["comments"], + interface["statement"], + removed=interface["remove_comments"], + comment_prefix=interface["comment_prefix"], + ), + ",", + interface["line_separator"], ) + f"{interface['line_separator']}{interface['indent']}{next_import}" ) interface["comments"] = [] interface["statement"] = next_statement - return f"{interface['statement']}{',' if interface['include_trailing_comma'] else ''})" + if interface["include_trailing_comma"]: + interface["statement"] = _add_syntax( + interface["statement"], ",", interface["line_separator"] + ) + return _add_syntax(interface["statement"], ")", interface["line_separator"]) @_wrap_mode diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 4e9b619d..7f3b4c83 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -1,5 +1,6 @@ """A growing set of tests designed to ensure isort doesn't have regressions in new versions""" +import ast from io import BytesIO, StringIO, TextIOWrapper import pytest @@ -8,6 +9,7 @@ import isort.sections from isort.core import STRING_PREFIXES from isort.main import main +from isort.wrap_modes import WrapModes def test_isort_duplicating_comments_issue_1264(): @@ -2489,3 +2491,49 @@ def test_add_import_keeps_a_prefixed_module_docstring_first_issue_1893(): docstring = f"{cased}{quote}module docstring\n{quote}\n" source = docstring + "import a\n" assert isort.code(source, add_imports=["import a"]) == source, cased + quote + + +def test_hanging_indent_with_parentheses_keeps_syntax_out_of_trailing_comments(): + """``multi_line_output=10`` must not append a comma or the closing parenthesis after a + trailing comment, which silently rewrote valid code into code that no longer parses. + + The mode keeps the comment on the import line (its siblings hoist it to the ``(`` line), + then appended punctuation to that line unconditionally. The closing ``)`` landed inside + the comment, and a wrapping comma was consumed by the comment re-splice in + ``comments.add_to_line`` -- so the output lost a separator it never got back. + """ + # Closing parenthesis captured by the comment: "'(' was never closed". + captured_paren = isort.code( + "from a import b, c # trailing\nfrom a import d\n", + multi_line_output=10, + line_length=40, + force_grid_wrap=2, + ) + assert ast.parse(captured_paren) + assert captured_paren == "from a import (b, c, d) # trailing\n" + + # Comma eaten at a wrap point: the names either side of it merged into "eta gamma". + lost_comma = isort.code( + "from mypkg.submodule import alpha, beta, gamma, delta, epsilon, zeta, eta # noqa: F401\n", + multi_line_output=10, + line_length=79, + ) + assert ast.parse(lost_comma) + assert "eta," in lost_comma + + # No wrap mode may turn parsing code into non-parsing code. + for mode in WrapModes: + for line_length in (40, 79, 88): + for source in ( + "from a import b, c # trailing\nfrom a import d\n", + "from mypkg.submodule import alpha, beta, gamma, delta, epsilon, zeta # noqa: F401\n", + "from mypkg.sub import alpha, beta, gamma, delta, epsilon # type: ignore\n", + ): + for trailing_comma in (False, True): + output = isort.code( + source, + multi_line_output=mode.value, + line_length=line_length, + include_trailing_comma=trailing_comma, + ) + ast.parse(output) # must never raise