diff --git a/newsfragments/3995.bugfix.rst b/newsfragments/3995.bugfix.rst new file mode 100644 index 0000000000..ba89ae6eb5 --- /dev/null +++ b/newsfragments/3995.bugfix.rst @@ -0,0 +1 @@ +Made imports in editable installs case-sensitive on case-insensitive filesystems -- by :user:`aganders3` diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index c978adad57..d877276fec 100644 --- 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 diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py index 0f1d716fef..2abcaee8fd 100644 --- 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.