Skip to content

Commit 6ed0051

Browse files
authored
Merge pull request #12043 from bluetech/pyargs-root
Fix collection failures due to permission errors when using `--pyargs`
2 parents b6bf58a + 31026a2 commit 6ed0051

File tree

4 files changed

+177
-47
lines changed

4 files changed

+177
-47
lines changed

Diff for: changelog/11904.bugfix.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed a regression in pytest 8.0.0 that would cause test collection to fail due to permission errors when using ``--pyargs``.
2+
3+
This change improves the collection tree for tests specified using ``--pyargs``, see :pull:`12043` for a comparison with pytest 8.0 and <8.

Diff for: src/_pytest/main.py

+68-25
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import fnmatch
66
import functools
77
import importlib
8+
import importlib.util
89
import os
910
from pathlib import Path
1011
import sys
@@ -563,7 +564,7 @@ def __init__(self, config: Config) -> None:
563564
self._initialpaths: FrozenSet[Path] = frozenset()
564565
self._initialpaths_with_parents: FrozenSet[Path] = frozenset()
565566
self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = []
566-
self._initial_parts: List[Tuple[Path, List[str]]] = []
567+
self._initial_parts: List[CollectionArgument] = []
567568
self._collection_cache: Dict[nodes.Collector, CollectReport] = {}
568569
self.items: List[nodes.Item] = []
569570

