Skip to content

Commit 463b4df

Browse files
committed
Handle complex case-sensitivity in filesystems for certain editable installations (#3995)
2 parents 3418d5d + e028aa6 commit 463b4df

File tree

3 files changed

+106
-4
lines changed

3 files changed

+106
-4
lines changed

newsfragments/3995.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Made imports in editable installs case-sensitive on case-insensitive filesystems -- by :user:`aganders3`

setuptools/command/editable_wheel.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ def _get_root(self):
744744

745745
_FINDER_TEMPLATE = """\
746746
import sys
747-
from importlib.machinery import ModuleSpec
747+
from importlib.machinery import ModuleSpec, PathFinder
748748
from importlib.machinery import all_suffixes as module_suffixes
749749
from importlib.util import spec_from_file_location
750750
from itertools import chain
@@ -758,10 +758,15 @@ def _get_root(self):
758758
class _EditableFinder: # MetaPathFinder
759759
@classmethod
760760
def find_spec(cls, fullname, path=None, target=None):
761+
# Top-level packages and modules (we know these exist in the FS)
762+
if fullname in MAPPING:
763+
pkg_path = MAPPING[fullname]
764+
return cls._find_spec(fullname, Path(pkg_path))
765+
766+
# Nested modules (apparently required for namespaces to work)
761767
for pkg, pkg_path in reversed(list(MAPPING.items())):
762-
if fullname == pkg or fullname.startswith(f"{{pkg}}."):
763-
rest = fullname.replace(pkg, "", 1).strip(".").split(".")
764-
return cls._find_spec(fullname, Path(pkg_path, *rest))
768+
if fullname.startswith(f"{{pkg}}."):
769+
return cls._find_nested_spec(fullname, pkg, pkg_path)
765770
766771
return None
767772
@@ -773,6 +778,20 @@ def _find_spec(cls, fullname, candidate_path):
773778
if candidate.exists():
774779
return spec_from_file_location(fullname, candidate)
775780
781+
@classmethod
782+
def _find_nested_spec(cls, fullname, parent, parent_path):
783+
'''
784+
To avoid problems with case sensitivity in the file system we delegate to the
785+
importlib.machinery implementation.
786+
'''
787+
rest = fullname.replace(parent, "", 1).strip(".")
788+
nested = PathFinder.find_spec(rest, path=[parent_path])
789+
return nested and spec_from_file_location(
790+
fullname,
791+
nested.origin,
792+
submodule_search_locations=nested.submodule_search_locations
793+
)
794+
776795
777796
class _EditableNamespaceFinder: # PathEntryFinder
778797
@classmethod

setuptools/tests/test_editable_install.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,88 @@ def test_similar_name(self, tmp_path):
577577
with pytest.raises(ImportError, match="foobar"):
578578
import_module("foobar")
579579

580+
def test_case_sensitivity(self, tmp_path):
581+
files = {
582+
"foo": {
583+
"__init__.py": "",
584+
"lowercase.py": "x = 1",
585+
"bar": {
586+
"__init__.py": "",
587+
"lowercase.py": "x = 2",
588+
},
589+
},
590+
}
591+
jaraco.path.build(files, prefix=tmp_path)
592+
mapping = {
593+
"foo": str(tmp_path / "foo"),
594+
}
595+
template = _finder_template(str(uuid4()), mapping, {})
596+
with contexts.save_paths(), contexts.save_sys_modules():
597+
sys.modules.pop("foo", None)
598+
599+
self.install_finder(template)
600+
with pytest.raises(ImportError, match="\'FOO\'"):
601+
import_module("FOO")
602+
603+
with pytest.raises(ImportError, match="\'foo\\.LOWERCASE\'"):
604+
import_module("foo.LOWERCASE")
605+
606+
with pytest.raises(ImportError, match="\'foo\\.bar\\.Lowercase\'"):
607+
import_module("foo.bar.Lowercase")
608+
609+
with pytest.raises(ImportError, match="\'foo\\.BAR\'"):
610+
import_module("foo.BAR.lowercase")
611+
612+
with pytest.raises(ImportError, match="\'FOO\'"):
613+
import_module("FOO.bar.lowercase")
614+
615+
mod = import_module("foo.lowercase")
616+
assert mod.x == 1
617+
618+
mod = import_module("foo.bar.lowercase")
619+
assert mod.x == 2
620+
621+
def test_namespace_case_sensitivity(self, tmp_path):
622+
files = {
623+
"pkg": {
624+
"__init__.py": "a = 13",
625+
"foo": {
626+
"__init__.py": "b = 37",
627+
"bar.py": "c = 42",
628+
},
629+
},
630+
}
631+
jaraco.path.build(files, prefix=tmp_path)
632+
633+
mapping = {"ns.othername": str(tmp_path / "pkg")}
634+
namespaces = {"ns": []}
635+
636+
template = _finder_template(str(uuid4()), mapping, namespaces)
637+
with contexts.save_paths(), contexts.save_sys_modules():
638+
for mod in ("ns", "ns.othername"):
639+
sys.modules.pop(mod, None)
640+
641+
self.install_finder(template)
642+
pkg = import_module("ns.othername")
643+
expected = str((tmp_path / "pkg").resolve())
644+
assert_path(pkg, expected)
645+
assert pkg.a == 13
646+
647+
foo = import_module("ns.othername.foo")
648+
assert foo.b == 37
649+
650+
bar = import_module("ns.othername.foo.bar")
651+
assert bar.c == 42
652+
653+
with pytest.raises(ImportError, match="\'NS\'"):
654+
import_module("NS.othername.foo")
655+
656+
with pytest.raises(ImportError, match="\'ns\\.othername\\.FOO\\'"):
657+
import_module("ns.othername.FOO")
658+
659+
with pytest.raises(ImportError, match="\'ns\\.othername\\.foo\\.BAR\\'"):
660+
import_module("ns.othername.foo.BAR")
661+
580662

581663
def test_pkg_roots(tmp_path):
582664
"""This test focus in getting a particular implementation detail right.

0 commit comments

Comments
 (0)