Skip to content

Commit 067daf9

Browse files
committed
pathlib: consider namespace packages in resolve_pkg_root_and_module_name
This applies to `append` and `prepend` import modes; support for `importlib` mode will be added in a separate change.
1 parent 5867426 commit 067daf9

File tree

3 files changed

+172
-2
lines changed

3 files changed

+172
-2
lines changed

changelog/11475.feature.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pytest now correctly identifies modules that are part of `namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages>`__, for example when importing user-level modules for doctesting.
2+
3+
Previously pytest was not aware of namespace packages, so running a doctest from a subpackage that is part of a namespace package would import just the subpackage (for example ``app.models``) instead of its full path (for example ``com.company.app.models``).

src/_pytest/pathlib.py

+26-2
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,9 @@ def import_path(
534534
return mod
535535

536536
try:
537-
pkg_root, module_name = resolve_pkg_root_and_module_name(path)
537+
pkg_root, module_name = resolve_pkg_root_and_module_name(
538+
path, consider_ns_packages=True
539+
)
538540
except CouldNotResolvePathError:
539541
pkg_root, module_name = path.parent, path.stem
540542

@@ -714,7 +716,9 @@ def resolve_package_path(path: Path) -> Optional[Path]:
714716
return result
715717

716718

717-
def resolve_pkg_root_and_module_name(path: Path) -> Tuple[Path, str]:
719+
def resolve_pkg_root_and_module_name(
720+
path: Path, *, consider_ns_packages: bool = False
721+
) -> Tuple[Path, str]:
718722
"""
719723
Return the path to the directory of the root package that contains the
720724
given Python file, and its module name:
@@ -728,11 +732,31 @@ def resolve_pkg_root_and_module_name(path: Path) -> Tuple[Path, str]:
728732
729733
Passing the full path to `models.py` will yield Path("src") and "app.core.models".
730734
735+
If consider_ns_packages is True, then we additionally check upwards in the hierarchy
736+
until we find a directory that is reachable from sys.path, which marks it as a namespace package:
737+
738+
https://packaging.python.org/en/latest/guides/packaging-namespace-packages
739+
731740
Raises CouldNotResolvePathError if the given path does not belong to a package (missing any __init__.py files).
732741
"""
733742
pkg_path = resolve_package_path(path)
734743
if pkg_path is not None:
735744
pkg_root = pkg_path.parent
745+
# https://packaging.python.org/en/latest/guides/packaging-namespace-packages/
746+
if consider_ns_packages:
747+
# Go upwards in the hierarchy, if we find a parent path included
748+
# in sys.path, it means the package found by resolve_package_path()
749+
# actually belongs to a namespace package.
750+
for parent in pkg_root.parents:
751+
# If any of the parent paths has a __init__.py, it means it is not
752+
# a namespace package (see the docs linked above).
753+
if (parent / "__init__.py").is_file():
754+
break
755+
if str(parent) in sys.path:
756+
# Point the pkg_root to the root of the namespace package.
757+
pkg_root = parent
758+
break
759+
736760
names = list(path.with_suffix("").relative_to(pkg_root).parts)
737761
if names[-1] == "__init__":
738762
names.pop()

testing/test_pathlib.py

+143
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from typing import Any
1010
from typing import Generator
1111
from typing import Iterator
12+
from typing import Tuple
1213
import unittest.mock
1314

1415
from _pytest.monkeypatch import MonkeyPatch
1516
from _pytest.pathlib import bestrelpath
1617
from _pytest.pathlib import commonpath
18+
from _pytest.pathlib import CouldNotResolvePathError
1719
from _pytest.pathlib import ensure_deletable
1820
from _pytest.pathlib import fnmatch_ex
1921
from _pytest.pathlib import get_extended_length_path_str
@@ -25,6 +27,7 @@
2527
from _pytest.pathlib import maybe_delete_a_numbered_dir
2628
from _pytest.pathlib import module_name_from_path
2729
from _pytest.pathlib import resolve_package_path
30+
from _pytest.pathlib import resolve_pkg_root_and_module_name
2831
from _pytest.pathlib import safe_exists
2932
from _pytest.pathlib import symlink_or_skip
3033
from _pytest.pathlib import visit
@@ -33,6 +36,20 @@
3336
import pytest
3437

3538

39+
@pytest.fixture(autouse=True)
40+
def autouse_pytester(pytester: Pytester) -> None:
41+
"""
42+
Fixture to make pytester() being autouse for all tests in this module.
43+
44+
pytester makes sure to restore sys.path to its previous state, and many tests in this module
45+
import modules and change sys.path because of that, so common module names such as "test" or "test.conftest"
46+
end up leaking to tests in other modules.
47+
48+
Note: we might consider extracting the sys.path restoration aspect into its own fixture, and apply it
49+
to the entire test suite always.
50+
"""
51+
52+
3653
class TestFNMatcherPort:
3754
"""Test our port of py.common.FNMatcher (fnmatch_ex)."""
3855

@@ -596,6 +613,33 @@ def test_module_name_from_path(self, tmp_path: Path) -> None:
596613
)
597614
assert result == "_env_310.tests.test_foo"
598615

