Support PEP420 (implicit namespace packages) as `--pyargs` target. (#13426)

Previously, when running `--pyargs pkg`, if you didn't have `pkg/__init__.py`, pytest would fail with `ERROR: module or package not found: pkg (missing __init__.py?)`.

If used in conjunction with `consider_namespace_packages` in config, pytest discovers the package and tests inside it correctly.

If used in conjunction with `consider_namespace_packages` in config, test
modules get correct `__package__` and `__name__` attributes as well.

In addition, remove `"namespace"` origin handling -- this value isn't used since python 3.8.
See:
- https://github.com/python/cpython/pull/5481
- https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations

Fixes #478
Fixes #2371
Fixes #10569
This commit is contained in:
karlicoss 2025-07-12 12:03:22 +01:00 committed by GitHub
parent 1d7d63555e
commit c02465cee0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 114 additions and 10 deletions

View File

@ -0,0 +1,3 @@
Support PEP420 (implicit namespace packages) as `--pyargs` target when :confval:`consider_namespace_packages` is `true` in the config.
Previously, this option only impacted package names, now it also impacts tests discovery.

View File

@ -1384,6 +1384,7 @@ passed multiple times. The expected format is ``name=value``. For example::
when collecting Python modules. Default is ``False``.
Set to ``True`` if the package you are testing is part of a namespace package.
Namespace packages are also supported as ``--pyargs`` target.
Only `native namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#native-namespace-packages>`__
are supported, with no plans to support `legacy namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#legacy-namespace-packages>`__.

View File

@ -774,6 +774,9 @@ class Session(nodes.Collector):
self._collection_cache = {}
self.items = []
items: Sequence[nodes.Item | nodes.Collector] = self.items
consider_namespace_packages: bool = self.config.getini(
"consider_namespace_packages"
)
try:
initialpaths: list[Path] = []
initialpaths_with_parents: list[Path] = []
@ -782,6 +785,7 @@ class Session(nodes.Collector):
self.config.invocation_params.dir,
arg,
as_pypath=self.config.option.pyargs,
consider_namespace_packages=consider_namespace_packages,
)
self._initial_parts.append(collection_argument)
initialpaths.append(collection_argument.path)
@ -981,7 +985,9 @@ class Session(nodes.Collector):
node.ihook.pytest_collectreport(report=rep)
def search_pypath(module_name: str) -> str | None:
def search_pypath(
module_name: str, *, consider_namespace_packages: bool = False
) -> str | None:
"""Search sys.path for the given a dotted module name, and return its file
system path if found."""
try:
@ -991,13 +997,29 @@ def search_pypath(module_name: str) -> str | None:
# ValueError: not a module name
except (AttributeError, ImportError, ValueError):
return None
if spec is None or spec.origin is None or spec.origin == "namespace":
if spec is None:
return None
elif spec.submodule_search_locations:
return os.path.dirname(spec.origin)
else:
if (
spec.submodule_search_locations is None
or len(spec.submodule_search_locations) == 0
):
# Must be a simple module.
return spec.origin
if consider_namespace_packages:
# If submodule_search_locations is set, it's a package (regular or namespace).
# Typically there is a single entry, but documentation claims it can be empty too
# (e.g. if the package has no physical location).
return spec.submodule_search_locations[0]
if spec.origin is None:
# This is only the case for namespace packages
return None
return os.path.dirname(spec.origin)
@dataclasses.dataclass(frozen=True)
class CollectionArgument:
@ -1009,7 +1031,11 @@ class CollectionArgument:
def resolve_collection_argument(
invocation_path: Path, arg: str, *, as_pypath: bool = False
invocation_path: Path,
arg: str,
*,
as_pypath: bool = False,
consider_namespace_packages: bool = False,
) -> CollectionArgument:
"""Parse path arguments optionally containing selection parts and return (fspath, names).
@ -1049,7 +1075,9 @@ def resolve_collection_argument(
parts[-1] = f"{parts[-1]}{squacket}{rest}"
module_name = None
if as_pypath:
pyarg_strpath = search_pypath(strpath)
pyarg_strpath = search_pypath(
strpath, consider_namespace_packages=consider_namespace_packages
)
if pyarg_strpath is not None:
module_name = strpath
strpath = pyarg_strpath

View File

@ -1935,3 +1935,64 @@ def test_annotations_deferred_314(pytester: Pytester):
result = pytester.runpytest()
assert result.ret == 0
result.stdout.fnmatch_lines(["*1 passed*"])
@pytest.mark.parametrize("import_mode", ["prepend", "importlib", "append"])
def test_namespace_packages(pytester: Pytester, import_mode: str):
pytester.makeini(
f"""
[pytest]
consider_namespace_packages = true
pythonpath = .
python_files = *.py
addopts = --import-mode {import_mode}
"""
)
pytester.makepyfile(
**{
"pkg/module1.py": "def test_module1(): pass",
"pkg/subpkg_namespace/module2.py": "def test_module1(): pass",
"pkg/subpkg_regular/__init__.py": "",
"pkg/subpkg_regular/module3": "def test_module3(): pass",
}
)
# should collect when called with top-level package correctly
result = pytester.runpytest("--collect-only", "--pyargs", "pkg")
result.stdout.fnmatch_lines(
[
"collected 3 items",
"<Dir pkg>",
" <Module module1.py>",
" <Function test_module1>",
" <Dir subpkg_namespace>",
" <Module module2.py>",
" <Function test_module1>",
" <Package subpkg_regular>",
" <Module module3.py>",
" <Function test_module3>",
]
)
# should also work when called against a more specific subpackage/module
result = pytester.runpytest("--collect-only", "--pyargs", "pkg.subpkg_namespace")
result.stdout.fnmatch_lines(
[
"collected 1 item",
"<Dir pkg>",
" <Dir subpkg_namespace>",
" <Module module2.py>",
" <Function test_module1>",
]
)
result = pytester.runpytest("--collect-only", "--pyargs", "pkg.subpkg_regular")
result.stdout.fnmatch_lines(
[
"collected 1 item",
"<Dir pkg>",
" <Package subpkg_regular>",
" <Module module3.py>",
" <Function test_module3>",
]
)

View File

@ -169,8 +169,13 @@ class TestResolveCollectionArgument:
):
resolve_collection_argument(invocation_path, "src/pkg::foo::bar")
def test_pypath(self, invocation_path: Path) -> None:
@pytest.mark.parametrize("namespace_package", [False, True])
def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None:
"""Dotted name and parts."""
if namespace_package:
# Namespace package doesn't have to contain __init__py
(invocation_path / "src/pkg/__init__.py").unlink()
assert resolve_collection_argument(
invocation_path, "pkg.test", as_pypath=True
) == CollectionArgument(
@ -186,7 +191,10 @@ class TestResolveCollectionArgument:
module_name="pkg.test",
)
assert resolve_collection_argument(
invocation_path, "pkg", as_pypath=True
invocation_path,
"pkg",
as_pypath=True,
consider_namespace_packages=namespace_package,
) == CollectionArgument(
path=invocation_path / "src/pkg",
parts=[],
@ -197,7 +205,10 @@ class TestResolveCollectionArgument:
UsageError, match=r"package argument cannot contain :: selection parts"
):
resolve_collection_argument(
invocation_path, "pkg::foo::bar", as_pypath=True
invocation_path,
"pkg::foo::bar",
as_pypath=True,
consider_namespace_packages=namespace_package,
)
def test_parametrized_name_with_colons(self, invocation_path: Path) -> None: