Repository: PyCQA/isort

Issue:
Unrecoverable exception with ``  from ..import ccc`` 
Hello, 
I got ` ERROR: Unrecoverable exception thrown when parsing ./aaa/bbb/ccc.py! This should NEVER happen.`  

~~~bash
$ python --version
Python 3.11.4
$ isort --version

                 _                 _
                (_) ___  ___  _ __| |_
                | |/ _/ / _ \/ '__  _/
                | |\__ \/\_\/| |  | |_
                |_|\___/\___/\_/   \_/

      isort your imports, so you don't have to.

                    VERSION 5.12.0
$ cat /etc/debian_version 
trixie/sid
$ tree
.
└── aaa
    └── bbb
        └── ccc.py
~~~

Content of the offending file:

~~~bash
$ cat aaa/bbb/ccc.py 
from ..import bbb
~~~

My file is valid (or I just found a Python bug):

~~~bash
$ python -m aaa.bbb.ccc
$
~~~

But isort fails:

~~~bash
$ isort .
ERROR: Unrecoverable exception thrown when parsing ./aaa/bbb/ccc.py! This should NEVER happen.
If encountered, please open an issue: https://github.com/PyCQA/isort/issues/new
Traceback (most recent call last):
  File "/home/louis/.virtualenvs/311/bin/isort", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/main.py", line 1226, in main
    for sort_attempt in attempt_iterator:
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/main.py", line 1210, in <genexpr>
    sort_imports(  # type: ignore
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/main.py", line 93, in sort_imports
    incorrectly_sorted = not api.sort_file(
                             ^^^^^^^^^^^^^^
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/api.py", line 429, in sort_file
    changed = sort_stream(
              ^^^^^^^^^^^^
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/api.py", line 210, in sort_stream
    changed = core.process(
              ^^^^^^^^^^^^^
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/core.py", line 422, in process
    parsed_content = parse.file_contents(import_section, config=config)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/louis/.virtualenvs/311/lib/python3.11/site-packages/isort/parse.py", line 522, in file_contents
    if "," in import_string.split(just_imports[-1])[-1]:
                                  ~~~~~~~~~~~~^^^^
IndexError: list index out of range
~~~

Everything works if I replace ` from ..import bbb`   with `from .. import bbb` (notice the space that was added/removed between `..` and `import`).

Thanks for your work!
-- Louis


Discussion:

Patch:
diff --git a/isort/identify.py b/isort/identify.py
index e45fcb39..8223e256 100644
--- a/isort/identify.py
+++ b/isort/identify.py
@@ -5,7 +5,7 @@ from functools import partial
 from pathlib import Path
 from typing import Iterator, NamedTuple, Optional, TextIO, Tuple
 
-from isort.parse import _normalize_line, _strip_syntax, skip_line
+from isort.parse import normalize_line, skip_line, strip_syntax
 
 from .comments import parse as parse_comments
 from .settings import DEFAULT_CONFIG, Config
@@ -84,7 +84,7 @@ def imports(
             statements[-1] = f"{statements[-1]}#{end_of_line_comment[0]}"
 
         for statement in statements:
-            line, _raw_line = _normalize_line(statement)
+            line, _raw_line = normalize_line(statement)
             if line.startswith(("import ", "cimport ")):
                 type_of_import = "straight"
             elif line.startswith("from "):
@@ -162,7 +162,7 @@ def imports(
 
             just_imports = [
                 item.replace("{|", "{ ").replace("|}", " }")
-                for item in _strip_syntax(import_string).split()
+                for item in strip_syntax(import_string).split()
             ]
 
             direct_imports = just_imports[1:]
diff --git a/isort/parse.py b/isort/parse.py
index 614afabd..7dfd7cef 100644
--- a/isort/parse.py
+++ b/isort/parse.py
@@ -1,4 +1,5 @@
 """Defines parsing functions used by isort for parsing import definitions"""
+import re
 from collections import OrderedDict, defaultdict
 from functools import partial
 from itertools import chain
@@ -36,18 +37,18 @@ def _infer_line_separator(contents: str) -> str:
     return "\n"
 
 
-def _normalize_line(raw_line: str) -> Tuple[str, str]:
+def normalize_line(raw_line: str) -> Tuple[str, str]:
     """Normalizes import related statements in the provided line.
 
     Returns (normalized_line: str, raw_line: str)
     """
-    line = raw_line.replace("from.import ", "from . import ")
-    line = line.replace("from.cimport ", "from . cimport ")
+    line = re.sub(r"from(\.+)cimport ", r"from \g<1> cimport ", raw_line)
+    line = re.sub(r"from(\.+)import ", r"from \g<1> import ", line)
     line = line.replace("import*", "import *")
-    line = line.replace(" .import ", " . import ")
-    line = line.replace(" .cimport ", " . cimport ")
+    line = re.sub(r" (\.+)import ", r" \g<1> import ", line)
+    line = re.sub(r" (\.+)cimport ", r" \g<1> cimport ", line)
     line = line.replace("\t", " ")
-    return (line, raw_line)
+    return line, raw_line
 
 
 def import_type(line: str, config: Config = DEFAULT_CONFIG) -> Optional[str]:
@@ -63,7 +64,7 @@ def import_type(line: str, config: Config = DEFAULT_CONFIG) -> Optional[str]:
     return None
 
 
-def _strip_syntax(import_string: str) -> str:
+def strip_syntax(import_string: str) -> str:
     import_string = import_string.replace("_import", "[[i]]")
     import_string = import_string.replace("_cimport", "[[ci]]")
     for remove_syntax in ["\\", "(", ")", ","]:
@@ -263,7 +264,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
             statements[-1] = f"{statements[-1]}#{end_of_line_comment[0]}"
 
         for statement in statements:
-            line, raw_line = _normalize_line(statement)
+            line, raw_line = normalize_line(statement)
             type_of_import = import_type(line, config) or ""
             raw_lines = [raw_line]
             if not type_of_import:
@@ -275,7 +276,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
             nested_comments = {}
             import_string, comment = parse_comments(line)
             comments = [comment] if comment else []
-            line_parts = [part for part in _strip_syntax(import_string).strip().split(" ") if part]
+            line_parts = [part for part in strip_syntax(import_string).strip().split(" ") if part]
             if type_of_import == "from" and len(line_parts) == 2 and comments:
                 nested_comments[line_parts[-1]] = comments[0]
 
@@ -285,7 +286,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
                     index += 1
                     if new_comment:
                         comments.append(new_comment)
-                    stripped_line = _strip_syntax(line).strip()
+                    stripped_line = strip_syntax(line).strip()
                     if (
                         type_of_import == "from"
                         and stripped_line
@@ -309,7 +310,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
                         and ")" not in line.split("#")[0]
                         and index < line_count
                     ):
-                        stripped_line = _strip_syntax(line).strip()
+                        stripped_line = strip_syntax(line).strip()
                         if (
                             type_of_import == "from"
                             and stripped_line
@@ -325,7 +326,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
                             index += 1
                             if new_comment:
                                 comments.append(new_comment)
-                            stripped_line = _strip_syntax(line).strip()
+                            stripped_line = strip_syntax(line).strip()
                             if (
                                 type_of_import == "from"
                                 and stripped_line
@@ -336,7 +337,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
                             import_string += line_separator + line
                             raw_lines.append(line)
 
-                    stripped_line = _strip_syntax(line).strip()
+                    stripped_line = strip_syntax(line).strip()
                     if (
                         type_of_import == "from"
                         and stripped_line
@@ -377,7 +378,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
 
             just_imports = [
                 item.replace("{|", "{ ").replace("|}", " }")
-                for item in _strip_syntax(import_string).split()
+                for item in strip_syntax(import_string).split()
             ]
 
             attach_comments_to: Optional[List[Any]] = None


Test patch:
diff --git a/tests/unit/test_parse.py b/tests/unit/test_parse.py
index 7d9e6606..e7d74aa5 100644
--- a/tests/unit/test_parse.py
+++ b/tests/unit/test_parse.py
@@ -1,3 +1,4 @@
+import pytest
 from hypothesis import given
 from hypothesis import strategies as st
 
@@ -58,7 +59,7 @@ def test_fuzz__infer_line_separator(contents):
 
 @given(import_string=st.text())
 def test_fuzz__strip_syntax(import_string):
-    parse._strip_syntax(import_string=import_string)
+    parse.strip_syntax(import_string=import_string)
 
 
 @given(line=st.text(), config=st.builds(Config))
@@ -81,3 +82,29 @@ def test_fuzz_skip_line(line, in_quote, index, section_comments, needs_import):
         section_comments=section_comments,
         needs_import=needs_import,
     )
+
+
+@pytest.mark.parametrize(
+    "raw_line, expected",
+    (
+        ("from . cimport a", "from . cimport a"),
+        ("from.cimport a", "from . cimport a"),
+        ("from..cimport a", "from .. cimport a"),
+        ("from . import a", "from . import a"),
+        ("from.import a", "from . import a"),
+        ("from..import a", "from .. import a"),
+        ("import *", "import *"),
+        ("import*", "import *"),
+        ("from . import a", "from . import a"),
+        ("from .import a", "from . import a"),
+        ("from ..import a", "from .. import a"),
+        ("from . cimport a", "from . cimport a"),
+        ("from .cimport a", "from . cimport a"),
+        ("from ..cimport a", "from .. cimport a"),
+        ("from\t.\timport a", "from . import a"),
+    ),
+)
+def test_normalize_line(raw_line, expected):
+    line, returned_raw_line = parse.normalize_line(raw_line)
+    assert line == expected
+    assert returned_raw_line == raw_line