616+
def test_resolve_pkg_root_and_module_name(
617+
self, tmp_path: Path, monkeypatch: MonkeyPatch
618+
) -> None:
619+
# Create a directory structure first without __init__.py files.
620+
(tmp_path / "src/app/core").mkdir(parents=True)
621+
models_py = tmp_path / "src/app/core/models.py"
622+
models_py.touch()
623+
with pytest.raises(CouldNotResolvePathError):
624+
_ = resolve_pkg_root_and_module_name(models_py)
625+
626+
# Create the __init__.py files, it should now resolve to a proper module name.
627+
(tmp_path / "src/app/__init__.py").touch()
628+
(tmp_path / "src/app/core/__init__.py").touch()
629+
assert resolve_pkg_root_and_module_name(models_py) == (
630+
tmp_path / "src",
631+
"app.core.models",
632+
)
633+
634+
# If we add tmp_path to sys.path, src becomes a namespace package.
635+
monkeypatch.syspath_prepend(tmp_path)
636+
assert resolve_pkg_root_and_module_name(
637+
models_py, consider_ns_packages=True
638+
) == (
639+
tmp_path,
640+
"src.app.core.models",
641+
)
642+
599643
def test_insert_missing_modules(
600644
self, monkeypatch: MonkeyPatch, tmp_path: Path
601645
) -> None:
@@ -741,3 +785,102 @@ def test_safe_exists(tmp_path: Path) -> None:
741785
side_effect=ValueError("name too long"),
742786
):
743787
assert safe_exists(p) is False
788+
789+
790+
class TestNamespacePackages:
791+
"""Test import_path support when importing from properly namespace packages."""
792+
793+
def setup_directories(
794+
self, tmp_path: Path, monkeypatch: MonkeyPatch, pytester: Pytester
795+
) -> Tuple[Path, Path]:
796+
# Set up a namespace package "com.company", containing
797+
# two subpackages, "app" and "calc".
798+
(tmp_path / "src/dist1/com/company/app/core").mkdir(parents=True)
799+
(tmp_path / "src/dist1/com/company/app/__init__.py").touch()
800+
(tmp_path / "src/dist1/com/company/app/core/__init__.py").touch()
801+
models_py = tmp_path / "src/dist1/com/company/app/core/models.py"
802+
models_py.touch()
803+
804+
(tmp_path / "src/dist2/com/company/calc/algo").mkdir(parents=True)
805+
(tmp_path / "src/dist2/com/company/calc/__init__.py").touch()
806+
(tmp_path / "src/dist2/com/company/calc/algo/__init__.py").touch()
807+
algorithms_py = tmp_path / "src/dist2/com/company/calc/algo/algorithms.py"
808+
algorithms_py.touch()
809+
810+
# Validate the namespace package by importing it in a Python subprocess.
811+
r = pytester.runpython_c(
812+
dedent(
813+
f"""
814+
import sys
815+
sys.path.append(r{str(tmp_path / "src/dist1")!r})
816+
sys.path.append(r{str(tmp_path / "src/dist2")!r})
817+
import com.company.app.core.models
818+
import com.company.calc.algo.algorithms
819+
"""
820+
)
821+
)
822+
assert r.ret == 0
823+
824+
monkeypatch.syspath_prepend(tmp_path / "src/dist1")
825+
monkeypatch.syspath_prepend(tmp_path / "src/dist2")
826+
return models_py, algorithms_py
827+
828+
@pytest.mark.parametrize("import_mode", ["prepend", "append"])
829+
def test_resolve_pkg_root_and_module_name_ns_multiple_levels(
830+
self,
831+
tmp_path: Path,
832+
monkeypatch: MonkeyPatch,
833+
pytester: Pytester,
834+
import_mode: str,
835+
) -> None:
836+
models_py, algorithms_py = self.setup_directories(
837+
tmp_path, monkeypatch, pytester
838+
)
839+
840+
pkg_root, module_name = resolve_pkg_root_and_module_name(
841+
models_py, consider_ns_packages=True
842+
)
843+
assert (pkg_root, module_name) == (
844+
tmp_path / "src/dist1",
845+
"com.company.app.core.models",
846+
)
847+
848+
mod = import_path(models_py, mode=import_mode, root=tmp_path)
849+
assert mod.__name__ == "com.company.app.core.models"
850+
assert mod.__file__ == str(models_py)
851+
852+
pkg_root, module_name = resolve_pkg_root_and_module_name(
853+
algorithms_py, consider_ns_packages=True
854+
)
855+
assert (pkg_root, module_name) == (
856+
tmp_path / "src/dist2",
857+
"com.company.calc.algo.algorithms",
858+
)
859+
860+
mod = import_path(algorithms_py, mode=import_mode, root=tmp_path)
861+
assert mod.__name__ == "com.company.calc.algo.algorithms"
862+
assert mod.__file__ == str(algorithms_py)
863+
864+
@pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"])
865+
def test_incorrect_namespace_package(
866+
self,
867+
tmp_path: Path,
868+
monkeypatch: MonkeyPatch,
869+
pytester: Pytester,
870+
import_mode: str,
871+
) -> None:
872+
models_py, algorithms_py = self.setup_directories(
873+
tmp_path, monkeypatch, pytester
874+
)
875+
# Namespace packages must not have an __init__.py at any of its
876+
# directories; if it does, we then fall back to importing just the
877+
# part of the package containing the __init__.py files.
878+
(tmp_path / "src/dist1/com/__init__.py").touch()
879+
880+
pkg_root, module_name = resolve_pkg_root_and_module_name(
881+
models_py, consider_ns_packages=True
882+
)
883+
assert (pkg_root, module_name) == (
884+
tmp_path / "src/dist1/com/company",
885+
"app.core.models",
886+
)

0 commit comments

Comments
 (0)