From 45b7914eb8bb42f597937a353a8d36039d47e84e Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Thu, 27 Jul 2023 14:58:20 -0400 Subject: [PATCH 01/13] Add failing test --- setuptools/tests/test_editable_install.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py index 0f1d716fef..5aa0711409 100644 --- a/setuptools/tests/test_editable_install.py +++ b/setuptools/tests/test_editable_install.py @@ -577,6 +577,27 @@ 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", + }, + } + 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\\.Lowercase"): + import_module("foo.Lowercase") + foo_lowercase = import_module("foo.lowercase") + assert foo_lowercase.x == 1 + def test_pkg_roots(tmp_path): """This test focus in getting a particular implementation detail right. From f0203062d2f52baeb7bf7c01b5030fb3557318bb Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Thu, 27 Jul 2023 14:59:24 -0400 Subject: [PATCH 02/13] Update _EditableFinder (in _FINDER_TEMPLATE) to be case-sensitive on case-insensitive filesystems --- setuptools/command/editable_wheel.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index c978adad57..883b4dffab 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -743,6 +743,7 @@ def _get_root(self): _FINDER_TEMPLATE = """\ +import os import sys from importlib.machinery import ModuleSpec from importlib.machinery import all_suffixes as module_suffixes @@ -770,7 +771,16 @@ def _find_spec(cls, fullname, candidate_path): init = candidate_path / "__init__.py" candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) for candidate in chain([init], candidates): - if candidate.exists(): + # this is meant to be a case-sensitive check that the candidate exists, for + # case-insensitive, case-preserving filesystems (e.g. APFS) + if ( + candidate.is_file() + and ( + candidate.parent.is_dir() + and candidate in candidate.parent.iterdir() + or "PYTHONCASEOK" in os.environ + ) + ): return spec_from_file_location(fullname, candidate) From de1b7573611baa76ba014d8cb6d75dab20a3923f Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Thu, 27 Jul 2023 15:17:23 -0400 Subject: [PATCH 03/13] Add news fragment --- newsfragments/3995.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3995.bugfix.rst 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` From 5bf344bca67f538b99af9512a387f9e8a9043157 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Thu, 27 Jul 2023 15:28:42 -0400 Subject: [PATCH 04/13] Remove redundant logic --- setuptools/command/editable_wheel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 883b4dffab..6b0be8088a 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -776,8 +776,7 @@ def _find_spec(cls, fullname, candidate_path): if ( candidate.is_file() and ( - candidate.parent.is_dir() - and candidate in candidate.parent.iterdir() + candidate in candidate.parent.iterdir() or "PYTHONCASEOK" in os.environ ) ): From 877af7b3e797134a2b0ae9446766e8c18536c84f Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Fri, 28 Jul 2023 13:30:55 -0400 Subject: [PATCH 05/13] Ensure posix paths to enforce case-sensitivity on Windows --- setuptools/command/editable_wheel.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 6b0be8088a..c46e51a5d0 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -742,7 +742,7 @@ def _get_root(self): return repr(str(self.src_root)) -_FINDER_TEMPLATE = """\ +_FINDER_TEMPLATE = '''\ import os import sys from importlib.machinery import ModuleSpec @@ -756,6 +756,15 @@ def _get_root(self): PATH_PLACEHOLDER = {name!r} + ".__path_hook__" +def _relax_case(): + return not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ + + +def _in_dir_case_sensitive(path, dir): + """Provide a case-sensitive confirmation that `path` exists in `dir`.""" + return path.as_posix() in [f.as_posix() for f in dir.iterdir()] + + class _EditableFinder: # MetaPathFinder @classmethod def find_spec(cls, fullname, path=None, target=None): @@ -771,14 +780,8 @@ def _find_spec(cls, fullname, candidate_path): init = candidate_path / "__init__.py" candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) for candidate in chain([init], candidates): - # this is meant to be a case-sensitive check that the candidate exists, for - # case-insensitive, case-preserving filesystems (e.g. APFS) - if ( - candidate.is_file() - and ( - candidate in candidate.parent.iterdir() - or "PYTHONCASEOK" in os.environ - ) + if candidate.is_file() and ( + _relax_case() or _in_dir_case_sensitive(candidate, candidate.parent) ): return spec_from_file_location(fullname, candidate) @@ -820,7 +823,7 @@ def install(): sys.path_hooks.append(_EditableNamespaceFinder._path_hook) if PATH_PLACEHOLDER not in sys.path: sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook -""" +''' def _finder_template( From be9a8b7b113b881c0879a85ac3ebbd5e93af42f6 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Sun, 30 Jul 2023 11:08:52 -0400 Subject: [PATCH 06/13] Expand case-sensitivity tests --- setuptools/tests/test_editable_install.py | 64 +++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py index 5aa0711409..3a3cba725e 100644 --- a/setuptools/tests/test_editable_install.py +++ b/setuptools/tests/test_editable_install.py @@ -582,6 +582,10 @@ def test_case_sensitivity(self, tmp_path): "foo": { "__init__.py": "", "lowercase.py": "x = 1", + "bar": { + "__init__.py": "", + "lowercase.py": "x = 2", + }, }, } jaraco.path.build(files, prefix=tmp_path) @@ -593,10 +597,62 @@ def test_case_sensitivity(self, tmp_path): sys.modules.pop("foo", None) self.install_finder(template) - with pytest.raises(ImportError, match="foo\\.Lowercase"): - import_module("foo.Lowercase") - foo_lowercase = import_module("foo.lowercase") - assert foo_lowercase.x == 1 + 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") + + 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\\.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): From ad1d39ac0c1b48556df27db3dfc2b40813b26fd1 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Sun, 30 Jul 2023 11:09:31 -0400 Subject: [PATCH 07/13] More robust checking of path case for imports from editable installations --- setuptools/command/editable_wheel.py | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index c46e51a5d0..8a45b4f891 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -742,7 +742,7 @@ def _get_root(self): return repr(str(self.src_root)) -_FINDER_TEMPLATE = '''\ +_FINDER_TEMPLATE = """\ import os import sys from importlib.machinery import ModuleSpec @@ -756,13 +756,20 @@ def _get_root(self): PATH_PLACEHOLDER = {name!r} + ".__path_hook__" -def _relax_case(): - return not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ - - -def _in_dir_case_sensitive(path, dir): - """Provide a case-sensitive confirmation that `path` exists in `dir`.""" - return path.as_posix() in [f.as_posix() for f in dir.iterdir()] +def _check_case(path, n): + '''Verify the last `n` parts of the path have correct case.''' + return ( + (not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ) + or ( + # check the case of the name by listing its parent directory + path.as_posix() in (p.as_posix() for p in path.parent.iterdir()) + # check the case of the next n - 1 parent directories the same way + and all( + p1.as_posix() in (p.as_posix() for p in p2.iterdir()) + for p1, p2 in list(zip(path.parents, path.parents[1:]))[:n - 1] + ) + ) + ) class _EditableFinder: # MetaPathFinder @@ -771,18 +778,17 @@ def find_spec(cls, fullname, path=None, target=None): 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)) + return cls._find_spec(fullname, pkg_path, rest) return None @classmethod - def _find_spec(cls, fullname, candidate_path): + def _find_spec(cls, fullname, pkg_path, rest): + candidate_path = Path(pkg_path, *rest) init = candidate_path / "__init__.py" candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) for candidate in chain([init], candidates): - if candidate.is_file() and ( - _relax_case() or _in_dir_case_sensitive(candidate, candidate.parent) - ): + if candidate.is_file() and _check_case(candidate, len(rest)): return spec_from_file_location(fullname, candidate) @@ -823,7 +829,7 @@ def install(): sys.path_hooks.append(_EditableNamespaceFinder._path_hook) if PATH_PLACEHOLDER not in sys.path: sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook -''' +""" def _finder_template( From 20a5899bb2b783b4c1b6beadf3e869e02855631c Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Sun, 30 Jul 2023 11:16:45 -0400 Subject: [PATCH 08/13] Move _check_case within the template --- setuptools/command/editable_wheel.py | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 8a45b4f891..6253ae2d00 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -756,22 +756,6 @@ def _get_root(self): PATH_PLACEHOLDER = {name!r} + ".__path_hook__" -def _check_case(path, n): - '''Verify the last `n` parts of the path have correct case.''' - return ( - (not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ) - or ( - # check the case of the name by listing its parent directory - path.as_posix() in (p.as_posix() for p in path.parent.iterdir()) - # check the case of the next n - 1 parent directories the same way - and all( - p1.as_posix() in (p.as_posix() for p in p2.iterdir()) - for p1, p2 in list(zip(path.parents, path.parents[1:]))[:n - 1] - ) - ) - ) - - class _EditableFinder: # MetaPathFinder @classmethod def find_spec(cls, fullname, path=None, target=None): @@ -817,6 +801,22 @@ def find_module(cls, fullname): return None +def _check_case(path, n): + '''Verify the last `n` parts of the path have correct case.''' + return ( + (not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ) + or ( + # check the case of the name by listing its parent directory + path.as_posix() in (p.as_posix() for p in path.parent.iterdir()) + # check the case of the next n parent directories the same way + and all( + p1.as_posix() in (p.as_posix() for p in p2.iterdir()) + for p1, p2 in list(zip(path.parents, path.parents[1:]))[:n] + ) + ) + ) + + def install(): if not any(finder == _EditableFinder for finder in sys.meta_path): sys.meta_path.append(_EditableFinder) From d2c7611b6a699bf1bdcb31afb42b66e549d70238 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Sun, 30 Jul 2023 20:49:04 -0400 Subject: [PATCH 09/13] Fix for Python <3.10 --- setuptools/command/editable_wheel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 6253ae2d00..7bef903d36 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -810,8 +810,8 @@ def _check_case(path, n): path.as_posix() in (p.as_posix() for p in path.parent.iterdir()) # check the case of the next n parent directories the same way and all( - p1.as_posix() in (p.as_posix() for p in p2.iterdir()) - for p1, p2 in list(zip(path.parents, path.parents[1:]))[:n] + part.as_posix() in (p.as_posix() for p in part.parent.iterdir()) + for part in list(path.parents)[:n] ) ) ) From 65d05dd54f3e14f4ed2ef8ace6e90ae50d7ee212 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Mon, 31 Jul 2023 09:17:47 -0400 Subject: [PATCH 10/13] Small refactor to fix off-by-one, lint --- setuptools/command/editable_wheel.py | 9 +++++---- setuptools/tests/test_editable_install.py | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 7bef903d36..dcf298a314 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -772,7 +772,8 @@ def _find_spec(cls, fullname, pkg_path, rest): init = candidate_path / "__init__.py" candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) for candidate in chain([init], candidates): - if candidate.is_file() and _check_case(candidate, len(rest)): + last_n = len(rest) + len(candidate.parts) - len(candidate_path.parts) + if candidate.is_file() and _check_case(candidate, last_n): return spec_from_file_location(fullname, candidate) @@ -801,17 +802,17 @@ def find_module(cls, fullname): return None -def _check_case(path, n): +def _check_case(path, last_n): '''Verify the last `n` parts of the path have correct case.''' return ( (not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ) or ( # check the case of the name by listing its parent directory path.as_posix() in (p.as_posix() for p in path.parent.iterdir()) - # check the case of the next n parent directories the same way + # check the case of the next n - 1 components the same way and all( part.as_posix() in (p.as_posix() for p in part.parent.iterdir()) - for part in list(path.parents)[:n] + for part in list(path.parents)[:last_n - 1] ) ) ) diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py index 3a3cba725e..256b13ec4e 100644 --- a/setuptools/tests/test_editable_install.py +++ b/setuptools/tests/test_editable_install.py @@ -654,7 +654,6 @@ def test_namespace_case_sensitivity(self, tmp_path): import_module("ns.othername.foo.BAR") - def test_pkg_roots(tmp_path): """This test focus in getting a particular implementation detail right. If at some point in time the implementation is changed for something different, From 70b51deab33e04c02fc8f1575bf0adbf0311e9d2 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 2 Aug 2023 14:28:45 +0100 Subject: [PATCH 11/13] Revert changes in _EditableFinder --- setuptools/command/editable_wheel.py | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index dcf298a314..c978adad57 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -743,7 +743,6 @@ def _get_root(self): _FINDER_TEMPLATE = """\ -import os import sys from importlib.machinery import ModuleSpec from importlib.machinery import all_suffixes as module_suffixes @@ -762,18 +761,16 @@ def find_spec(cls, fullname, path=None, target=None): 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, pkg_path, rest) + return cls._find_spec(fullname, Path(pkg_path, *rest)) return None @classmethod - def _find_spec(cls, fullname, pkg_path, rest): - candidate_path = Path(pkg_path, *rest) + def _find_spec(cls, fullname, candidate_path): init = candidate_path / "__init__.py" candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) for candidate in chain([init], candidates): - last_n = len(rest) + len(candidate.parts) - len(candidate_path.parts) - if candidate.is_file() and _check_case(candidate, last_n): + if candidate.exists(): return spec_from_file_location(fullname, candidate) @@ -802,22 +799,6 @@ def find_module(cls, fullname): return None -def _check_case(path, last_n): - '''Verify the last `n` parts of the path have correct case.''' - return ( - (not sys.flags.ignore_environment and "PYTHONCASEOK" in os.environ) - or ( - # check the case of the name by listing its parent directory - path.as_posix() in (p.as_posix() for p in path.parent.iterdir()) - # check the case of the next n - 1 components the same way - and all( - part.as_posix() in (p.as_posix() for p in part.parent.iterdir()) - for part in list(path.parents)[:last_n - 1] - ) - ) - ) - - def install(): if not any(finder == _EditableFinder for finder in sys.meta_path): sys.meta_path.append(_EditableFinder) From db3743a90b4948ca4d8435ed52bc76ca98152e57 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 2 Aug 2023 15:47:50 +0100 Subject: [PATCH 12/13] Reuse PathFinder to deal with fs case sensitivity in editable_wheel --- setuptools/command/editable_wheel.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) 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 From e028aa6d9c56f55dbf9b655e8436aa9f944f0211 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 2 Aug 2023 16:35:43 +0100 Subject: [PATCH 13/13] Add more checks for case sensitivity in editable_wheel tests --- setuptools/tests/test_editable_install.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py index 256b13ec4e..2abcaee8fd 100644 --- a/setuptools/tests/test_editable_install.py +++ b/setuptools/tests/test_editable_install.py @@ -609,6 +609,9 @@ def test_case_sensitivity(self, tmp_path): 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 @@ -647,6 +650,9 @@ def test_namespace_case_sensitivity(self, tmp_path): 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")