Skip to content

Commit 6b8827d

Browse files
dongfangtianyupatchback[bot]
authored andcommitted
Fix KeyError with importlib mode (directories with same name) (#12752)
Directories inside a namespace package with the same name as the namespace package would cause a `KeyError` with `--import-mode=importlib`. Fixes #12592 Co-authored-by: Bruno Oliveira <[email protected]> (cherry picked from commit 6486c3f)
1 parent 72c9c35 commit 6b8827d

File tree

3 files changed

+171
-35
lines changed

3 files changed

+171
-35
lines changed

Diff for: changelog/12592.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed :class:`KeyError` crash when using ``--import-mode=importlib`` in a directory layout where a directory contains a child directory with the same name.

Diff for: src/_pytest/pathlib.py

+97-34
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import fnmatch
1111
from functools import partial
1212
from importlib.machinery import ModuleSpec
13+
from importlib.machinery import PathFinder
1314
import importlib.util
1415
import itertools
1516
import os
@@ -37,8 +38,12 @@
3738
from _pytest.warning_types import PytestWarning
3839

3940

40-
LOCK_TIMEOUT = 60 * 60 * 24 * 3
41+
if sys.version_info < (3, 11):
42+
from importlib._bootstrap_external import _NamespaceLoader as NamespaceLoader
43+
else:
44+
from importlib.machinery import NamespaceLoader
4145

46+
LOCK_TIMEOUT = 60 * 60 * 24 * 3
4247

4348
_AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath)
4449

