Repository: pypa/setuptools

Issue:
[BUG] Realtive import of nonexistent package with same name throws RecursionError
### setuptools version

setuptools 64.0.0

### Python version

Python 3.10

### OS

Ubuntu 22.04

### Additional environment information

Seen in the wild at https://github.com/AMYPAD/CuVec, had to pin `setuptools` in https://github.com/AMYPAD/CuVec/pull/25 to fix this issue.

### Description

Introduced with https://github.com/pypa/setuptools/pull/3488, throws `RecursionError` instead of `ModuleNotFoundError` when importing an editable package with an `__init__.py` containing an import in the form `from .example import anything` where `example` is also the package name.

### Expected behavior

In this specific case, the expected outcome is `ModuleNotFoundError: No module named 'test.test'` like when pinning `setuptools` in the minimal reproducible example below to any versions lower than 64.

```console
$ docker build .
[+] Building 7.3s (7/7) FINISHED                                                                                                                                                                                                                                                                     
 => [internal] load build definition from Dockerfile                                                                                                                                                                                                                                            0.1s
 => => transferring dockerfile: 337B                                                                                                                                                                                                                                                            0.0s
 => [internal] load .dockerignore                                                                                                                                                                                                                                                               0.2s
 => => transferring context: 2B                                                                                                                                                                                                                                                                 0.0s
 => [internal] load metadata for docker.io/library/python:3                                                                                                                                                                                                                                     0.9s
 => [auth] library/python:pull token for registry-1.docker.io                                                                                                                                                                                                                                   0.0s
 => CACHED [1/3] FROM docker.io/library/python:3@sha256:cbf49327fae903d64ab28251912fc00faea2c1baee493d347a07973a2cb50f98                                                                                                                                                                        0.0s
 => [2/3] RUN mkdir test && echo 'build-system.requires = ["setuptools<64"]' >pyproject.toml && echo 'from distutils.core import setup; setup(packages=["test"])' >setup.py && echo 'from .test import nonexistent' >test/__init__.py                                                           0.6s
 => ERROR [3/3] RUN pip install -e . && python -c 'import test'                                                                                                                                                                                                                                 5.5s
------                                                                                                                                                                                                                                                                                               
 > [3/3] RUN pip install -e . && python -c 'import test':                                                                                                                                                                                                                                            
#7 2.005 Obtaining file:///                                                                                                                                                                                                                                                                          
#7 2.008   Installing build dependencies: started                                                                                                                                                                                                                                                    
#7 3.556   Installing build dependencies: finished with status 'done'                                                                                                                                                                                                                                
#7 3.559   WARNING: Missing build requirements in pyproject.toml for file:///.                                                                                                                                                                                                                       
#7 3.559   WARNING: The project does not specify a build backend, and pip cannot fall back to setuptools without 'wheel'.
#7 3.560   Checking if build backend supports build_editable: started
#7 3.790   Checking if build backend supports build_editable: finished with status 'done'
#7 3.791   Getting requirements to build wheel: started
#7 4.020   Getting requirements to build wheel: finished with status 'done'
#7 4.022   Installing backend dependencies: started
#7 4.715   Installing backend dependencies: finished with status 'done'
#7 4.717   Preparing metadata (pyproject.toml): started
#7 4.967   Preparing metadata (pyproject.toml): finished with status 'done'
#7 5.007 Installing collected packages: test
#7 5.008   Running setup.py develop for test
#7 5.291 Successfully installed test-0.0.0
#7 5.291 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
#7 5.296 
#7 5.296 [notice] A new release of pip available: 22.2.1 -> 22.2.2
#7 5.296 [notice] To update, run: pip install --upgrade pip
#7 5.409 Traceback (most recent call last):
#7 5.409   File "<string>", line 1, in <module>
#7 5.409   File "/test/__init__.py", line 1, in <module>
#7 5.410     from .test import nonexistent
#7 5.410 ModuleNotFoundError: No module named 'test.test'
------
executor failed running [/bin/sh -c pip install -e . && python -c 'import test']: exit code: 1
```

### How to Reproduce

Create a file named `Dockerfile` with the contents below and run `docker build .` to reproduce the issue.

#### `Dockerfile`
```dockerfile
FROM python:3

RUN mkdir test\
 && echo 'build-system.requires = ["setuptools==64.0.0"]' >pyproject.toml\
 && echo 'from distutils.core import setup; setup(packages=["test"])' >setup.py\
 && echo 'from .test import nonexistent' >test/__init__.py

RUN pip install -e .\
 && python -c 'import test'
```

### Output

