Skip to content

Commit 9989063

Browse files
nicoddemusbluetech
andauthored
Refine how we detect namespace packages (#12169)
Previously we used a hand crafted approach to detect namespace packages, however we should rely on ``importlib`` to detect them for us. Fix #12112 --------- Co-authored-by: Ran Benita <[email protected]>
1 parent 17fc20a commit 9989063

File tree

4 files changed

+298
-49
lines changed

4 files changed

+298
-49
lines changed

changelog/12112.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve namespace packages detection when :confval:`consider_namespace_packages` is enabled, covering more situations (like editable installs).

doc/en/reference/reference.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,8 +1279,7 @@ passed multiple times. The expected format is ``name=value``. For example::
12791279
Controls if pytest should attempt to identify `namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages>`__
12801280
when collecting Python modules. Default is ``False``.
12811281

1282-
Set to ``True`` if you are testing namespace packages installed into a virtual environment and it is important for
1283-
your packages to be imported using their full namespace package name.
1282+
Set to ``True`` if the package you are testing is part of a namespace package.
12841283

12851284
Only `native namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#native-namespace-packages>`__
12861285
are supported, with no plans to support `legacy namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#legacy-namespace-packages>`__.

src/_pytest/pathlib.py

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from errno import ENOTDIR
99
import fnmatch
1010
from functools import partial
11+
from importlib.machinery import ModuleSpec
1112
import importlib.util
1213
import itertools
1314
import os
@@ -628,11 +629,13 @@ def _import_module_using_spec(
628629
# such as our own assertion-rewrite hook.
629630
for meta_importer in sys.meta_path:
630631
spec = meta_importer.find_spec(module_name, [str(module_location)])
631-
if spec is not None:
632+
if spec_matches_module_path(spec, module_path):
632633
break
633634
else:
634635
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
635-
if spec is not None:
636+
637+
if spec_matches_module_path(spec, module_path):
638+
assert spec is not None
636639
mod = importlib.util.module_from_spec(spec)
637640
sys.modules[module_name] = mod
638641
spec.loader.exec_module(mod) # type: ignore[union-attr]
@@ -643,6 +646,16 @@ def _import_module_using_spec(
643646
return None
644647

645648

649+
def spec_matches_module_path(
650+
module_spec: Optional[ModuleSpec], module_path: Path
651+
) -> bool:
652+
"""Return true if the given ModuleSpec can be used to import the given module path."""
653+
if module_spec is None or module_spec.origin is None:
654+
return False
655+
656+
return Path(module_spec.origin) == module_path
657+
658+
646659
# Implement a special _is_same function on Windows which returns True if the two filenames
647660
# compare equal, to circumvent os.path.samefile returning False for mounts in UNC (#7678).
648661
if sys.platform.startswith("win"):
@@ -762,39 +775,79 @@ def resolve_pkg_root_and_module_name(
762775
Passing the full path to `models.py` will yield Path("src") and "app.core.models".
763776
764777
If consider_namespace_packages is True, then we additionally check upwards in the hierarchy
765-
until we find a directory that is reachable from sys.path, which marks it as a namespace package:
778+
for namespace packages:
766779
767780
https://packaging.python.org/en/latest/guides/packaging-namespace-packages
768781
769782
Raises CouldNotResolvePathError if the given path does not belong to a package (missing any __init__.py files).
770783
"""
784+
pkg_root: Optional[Path] = None
771785
pkg_path = resolve_package_path(path)
772786
if pkg_path is not None:
773787
pkg_root = pkg_path.parent
774-
# https://packaging.python.org/en/latest/guides/packaging-namespace-packages/
775-
if consider_namespace_packages:
776-
# Go upwards in the hierarchy, if we find a parent path included
777-
# in sys.path, it means the package found by resolve_package_path()
778-
# actually belongs to a namespace package.
779-
for parent in pkg_root.parents:
780-
# If any of the parent paths has a __init__.py, it means it is not
781-
# a namespace package (see the docs linked above).
782-
if (parent / "__init__.py").is_file():
783-
break
784-
if str(parent) in sys.path:
785-
# Point the pkg_root to the root of the namespace package.
786-
pkg_root = parent
787-
break
788-
789-
names = list(path.with_suffix("").relative_to(pkg_root).parts)
790-
if names[-1] == "__init__":
791-
names.pop()
792-
module_name = ".".join(names)
793-
return pkg_root, module_name
788+
if consider_namespace_packages:
789+
start = pkg_root if pkg_root is not None else path.parent
790+
for candidate in (start, *start.parents):
791+
module_name = compute_module_name(candidate, path)
792+
if module_name and is_importable(module_name, path):
793+
# Point the pkg_root to the root of the namespace package.
794+
pkg_root = candidate
795+
break
796+
797+
if pkg_root is not None:
798+
module_name = compute_module_name(pkg_root, path)
799+
if module_name:
800+
return pkg_root, module_name
794801

795802
raise CouldNotResolvePathError(f"Could not resolve for {path}")
796803

797804

805+
def is_importable(module_name: str, module_path: Path) -> bool:
806+
"""
807+
Return if the given module path could be imported normally by Python, akin to the user
808+
entering the REPL and importing the corresponding module name directly, and corresponds
809+
to the module_path specified.
810+
811+
:param module_name:
812+
Full module name that we want to check if is importable.
813+
For example, "app.models".
814+
815+
:param module_path:
816+
Full path to the python module/package we want to check if is importable.
817+
For example, "/projects/src/app/models.py".
818+
"""
819+
try:
820+
# Note this is different from what we do in ``_import_module_using_spec``, where we explicitly search through
821+
# sys.meta_path to be able to pass the path of the module that we want to import (``meta_importer.find_spec``).
822+
# Using importlib.util.find_spec() is different, it gives the same results as trying to import
823+
# the module normally in the REPL.
824+
spec = importlib.util.find_spec(module_name)
825+
except (ImportError, ValueError, ImportWarning):
826+
return False
827+
else:
828+
return spec_matches_module_path(spec, module_path)
829+
830+
831+
def compute_module_name(root: Path, module_path: Path) -> Optional[str]:
832+
"""Compute a module name based on a path and a root anchor."""
833+
try:
834+
path_without_suffix = module_path.with_suffix("")
835+
except ValueError:
836+
# Empty paths (such as Path.cwd()) might break meta_path hooks (like our own assertion rewriter).
837+
return None
838+
839+
try:
840+
relative = path_without_suffix.relative_to(root)
841+
except ValueError: # pragma: no cover
842+
return None
843+
names = list(relative.parts)
844+
if not names:
845+
return None
846+
if names[-1] == "__init__":
847+
names.pop()
848+
return ".".join(names)
849+
850+
798851
class CouldNotResolvePathError(Exception):
799852
"""Custom exception raised by resolve_pkg_root_and_module_name."""
800853

0 commit comments

Comments
 (0)