@@ -769,15 +770,15 @@ def perform_collect(
769770
initialpaths: List[Path] = []
770771
initialpaths_with_parents: List[Path] = []
771772
for arg in args:
772-
fspath, parts = resolve_collection_argument(
773+
collection_argument = resolve_collection_argument(
773774
self.config.invocation_params.dir,
774775
arg,
775776
as_pypath=self.config.option.pyargs,
776777
)
777-
self._initial_parts.append((fspath, parts))
778-
initialpaths.append(fspath)
779-
initialpaths_with_parents.append(fspath)
780-
initialpaths_with_parents.extend(fspath.parents)
778+
self._initial_parts.append(collection_argument)
779+
initialpaths.append(collection_argument.path)
780+
initialpaths_with_parents.append(collection_argument.path)
781+
initialpaths_with_parents.extend(collection_argument.path.parents)
781782
self._initialpaths = frozenset(initialpaths)
782783
self._initialpaths_with_parents = frozenset(initialpaths_with_parents)
783784

@@ -839,29 +840,43 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
839840

840841
pm = self.config.pluginmanager
841842

842-
for argpath, names in self._initial_parts:
843-
self.trace("processing argument", (argpath, names))
843+
for collection_argument in self._initial_parts:
844+
self.trace("processing argument", collection_argument)
844845
self.trace.root.indent += 1
845846

847+
argpath = collection_argument.path
848+
names = collection_argument.parts
849+
module_name = collection_argument.module_name
850+
846851
# resolve_collection_argument() ensures this.
847852
if argpath.is_dir():
848853
assert not names, f"invalid arg {(argpath, names)!r}"
849854

850-
# Match the argpath from the root, e.g.
855+
paths = [argpath]
856+
# Add relevant parents of the path, from the root, e.g.
851857
# /a/b/c.py -> [/, /a, /a/b, /a/b/c.py]
852-
paths = [*reversed(argpath.parents), argpath]
853-
# Paths outside of the confcutdir should not be considered, unless
854-
# it's the argpath itself.
855-
while len(paths) > 1 and not pm._is_in_confcutdir(paths[0]):
856-
paths = paths[1:]
858+
if module_name is None:
859+
# Paths outside of the confcutdir should not be considered.
860+
for path in argpath.parents:
861+
if not pm._is_in_confcutdir(path):
862+
break
863+
paths.insert(0, path)
864+
else:
865+
# For --pyargs arguments, only consider paths matching the module
866+
# name. Paths beyond the package hierarchy are not included.
867+
module_name_parts = module_name.split(".")
868+
for i, path in enumerate(argpath.parents, 2):
869+
if i > len(module_name_parts) or path.stem != module_name_parts[-i]:
870+
break
871+
paths.insert(0, path)
857872

858873
# Start going over the parts from the root, collecting each level
859874
# and discarding all nodes which don't match the level's part.
860875
any_matched_in_initial_part = False
861876
notfound_collectors = []
862877
work: List[
863878
Tuple[Union[nodes.Collector, nodes.Item], List[Union[Path, str]]]
864-
] = [(self, paths + names)]
879+
] = [(self, [*paths, *names])]
865880
while work:
866881
matchnode, matchparts = work.pop()
867882

@@ -953,44 +968,64 @@ def genitems(
953968
node.ihook.pytest_collectreport(report=rep)
954969

955970

956-
def search_pypath(module_name: str) -> str:
957-
"""Search sys.path for the given a dotted module name, and return its file system path."""
971+
def search_pypath(module_name: str) -> Optional[str]:
972+
"""Search sys.path for the given a dotted module name, and return its file
973+
system path if found."""
958974
try:
959975
spec = importlib.util.find_spec(module_name)
960976
# AttributeError: looks like package module, but actually filename
961977
# ImportError: module does not exist
962978
# ValueError: not a module name
963979
except (AttributeError, ImportError, ValueError):
964-
return module_name
980+
return None
965981
if spec is None or spec.origin is None or spec.origin == "namespace":
966-
return module_name
982+
return None
967983
elif spec.submodule_search_locations:
968984
return os.path.dirname(spec.origin)
969985
else:
970986
return spec.origin
971987

972988

989+
@dataclasses.dataclass(frozen=True)
990+
class CollectionArgument:
991+
"""A resolved collection argument."""
992+
993+
path: Path
994+
parts: Sequence[str]
995+
module_name: Optional[str]
996+
997+
973998
def resolve_collection_argument(
974999
invocation_path: Path, arg: str, *, as_pypath: bool = False
975-
) -> Tuple[Path, List[str]]:
1000+
) -> CollectionArgument:
9761001
"""Parse path arguments optionally containing selection parts and return (fspath, names).
9771002
9781003
Command-line arguments can point to files and/or directories, and optionally contain
9791004
parts for specific tests selection, for example:
9801005
9811006
"pkg/tests/test_foo.py::TestClass::test_foo"
9821007
983-
This function ensures the path exists, and returns a tuple:
1008+
This function ensures the path exists, and returns a resolved `CollectionArgument`:
9841009
985-
(Path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"])
1010+
CollectionArgument(
1011+
path=Path("/full/path/to/pkg/tests/test_foo.py"),
1012+
parts=["TestClass", "test_foo"],
1013+
module_name=None,
1014+
)
9861015
9871016
When as_pypath is True, expects that the command-line argument actually contains
9881017
module paths instead of file-system paths:
9891018
9901019
"pkg.tests.test_foo::TestClass::test_foo"
9911020
9921021
In which case we search sys.path for a matching module, and then return the *path* to the
993-
found module.
1022+
found module, which may look like this:
1023+
1024+
CollectionArgument(
1025+
path=Path("/home/u/myvenv/lib/site-packages/pkg/tests/test_foo.py"),
1026+
parts=["TestClass", "test_foo"],
1027+
module_name="pkg.tests.test_foo",
1028+
)
9941029
9951030
If the path doesn't exist, raise UsageError.
9961031
If the path is a directory and selection parts are present, raise UsageError.
@@ -999,8 +1034,12 @@ def resolve_collection_argument(
9991034
strpath, *parts = base.split("::")
10001035
if parts:
10011036
parts[-1] = f"{parts[-1]}{squacket}{rest}"
1037+
module_name = None
10021038
if as_pypath:
1003-
strpath = search_pypath(strpath)
1039+
pyarg_strpath = search_pypath(strpath)
1040+
if pyarg_strpath is not None:
1041+
module_name = strpath
1042+
strpath = pyarg_strpath
10041043
fspath = invocation_path / strpath
10051044
fspath = absolutepath(fspath)
10061045
if not safe_exists(fspath):
@@ -1017,4 +1056,8 @@ def resolve_collection_argument(
10171056
else "directory argument cannot contain :: selection parts: {arg}"
10181057
)
10191058
raise UsageError(msg.format(arg=arg))
1020-
return fspath, parts
1059+
return CollectionArgument(
1060+
path=fspath,
1061+
parts=parts,
1062+
module_name=module_name,
1063+
)

Diff for: testing/test_collection.py

+45
Original file line numberDiff line numberDiff line change
@@ -1787,3 +1787,48 @@ def test_collect_short_file_windows(pytester: Pytester) -> None:
17871787
test_file.write_text("def test(): pass", encoding="UTF-8")
17881788
result = pytester.runpytest(short_path)
17891789
assert result.parseoutcomes() == {"passed": 1}
1790+
1791+
1792+
def test_pyargs_collection_tree(pytester: Pytester, monkeypatch: MonkeyPatch) -> None:
1793+
"""When using `--pyargs`, the collection tree of a pyargs collection
1794+
argument should only include parents in the import path, not up to confcutdir.
1795+
1796+
Regression test for #11904.
1797+
"""
1798+
site_packages = pytester.path / "venv/lib/site-packages"
1799+
site_packages.mkdir(parents=True)
1800+
monkeypatch.syspath_prepend(site_packages)
1801+
pytester.makepyfile(
1802+
**{
1803+
"venv/lib/site-packages/pkg/__init__.py": "",
1804+
"venv/lib/site-packages/pkg/sub/__init__.py": "",
1805+
"venv/lib/site-packages/pkg/sub/test_it.py": "def test(): pass",
1806+
}
1807+
)
1808+
1809+
result = pytester.runpytest("--pyargs", "--collect-only", "pkg.sub.test_it")
1810+
assert result.ret == ExitCode.OK
1811+
result.stdout.fnmatch_lines(
1812+
[
1813+
"<Package venv/lib/site-packages/pkg>",
1814+
" <Package sub>",
1815+
" <Module test_it.py>",
1816+
" <Function test>",
1817+
],
1818+
consecutive=True,
1819+
)
1820+
1821+
# Now with an unrelated rootdir with unrelated files.
1822+
monkeypatch.chdir(tempfile.gettempdir())
1823+
1824+
result = pytester.runpytest("--pyargs", "--collect-only", "pkg.sub.test_it")
1825+
assert result.ret == ExitCode.OK
1826+
result.stdout.fnmatch_lines(
1827+
[
1828+
"<Package *pkg>",
1829+
" <Package sub>",
1830+
" <Module test_it.py>",
1831+
" <Function test>",
1832+
],
1833+
consecutive=True,
1834+
)

Diff for: testing/test_main.py

+61-22
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from _pytest.config import ExitCode
1010
from _pytest.config import UsageError
11+
from _pytest.main import CollectionArgument
1112
from _pytest.main import resolve_collection_argument
1213
from _pytest.main import validate_basetemp
1314
from _pytest.pytester import Pytester
@@ -133,26 +134,43 @@ def invocation_path(self, pytester: Pytester) -> Path:
133134

134135
def test_file(self, invocation_path: Path) -> None:
135136
"""File and parts."""
136-
assert resolve_collection_argument(invocation_path, "src/pkg/test.py") == (
137-
invocation_path / "src/pkg/test.py",
138-
[],
137+
assert resolve_collection_argument(
138+
invocation_path, "src/pkg/test.py"
139+
) == CollectionArgument(
140+
path=invocation_path / "src/pkg/test.py",
141+
parts=[],
142+
module_name=None,
139143
)
140-
assert resolve_collection_argument(invocation_path, "src/pkg/test.py::") == (
141-
invocation_path / "src/pkg/test.py",
142-
[""],
144+
assert resolve_collection_argument(
145+
invocation_path, "src/pkg/test.py::"
146+
) == CollectionArgument(
147+
path=invocation_path / "src/pkg/test.py",
148+
parts=[""],
149+
module_name=None,
143150
)
144151
assert resolve_collection_argument(
145152
invocation_path, "src/pkg/test.py::foo::bar"
146-
) == (invocation_path / "src/pkg/test.py", ["foo", "bar"])
153+
) == CollectionArgument(
154+
path=invocation_path / "src/pkg/test.py",
155+
parts=["foo", "bar"],
156+
module_name=None,
157+
)
147158
assert resolve_collection_argument(
148159
invocation_path, "src/pkg/test.py::foo::bar::"
149-
) == (invocation_path / "src/pkg/test.py", ["foo", "bar", ""])
160+
) == CollectionArgument(
161+
path=invocation_path / "src/pkg/test.py",
162+
parts=["foo", "bar", ""],
163+
module_name=None,
164+
)
150165

151166
def test_dir(self, invocation_path: Path) -> None:
152167
"""Directory and parts."""
153-
assert resolve_collection_argument(invocation_path, "src/pkg") == (
154-
invocation_path / "src/pkg",
155-
[],
168+
assert resolve_collection_argument(
169+
invocation_path, "src/pkg"
170+
) == CollectionArgument(
171+
path=invocation_path / "src/pkg",
172+
parts=[],
173+
module_name=None,
156174
)
157175

158176
with pytest.raises(
@@ -169,13 +187,24 @@ def test_pypath(self, invocation_path: Path) -> None:
169187
"""Dotted name and parts."""
170188
assert resolve_collection_argument(
171189
invocation_path, "pkg.test", as_pypath=True
172-
) == (invocation_path / "src/pkg/test.py", [])
190+
) == CollectionArgument(
191+
path=invocation_path / "src/pkg/test.py",
192+
parts=[],
193+
module_name="pkg.test",
194+
)
173195
assert resolve_collection_argument(
174196
invocation_path, "pkg.test::foo::bar", as_pypath=True
175-
) == (invocation_path / "src/pkg/test.py", ["foo", "bar"])
176-
assert resolve_collection_argument(invocation_path, "pkg", as_pypath=True) == (
177-
invocation_path / "src/pkg",
178-
[],
197+
) == CollectionArgument(
198+
path=invocation_path / "src/pkg/test.py",
199+
parts=["foo", "bar"],
200+
module_name="pkg.test",
201+
)
202+
assert resolve_collection_argument(
203+
invocation_path, "pkg", as_pypath=True
204+
) == CollectionArgument(
205+
path=invocation_path / "src/pkg",
206+
parts=[],
207+
module_name="pkg",
179208
)
180209

181210
with pytest.raises(
@@ -186,10 +215,13 @@ def test_pypath(self, invocation_path: Path) -> None:
186215
)
187216

188217
def test_parametrized_name_with_colons(self, invocation_path: Path) -> None:
189-
ret = resolve_collection_argument(
218+
assert resolve_collection_argument(
190219
invocation_path, "src/pkg/test.py::test[a::b]"
220+
) == CollectionArgument(
221+
path=invocation_path / "src/pkg/test.py",
222+
parts=["test[a::b]"],
223+
module_name=None,
191224
)
192-
assert ret == (invocation_path / "src/pkg/test.py", ["test[a::b]"])
193225

194226
def test_does_not_exist(self, invocation_path: Path) -> None:
195227
"""Given a file/module that does not exist raises UsageError."""
@@ -209,17 +241,24 @@ def test_does_not_exist(self, invocation_path: Path) -> None:
209241
def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> None:
210242
"""Absolute paths resolve back to absolute paths."""
211243
full_path = str(invocation_path / "src")
212-
assert resolve_collection_argument(invocation_path, full_path) == (
213-
Path(os.path.abspath("src")),
214-
[],
244+
assert resolve_collection_argument(
245+
invocation_path, full_path
246+
) == CollectionArgument(
247+
path=Path(os.path.abspath("src")),
248+
parts=[],
249+
module_name=None,
215250
)
216251

217252
# ensure full paths given in the command-line without the drive letter resolve
218253
# to the full path correctly (#7628)
219254
drive, full_path_without_drive = os.path.splitdrive(full_path)
220255
assert resolve_collection_argument(
221256
invocation_path, full_path_without_drive
222-
) == (Path(os.path.abspath("src")), [])
257+
) == CollectionArgument(
258+
path=Path(os.path.abspath("src")),
259+
parts=[],
260+
module_name=None,
261+
)
223262

224263

225264
def test_module_full_path_without_drive(pytester: Pytester) -> None:

0 commit comments

Comments
 (0)