Repository: pypa/setuptools

Issue:
[BUG] certain editable installs allow case-insensitive imports
### setuptools version

setuptools==68.0.0

### Python version

Python 3.10.12

### OS

macOS

### Additional environment information

I believe this is only a problem on macOS because of its case-preserving but case-insensitive filesytem. I'm not too familiar with filesystems but it may be a problem on others. See also [PEP 235 - Import on Case-Insensitive Platforms](https://peps.python.org/pep-0235/).

I think this also depends on exactly how it is installed, but I'm not familiar with what determines which editable installation method is used.

### Description

I have a project with the following structure:
```
├── pyproject.toml
├── setup.cfg
└── setuptools_editable_case_insensitive
    ├── __init__.py
    └── module_1.py
```

When the package is installed as editable, I am able to import `module_1` with arbitrary case:
```
>>> from setuptools_editable_case_insensitive import module_1
>>> from setuptools_editable_case_insensitive import MODULE_1
>>> import setuptools_editable_case_insensitive.Module_1
```

### Expected behavior

I expected to get an `ImportError` when importing a module with incorrect case, as is the behavior when installed normally:
```
>>> from setuptools_editable_case_insensitive import Module_1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: cannot import name 'Module_1' from 'setuptools_editable_case_insensitive' (/Users/aandersoniii/src/setuptools-example/setuptools_editable_case_insensitive/__init__.py)
```

### How to Reproduce

1. Clone this simplified example: `git clone git@github.com:aganders3/setuptools-editable-case-insensitive.git`
2. Create a virtual environment: `cd setuptools-editable-case-insensitive && python3.10 -m venv venv && source venv/bin/activate`
3. Install the project as editable: `pip install -e .`
4. Run the test script: `python test_imports.py`
5. Note `module_1` is successfully imported with arbitrary case

### Output

```console
❯ git clone git@github.com:aganders3/setuptools-editable-case-insensitive.git
Cloning into 'setuptools-editable-case-insensitive'...
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 7 (delta 0), reused 7 (delta 0), pack-reused 0
Receiving objects: 100% (7/7), done.

❯ cd setuptools-editable-case-insensitive && python3.10 -m venv venv && source venv/bin/activate

❯ pip install -e .
Obtaining file:///Users/aandersoniii/src/setuptools-editable-case-insensitive
  Installing build dependencies ... done
  Checking if build backend supports build_editable ... done
  Getting requirements to build editable ... done
  Installing backend dependencies ... done
  Preparing editable metadata (pyproject.toml) ... done
Building wheels for collected packages: setuptools-editable-case-insensitive
  Building editable for setuptools-editable-case-insensitive (pyproject.toml) ... done
  Created wheel for setuptools-editable-case-insensitive: filename=setuptools_editable_case_insensitive-0.0.0-0.editable-py3-none-any.whl size=2787 sha256=580be31a2260f44e9e877f905ac5875526d31d0f46a433eb72b9103b55f7cb31
  Stored in directory: /private/var/folders/dd/w2cggkp91dq_xn53d6fphp8c0000gq/T/pip-ephem-wheel-cache-ik0hfo9g/wheels/a7/9d/6e/26a7dd6200d0678dcfb12a1d9447413c2a95d67d6a00fefc74
Successfully built setuptools-editable-case-insensitive
Installing collected packages: setuptools-editable-case-insensitive
Successfully installed setuptools-editable-case-insensitive-0.0.0

❯ python test_imports.py 
Hello from setuptools_editable_case_insensitive.module_1!
Hooray, I was imported with the correct name!
	__name__='setuptools_editable_case_insensitive.module_1'
	__file__='/Users/aandersoniii/src/setuptools-editable-case-insensitive/setuptools_editable_case_insensitive/module_1.py'

Hello from setuptools_editable_case_insensitive.Module_1!
Oh no, I was imported with the wrong name!
	__name__='setuptools_editable_case_insensitive.Module_1'
	__file__='/Users/aandersoniii/src/setuptools-editable-case-insensitive/setuptools_editable_case_insensitive/Module_1.py'
```



Discussion:
This seems to be a problem when using the [`_EditableFinder`](https://github.com/pypa/setuptools/blob/80e60c3bdd5aa57f32560e5b0d946d0c07c71ac2/setuptools/command/editable_wheel.py#L758). I can open a PR with a potential fix if it is welcome.
Hi @aganders3 a PR would be very useful, thank you for working on that.


For the finder we use `pathlib.Path.exists` and `importlib.utils.spec_from_file_location`.  Do you know if these functions also suffer from the same problem?

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
@@ -744,7 +744,7 @@ def _get_root(self):
 
 _FINDER_TEMPLATE = """\
 import sys
-from importlib.machinery import ModuleSpec
+from importlib.machinery import ModuleSpec, PathFinder
 from importlib.machinery import all_suffixes as module_suffixes
 from importlib.util import spec_from_file_location
 from itertools import chain
@@ -758,10 +758,15 @@ def _get_root(self):
 class _EditableFinder:  # MetaPathFinder
     @classmethod
     def find_spec(cls, fullname, path=None, target=None):
+        # Top-level packages and modules (we know these exist in the FS)
+        if fullname in MAPPING:
+            pkg_path = MAPPING[fullname]
+            return cls._find_spec(fullname, Path(pkg_path))
+
+        # Nested modules (apparently required for namespaces to work)
         for pkg, pkg_path in reversed(list(MAPPING.items())):
-            if fullname == pkg or fullname.startswith(f"{{pkg}}."):
-                rest = fullname.replace(pkg, "", 1).strip(".").split(".")
-                return cls._find_spec(fullname, Path(pkg_path, *rest))
+            if fullname.startswith(f"{{pkg}}."):
+                return cls._find_nested_spec(fullname, pkg, pkg_path)
 
         return None
 
@@ -773,6 +778,20 @@ def _find_spec(cls, fullname, candidate_path):
             if candidate.exists():
                 return spec_from_file_location(fullname, candidate)
 
+    @classmethod
+    def _find_nested_spec(cls, fullname, parent, parent_path):
+        '''
+        To avoid problems with case sensitivity in the file system we delegate to the
+        importlib.machinery implementation.
+        '''
+        rest = fullname.replace(parent, "", 1).strip(".")
+        nested = PathFinder.find_spec(rest, path=[parent_path])
+        return nested and spec_from_file_location(
+            fullname,
+            nested.origin,
+            submodule_search_locations=nested.submodule_search_locations
+        )
+
 
 class _EditableNamespaceFinder:  # PathEntryFinder
     @classmethod


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
@@ -577,6 +577,88 @@ def test_similar_name(self, tmp_path):
             with pytest.raises(ImportError, match="foobar"):
                 import_module("foobar")
 
+    def test_case_sensitivity(self, tmp_path):
+        files = {
+            "foo": {
+                "__init__.py": "",
+                "lowercase.py": "x = 1",
+                "bar": {
+                    "__init__.py": "",
+                    "lowercase.py": "x = 2",
+                },
+            },
+        }
+        jaraco.path.build(files, prefix=tmp_path)
+        mapping = {
+            "foo": str(tmp_path / "foo"),
+        }
+        template = _finder_template(str(uuid4()), mapping, {})
+        with contexts.save_paths(), contexts.save_sys_modules():
+            sys.modules.pop("foo", None)
+
+            self.install_finder(template)
+            with pytest.raises(ImportError, match="\'FOO\'"):
+                import_module("FOO")
+
+            with pytest.raises(ImportError, match="\'foo\\.LOWERCASE\'"):
+                import_module("foo.LOWERCASE")
+
+            with pytest.raises(ImportError, match="\'foo\\.bar\\.Lowercase\'"):
+                import_module("foo.bar.Lowercase")
+
+            with pytest.raises(ImportError, match="\'foo\\.BAR\'"):
+                import_module("foo.BAR.lowercase")
+
+            with pytest.raises(ImportError, match="\'FOO\'"):
+                import_module("FOO.bar.lowercase")
+
+            mod = import_module("foo.lowercase")
+            assert mod.x == 1
+
+            mod = import_module("foo.bar.lowercase")
+            assert mod.x == 2
+
+    def test_namespace_case_sensitivity(self, tmp_path):
+        files = {
+            "pkg": {
+                "__init__.py": "a = 13",
+                "foo": {
+                    "__init__.py": "b = 37",
+                    "bar.py": "c = 42",
+                },
+            },
+        }
+        jaraco.path.build(files, prefix=tmp_path)
+
+        mapping = {"ns.othername": str(tmp_path / "pkg")}
+        namespaces = {"ns": []}
+
+        template = _finder_template(str(uuid4()), mapping, namespaces)
+        with contexts.save_paths(), contexts.save_sys_modules():
+            for mod in ("ns", "ns.othername"):
+                sys.modules.pop(mod, None)
+
+            self.install_finder(template)
+            pkg = import_module("ns.othername")
+            expected = str((tmp_path / "pkg").resolve())
+            assert_path(pkg, expected)
+            assert pkg.a == 13
+
+            foo = import_module("ns.othername.foo")
+            assert foo.b == 37
+
+            bar = import_module("ns.othername.foo.bar")
+            assert bar.c == 42
+
+            with pytest.raises(ImportError, match="\'NS\'"):
+                import_module("NS.othername.foo")
+
+            with pytest.raises(ImportError, match="\'ns\\.othername\\.FOO\\'"):
+                import_module("ns.othername.FOO")
+
+            with pytest.raises(ImportError, match="\'ns\\.othername\\.foo\\.BAR\\'"):
+                import_module("ns.othername.foo.BAR")
+
 
 def test_pkg_roots(tmp_path):
     """This test focus in getting a particular implementation detail right.