Skip to content

Commit 8a0f1a9

Browse files
committed
Make first import also use specs
1 parent b95d86b commit 8a0f1a9

File tree

3 files changed

+105
-41
lines changed

3 files changed

+105
-41
lines changed

src/_pytest/pathlib.py

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -522,22 +522,19 @@ def import_path(
522522
raise ImportError(path)
523523

524524
if mode is ImportMode.importlib:
525-
# Obtain the canonical name of this path if we can resolve it to a python package,
526-
# and try to import it without changing sys.path.
527-
# If it works, we import it and return the module.
528-
_, module_name = resolve_pkg_root_and_module_name(path)
525+
# Try to import this module using the standard import mechanisms, but
526+
# without touching sys.path.
529527
try:
530-
importlib.import_module(module_name)
531-
except (ImportError, ImportWarning):
532-
# Cannot be imported normally with the current sys.path, so fallback
533-
# to the more complex import-path mechanism.
528+
pkg_root, module_name = resolve_pkg_root_and_module_name(
529+
path, consider_ns_packages=True
530+
)
531+
except ValueError:
534532
pass
535533
else:
536-
# Double check that the imported module is the one we
537-
# were given, otherwise it is easy to import modules with common names like "test"
538-
# from another location.
539-
mod = sys.modules[module_name]
540-
if mod.__file__ and Path(mod.__file__) == path:
534+
mod = _import_module_using_spec(
535+
module_name, path, pkg_root, insert_modules=False
536+
)
537+
if mod is not None:
541538
return mod
542539

543540
# Could not import the module with the current sys.path, so we fall back
@@ -546,22 +543,17 @@ def import_path(
546543
with contextlib.suppress(KeyError):
547544
return sys.modules[module_name]
548545

549-
for meta_importer in sys.meta_path:
550-
spec = meta_importer.find_spec(module_name, [str(path.parent)])
551-
if spec is not None:
552-
break
553-
else:
554-
spec = importlib.util.spec_from_file_location(module_name, str(path))
555-
556-
if spec is None:
546+
mod = _import_module_using_spec(
547+
module_name, path, path.parent, insert_modules=True
548+
)
549+
if mod is None:
557550
raise ImportError(f"Can't find module {module_name} at location {path}")
558-
mod = importlib.util.module_from_spec(spec)
559-
sys.modules[module_name] = mod
560-
spec.loader.exec_module(mod) # type: ignore[union-attr]
561-
insert_missing_modules(sys.modules, module_name)
562551
return mod
563552

564-
pkg_root, module_name = resolve_pkg_root_and_module_name(path)
553+
try:
554+
pkg_root, module_name = resolve_pkg_root_and_module_name(path)
555+
except ValueError:
556+
pkg_root, module_name = path.parent, path.stem
565557

566558
# Change sys.path permanently: restoring it at the end of this function would cause surprising
567559
# problems because of delayed imports: for example, a conftest.py file imported by this function
@@ -603,6 +595,27 @@ def import_path(
603595
return mod
604596

605597

598+
def _import_module_using_spec(
599+
module_name: str, module_path: Path, module_location: Path, *, insert_modules: bool
600+
) -> Optional[ModuleType]:
601+
"""Tries to import a module by its canonical name, path to the .py file and its location."""
602+
for meta_importer in sys.meta_path:
603+
spec = meta_importer.find_spec(module_name, [str(module_location)])
604+
if spec is not None:
605+
break
606+
else:
607+
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
608+
if spec is not None:
609+
mod = importlib.util.module_from_spec(spec)
610+
sys.modules[module_name] = mod
611+
spec.loader.exec_module(mod) # type: ignore[union-attr]
612+
if insert_modules:
613+
insert_missing_modules(sys.modules, module_name)
614+
return mod
615+
616+
return None
617+
618+
606619
# Implement a special _is_same function on Windows which returns True if the two filenames
607620
# compare equal, to circumvent os.path.samefile returning False for mounts in UNC (#7678).
608621
if sys.platform.startswith("win"):
@@ -707,7 +720,9 @@ def resolve_package_path(path: Path) -> Optional[Path]:
707720
return result
708721

709722

710-
def resolve_pkg_root_and_module_name(path: Path) -> Tuple[Path, str]:
723+
def resolve_pkg_root_and_module_name(
724+
path: Path, *, consider_ns_packages: bool = False
725+
) -> Tuple[Path, str]:
711726
"""
712727
Return the path to the directory of the root package that contains the
713728
given Python file, and its module name:
@@ -721,20 +736,30 @@ def resolve_pkg_root_and_module_name(path: Path) -> Tuple[Path, str]:
721736
722737
Passing the full path to `models.py` will yield Path("src") and "app.core.models".
723738
724-
If the given path does not belong to a package (missing __init__.py files), returns
725-
just the parent path and the name of the file as module name.
739+
If consider_ns_packages is True, then we additionally check if the top-level directory
740+
without __init__.py is reachable from sys.path; if it is, it is then considered a namespace package:
741+
742+
https://packaging.python.org/en/latest/guides/packaging-namespace-packages
743+
744+
This is not the default because we need to analyze carefully if it is safe to assume this
745+
for all imports.
746+
747+
Raises ValueError if the given path does not belong to a package (missing any __init__.py files).
726748
"""
727749
pkg_path = resolve_package_path(path)
728750
if pkg_path is not None:
729751
pkg_root = pkg_path.parent
752+
# pkg_root.parent does not contain a __init__.py file, as per resolve_package_path,
753+
# but if it is reachable from sys.argv, it should be considered a namespace package.
754+
# https://packaging.python.org/en/latest/guides/packaging-namespace-packages/
755+
if consider_ns_packages and str(pkg_root.parent) in sys.path:
756+
pkg_root = pkg_root.parent
730757
names = list(path.with_suffix("").relative_to(pkg_root).parts)
731758
if names[-1] == "__init__":
732759
names.pop()
733760
module_name = ".".join(names)
734-
else:
735-
pkg_root = path.parent
736-
module_name = path.stem
737-
return pkg_root, module_name
761+
return pkg_root, module_name
762+
raise ValueError(f"Could not resolve for {path}")
738763

739764

740765
def scandir(

testing/test_doctest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,12 @@ def test_simple_doctestfile(self, pytester: Pytester):
117117
def test_importmode(self, pytester: Pytester):
118118
pytester.makepyfile(
119119
**{
120-
"namespacepkg/innerpkg/__init__.py": "",
121-
"namespacepkg/innerpkg/a.py": """
120+
"src/namespacepkg/innerpkg/__init__.py": "",
121+
"src/namespacepkg/innerpkg/a.py": """
122122
def some_func():
123123
return 42
124124
""",
125-
"namespacepkg/innerpkg/b.py": """
125+
"src/namespacepkg/innerpkg/b.py": """
126126
from namespacepkg.innerpkg.a import some_func
127127
def my_func():
128128
'''
@@ -133,6 +133,10 @@ def my_func():
133133
""",
134134
}
135135
)
136+
# For 'namespacepkg' to be considered a namespace package, it needs to be reachable
137+
# from sys.path:
138+
# https://packaging.python.org/en/latest/guides/packaging-namespace-packages
139+
pytester.syspathinsert(pytester.path / "src")
136140
reprec = pytester.inline_run("--doctest-modules", "--import-mode=importlib")
137141
reprec.assertoutcome(passed=1)
138142

testing/test_pathlib.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -620,15 +620,15 @@ def test_module_name_from_path(self, tmp_path: Path) -> None:
620620
result = module_name_from_path(tmp_path / ".env/tests/test_foo.py", tmp_path)
621621
assert result == "_env.tests.test_foo"
622622

623-
def test_resolve_pkg_root_and_module_name(self, tmp_path: Path) -> None:
623+
def test_resolve_pkg_root_and_module_name(
624+
self, tmp_path: Path, monkeypatch: MonkeyPatch
625+
) -> None:
624626
# Create a directory structure first without __init__.py files.
625627
(tmp_path / "src/app/core").mkdir(parents=True)
626628
models_py = tmp_path / "src/app/core/models.py"
627629
models_py.touch()
628-
assert resolve_pkg_root_and_module_name(models_py) == (
629-
models_py.parent,
630-
"models",
631-
)
630+
with pytest.raises(ValueError):
631+
_ = resolve_pkg_root_and_module_name(models_py)
632632

633633
# Create the __init__.py files, it should now resolve to a proper module name.
634634
(tmp_path / "src/app/__init__.py").touch()
@@ -638,6 +638,15 @@ def test_resolve_pkg_root_and_module_name(self, tmp_path: Path) -> None:
638638
"app.core.models",
639639
)
640640

641+
# If we add tmp_path to sys.path, src becomes a namespace package.
642+
monkeypatch.syspath_prepend(tmp_path)
643+
assert resolve_pkg_root_and_module_name(
644+
models_py, consider_ns_packages=True
645+
) == (
646+
tmp_path,
647+
"src.app.core.models",
648+
)
649+
641650
def test_insert_missing_modules(
642651
self, monkeypatch: MonkeyPatch, tmp_path: Path
643652
) -> None:
@@ -893,6 +902,32 @@ def test_import_using_normal_mechanism_first_integration(
893902
]
894903
)
895904

905+
def test_import_path_imports_correct_file(self, pytester: Pytester) -> None:
906+
"""
907+
Import the module by the given path, even if other module with the same name
908+
is reachable from sys.path.
909+
"""
910+
pytester.syspathinsert()
911+
# Create a 'x.py' module reachable from sys.path that raises AssertionError
912+
# if imported.
913+
x_at_root = pytester.path / "x.py"
914+
x_at_root.write_text("assert False", encoding="ascii")
915+
916+
# Create another x.py module, but in some subdirectories to ensure it is not
917+
# accessible from sys.path.
918+
x_in_sub_folder = pytester.path / "a/b/x.py"
919+
x_in_sub_folder.parent.mkdir(parents=True)
920+
x_in_sub_folder.write_text("X = 'a/b/x'", encoding="ascii")
921+
922+
# Import our x.py module from the subdirectories.
923+
# The 'x.py' module from sys.path was not imported for sure because
924+
# it did not raise an error.
925+
mod = import_path(
926+
x_in_sub_folder, mode=ImportMode.importlib, root=pytester.path
927+
)
928+
assert mod.__file__ and Path(mod.__file__) == x_in_sub_folder
929+
assert mod.X == "a/b/x"
930+
896931

897932
def test_safe_exists(tmp_path: Path) -> None:
898933
d = tmp_path.joinpath("some_dir")

0 commit comments

Comments
 (0)