diff --git a/isort/wrap.py b/isort/wrap.py index 4d1e7391..ef341c39 100644 --- a/isort/wrap.py +++ b/isort/wrap.py @@ -76,6 +76,15 @@ def line(content: str, line_separator: str, config: Config = DEFAULT_CONFIG) -> comment = None if "#" in content: line_without_comment, comment = content.split("#", 1) + # A wildcard (star) import such as ``from x.y.z import *`` cannot be + # wrapped: parenthesising a star (``from x.y.z import (*)``) is a + # SyntaxError, and the only over-long part - the dotted module path - + # cannot be split either. Without this guard the ``import `` splitter + # below fails to match (there is no word boundary between the space and + # ``*``) and isort falls through to the ``.`` splitter, mangling the + # module path into invalid Python. See issue #2267. + if line_without_comment.rstrip().endswith("import *"): + return content for splitter in ("import ", "cimport ", ".", "as "): exp = r"\b" + re.escape(splitter) + r"\b" if re.search(exp, line_without_comment) and not line_without_comment.strip().startswith( diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index f843ff84..3d3d7ac6 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2117,3 +2117,43 @@ def test_noqa_wrap_mode_does_not_accumulate_spaces_with_as_import(): first_pass, multi_line_output=7, force_single_line=True, line_length=40 ) assert second_pass == first_pass + + +def test_long_star_import_should_not_be_corrupted_issue_2267(): + """Ensure an over-long wildcard import is left intact instead of being mangled. + + A wildcard import such as ``from x.y.z import *`` cannot be wrapped - parenthesising a + star (``from x.y.z import (*)``) is a SyntaxError and the dotted module path cannot be + split. When the line exceeded the configured length and ``force_single_line`` was set, + isort used to fall through to its ``.`` splitter and rewrite the statement into invalid + Python, e.g.:: + + from very.very.( + line import *.long.very.very, + ) + + as reported in issue #2267: https://github.com/PyCQA/isort/issues/2267 + + The statement must be returned unchanged (matching Black) and must remain valid, + parseable Python across repeated runs. + """ + test_input = ( + "from very.very.very.very.very.very.very.very.very.very." + "very.very.very.very.very.very.very.very.long.line import *\n" + ) + + for kwargs in ( + {"profile": "black", "force_single_line": True}, + {"force_single_line": True}, + {"profile": "black"}, + {"profile": "black", "force_single_line": True, "combine_star": True}, + ): + first_pass = isort.code(test_input, **kwargs) + assert first_pass == test_input, f"corrupted with {kwargs}" + # Must be valid Python and idempotent. + compile(first_pass, "", "exec") + assert isort.code(first_pass, **kwargs) == first_pass, f"not idempotent with {kwargs}" + + # A trailing comment on the wildcard import must be preserved, not dropped. + with_comment = test_input.rstrip("\n") + " # noqa: F403\n" + assert isort.code(with_comment, profile="black", force_single_line=True) == with_comment