@@ -611,13 +616,78 @@ def _import_module_using_spec(
611616
module_name: str, module_path: Path, module_location: Path, *, insert_modules: bool
612617
) -> ModuleType | None:
613618
"""
614-
Tries to import a module by its canonical name, path to the .py file, and its
615-
parent location.
619+
Tries to import a module by its canonical name, path, and its parent location.
620+
621+
:param module_name:
622+
The expected module name, will become the key of `sys.modules`.
623+
624+
:param module_path:
625+
The file path of the module, for example `/foo/bar/test_demo.py`.
626+
If module is a package, pass the path to the `__init__.py` of the package.
627+
If module is a namespace package, pass directory path.
628+
629+
:param module_location:
630+
The parent location of the module.
631+
If module is a package, pass the directory containing the `__init__.py` file.
616632
617633
:param insert_modules:
618-
If True, will call insert_missing_modules to create empty intermediate modules
619-
for made-up module names (when importing test files not reachable from sys.path).
634+
If True, will call `insert_missing_modules` to create empty intermediate modules
635+
with made-up module names (when importing test files not reachable from `sys.path`).
636+
637+
Example 1 of parent_module_*:
638+
639+
module_name: "a.b.c.demo"
640+
module_path: Path("a/b/c/demo.py")
641+
module_location: Path("a/b/c/")
642+
if "a.b.c" is package ("a/b/c/__init__.py" exists), then
643+
parent_module_name: "a.b.c"
644+
parent_module_path: Path("a/b/c/__init__.py")
645+
parent_module_location: Path("a/b/c/")
646+
else:
647+
parent_module_name: "a.b.c"
648+
parent_module_path: Path("a/b/c")
649+
parent_module_location: Path("a/b/")
650+
651+
Example 2 of parent_module_*:
652+
653+
module_name: "a.b.c"
654+
module_path: Path("a/b/c/__init__.py")
655+
module_location: Path("a/b/c/")
656+
if "a.b" is package ("a/b/__init__.py" exists), then
657+
parent_module_name: "a.b"
658+
parent_module_path: Path("a/b/__init__.py")
659+
parent_module_location: Path("a/b/")
660+
else:
661+
parent_module_name: "a.b"
662+
parent_module_path: Path("a/b/")
663+
parent_module_location: Path("a/")
620664
"""
665+
# Attempt to import the parent module, seems is our responsibility:
666+
# https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311
667+
parent_module_name, _, name = module_name.rpartition(".")
668+
parent_module: ModuleType | None = None
669+
if parent_module_name:
670+
parent_module = sys.modules.get(parent_module_name)
671+
if parent_module is None:
672+
# Get parent_location based on location, get parent_path based on path.
673+
if module_path.name == "__init__.py":
674+
# If the current module is in a package,
675+
# need to leave the package first and then enter the parent module.
676+
parent_module_path = module_path.parent.parent
677+
else:
678+
parent_module_path = module_path.parent
679+
680+
if (parent_module_path / "__init__.py").is_file():
681+
# If the parent module is a package, loading by __init__.py file.
682+
parent_module_path = parent_module_path / "__init__.py"
683+
684+
parent_module = _import_module_using_spec(
685+
parent_module_name,
686+
parent_module_path,
687+
parent_module_path.parent,
688+
insert_modules=insert_modules,
689+
)
690+
621691
# Checking with sys.meta_path first in case one of its hooks can import this module,
622692
# such as our own assertion-rewrite hook.
623693
for meta_importer in sys.meta_path:
@@ -627,36 +697,18 @@ def _import_module_using_spec(
627697
if spec_matches_module_path(spec, module_path):
628698
break
629699
else:
630-
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
700+
loader = None
701+
if module_path.is_dir():
702+
# The `spec_from_file_location` matches a loader based on the file extension by default.
703+
# For a namespace package, need to manually specify a loader.
704+
loader = NamespaceLoader(name, module_path, PathFinder())
705+
706+
spec = importlib.util.spec_from_file_location(
707+
module_name, str(module_path), loader=loader
708+
)
631709

632710
if spec_matches_module_path(spec, module_path):
633711
assert spec is not None
634-
# Attempt to import the parent module, seems is our responsibility:
635-
# https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311
636-
parent_module_name, _, name = module_name.rpartition(".")
637-
parent_module: ModuleType | None = None
638-
if parent_module_name:
639-
parent_module = sys.modules.get(parent_module_name)
640-
if parent_module is None:
641-
# Find the directory of this module's parent.
642-
parent_dir = (
643-
module_path.parent.parent
644-
if module_path.name == "__init__.py"
645-
else module_path.parent
646-
)
647-
# Consider the parent module path as its __init__.py file, if it has one.
648-
parent_module_path = (
649-
parent_dir / "__init__.py"
650-
if (parent_dir / "__init__.py").is_file()
651-
else parent_dir
652-
)
653-
parent_module = _import_module_using_spec(
654-
parent_module_name,
655-
parent_module_path,
656-
parent_dir,
657-
insert_modules=insert_modules,
658-
)
659-
660712
# Find spec and import this module.
661713
mod = importlib.util.module_from_spec(spec)
662714
sys.modules[module_name] = mod
@@ -675,10 +727,21 @@ def _import_module_using_spec(
675727

676728
def spec_matches_module_path(module_spec: ModuleSpec | None, module_path: Path) -> bool:
677729
"""Return true if the given ModuleSpec can be used to import the given module path."""
678-
if module_spec is None or module_spec.origin is None:
730+
if module_spec is None:
679731
return False
680732

681-
return Path(module_spec.origin) == module_path
733+
if module_spec.origin:
734+
return Path(module_spec.origin) == module_path
735+
736+
# Compare the path with the `module_spec.submodule_Search_Locations` in case
737+
# the module is part of a namespace package.
738+
# https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations
739+
if module_spec.submodule_search_locations: # can be None.
740+
for path in module_spec.submodule_search_locations:
741+
if Path(path) == module_path:
742+
return True
743+
744+
return False
682745

683746

684747
# Implement a special _is_same function on Windows which returns True if the two filenames

Diff for: testing/test_pathlib.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
from typing import Sequence
1818
import unittest.mock
1919

20+
from _pytest.config import ExitCode
2021
from _pytest.monkeypatch import MonkeyPatch
22+
from _pytest.pathlib import _import_module_using_spec
2123
from _pytest.pathlib import bestrelpath
2224
from _pytest.pathlib import commonpath
2325
from _pytest.pathlib import compute_module_name
@@ -36,6 +38,7 @@
3638
from _pytest.pathlib import resolve_package_path
3739
from _pytest.pathlib import resolve_pkg_root_and_module_name
3840
from _pytest.pathlib import safe_exists
41+
from _pytest.pathlib import spec_matches_module_path
3942
from _pytest.pathlib import symlink_or_skip
4043
from _pytest.pathlib import visit
4144
from _pytest.pytester import Pytester
@@ -416,7 +419,7 @@ def test_no_meta_path_found(
416419
del sys.modules[module.__name__]
417420

418421
monkeypatch.setattr(
419-
importlib.util, "spec_from_file_location", lambda *args: None
422+
importlib.util, "spec_from_file_location", lambda *args, **kwargs: None
420423
)
421424
with pytest.raises(ImportError):
422425
import_path(
@@ -780,6 +783,62 @@ def test_insert_missing_modules(
780783
insert_missing_modules(modules, "")
781784
assert modules == {}
782785

786+
@pytest.mark.parametrize("b_is_package", [True, False])
787+
@pytest.mark.parametrize("insert_modules", [True, False])
788+
def test_import_module_using_spec(
789+
self, b_is_package, insert_modules, tmp_path: Path
790+
):
791+
"""
792+
Verify that `_import_module_using_spec` can obtain a spec based on the path, thereby enabling the import.
793+
When importing, not only the target module is imported, but also the parent modules are recursively imported.
794+
"""
795+
file_path = tmp_path / "a/b/c/demo.py"
796+
file_path.parent.mkdir(parents=True)
797+
file_path.write_text("my_name='demo'", encoding="utf-8")
798+
799+
if b_is_package:
800+
(tmp_path / "a/b/__init__.py").write_text(
801+
"my_name='b.__init__'", encoding="utf-8"
802+
)
803+
804+
mod = _import_module_using_spec(
805+
"a.b.c.demo",
806+
file_path,
807+
file_path.parent,
808+
insert_modules=insert_modules,
809+
)
810+
811+
# target module is imported
812+
assert mod is not None
813+
assert spec_matches_module_path(mod.__spec__, file_path) is True
814+
815+
mod_demo = sys.modules["a.b.c.demo"]
816+
assert "demo.py" in str(mod_demo)
817+
assert mod_demo.my_name == "demo" # Imported and available for use
818+
819+
# parent modules are recursively imported.
820+
mod_a = sys.modules["a"]
821+
mod_b = sys.modules["a.b"]
822+
mod_c = sys.modules["a.b.c"]
823+
824+
assert mod_a.b is mod_b
825+
assert mod_a.b.c is mod_c
826+
assert mod_a.b.c.demo is mod_demo
827+
828+
assert "namespace" in str(mod_a).lower()
829+
assert "namespace" in str(mod_c).lower()
830+
831+
# Compatibility package and namespace package.
832+
if b_is_package:
833+
assert "namespace" not in str(mod_b).lower()
834+
assert "__init__.py" in str(mod_b).lower() # Imported __init__.py
835+
assert mod_b.my_name == "b.__init__" # Imported and available for use
836+
837+
else:
838+
assert "namespace" in str(mod_b).lower()
839+
with pytest.raises(AttributeError): # Not imported __init__.py
840+
assert mod_b.my_name
841+
783842
def test_parent_contains_child_module_attribute(
784843
self, monkeypatch: MonkeyPatch, tmp_path: Path
785844
):
@@ -1542,6 +1601,19 @@ def test_full_ns_packages_without_init_files(
15421601
) == (tmp_path / "src/dist2", "ns.a.core.foo.m")
15431602

15441603

1604+
def test_ns_import_same_name_directory_12592(
1605+
tmp_path: Path, pytester: Pytester
1606+
) -> None:
1607+
"""Regression for `--import-mode=importlib` with directory parent and child with same name (#12592)."""
1608+
y_dir = tmp_path / "x/y/y"
1609+
y_dir.mkdir(parents=True)
1610+
test_y = tmp_path / "x/y/test_y.py"
1611+
test_y.write_text("def test(): pass", encoding="UTF-8")
1612+
1613+
result = pytester.runpytest("--import-mode=importlib", test_y)
1614+
assert result.ret == ExitCode.OK
1615+
1616+
15451617
def test_is_importable(pytester: Pytester) -> None:
15461618
pytester.syspathinsert()
15471619

0 commit comments

Comments
 (0)