From 10d644ee3566341250ebefc97521ee1ad57a3ad2 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 17:27:02 +0530 Subject: [PATCH 01/11] Fix importlib mode shadowing stdlib modules from same-named test dirs (#12303) When test files live inside directories that share names with stdlib modules (e.g. test/), import_path in --import-mode=importlib would register intermediate parent modules under those names in sys.modules, shadowing the real stdlib modules. This caused imports like "import test.support" to fail with ModuleNotFoundError. Add a shadow-detection helper (_top_level_shadows_external) that checks whether the top-level component of a dotted module name would overwrite an external module at a different filesystem location. When a shadow is detected, the standard-import path is skipped and the fallback path prefixes the top-level component (_pytest_shadow_test.foo) instead of flattening, preserving module-name uniqueness. --- changelog/12303.bugfix.rst | 1 + src/_pytest/pathlib.py | 87 ++++++++++++++++++++++++++++++----- testing/test_pathlib.py | 92 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 changelog/12303.bugfix.rst diff --git a/changelog/12303.bugfix.rst b/changelog/12303.bugfix.rst new file mode 100644 index 00000000000..f38c6055f06 --- /dev/null +++ b/changelog/12303.bugfix.rst @@ -0,0 +1 @@ +Fixed ``--import-mode=importlib`` shadowing stdlib and installed packages when test directories share their name (e.g. ``test/``). diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 3619d8cd3fc..ec95cca843a 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -542,19 +542,29 @@ def import_path( except CouldNotResolvePathError: pass else: - # If the given module name is already in sys.modules, do not import it again. - with contextlib.suppress(KeyError): - return sys.modules[module_name] - - mod = _import_module_using_spec( - module_name, path, pkg_root, insert_modules=False - ) - if mod is not None: - return mod + # Skip dotted names that would shadow stdlib/installed packages (#12303). + if "." not in module_name or not _top_level_shadows_external( + module_name, pkg_root + ): + # If the given module name is already in sys.modules, do not import it again. + with contextlib.suppress(KeyError): + return sys.modules[module_name] + + mod = _import_module_using_spec( + module_name, path, pkg_root, insert_modules=False + ) + if mod is not None: + return mod # Could not import the module with the current sys.path, so we fall back # to importing the file as a single module, not being a part of a package. module_name = module_name_from_path(path, root) + + # Prefix to avoid shadowing stdlib/installed packages (#12303). + if "." in module_name and _top_level_shadows_external(module_name, root): + top, _, rest = module_name.partition(".") + module_name = f"_pytest_shadow_{top}.{rest}" + with contextlib.suppress(KeyError): return sys.modules[module_name] @@ -754,6 +764,61 @@ def spec_matches_module_path(module_spec: ModuleSpec | None, module_path: Path) return False +def _top_level_shadows_external(module_name: str, local_root: Path) -> bool: + """Return True if the top-level component of *module_name* would collide + with a stdlib or installed package that lives outside *local_root*. + See #12303.""" + top = module_name.partition(".")[0] + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ImportWarning) + existing_spec = importlib.util.find_spec(top) + except (ImportError, ValueError, ModuleNotFoundError): + return False + if existing_spec is None: + return False + + # Built-in or frozen modules are always external. + if existing_spec.origin in ("built-in", "frozen"): + return True + + # Also pick up dirs whose name normalizes to `top` (e.g. ".tests" → "_tests"). + # Resolve everything so symlinks like /var → /private/var don't trip us up. + local_candidates: list[Path] = [local_root / top] + try: + for child in local_root.iterdir(): + if ( + child.is_dir() + and child.name.replace(".", "_") == top + and child not in local_candidates + ): + local_candidates.append(child) + except OSError: + pass + resolved_candidates = [c.resolve() for c in local_candidates if c.exists()] + + def _is_local(p: Path) -> bool: + rp = p.resolve() + for candidate in resolved_candidates: + try: + rp.relative_to(candidate) + return True + except ValueError: + pass + return False + + if existing_spec.origin is not None: + if _is_local(Path(existing_spec.origin)): + return False + + if existing_spec.submodule_search_locations: + for loc in existing_spec.submodule_search_locations: + if _is_local(Path(loc)): + return False + + return True + + # Implement a special _is_same function on Windows which returns True if the two filenames # compare equal, to circumvent os.path.samefile returning False for mounts in UNC (#7678). if sys.platform.startswith("win"): @@ -819,10 +884,12 @@ def insert_missing_modules(modules: dict[str, ModuleType], module_name: str) -> # ourselves to fall back to creating a dummy module. if not sys.meta_path: raise ModuleNotFoundError + # May import an unrelated module on name collision; + # callers use the _pytest_shadow_ prefix to avoid this (#12303). parent_module = importlib.import_module(parent_module_name) except ModuleNotFoundError: parent_module = ModuleType( - module_name, + parent_module_name, doc="Empty module created by pytest's importmode=importlib.", ) modules[parent_module_name] = parent_module diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index be20f3fea48..5b82943f762 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -973,10 +973,102 @@ def test_demo(): consider_namespace_packages=ns_param, ) + # stdlib module must still be the real one, not a local dummy (#12303). + stdlib_mod = sys.modules[name] + assert not (getattr(stdlib_mod, "__file__", "") or "").startswith( + str(pytester.path) + ) + # E2E test result = pytester.runpytest("--import-mode=importlib") result.stdout.fnmatch_lines("* 1 passed *") + @pytest.mark.parametrize("subdir", ["", "subdir/"]) + def test_importlib_does_not_shadow_stdlib( + self, pytester, ns_param: bool, subdir: str + ): + """Regression test for #12303.""" + file_path = pytester.path / f"test/{subdir}test_demo.py" + file_path.parent.mkdir(parents=True) + file_path.write_text( + dedent( + """ + import test.support + + def test_stdlib_accessible(): + assert hasattr(test.support, "verbose") + # Must be the real stdlib, not a pytest-generated dummy. + assert "site-packages" not in (getattr(test, "__file__", "") or "") + """ + ), + encoding="utf-8", + ) + + ns_opt = str(ns_param).lower() + result = pytester.runpytest_subprocess( + "--import-mode=importlib", + "-o", + f"consider_namespace_packages={ns_opt}", + ) + result.stdout.fnmatch_lines("* 1 passed *") + + def test_importlib_does_not_shadow_installed_package( + self, pytester, ns_param: bool + ): + """Regression test for #12303 (installed packages).""" + pytest.importorskip("packaging.version") + + file_path = pytester.path / "packaging/test_demo.py" + file_path.parent.mkdir(parents=True) + file_path.write_text( + dedent( + """ + from packaging.version import Version + + def test_installed_pkg(): + assert str(Version("1.0")) == "1.0" + """ + ), + encoding="utf-8", + ) + + ns_opt = str(ns_param).lower() + result = pytester.runpytest_subprocess( + "--import-mode=importlib", + "-o", + f"consider_namespace_packages={ns_opt}", + ) + result.stdout.fnmatch_lines("* 1 passed *") + + def test_importlib_canonical_name_preserved( + self, pytester, ns_param: bool, monkeypatch: MonkeyPatch + ): + """Shadow detection must not fire for real local packages (#12303).""" + pkg = pytester.path / "myapp" + pkg.mkdir() + (pkg / "__init__.py").touch() + test_file = pkg / "test_core.py" + test_file.write_text( + dedent( + """ + def test_canonical(): + pass + """ + ), + encoding="utf-8", + ) + # Make the package importable via sys.path. + monkeypatch.syspath_prepend(pytester.path) + + mod = import_path( + test_file, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + # Must use the real dotted name, not a shadow-prefixed name. + assert mod.__name__ == "myapp.test_core" + def create_installed_doctests_and_tests_dir( self, path: Path, monkeypatch: MonkeyPatch ) -> tuple[Path, Path, Path]: From 11405b799e2647037212595d5d7aca443f34353e Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 17:29:50 +0530 Subject: [PATCH 02/11] Add Fazeel Usmani to AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index f3c8d016c28..4cf19209192 100644 --- a/AUTHORS +++ b/AUTHORS @@ -170,6 +170,7 @@ Fabien Zarifian Fabio Zadrozny Farbod Ahmadian faph +Fazeel Usmani Felix Hofstätter Felix Nieuwenhuizen Feng Ma From d3ca26bc46f8021dda0f99debe80c27f8caf67d0 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 20:13:31 +0530 Subject: [PATCH 03/11] Add unit tests for _top_level_shadows_external to reach 100% patch coverage --- testing/test_pathlib.py | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 5b82943f762..7784f674fff 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -20,6 +20,7 @@ from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import _import_module_using_spec +from _pytest.pathlib import _top_level_shadows_external from _pytest.pathlib import bestrelpath from _pytest.pathlib import commonpath from _pytest.pathlib import compute_module_name @@ -1828,6 +1829,55 @@ def test_compute_module_name(tmp_path: Path) -> None: ) +class TestTopLevelShadowsExternal: + """Unit tests for ``_top_level_shadows_external`` to cover all branches.""" + + def test_no_external_module(self, tmp_path: Path) -> None: + """When find_spec returns None (no such external module), return False.""" + (tmp_path / "zzz_nonexistent_pkg").mkdir() + assert not _top_level_shadows_external("zzz_nonexistent_pkg.foo", tmp_path) + + def test_builtin_or_frozen(self, tmp_path: Path) -> None: + """Built-in/frozen modules (e.g. 'sys', 'os') are always external.""" + (tmp_path / "sys").mkdir() + assert _top_level_shadows_external("sys.something", tmp_path) + + def test_find_spec_raises(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + """When find_spec raises, treat as no external module (return False).""" + import importlib.util + + def _raise(*a: Any, **kw: Any) -> None: + raise ValueError("broken") + + monkeypatch.setattr(importlib.util, "find_spec", _raise) + assert not _top_level_shadows_external("whatever.foo", tmp_path) + + def test_normalized_dir_name(self, tmp_path: Path) -> None: + """A dir named '.tests' normalizes to '_tests' and should be + recognized as local, not external.""" + dot_tests = tmp_path / ".tests" + dot_tests.mkdir() + (dot_tests / "foo.py").write_text("", encoding="utf-8") + # '_tests' is not a real external module, so find_spec returns None. + # This just exercises the iterdir + normalization branch. + assert not _top_level_shadows_external("_tests.foo", tmp_path) + + def test_external_package_detected(self, tmp_path: Path) -> None: + """An installed package at a different location is external.""" + (tmp_path / "test").mkdir() + assert _top_level_shadows_external("test.support", tmp_path) + + def test_local_package_not_external( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """A package whose spec resolves inside local_root is not external.""" + pkg = tmp_path / "mypkg" + pkg.mkdir() + (pkg / "__init__.py").touch() + monkeypatch.syspath_prepend(tmp_path) + assert not _top_level_shadows_external("mypkg.sub", tmp_path) + + def validate_namespace_package( pytester: Pytester, paths: Sequence[Path], modules: Sequence[str] ) -> RunResult: From 437a5105558b15af6833a4b64dab9bea8e6cfdb8 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 20:22:00 +0530 Subject: [PATCH 04/11] Improve patch coverage: test Path 1 shadow branch, pragma on OSError --- src/_pytest/pathlib.py | 2 +- testing/test_pathlib.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index ec95cca843a..beb9e5c9e5b 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -793,7 +793,7 @@ def _top_level_shadows_external(module_name: str, local_root: Path) -> bool: and child not in local_candidates ): local_candidates.append(child) - except OSError: + except OSError: # pragma: no cover pass resolved_candidates = [c.resolve() for c in local_candidates if c.exists()] diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 7784f674fff..bbc760ed57b 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1070,6 +1070,37 @@ def test_canonical(): # Must use the real dotted name, not a shadow-prefixed name. assert mod.__name__ == "myapp.test_core" + def test_importlib_shadow_skips_standard_import_path( + self, pytester, ns_param: bool + ): + """When test/__init__.py exists AND the name shadows stdlib, + the standard-import path (Path 1) must be skipped so that + the fallback prefixes the name instead. This exercises the + shadow check inside the ``else`` block of + ``resolve_pkg_root_and_module_name`` (#12303).""" + test_dir = pytester.path / "test" + test_dir.mkdir() + (test_dir / "__init__.py").touch() + file_path = test_dir / "test_demo.py" + file_path.write_text( + dedent( + """ + def test_passes(): + pass + """ + ), + encoding="utf-8", + ) + + mod = import_path( + file_path, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + # Must NOT be imported as "test.test_demo" (would shadow stdlib). + assert not mod.__name__.startswith("test.") + def create_installed_doctests_and_tests_dir( self, path: Path, monkeypatch: MonkeyPatch ) -> tuple[Path, Path, Path]: From 48c3bf670fb0a414408624eaa14a75681ef651cc Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 20:37:43 +0530 Subject: [PATCH 05/11] Fix test_normalized_dir_name to exercise iterdir branch (line 795) --- testing/test_pathlib.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index bbc760ed57b..b47460fa74b 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1883,15 +1883,25 @@ def _raise(*a: Any, **kw: Any) -> None: monkeypatch.setattr(importlib.util, "find_spec", _raise) assert not _top_level_shadows_external("whatever.foo", tmp_path) - def test_normalized_dir_name(self, tmp_path: Path) -> None: - """A dir named '.tests' normalizes to '_tests' and should be - recognized as local, not external.""" - dot_tests = tmp_path / ".tests" - dot_tests.mkdir() - (dot_tests / "foo.py").write_text("", encoding="utf-8") - # '_tests' is not a real external module, so find_spec returns None. - # This just exercises the iterdir + normalization branch. - assert not _top_level_shadows_external("_tests.foo", tmp_path) + def test_normalized_dir_name( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """A dir named '.xmod' normalizes to '_xmod' and should be + recognized as local when the spec points inside it.""" + dot_dir = tmp_path / ".xmod" + dot_dir.mkdir() + + # Fake an already-imported module whose search location is inside + # the normalized dir — this exercises the ``iterdir`` + + # ``name.replace(".", "_")`` branch (line 795 of pathlib.py). + dummy = ModuleType("_xmod") + dummy.__path__ = [str(dot_dir)] + spec = importlib.machinery.ModuleSpec("_xmod", None, origin=None) + spec.submodule_search_locations = [str(dot_dir)] + dummy.__spec__ = spec + monkeypatch.setitem(sys.modules, "_xmod", dummy) + + assert not _top_level_shadows_external("_xmod.foo", tmp_path) def test_external_package_detected(self, tmp_path: Path) -> None: """An installed package at a different location is external.""" From a73bd96649a5b6c3fcbe168d95bb454d552f0d4e Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 20:54:16 +0530 Subject: [PATCH 06/11] pragma: no cover on defensive empty-meta_path guard in insert_missing_modules --- src/_pytest/pathlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index beb9e5c9e5b..44133ed3451 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -882,7 +882,7 @@ def insert_missing_modules(modules: dict[str, ModuleType], module_name: str) -> # a warning and raise ModuleNotFoundError. To avoid the # warning, we check sys.meta_path explicitly and raise the error # ourselves to fall back to creating a dummy module. - if not sys.meta_path: + if not sys.meta_path: # pragma: no cover raise ModuleNotFoundError # May import an unrelated module on name collision; # callers use the _pytest_shadow_ prefix to avoid this (#12303). From 3633fbb0914a3dbe09aa6e544f44480ab2bbe5f4 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 22:36:14 +0530 Subject: [PATCH 07/11] Harden shadow detection: fix find_spec ordering, add cache, improve tests - Fix find_spec ordering hazard: when local_root is reachable via sys.path (e.g. '' / CWD), find_spec returns the local package first, hiding the external module behind it. Now falls back to PathFinder with non-local paths filtered out, and guards against false positives on multi-directory namespace packages by checking for genuinely new locations. - Cache _top_level_shadows_external on (top, local_root) via lru_cache to avoid repeated find_spec + iterdir + resolve per test file. - Fix except tuple: replace redundant ModuleNotFoundError (subclass of ImportError) with AttributeError for misbehaving meta-path finders. - Replace OSError pragma with real test (test_iterdir_oserror). - Revert unrelated insert_missing_modules fix (module_name vs parent_module_name) to keep this PR focused on #12303. - Update changelog to document user-visible _pytest_shadow_ prefix. --- changelog/12303.bugfix.rst | 6 ++++ src/_pytest/pathlib.py | 68 +++++++++++++++++++++++++++++++++----- testing/test_pathlib.py | 40 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/changelog/12303.bugfix.rst b/changelog/12303.bugfix.rst index f38c6055f06..19bf96540d9 100644 --- a/changelog/12303.bugfix.rst +++ b/changelog/12303.bugfix.rst @@ -1 +1,7 @@ Fixed ``--import-mode=importlib`` shadowing stdlib and installed packages when test directories share their name (e.g. ``test/``). + +Test modules whose top-level directory collides with an external package are now +registered in ``sys.modules`` under a ``_pytest_shadow_`` prefix (e.g. +``_pytest_shadow_test.test_demo`` instead of ``test.test_demo``). This is an +internal detail and should not affect test behaviour, but it will appear in +tracebacks and ``__name__``. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 44133ed3451..304c0334f6d 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -11,6 +11,7 @@ from errno import ENOENT from errno import ENOTDIR import fnmatch +from functools import lru_cache from functools import partial from importlib.machinery import ModuleSpec from importlib.machinery import PathFinder @@ -769,11 +770,20 @@ def _top_level_shadows_external(module_name: str, local_root: Path) -> bool: with a stdlib or installed package that lives outside *local_root*. See #12303.""" top = module_name.partition(".")[0] + return _top_shadows_external_cached(top, local_root) + + +@lru_cache(maxsize=None) +def _top_shadows_external_cached(top: str, local_root: Path) -> bool: + """Cached core of :func:`_top_level_shadows_external`. + + Keyed on the top-level name and root so that every test file under the + same directory reuses a single ``find_spec`` + ``iterdir`` result.""" try: with warnings.catch_warnings(): warnings.simplefilter("ignore", ImportWarning) existing_spec = importlib.util.find_spec(top) - except (ImportError, ValueError, ModuleNotFoundError): + except (ImportError, ValueError, AttributeError): return False if existing_spec is None: return False @@ -793,7 +803,7 @@ def _top_level_shadows_external(module_name: str, local_root: Path) -> bool: and child not in local_candidates ): local_candidates.append(child) - except OSError: # pragma: no cover + except OSError: pass resolved_candidates = [c.resolve() for c in local_candidates if c.exists()] @@ -807,16 +817,58 @@ def _is_local(p: Path) -> bool: pass return False + # Check whether the spec found by find_spec points into local_root. + spec_is_local = False if existing_spec.origin is not None: if _is_local(Path(existing_spec.origin)): - return False - - if existing_spec.submodule_search_locations: + spec_is_local = True + if not spec_is_local and existing_spec.submodule_search_locations: for loc in existing_spec.submodule_search_locations: if _is_local(Path(loc)): - return False + spec_is_local = True + break - return True + if not spec_is_local: + # The module found by find_spec lives outside local_root → shadow. + return True + + # find_spec returned the local package (e.g. because the project root is + # reachable via '' or an explicit sys.path entry). An external module + # with the same name may still exist behind it. Search only non-local + # sys.path entries via PathFinder so we never modify sys.path. + local_root_resolved = local_root.resolve() + + def _is_local_path_entry(entry: str) -> bool: + p = Path.cwd() if entry == "" else Path(entry) + try: + p.resolve().relative_to(local_root_resolved) + return True + except ValueError: + return False + + non_local = [p for p in sys.path if not _is_local_path_entry(p)] + try: + behind = PathFinder.find_spec(top, path=non_local) + except (ImportError, ValueError, AttributeError): + behind = None + + if behind is None: + return False + + # Guard against namespace packages that span multiple project directories + # (e.g. dist1/com + dist2/com). If the "behind" spec's locations are all + # already present in the original spec, it is the same package — not a + # genuinely external shadow. + def _spec_locations(spec: ModuleSpec) -> set[str]: + locs: set[str] = set() + if spec.origin is not None: + locs.add(str(Path(spec.origin).resolve())) + if spec.submodule_search_locations: + for loc in spec.submodule_search_locations: + locs.add(str(Path(loc).resolve())) + return locs + + return bool(_spec_locations(behind) - _spec_locations(existing_spec)) # Implement a special _is_same function on Windows which returns True if the two filenames @@ -889,7 +941,7 @@ def insert_missing_modules(modules: dict[str, ModuleType], module_name: str) -> parent_module = importlib.import_module(parent_module_name) except ModuleNotFoundError: parent_module = ModuleType( - parent_module_name, + module_name, doc="Empty module created by pytest's importmode=importlib.", ) modules[parent_module_name] = parent_module diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index b47460fa74b..247ed00023d 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -21,6 +21,7 @@ from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import _import_module_using_spec from _pytest.pathlib import _top_level_shadows_external +from _pytest.pathlib import _top_shadows_external_cached from _pytest.pathlib import bestrelpath from _pytest.pathlib import commonpath from _pytest.pathlib import compute_module_name @@ -1908,6 +1909,22 @@ def test_external_package_detected(self, tmp_path: Path) -> None: (tmp_path / "test").mkdir() assert _top_level_shadows_external("test.support", tmp_path) + def test_external_detected_when_root_on_sys_path( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """When local_root is on sys.path (e.g. via '' / CWD) and test/ + has __init__.py, find_spec sees the local package first. + The function must still detect the stdlib shadow behind it.""" + test_dir = tmp_path / "test" + test_dir.mkdir() + (test_dir / "__init__.py").touch() + monkeypatch.syspath_prepend(tmp_path) + _top_shadows_external_cached.cache_clear() + try: + assert _top_level_shadows_external("test.test_demo", tmp_path) + finally: + _top_shadows_external_cached.cache_clear() + def test_local_package_not_external( self, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -1918,6 +1935,29 @@ def test_local_package_not_external( monkeypatch.syspath_prepend(tmp_path) assert not _top_level_shadows_external("mypkg.sub", tmp_path) + def test_iterdir_oserror( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """When iterdir raises (e.g. PermissionError), fall through gracefully.""" + (tmp_path / "test").mkdir() + _top_shadows_external_cached.cache_clear() + + real_iterdir = Path.iterdir + + def _broken_iterdir(self: Path) -> Any: + if self == tmp_path: + raise PermissionError("denied") + return real_iterdir(self) + + monkeypatch.setattr(Path, "iterdir", _broken_iterdir) + # Should still detect the shadow (stdlib test exists externally) + # even though iterdir failed — the base candidate local_root/top + # is added unconditionally before iterdir. + try: + assert _top_level_shadows_external("test.support", tmp_path) + finally: + _top_shadows_external_cached.cache_clear() + def validate_namespace_package( pytester: Pytester, paths: Sequence[Path], modules: Sequence[str] From 77f47c8175925ea11503875f9bced92dc98becff Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:07:09 +0000 Subject: [PATCH 08/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/pathlib.py | 4 ++-- testing/test_pathlib.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 304c0334f6d..68be2739ff7 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -11,7 +11,7 @@ from errno import ENOENT from errno import ENOTDIR import fnmatch -from functools import lru_cache +from functools import cache from functools import partial from importlib.machinery import ModuleSpec from importlib.machinery import PathFinder @@ -773,7 +773,7 @@ def _top_level_shadows_external(module_name: str, local_root: Path) -> bool: return _top_shadows_external_cached(top, local_root) -@lru_cache(maxsize=None) +@cache def _top_shadows_external_cached(top: str, local_root: Path) -> bool: """Cached core of :func:`_top_level_shadows_external`. diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 247ed00023d..f133c490ace 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1935,9 +1935,7 @@ def test_local_package_not_external( monkeypatch.syspath_prepend(tmp_path) assert not _top_level_shadows_external("mypkg.sub", tmp_path) - def test_iterdir_oserror( - self, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: + def test_iterdir_oserror(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: """When iterdir raises (e.g. PermissionError), fall through gracefully.""" (tmp_path / "test").mkdir() _top_shadows_external_cached.cache_clear() From 83b4bbaa2083905e7ab430f11d452046aeebabbb Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 23:04:48 +0530 Subject: [PATCH 09/11] Fix codecov patch misses: cover PathFinder.find_spec except branch, simplify iterdir mock --- testing/test_pathlib.py | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index f133c490ace..572ed83f90b 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1935,17 +1935,44 @@ def test_local_package_not_external( monkeypatch.syspath_prepend(tmp_path) assert not _top_level_shadows_external("mypkg.sub", tmp_path) + def test_pathfinder_find_spec_raises( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """When PathFinder.find_spec raises after the initial find_spec + returned a local spec, treat as no external shadow (return False).""" + from importlib.machinery import PathFinder + + # Set up a local package so find_spec returns a local spec + # and the code reaches the PathFinder.find_spec call. + pkg = tmp_path / "test" + pkg.mkdir() + (pkg / "__init__.py").touch() + monkeypatch.syspath_prepend(tmp_path) + _top_shadows_external_cached.cache_clear() + + real_pathfinder_find_spec = PathFinder.find_spec + + def _raise(name: str, path: Any = None, target: Any = None) -> Any: + if path is not None: + # The "behind" search — raise to exercise the except branch. + raise ValueError("broken PathFinder") + return real_pathfinder_find_spec(name, path, target) + + monkeypatch.setattr(PathFinder, "find_spec", staticmethod(_raise)) + try: + # With PathFinder broken, the function can't find anything + # behind the local spec → returns False. + assert not _top_level_shadows_external("test.test_demo", tmp_path) + finally: + _top_shadows_external_cached.cache_clear() + def test_iterdir_oserror(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: """When iterdir raises (e.g. PermissionError), fall through gracefully.""" (tmp_path / "test").mkdir() _top_shadows_external_cached.cache_clear() - real_iterdir = Path.iterdir - def _broken_iterdir(self: Path) -> Any: - if self == tmp_path: - raise PermissionError("denied") - return real_iterdir(self) + raise PermissionError("denied") monkeypatch.setattr(Path, "iterdir", _broken_iterdir) # Should still detect the shadow (stdlib test exists externally) From 5a9bfc475f4d8fdae16dbc6a2d4ebf3ea4d74f58 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Fri, 10 Apr 2026 23:32:00 +0530 Subject: [PATCH 10/11] Cover remaining branch misses: _spec_locations False branches and path-1 mod=None fallback Add two targeted tests to reach 100% patch coverage: 1. TestTopLevelShadowsExternal.test_spec_locations_degenerate_behind: Mocks PathFinder.find_spec to return a spec with origin=None and no submodule_search_locations, triggering both False-branches in _spec_locations (lines 864->866 and 866->869). 2. TestImportLibMode.test_importlib_path1_spec_returns_none_fallback: Mocks _import_module_using_spec to return None on the path-1 call, covering the fallback branch (line 557->562) where path-2 retries the import with insert_modules=True. --- testing/test_pathlib.py | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 572ed83f90b..ee98bcd994d 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1102,6 +1102,42 @@ def test_passes(): # Must NOT be imported as "test.test_demo" (would shadow stdlib). assert not mod.__name__.startswith("test.") + def test_importlib_path1_spec_returns_none_fallback( + self, pytester: Pytester, ns_param: bool, monkeypatch: MonkeyPatch + ) -> None: + """When _import_module_using_spec returns None in path 1 (insert_modules=False), + import_path must fall through to path 2 and still import the module (#12303).""" + import _pytest.pathlib as pathlib_module + + pkg = pytester.path / "mypkg" + pkg.mkdir() + (pkg / "__init__.py").touch() + test_file = pkg / "test_core.py" + test_file.write_text("def test(): pass", encoding="ascii") + monkeypatch.syspath_prepend(pytester.path) + + real_fn = pathlib_module._import_module_using_spec + + def _mock( + module_name: str, + path: Any, + module_location: Any, + *, + insert_modules: bool, + ) -> Any: + if module_name == "mypkg.test_core" and not insert_modules: + return None # simulate path-1 spec failure → triggers 557->562 branch + return real_fn(module_name, path, module_location, insert_modules=insert_modules) + + monkeypatch.setattr(pathlib_module, "_import_module_using_spec", _mock) + mod = import_path( + test_file, + mode=ImportMode.importlib, + root=pytester.path, + consider_namespace_packages=ns_param, + ) + assert mod.__name__ == "mypkg.test_core" + def create_installed_doctests_and_tests_dir( self, path: Path, monkeypatch: MonkeyPatch ) -> tuple[Path, Path, Path]: @@ -1983,6 +2019,43 @@ def _broken_iterdir(self: Path) -> Any: finally: _top_shadows_external_cached.cache_clear() + def test_spec_locations_degenerate_behind( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """When the 'behind' spec has no origin and no submodule_search_locations + (degenerate namespace package), _spec_locations returns an empty set so + no genuine external shadow is reported. + + This exercises the False-branch of both + ``if spec.origin is not None:`` and ``if spec.submodule_search_locations:`` + inside _spec_locations (#12303).""" + from importlib.machinery import PathFinder + + test_dir = tmp_path / "test" + test_dir.mkdir() + (test_dir / "__init__.py").touch() + monkeypatch.syspath_prepend(tmp_path) + _top_shadows_external_cached.cache_clear() + + # A spec with no origin and no submodule_search_locations. + degenerate = importlib.machinery.ModuleSpec("test", None, origin=None) + + real_find_spec = PathFinder.find_spec + + def _degenerate_behind( + name: str, path: Any = None, target: Any = None + ) -> Any: + if path is not None: + return degenerate + return real_find_spec(name, path, target) + + monkeypatch.setattr(PathFinder, "find_spec", staticmethod(_degenerate_behind)) + try: + # _spec_locations(behind) == {} → no new external locations → no shadow. + assert not _top_level_shadows_external("test.demo", tmp_path) + finally: + _top_shadows_external_cached.cache_clear() + def validate_namespace_package( pytester: Pytester, paths: Sequence[Path], modules: Sequence[str] From 6d20388c3643f316293fcd2d8c62ea8131d0abe9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:02:41 +0000 Subject: [PATCH 11/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_pathlib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index ee98bcd994d..2c9b04b7782 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -1127,7 +1127,9 @@ def _mock( ) -> Any: if module_name == "mypkg.test_core" and not insert_modules: return None # simulate path-1 spec failure → triggers 557->562 branch - return real_fn(module_name, path, module_location, insert_modules=insert_modules) + return real_fn( + module_name, path, module_location, insert_modules=insert_modules + ) monkeypatch.setattr(pathlib_module, "_import_module_using_spec", _mock) mod = import_path( @@ -2042,9 +2044,7 @@ def test_spec_locations_degenerate_behind( real_find_spec = PathFinder.find_spec - def _degenerate_behind( - name: str, path: Any = None, target: Any = None - ) -> Any: + def _degenerate_behind(name: str, path: Any = None, target: Any = None) -> Any: if path is not None: return degenerate return real_find_spec(name, path, target)