diff --git a/isort/wrap.py b/isort/wrap.py index cd6db86a..b6b71369 100644 --- a/isort/wrap.py +++ b/isort/wrap.py @@ -19,7 +19,7 @@ def import_statement( """Returns a multi-line wrapped form of the provided from import statement.""" if explode: formatter = vertical_hanging_indent - line_length = 1 + line_length = config.wrap_length or config.line_length include_trailing_comma = True else: formatter = formatter_from_string((multi_line_output or config.multi_line_output).name) diff --git a/isort/wrap_modes.py b/isort/wrap_modes.py index ea69dff0..f780259a 100644 --- a/isort/wrap_modes.py +++ b/isort/wrap_modes.py @@ -173,8 +173,41 @@ def vertical_hanging_indent(**interface: Any) -> str: ) _imports = ("," + interface["line_separator"] + interface["indent"]).join(interface["imports"]) _comma_maybe = "," if interface["include_trailing_comma"] else "" + opening = f"{interface['statement']}({_line_with_comments}" + functional_comments = [ + comment + for comment in (interface["comments"] or []) + if comment.strip().lower().startswith(("noqa", "type: ignore")) + ] + movable_comments = [ + comment + for comment in (interface["comments"] or []) + if not comment.strip().lower().startswith(("noqa", "type: ignore")) + ] + if _line_with_comments and movable_comments and len(opening) > interface["line_length"]: + _functional_on_opening = isort.comments.add_to_line( + functional_comments, + "", + removed=interface["remove_comments"], + comment_prefix=interface["comment_prefix"], + ) + _comment_on_own_line = interface["line_separator"].join( + isort.comments.add_to_line( + [single_comment], + interface["indent"], + removed=interface["remove_comments"], + comment_prefix=interface["comment_prefix"].lstrip(), + ) + for single_comment in movable_comments + ) + return ( + f"{interface['statement']}({_functional_on_opening}{interface['line_separator']}" + f"{_comment_on_own_line}{interface['line_separator']}" + f"{interface['indent']}{_imports}{_comma_maybe}" + f"{interface['line_separator']})" + ) return ( - f"{interface['statement']}({_line_with_comments}{interface['line_separator']}" + f"{opening}{interface['line_separator']}" f"{interface['indent']}{_imports}{_comma_maybe}{interface['line_separator']})" ) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 4e9b619d..b1e5cfd8 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2489,3 +2489,57 @@ 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_comments_should_cause_wrapping_on_long_lines_black_mode_issue_2124(): + """Ensure isort doesn't merge a long comment onto the opening line of a + multiline from import when using the black profile. + See: https://github.com/PyCQA/isort/issues/2124 + """ + test_input = """from os.path import ( + join, + # this is a really really really really really really really really + # really really really really really really long comment + getsize, +) +""" + expected = """from os.path import ( + # this is a really really really really really really really really + # really really really really really really long comment + getsize, + join, +) +""" + output = isort.code(test_input, profile="black") + assert output == expected + for line in output.splitlines(): + assert len(line) <= 88, f"line exceeds 88: {line!r}" + assert isort.code(output, profile="black") == output + + +def test_mixed_functional_and_plain_comments_black_mode_issue_2124(): + """A functional comment stays on the opening line while plain fragments move off. + + Guards the whole-list ``any()`` gate that kept an over-long plain comment + merged once a single ``noqa`` fragment was present. + See: https://github.com/PyCQA/isort/issues/2124 + """ + test_input = """from os.path import ( + join, + # noqa: F401 + # this is a really really really really really really really really + # really really really really really really long comment + getsize, +) +""" + expected = """from os.path import ( # noqa: F401 + # this is a really really really really really really really really + # really really really really really really long comment + getsize, + join, +) +""" + output = isort.code(test_input, profile="black") + assert output == expected + assert len(output.splitlines()[0]) <= 88 + assert isort.code(output, profile="black") == output diff --git a/tests/unit/test_wrap.py b/tests/unit/test_wrap.py index f922e7a8..b2dd2667 100644 --- a/tests/unit/test_wrap.py +++ b/tests/unit/test_wrap.py @@ -93,3 +93,20 @@ def test_star_import_wrapped_end_to_end() -> None: source = "from very.very.very.very.very.very.very.very.very.long.line import *\n" expected = "from very.very.very.very.very.very.very.very.very.long.line import \\\n *\n" assert code(source, line_length=20, force_single_line=True) == expected + + +def test_import_statement_explode_long_comment_respects_line_length() -> None: + """A long comment in explode mode is placed on its own line.""" + result = wrap.import_statement( + import_start="from os.path import ", + from_imports=["getsize", "join"], + comments=[ + " this is a really really really really really really really really" + " really really really really really really long comment" + ], + config=Config(profile="black"), + explode=True, + ) + lines = result.split("\n") + assert lines[0] == "from os.path import (" + assert "long comment" in lines[1] diff --git a/tests/unit/test_wrap_modes.py b/tests/unit/test_wrap_modes.py index 3497679b..1774abcb 100644 --- a/tests/unit/test_wrap_modes.py +++ b/tests/unit/test_wrap_modes.py @@ -127,6 +127,171 @@ def test_vertical_grid_size_near_line_length( ) +def test_vertical_hanging_indent_long_comment_respects_line_length(): + """A long comment that exceeds line_length is placed on its own line.""" + assert ( + wrap_modes.vertical_hanging_indent( + statement="from os.path import ", + imports=["getsize", "join"], + white_space=" ", + indent=" ", + line_length=88, + comments=[ + " this is a really really really really really really" + " really really really really really really long comment" + ], + line_separator="\n", + comment_prefix=" #", + include_trailing_comma=True, + remove_comments=False, + ) + == "from os.path import (\n" + " # this is a really really really really really really really really" + " really really really really long comment\n" + " getsize,\n" + " join,\n" + ")" + ) + + +def test_vertical_hanging_indent_short_comment_stays_on_opening_line(): + """A short comment that fits within line_length stays on the opening line.""" + assert ( + wrap_modes.vertical_hanging_indent( + statement="from os.path import ", + imports=["getsize", "join"], + white_space=" ", + indent=" ", + line_length=88, + comments=[" short comment"], + line_separator="\n", + comment_prefix=" #", + include_trailing_comma=True, + remove_comments=False, + ) + == "from os.path import ( # short comment\n getsize,\n join,\n)" + ) + + +def test_vertical_hanging_indent_non_directive_type_comment_moves_off(): + """A long comment containing 'type:' but not a 'type: ignore' directive moves off.""" + assert ( + wrap_modes.vertical_hanging_indent( + statement="from os.path import ", + imports=["getsize", "join"], + white_space=" ", + indent=" ", + line_length=88, + comments=[ + " check type: int and more really really really really really really" + " really really really really really really long comment" + ], + line_separator="\n", + comment_prefix=" #", + include_trailing_comma=True, + remove_comments=False, + ) + == "from os.path import (\n" + " # check type: int and more really really really really really really really really" + " really really really really long comment\n" + " getsize,\n" + " join,\n" + ")" + ) + + +def test_vertical_hanging_indent_multi_fragment_comments_each_on_own_line(): + """Each comment fragment renders on its own # line to respect line_length.""" + result = wrap_modes.vertical_hanging_indent( + statement="from os.path import ", + imports=["getsize", "join"], + white_space=" ", + indent=" ", + line_length=88, + comments=[ + " this is a really really really really really really really really", + " really really really really really really long comment", + ], + line_separator="\n", + comment_prefix=" #", + include_trailing_comma=True, + remove_comments=False, + ) + assert result == ( + "from os.path import (\n" + " # this is a really really really really really really really really\n" + " # really really really really really really long comment\n" + " getsize,\n" + " join,\n" + ")" + ) + for line in result.split("\n"): + assert len(line) <= 88, f"line exceeds 88: {line!r}" + + +def test_vertical_hanging_indent_mixed_functional_and_plain_comments(): + """A functional comment stays on the opening line while plain fragments move off. + + Guards against the whole-list ``any()`` gate that kept an over-long plain + comment merged once a single ``noqa`` fragment was present (see #2124). + """ + result = wrap_modes.vertical_hanging_indent( + statement="from os.path import ", + imports=["getsize", "join"], + white_space=" ", + indent=" ", + line_length=88, + comments=[ + "noqa: F401", + " this is a really really really really really really really really", + " really really really really really really long comment", + ], + line_separator="\n", + comment_prefix=" #", + include_trailing_comma=True, + remove_comments=False, + ) + assert result == ( + "from os.path import ( # noqa: F401\n" + " # this is a really really really really really really really really\n" + " # really really really really really really long comment\n" + " getsize,\n" + " join,\n" + ")" + ) + for line in result.split("\n"): + assert len(line) <= 88, f"line exceeds 88: {line!r}" + + +def test_vertical_hanging_indent_bracket_long_comment_respects_line_length(): + """The bracket consumer stays valid when comments move off the opening line.""" + result = wrap_modes.vertical_hanging_indent_bracket( + statement="from os.path import ", + imports=["getsize", "join"], + white_space=" ", + indent=" ", + line_length=88, + comments=[ + " this is a really really really really really really really really", + " really really really really really really long comment", + ], + line_separator="\n", + comment_prefix=" #", + include_trailing_comma=True, + remove_comments=False, + ) + assert result == ( + "from os.path import (\n" + " # this is a really really really really really really really really\n" + " # really really really really really really long comment\n" + " getsize,\n" + " join,\n" + " )" + ) + for line in result.split("\n"): + assert len(line) <= 88, f"line exceeds 88: {line!r}" + + # This test code was written by the `hypothesis.extra.ghostwriter` module # and is provided under the Creative Commons Zero public domain dedication.