Skip to content

Commit e078e0c

Browse files
karlicosspull[bot]
authored andcommitted
Support PEP420 (implicit namespace packages) as --pyargs target. (pytest-dev#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: - python/cpython#5481 - https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations Fixes pytest-dev#478 Fixes pytest-dev#2371 Fixes pytest-dev#10569
1 parent 2e037d3 commit e078e0c

File tree

5 files changed

+114
-10
lines changed

5 files changed

+114
-10
lines changed

changelog/478.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Support PEP420 (implicit namespace packages) as `--pyargs` target when :confval:`consider_namespace_packages` is `true` in the config.
2+
3+
Previously, this option only impacted package names, now it also impacts tests discovery.

doc/en/reference/reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,6 +1384,7 @@ passed multiple times. The expected format is ``name=value``. For example::
13841384
when collecting Python modules. Default is ``False``.
13851385

13861386
Set to ``True`` if the package you are testing is part of a namespace package.
1387+
Namespace packages are also supported as ``--pyargs`` target.
13871388

13881389
Only `native namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#native-namespace-packages>`__
13891390
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/main.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,9 @@ def perform_collect(
774774
self._collection_cache = {}
775775
self.items = []
776776
items: Sequence[nodes.Item | nodes.Collector] = self.items
777+
consider_namespace_packages: bool = self.config.getini(
778+
"consider_namespace_packages"
779+
)
777780
try:
778781
initialpaths: list[Path] = []
779782
initialpaths_with_parents: list[Path] = []
@@ -782,6 +785,7 @@ def perform_collect(
782785
self.config.invocation_params.dir,
783786
arg,
784787
as_pypath=self.config.option.pyargs,
788+
consider_namespace_packages=consider_namespace_packages,
785789
)
786790
self._initial_parts.append(collection_argument)
787791
initialpaths.append(collection_argument.path)
@@ -981,7 +985,9 @@ def genitems(self, node: nodes.Item | nodes.Collector) -> Iterator[nodes.Item]:
981985
node.ihook.pytest_collectreport(report=rep)
982986

983987

984-
def search_pypath(module_name: str) -> str | None:
988+
def search_pypath(
989+
module_name: str, *, consider_namespace_packages: bool = False
990+
) -> str | None:
985991
"""Search sys.path for the given a dotted module name, and return its file
986992
system path if found."""
987993
try:
@@ -991,13 +997,29 @@ def search_pypath(module_name: str) -> str | None:
991997
# ValueError: not a module name
992998
except (AttributeError, ImportError, ValueError):
993999
return None
994-
if spec is None or spec.origin is None or spec.origin == "namespace":
1000+
1001+
if spec is None:
9951002
return None
996-
elif spec.submodule_search_locations:
997-
return os.path.dirname(spec.origin)
998-
else:
1003+
1004+
if (
1005+
spec.submodule_search_locations is None
1006+
or len(spec.submodule_search_locations) == 0
1007+
):
1008+
# Must be a simple module.
9991009
return spec.origin
10001010

1011+
if consider_namespace_packages:
1012+
# If submodule_search_locations is set, it's a package (regular or namespace).
1013+
# Typically there is a single entry, but documentation claims it can be empty too
1014+
# (e.g. if the package has no physical location).
1015+
return spec.submodule_search_locations[0]
1016+
1017+
if spec.origin is None:
1018+
# This is only the case for namespace packages
1019+
return None
1020+
1021+
return os.path.dirname(spec.origin)
1022+
10011023

10021024
@dataclasses.dataclass(frozen=True)
10031025
class CollectionArgument:
@@ -1009,7 +1031,11 @@ class CollectionArgument:
10091031

10101032

10111033
def resolve_collection_argument(
1012-
invocation_path: Path, arg: str, *, as_pypath: bool = False
1034+
invocation_path: Path,
1035+
arg: str,
1036+
*,
1037+
as_pypath: bool = False,
1038+
consider_namespace_packages: bool = False,
10131039
) -> CollectionArgument:
10141040
"""Parse path arguments optionally containing selection parts and return (fspath, names).
10151041
@@ -1049,7 +1075,9 @@ def resolve_collection_argument(
10491075
parts[-1] = f"{parts[-1]}{squacket}{rest}"
10501076
module_name = None
10511077
if as_pypath:
1052-
pyarg_strpath = search_pypath(strpath)
1078+
pyarg_strpath = search_pypath(
1079+
strpath, consider_namespace_packages=consider_namespace_packages
1080+
)
10531081
if pyarg_strpath is not None:
10541082
module_name = strpath
10551083
strpath = pyarg_strpath

testing/test_collection.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,3 +1935,64 @@ def test_func():
19351935
result = pytester.runpytest()
19361936
assert result.ret == 0
19371937
result.stdout.fnmatch_lines(["*1 passed*"])
1938+
1939+
1940+
@pytest.mark.parametrize("import_mode", ["prepend", "importlib", "append"])
1941+
def test_namespace_packages(pytester: Pytester, import_mode: str):
1942+
pytester.makeini(
1943+
f"""
1944+
[pytest]
1945+
consider_namespace_packages = true
1946+
pythonpath = .
1947+
python_files = *.py
1948+
addopts = --import-mode {import_mode}
1949+
"""
1950+
)
1951+
pytester.makepyfile(
1952+
**{
1953+
"pkg/module1.py": "def test_module1(): pass",
1954+
"pkg/subpkg_namespace/module2.py": "def test_module1(): pass",
1955+
"pkg/subpkg_regular/__init__.py": "",
1956+
"pkg/subpkg_regular/module3": "def test_module3(): pass",
1957+
}
1958+
)
1959+
1960+
# should collect when called with top-level package correctly
1961+
result = pytester.runpytest("--collect-only", "--pyargs", "pkg")
1962+
result.stdout.fnmatch_lines(
1963+
[
1964+
"collected 3 items",
1965+
"<Dir pkg>",
1966+
" <Module module1.py>",
1967+
" <Function test_module1>",
1968+
" <Dir subpkg_namespace>",
1969+
" <Module module2.py>",
1970+
" <Function test_module1>",
1971+
" <Package subpkg_regular>",
1972+
" <Module module3.py>",
1973+
" <Function test_module3>",
1974+
]
1975+
)
1976+
1977+
# should also work when called against a more specific subpackage/module
1978+
result = pytester.runpytest("--collect-only", "--pyargs", "pkg.subpkg_namespace")
1979+
result.stdout.fnmatch_lines(
1980+
[
1981+
"collected 1 item",
1982+
"<Dir pkg>",
1983+
" <Dir subpkg_namespace>",
1984+
" <Module module2.py>",
1985+
" <Function test_module1>",
1986+
]
1987+
)
1988+
1989+
result = pytester.runpytest("--collect-only", "--pyargs", "pkg.subpkg_regular")
1990+
result.stdout.fnmatch_lines(
1991+
[
1992+
"collected 1 item",
1993+
"<Dir pkg>",
1994+
" <Package subpkg_regular>",
1995+
" <Module module3.py>",
1996+
" <Function test_module3>",
1997+
]
1998+
)

testing/test_main.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,13 @@ def test_dir(self, invocation_path: Path) -> None:
169169
):
170170
resolve_collection_argument(invocation_path, "src/pkg::foo::bar")
171171

172-
def test_pypath(self, invocation_path: Path) -> None:
172+
@pytest.mark.parametrize("namespace_package", [False, True])
173+
def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None:
173174
"""Dotted name and parts."""
175+
if namespace_package:
176+
# Namespace package doesn't have to contain __init__py
177+
(invocation_path / "src/pkg/__init__.py").unlink()
178+
174179
assert resolve_collection_argument(
175180
invocation_path, "pkg.test", as_pypath=True
176181
) == CollectionArgument(
@@ -186,7 +191,10 @@ def test_pypath(self, invocation_path: Path) -> None:
186191
module_name="pkg.test",
187192
)
188193
assert resolve_collection_argument(
189-
invocation_path, "pkg", as_pypath=True
194+
invocation_path,
195+
"pkg",
196+
as_pypath=True,
197+
consider_namespace_packages=namespace_package,
190198
) == CollectionArgument(
191199
path=invocation_path / "src/pkg",
192200
parts=[],
@@ -197,7 +205,10 @@ def test_pypath(self, invocation_path: Path) -> None:
197205
UsageError, match=r"package argument cannot contain :: selection parts"
198206
):
199207
resolve_collection_argument(
200-
invocation_path, "pkg::foo::bar", as_pypath=True
208+
invocation_path,
209+
"pkg::foo::bar",
210+
as_pypath=True,
211+
consider_namespace_packages=namespace_package,
201212
)
202213

203214
def test_parametrized_name_with_colons(self, invocation_path: Path) -> None:

0 commit comments

Comments
 (0)