```console
$ docker build .
[+] Building 7.5s (6/6) FINISHED                                                                                                                                                                                                                                                                     
 => [internal] load build definition from Dockerfile                                                                                                                                                                                                                                            0.7s
 => => transferring dockerfile: 38B                                                                                                                                                                                                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                                                                                                                                                               0.8s
 => => transferring context: 2B                                                                                                                                                                                                                                                                 0.0s
 => [internal] load metadata for docker.io/library/python:3                                                                                                                                                                                                                                     0.7s
 => [1/3] FROM docker.io/library/python:3@sha256:cbf49327fae903d64ab28251912fc00faea2c1baee493d347a07973a2cb50f98                                                                                                                                                                               0.0s
 => CACHED [2/3] RUN mkdir test && echo 'build-system.requires = ["setuptools==64.0.0"]' >pyproject.toml && echo 'from distutils.core import setup; setup(packages=["test"])' >setup.py && echo 'from .test import nonexistent' >test/__init__.py                                               0.0s
 => ERROR [3/3] RUN pip install -e . && python -c 'import test'                                                                                                                                                                                                                                 6.0s
------                                                                                                                                                                                                                                                                                               
 > [3/3] RUN pip install -e . && python -c 'import test':                                                                                                                                                                                                                                            
#6 1.921 Obtaining file:///                                                                                                                                                                                                                                                                          
#6 1.923   Installing build dependencies: started                                                                                                                                                                                                                                                    
#6 3.514   Installing build dependencies: finished with status 'done'                                                                                                                                                                                                                                
#6 3.517   WARNING: Missing build requirements in pyproject.toml for file:///.                                                                                                                                                                                                                       
#6 3.517   WARNING: The project does not specify a build backend, and pip cannot fall back to setuptools without 'wheel'.
#6 3.518   Checking if build backend supports build_editable: started
#6 3.755   Checking if build backend supports build_editable: finished with status 'done'
#6 3.756   Getting requirements to build editable: started
#6 3.995   Getting requirements to build editable: finished with status 'done'
#6 3.997   Installing backend dependencies: started
#6 4.945   Installing backend dependencies: finished with status 'done'
#6 4.946   Preparing editable metadata (pyproject.toml): started
#6 5.196   Preparing editable metadata (pyproject.toml): finished with status 'done'
#6 5.201 Building wheels for collected packages: test
#6 5.202   Building editable for test (pyproject.toml): started
#6 5.521   Building editable for test (pyproject.toml): finished with status 'done'
#6 5.522   Created wheel for test: filename=test-0.0.0-0.editable-cp310-cp310-linux_x86_64.whl size=2179 sha256=f590bda7d3eba788bb56059a8bf19d008194c7df0d0ac2c7ef90286629c3c38a
#6 5.523   Stored in directory: /tmp/pip-ephem-wheel-cache-rpsc66p0/wheels/31/76/a9/e09ab97e8ad0c49e207a3bddaf35a323dc6d5d03bb95d5511f
#6 5.527 Successfully built test
#6 5.566 Installing collected packages: test
#6 5.625 Successfully installed test-0.0.0
#6 5.626 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
#6 5.631 
#6 5.631 [notice] A new release of pip available: 22.2.1 -> 22.2.2
#6 5.631 [notice] To update, run: pip install --upgrade pip
#6 5.795 Traceback (most recent call last):
#6 5.795   File "/usr/local/lib/python3.10/pathlib.py", line 625, in __str__
#6 5.795     return self._str
#6 5.795 AttributeError: 'PosixPath' object has no attribute '_str'
#6 5.795 
#6 5.795 During handling of the above exception, another exception occurred:
#6 5.795 
#6 5.795 Traceback (most recent call last):
#6 5.795   File "<string>", line 1, in <module>
#6 5.795   File "/test/__init__.py", line 1, in <module>
#6 5.796     from .test import nonexistent
#6 5.796   File "/test/__init__.py", line 1, in <module>
#6 5.796     from .test import nonexistent
#6 5.796   File "/test/__init__.py", line 1, in <module>
#6 5.796     from .test import nonexistent

[OMITTED ~270 SIMILAR LINES]

#6 5.805   File "/test/__init__.py", line 1, in <module>
#6 5.805     from .test import nonexistent
#6 5.805   File "/test/__init__.py", line 1, in <module>
#6 5.805     from .test import nonexistent
#6 5.805   File "/test/__init__.py", line 1, in <module>
#6 5.805     from .test import nonexistent
#6 5.805   File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
#6 5.805   File "<frozen importlib._bootstrap>", line 1002, in _find_and_load_unlocked
#6 5.806   File "<frozen importlib._bootstrap>", line 945, in _find_spec
#6 5.806   File "/usr/local/lib/python3.10/site-packages/__editable___test_0_0_0_finder.py", line 19, in find_spec
#6 5.806     return cls._find_spec(fullname, Path(pkg_path, *rest))
#6 5.806   File "/usr/local/lib/python3.10/site-packages/__editable___test_0_0_0_finder.py", line 28, in _find_spec
#6 5.806     if candidate.exists():
#6 5.806   File "/usr/local/lib/python3.10/pathlib.py", line 1290, in exists
#6 5.806     self.stat()
#6 5.806   File "/usr/local/lib/python3.10/pathlib.py", line 1097, in stat
#6 5.806     return self._accessor.stat(self, follow_symlinks=follow_symlinks)
#6 5.806   File "/usr/local/lib/python3.10/pathlib.py", line 632, in __fspath__
#6 5.806     return str(self)
#6 5.806   File "/usr/local/lib/python3.10/pathlib.py", line 627, in __str__
#6 5.807     self._str = self._format_parsed_parts(self._drv, self._root,
#6 5.807 RecursionError: maximum recursion depth exceeded
------
executor failed running [/bin/sh -c pip install -e . && python -c 'import test']: exit code: 1
```



Discussion:
Hi @0x2b3bfa0, thank you very much for bringing this issue for discussion.

In your `test/__init__.py` file you are trying to do a relative import of a module that don't exist...
That is the main reason for the error... Although I have to investigate better the cause of the recursion.

I can demonstrate an error with setuptools v63.4.3, which was the last version of setuptools without PEP 660 support:

```Dockerfile
FROM python:3

RUN mkdir test\
 && echo 'build-system.requires = ["setuptools==63.4.3"]' >pyproject.toml\
 && echo 'from distutils.core import setup; setup(packages=["test"])' >setup.py\
 && echo 'from .test import nonexistent' >test/__init__.py

RUN pip install -e .\
 && python -c 'import test'
```

```console
[+] Building 4.9s (6/6) FINISHED
 => [internal] load build definition from Dockerfile                                                               0.0s
 => => transferring dockerfile: 343B                                                                               0.0s
 => [internal] load .dockerignore                                                                                  0.0s
 => => transferring context: 2B                                                                                    0.0s
 => [internal] load metadata for docker.io/library/python:3                                                        0.7s
 => [1/3] FROM docker.io/library/python:3@sha256:cbf49327fae903d64ab28251912fc00faea2c1baee493d347a07973a2cb50f98  0.0s
 => CACHED [2/3] RUN mkdir test && echo 'build-system.requires = ["setuptools==63.4.3"]' >pyproject.toml && echo   0.0s
 => ERROR [3/3] RUN pip install -e . && python -c 'import test'                                                    4.1s
------
 > [3/3] RUN pip install -e . && python -c 'import test':
#6 1.317 Obtaining file:///
#6 1.320   Installing build dependencies: started
#6 2.738   Installing build dependencies: finished with status 'done'
#6 2.740   WARNING: Missing build requirements in pyproject.toml for file:///.
#6 2.740   WARNING: The project does not specify a build backend, and pip cannot fall back to setuptools without 'wheel'.
#6 2.741   Checking if build backend supports build_editable: started
#6 2.893   Checking if build backend supports build_editable: finished with status 'done'
#6 2.894   Getting requirements to build wheel: started
#6 3.041   Getting requirements to build wheel: finished with status 'done'
#6 3.043   Installing backend dependencies: started
#6 3.587   Installing backend dependencies: finished with status 'done'
#6 3.588   Preparing metadata (pyproject.toml): started
#6 3.760   Preparing metadata (pyproject.toml): finished with status 'done'
#6 3.794 Installing collected packages: test
#6 3.795   Running setup.py develop for test
#6 3.979 Successfully installed test-0.0.0
#6 3.980 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
#6 3.983
#6 3.983 [notice] A new release of pip available: 22.2.1 -> 22.2.2
#6 3.983 [notice] To update, run: pip install --upgrade pip
#6 4.042 Traceback (most recent call last):
#6 4.042   File "<string>", line 1, in <module>
#6 4.042   File "/test/__init__.py", line 1, in <module>
#6 4.042     from .test import nonexistent
#6 4.042 ModuleNotFoundError: No module named 'test.test'
------
executor failed running [/bin/sh -c pip install -e . && python -c 'import test']: exit code: 1
```


On the other hand, I can verify version 65.1.0 working normally (the latest version of setuptools, which includes PEP 660 support), by tweaking a little bit the Dockerfile:

```Dockerfile
FROM python:3

WORKDIR /tmp
# Let's avoid naming the package `test`,
# because there is a `test` in Python's stdlib...
RUN mkdir -p /tmp/pkg_root/pkg \
 && echo 'build-system.requires = ["setuptools==65.1.0"]' > /tmp/pkg_root/pyproject.toml \
 && echo 'from distutils.core import setup; setup(packages=["pkg"])' > /tmp/pkg_root/setup.py \
 && echo 'from . import othermodule' > /tmp/pkg_root/pkg/__init__.py \
 && echo 'print("hello world")' > /tmp/pkg_root/pkg/othermodule.py

RUN pip install -e /tmp/pkg_root
WORKDIR /var
# We change directories here to avoid the side-effect
# of CWD being accidentally added to `sys.path`...
RUN python -c 'import pkg; print(pkg.__path__)'
```

This Dockerfile is able to build normally. I can also see the import statement working normally by doing the following:

```bash
docker build -t testcase .
docker run --rm -it testcase /bin/bash
```

```bash
$ docker build -t testcase .
[+] Building 8.6s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                                               0.0s
 => => transferring dockerfile: 731B                                                                               0.0s
 => [internal] load .dockerignore                                                                                  0.0s
 => => transferring context: 2B                                                                                    0.0s
 => [internal] load metadata for docker.io/library/python:3                                                        2.9s
 => [1/6] FROM docker.io/library/python:3@sha256:cbf49327fae903d64ab28251912fc00faea2c1baee493d347a07973a2cb50f98  0.0s
 => CACHED [2/6] WORKDIR /tmp                                                                                      0.0s
 => [3/6] RUN mkdir -p /tmp/pkg_root/pkg  && echo 'build-system.requires = ["setuptools==65.1.0"]' > /tmp/pkg_roo  0.4s
 => [4/6] RUN pip install -e /tmp/pkg_root                                                                         4.5s
 => [5/6] WORKDIR /var                                                                                             0.0s
 => [6/6] RUN python -c 'import pkg; print(pkg.__path__)'                                                          0.5s
 => exporting to image                                                                                             0.1s
 => => exporting layers                                                                                            0.1s
 => => writing image sha256:e463267a33fffafbb2c1ec432744b934a8e16e50a2f1166032e251729628c0a4                       0.0s
 => => naming to docker.io/library/testcase                                                                        0.0s

Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them

$ docker run --rm -it testcase /bin/bash
root@6f9db6fe1c05:/var# python -c 'import pkg; print(pkg.__path__)'
hello world
['/tmp/pkg_root/pkg']
```

Resolving patch:
diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py
--- a/setuptools/command/editable_wheel.py
+++ b/setuptools/command/editable_wheel.py
@@ -684,9 +684,13 @@ def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool:
     False
     >>> _is_nested("a.b", "path/a/b", "c", "path/c")
     False
+    >>> _is_nested("a.a", "path/a/a", "a", "path/a")
+    True
+    >>> _is_nested("b.a", "path/b/a", "a", "path/a")
+    False
     """
     norm_pkg_path = _normalize_path(pkg_path)
-    rest = pkg.replace(parent, "").strip(".").split(".")
+    rest = pkg.replace(parent, "", 1).strip(".").split(".")
     return (
         pkg.startswith(parent)
         and norm_pkg_path == _normalize_path(Path(parent_path, *rest))
@@ -755,7 +759,7 @@ class _EditableFinder:  # MetaPathFinder
     def find_spec(cls, fullname, path=None, target=None):
         for pkg, pkg_path in reversed(list(MAPPING.items())):
             if fullname.startswith(pkg):
-                rest = fullname.replace(pkg, "").strip(".").split(".")
+                rest = fullname.replace(pkg, "", 1).strip(".").split(".")
                 return cls._find_spec(fullname, Path(pkg_path, *rest))
 
         return None


Test patch:
diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py
--- a/setuptools/tests/test_editable_install.py
+++ b/setuptools/tests/test_editable_install.py
@@ -494,6 +494,27 @@ def test_dynamic_path_computation(self, tmp_path):
             three = import_module("parent.child.three")
             assert three.x == 3
 
+    def test_no_recursion(self, tmp_path):
+        # See issue #3550
+        files = {
+            "pkg": {
+                "__init__.py": "from . import pkg",
+            },
+        }
+        jaraco.path.build(files, prefix=tmp_path)
+
+        mapping = {
+            "pkg": str(tmp_path / "pkg"),
+        }
+        template = _finder_template(str(uuid4()), mapping, {})
+
+        with contexts.save_paths(), contexts.save_sys_modules():
+            sys.modules.pop("pkg", None)
+
+            self.install_finder(template)
+            with pytest.raises(ImportError, match="pkg"):
+                import_module("pkg")
+
 
 def test_pkg_roots(tmp_path):
     """This test focus in getting a particular implementation detail right.