Merge pull request #12043 from bluetech/pyargs-root
Fix collection failures due to permission errors when using `--pyargs`
This commit is contained in:
commit
6ed005161d
|
@ -0,0 +1,3 @@
|
||||||
|
Fixed a regression in pytest 8.0.0 that would cause test collection to fail due to permission errors when using ``--pyargs``.
|
||||||
|
|
||||||
|
This change improves the collection tree for tests specified using ``--pyargs``, see :pull:`12043` for a comparison with pytest 8.0 and <8.
|
|
@ -5,6 +5,7 @@ import dataclasses
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import functools
|
import functools
|
||||||
import importlib
|
import importlib
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
@ -563,7 +564,7 @@ class Session(nodes.Collector):
|
||||||
self._initialpaths: FrozenSet[Path] = frozenset()
|
self._initialpaths: FrozenSet[Path] = frozenset()
|
||||||
self._initialpaths_with_parents: FrozenSet[Path] = frozenset()
|
self._initialpaths_with_parents: FrozenSet[Path] = frozenset()
|
||||||
self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = []
|
self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = []
|
||||||
self._initial_parts: List[Tuple[Path, List[str]]] = []
|
self._initial_parts: List[CollectionArgument] = []
|
||||||
self._collection_cache: Dict[nodes.Collector, CollectReport] = {}
|
self._collection_cache: Dict[nodes.Collector, CollectReport] = {}
|
||||||
self.items: List[nodes.Item] = []
|
self.items: List[nodes.Item] = []
|
||||||
|
|
||||||
|
@ -769,15 +770,15 @@ class Session(nodes.Collector):
|
||||||
initialpaths: List[Path] = []
|
initialpaths: List[Path] = []
|
||||||
initialpaths_with_parents: List[Path] = []
|
initialpaths_with_parents: List[Path] = []
|
||||||
for arg in args:
|
for arg in args:
|
||||||
fspath, parts = resolve_collection_argument(
|
collection_argument = resolve_collection_argument(
|
||||||
self.config.invocation_params.dir,
|
self.config.invocation_params.dir,
|
||||||
arg,
|
arg,
|
||||||
as_pypath=self.config.option.pyargs,
|
as_pypath=self.config.option.pyargs,
|
||||||
)
|
)
|
||||||
self._initial_parts.append((fspath, parts))
|
self._initial_parts.append(collection_argument)
|
||||||
initialpaths.append(fspath)
|
initialpaths.append(collection_argument.path)
|
||||||
initialpaths_with_parents.append(fspath)
|
initialpaths_with_parents.append(collection_argument.path)
|
||||||
initialpaths_with_parents.extend(fspath.parents)
|
initialpaths_with_parents.extend(collection_argument.path.parents)
|
||||||
self._initialpaths = frozenset(initialpaths)
|
self._initialpaths = frozenset(initialpaths)
|
||||||
self._initialpaths_with_parents = frozenset(initialpaths_with_parents)
|
self._initialpaths_with_parents = frozenset(initialpaths_with_parents)
|
||||||
|
|
||||||
|
@ -839,21 +840,35 @@ class Session(nodes.Collector):
|
||||||
|
|
||||||
pm = self.config.pluginmanager
|
pm = self.config.pluginmanager
|
||||||
|
|
||||||
for argpath, names in self._initial_parts:
|
for collection_argument in self._initial_parts:
|
||||||
self.trace("processing argument", (argpath, names))
|
self.trace("processing argument", collection_argument)
|
||||||
self.trace.root.indent += 1
|
self.trace.root.indent += 1
|
||||||
|
|
||||||
|
argpath = collection_argument.path
|
||||||
|
names = collection_argument.parts
|
||||||
|
module_name = collection_argument.module_name
|
||||||
|
|
||||||
# resolve_collection_argument() ensures this.
|
# resolve_collection_argument() ensures this.
|
||||||
if argpath.is_dir():
|
if argpath.is_dir():
|
||||||
assert not names, f"invalid arg {(argpath, names)!r}"
|
assert not names, f"invalid arg {(argpath, names)!r}"
|
||||||
|
|
||||||
# Match the argpath from the root, e.g.
|
paths = [argpath]
|
||||||
|
# Add relevant parents of the path, from the root, e.g.
|
||||||
# /a/b/c.py -> [/, /a, /a/b, /a/b/c.py]
|
# /a/b/c.py -> [/, /a, /a/b, /a/b/c.py]
|
||||||
paths = [*reversed(argpath.parents), argpath]
|
if module_name is None:
|
||||||
# Paths outside of the confcutdir should not be considered, unless
|
# Paths outside of the confcutdir should not be considered.
|
||||||
# it's the argpath itself.
|
for path in argpath.parents:
|
||||||
while len(paths) > 1 and not pm._is_in_confcutdir(paths[0]):
|
if not pm._is_in_confcutdir(path):
|
||||||
paths = paths[1:]
|
break
|
||||||
|
paths.insert(0, path)
|
||||||
|
else:
|
||||||
|
# For --pyargs arguments, only consider paths matching the module
|
||||||
|
# name. Paths beyond the package hierarchy are not included.
|
||||||
|
module_name_parts = module_name.split(".")
|
||||||
|
for i, path in enumerate(argpath.parents, 2):
|
||||||
|
if i > len(module_name_parts) or path.stem != module_name_parts[-i]:
|
||||||
|
break
|
||||||
|
paths.insert(0, path)
|
||||||
|
|
||||||
# Start going over the parts from the root, collecting each level
|
# Start going over the parts from the root, collecting each level
|
||||||
# and discarding all nodes which don't match the level's part.
|
# and discarding all nodes which don't match the level's part.
|
||||||
|
@ -861,7 +876,7 @@ class Session(nodes.Collector):
|
||||||
notfound_collectors = []
|
notfound_collectors = []
|
||||||
work: List[
|
work: List[
|
||||||
Tuple[Union[nodes.Collector, nodes.Item], List[Union[Path, str]]]
|
Tuple[Union[nodes.Collector, nodes.Item], List[Union[Path, str]]]
|
||||||
] = [(self, paths + names)]
|
] = [(self, [*paths, *names])]
|
||||||
while work:
|
while work:
|
||||||
matchnode, matchparts = work.pop()
|
matchnode, matchparts = work.pop()
|
||||||
|
|
||||||
|
@ -953,26 +968,36 @@ class Session(nodes.Collector):
|
||||||
node.ihook.pytest_collectreport(report=rep)
|
node.ihook.pytest_collectreport(report=rep)
|
||||||
|
|
||||||
|
|
||||||
def search_pypath(module_name: str) -> str:
|
def search_pypath(module_name: str) -> Optional[str]:
|
||||||
"""Search sys.path for the given a dotted module name, and return its file system path."""
|
"""Search sys.path for the given a dotted module name, and return its file
|
||||||
|
system path if found."""
|
||||||
try:
|
try:
|
||||||
spec = importlib.util.find_spec(module_name)
|
spec = importlib.util.find_spec(module_name)
|
||||||
# AttributeError: looks like package module, but actually filename
|
# AttributeError: looks like package module, but actually filename
|
||||||
# ImportError: module does not exist
|
# ImportError: module does not exist
|
||||||
# ValueError: not a module name
|
# ValueError: not a module name
|
||||||
except (AttributeError, ImportError, ValueError):
|
except (AttributeError, ImportError, ValueError):
|
||||||
return module_name
|
return None
|
||||||
if spec is None or spec.origin is None or spec.origin == "namespace":
|
if spec is None or spec.origin is None or spec.origin == "namespace":
|
||||||
return module_name
|
return None
|
||||||
elif spec.submodule_search_locations:
|
elif spec.submodule_search_locations:
|
||||||
return os.path.dirname(spec.origin)
|
return os.path.dirname(spec.origin)
|
||||||
else:
|
else:
|
||||||
return spec.origin
|
return spec.origin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class CollectionArgument:
|
||||||
|
"""A resolved collection argument."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
parts: Sequence[str]
|
||||||
|
module_name: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
def resolve_collection_argument(
|
def resolve_collection_argument(
|
||||||
invocation_path: Path, arg: str, *, as_pypath: bool = False
|
invocation_path: Path, arg: str, *, as_pypath: bool = False
|
||||||
) -> Tuple[Path, List[str]]:
|
) -> CollectionArgument:
|
||||||
"""Parse path arguments optionally containing selection parts and return (fspath, names).
|
"""Parse path arguments optionally containing selection parts and return (fspath, names).
|
||||||
|
|
||||||
Command-line arguments can point to files and/or directories, and optionally contain
|
Command-line arguments can point to files and/or directories, and optionally contain
|
||||||
|
@ -980,9 +1005,13 @@ def resolve_collection_argument(
|
||||||
|
|
||||||
"pkg/tests/test_foo.py::TestClass::test_foo"
|
"pkg/tests/test_foo.py::TestClass::test_foo"
|
||||||
|
|
||||||
This function ensures the path exists, and returns a tuple:
|
This function ensures the path exists, and returns a resolved `CollectionArgument`:
|
||||||
|
|
||||||
(Path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"])
|
CollectionArgument(
|
||||||
|
path=Path("/full/path/to/pkg/tests/test_foo.py"),
|
||||||
|
parts=["TestClass", "test_foo"],
|
||||||
|
module_name=None,
|
||||||
|
)
|
||||||
|
|
||||||
When as_pypath is True, expects that the command-line argument actually contains
|
When as_pypath is True, expects that the command-line argument actually contains
|
||||||
module paths instead of file-system paths:
|
module paths instead of file-system paths:
|
||||||
|
@ -990,7 +1019,13 @@ def resolve_collection_argument(
|
||||||
"pkg.tests.test_foo::TestClass::test_foo"
|
"pkg.tests.test_foo::TestClass::test_foo"
|
||||||
|
|
||||||
In which case we search sys.path for a matching module, and then return the *path* to the
|
In which case we search sys.path for a matching module, and then return the *path* to the
|
||||||
found module.
|
found module, which may look like this:
|
||||||
|
|
||||||
|
CollectionArgument(
|
||||||
|
path=Path("/home/u/myvenv/lib/site-packages/pkg/tests/test_foo.py"),
|
||||||
|
parts=["TestClass", "test_foo"],
|
||||||
|
module_name="pkg.tests.test_foo",
|
||||||
|
)
|
||||||
|
|
||||||
If the path doesn't exist, raise UsageError.
|
If the path doesn't exist, raise UsageError.
|
||||||
If the path is a directory and selection parts are present, raise UsageError.
|
If the path is a directory and selection parts are present, raise UsageError.
|
||||||
|
@ -999,8 +1034,12 @@ def resolve_collection_argument(
|
||||||
strpath, *parts = base.split("::")
|
strpath, *parts = base.split("::")
|
||||||
if parts:
|
if parts:
|
||||||
parts[-1] = f"{parts[-1]}{squacket}{rest}"
|
parts[-1] = f"{parts[-1]}{squacket}{rest}"
|
||||||
|
module_name = None
|
||||||
if as_pypath:
|
if as_pypath:
|
||||||
strpath = search_pypath(strpath)
|
pyarg_strpath = search_pypath(strpath)
|
||||||
|
if pyarg_strpath is not None:
|
||||||
|
module_name = strpath
|
||||||
|
strpath = pyarg_strpath
|
||||||
fspath = invocation_path / strpath
|
fspath = invocation_path / strpath
|
||||||
fspath = absolutepath(fspath)
|
fspath = absolutepath(fspath)
|
||||||
if not safe_exists(fspath):
|
if not safe_exists(fspath):
|
||||||
|
@ -1017,4 +1056,8 @@ def resolve_collection_argument(
|
||||||
else "directory argument cannot contain :: selection parts: {arg}"
|
else "directory argument cannot contain :: selection parts: {arg}"
|
||||||
)
|
)
|
||||||
raise UsageError(msg.format(arg=arg))
|
raise UsageError(msg.format(arg=arg))
|
||||||
return fspath, parts
|
return CollectionArgument(
|
||||||
|
path=fspath,
|
||||||
|
parts=parts,
|
||||||
|
module_name=module_name,
|
||||||
|
)
|
||||||
|
|
|
@ -1787,3 +1787,48 @@ def test_collect_short_file_windows(pytester: Pytester) -> None:
|
||||||
test_file.write_text("def test(): pass", encoding="UTF-8")
|
test_file.write_text("def test(): pass", encoding="UTF-8")
|
||||||
result = pytester.runpytest(short_path)
|
result = pytester.runpytest(short_path)
|
||||||
assert result.parseoutcomes() == {"passed": 1}
|
assert result.parseoutcomes() == {"passed": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyargs_collection_tree(pytester: Pytester, monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""When using `--pyargs`, the collection tree of a pyargs collection
|
||||||
|
argument should only include parents in the import path, not up to confcutdir.
|
||||||
|
|
||||||
|
Regression test for #11904.
|
||||||
|
"""
|
||||||
|
site_packages = pytester.path / "venv/lib/site-packages"
|
||||||
|
site_packages.mkdir(parents=True)
|
||||||
|
monkeypatch.syspath_prepend(site_packages)
|
||||||
|
pytester.makepyfile(
|
||||||
|
**{
|
||||||
|
"venv/lib/site-packages/pkg/__init__.py": "",
|
||||||
|
"venv/lib/site-packages/pkg/sub/__init__.py": "",
|
||||||
|
"venv/lib/site-packages/pkg/sub/test_it.py": "def test(): pass",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = pytester.runpytest("--pyargs", "--collect-only", "pkg.sub.test_it")
|
||||||
|
assert result.ret == ExitCode.OK
|
||||||
|
result.stdout.fnmatch_lines(
|
||||||
|
[
|
||||||
|
"<Package venv/lib/site-packages/pkg>",
|
||||||
|
" <Package sub>",
|
||||||
|
" <Module test_it.py>",
|
||||||
|
" <Function test>",
|
||||||
|
],
|
||||||
|
consecutive=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now with an unrelated rootdir with unrelated files.
|
||||||
|
monkeypatch.chdir(tempfile.gettempdir())
|
||||||
|
|
||||||
|
result = pytester.runpytest("--pyargs", "--collect-only", "pkg.sub.test_it")
|
||||||
|
assert result.ret == ExitCode.OK
|
||||||
|
result.stdout.fnmatch_lines(
|
||||||
|
[
|
||||||
|
"<Package *pkg>",
|
||||||
|
" <Package sub>",
|
||||||
|
" <Module test_it.py>",
|
||||||
|
" <Function test>",
|
||||||
|
],
|
||||||
|
consecutive=True,
|
||||||
|
)
|
||||||
|
|
|
@ -8,6 +8,7 @@ from typing import Optional
|
||||||
|
|
||||||
from _pytest.config import ExitCode
|
from _pytest.config import ExitCode
|
||||||
from _pytest.config import UsageError
|
from _pytest.config import UsageError
|
||||||
|
from _pytest.main import CollectionArgument
|
||||||
from _pytest.main import resolve_collection_argument
|
from _pytest.main import resolve_collection_argument
|
||||||
from _pytest.main import validate_basetemp
|
from _pytest.main import validate_basetemp
|
||||||
from _pytest.pytester import Pytester
|
from _pytest.pytester import Pytester
|
||||||
|
@ -133,26 +134,43 @@ class TestResolveCollectionArgument:
|
||||||
|
|
||||||
def test_file(self, invocation_path: Path) -> None:
|
def test_file(self, invocation_path: Path) -> None:
|
||||||
"""File and parts."""
|
"""File and parts."""
|
||||||
assert resolve_collection_argument(invocation_path, "src/pkg/test.py") == (
|
assert resolve_collection_argument(
|
||||||
invocation_path / "src/pkg/test.py",
|
invocation_path, "src/pkg/test.py"
|
||||||
[],
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg/test.py",
|
||||||
|
parts=[],
|
||||||
|
module_name=None,
|
||||||
)
|
)
|
||||||
assert resolve_collection_argument(invocation_path, "src/pkg/test.py::") == (
|
assert resolve_collection_argument(
|
||||||
invocation_path / "src/pkg/test.py",
|
invocation_path, "src/pkg/test.py::"
|
||||||
[""],
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg/test.py",
|
||||||
|
parts=[""],
|
||||||
|
module_name=None,
|
||||||
)
|
)
|
||||||
assert resolve_collection_argument(
|
assert resolve_collection_argument(
|
||||||
invocation_path, "src/pkg/test.py::foo::bar"
|
invocation_path, "src/pkg/test.py::foo::bar"
|
||||||
) == (invocation_path / "src/pkg/test.py", ["foo", "bar"])
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg/test.py",
|
||||||
|
parts=["foo", "bar"],
|
||||||
|
module_name=None,
|
||||||
|
)
|
||||||
assert resolve_collection_argument(
|
assert resolve_collection_argument(
|
||||||
invocation_path, "src/pkg/test.py::foo::bar::"
|
invocation_path, "src/pkg/test.py::foo::bar::"
|
||||||
) == (invocation_path / "src/pkg/test.py", ["foo", "bar", ""])
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg/test.py",
|
||||||
|
parts=["foo", "bar", ""],
|
||||||
|
module_name=None,
|
||||||
|
)
|
||||||
|
|
||||||
def test_dir(self, invocation_path: Path) -> None:
|
def test_dir(self, invocation_path: Path) -> None:
|
||||||
"""Directory and parts."""
|
"""Directory and parts."""
|
||||||
assert resolve_collection_argument(invocation_path, "src/pkg") == (
|
assert resolve_collection_argument(
|
||||||
invocation_path / "src/pkg",
|
invocation_path, "src/pkg"
|
||||||
[],
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg",
|
||||||
|
parts=[],
|
||||||
|
module_name=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
|
@ -169,13 +187,24 @@ class TestResolveCollectionArgument:
|
||||||
"""Dotted name and parts."""
|
"""Dotted name and parts."""
|
||||||
assert resolve_collection_argument(
|
assert resolve_collection_argument(
|
||||||
invocation_path, "pkg.test", as_pypath=True
|
invocation_path, "pkg.test", as_pypath=True
|
||||||
) == (invocation_path / "src/pkg/test.py", [])
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg/test.py",
|
||||||
|
parts=[],
|
||||||
|
module_name="pkg.test",
|
||||||
|
)
|
||||||
assert resolve_collection_argument(
|
assert resolve_collection_argument(
|
||||||
invocation_path, "pkg.test::foo::bar", as_pypath=True
|
invocation_path, "pkg.test::foo::bar", as_pypath=True
|
||||||
) == (invocation_path / "src/pkg/test.py", ["foo", "bar"])
|
) == CollectionArgument(
|
||||||
assert resolve_collection_argument(invocation_path, "pkg", as_pypath=True) == (
|
path=invocation_path / "src/pkg/test.py",
|
||||||
invocation_path / "src/pkg",
|
parts=["foo", "bar"],
|
||||||
[],
|
module_name="pkg.test",
|
||||||
|
)
|
||||||
|
assert resolve_collection_argument(
|
||||||
|
invocation_path, "pkg", as_pypath=True
|
||||||
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg",
|
||||||
|
parts=[],
|
||||||
|
module_name="pkg",
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
|
@ -186,10 +215,13 @@ class TestResolveCollectionArgument:
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_parametrized_name_with_colons(self, invocation_path: Path) -> None:
|
def test_parametrized_name_with_colons(self, invocation_path: Path) -> None:
|
||||||
ret = resolve_collection_argument(
|
assert resolve_collection_argument(
|
||||||
invocation_path, "src/pkg/test.py::test[a::b]"
|
invocation_path, "src/pkg/test.py::test[a::b]"
|
||||||
|
) == CollectionArgument(
|
||||||
|
path=invocation_path / "src/pkg/test.py",
|
||||||
|
parts=["test[a::b]"],
|
||||||
|
module_name=None,
|
||||||
)
|
)
|
||||||
assert ret == (invocation_path / "src/pkg/test.py", ["test[a::b]"])
|
|
||||||
|
|
||||||
def test_does_not_exist(self, invocation_path: Path) -> None:
|
def test_does_not_exist(self, invocation_path: Path) -> None:
|
||||||
"""Given a file/module that does not exist raises UsageError."""
|
"""Given a file/module that does not exist raises UsageError."""
|
||||||
|
@ -209,9 +241,12 @@ class TestResolveCollectionArgument:
|
||||||
def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> None:
|
def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> None:
|
||||||
"""Absolute paths resolve back to absolute paths."""
|
"""Absolute paths resolve back to absolute paths."""
|
||||||
full_path = str(invocation_path / "src")
|
full_path = str(invocation_path / "src")
|
||||||
assert resolve_collection_argument(invocation_path, full_path) == (
|
assert resolve_collection_argument(
|
||||||
Path(os.path.abspath("src")),
|
invocation_path, full_path
|
||||||
[],
|
) == CollectionArgument(
|
||||||
|
path=Path(os.path.abspath("src")),
|
||||||
|
parts=[],
|
||||||
|
module_name=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ensure full paths given in the command-line without the drive letter resolve
|
# ensure full paths given in the command-line without the drive letter resolve
|
||||||
|
@ -219,7 +254,11 @@ class TestResolveCollectionArgument:
|
||||||
drive, full_path_without_drive = os.path.splitdrive(full_path)
|
drive, full_path_without_drive = os.path.splitdrive(full_path)
|
||||||
assert resolve_collection_argument(
|
assert resolve_collection_argument(
|
||||||
invocation_path, full_path_without_drive
|
invocation_path, full_path_without_drive
|
||||||
) == (Path(os.path.abspath("src")), [])
|
) == CollectionArgument(
|
||||||
|
path=Path(os.path.abspath("src")),
|
||||||
|
parts=[],
|
||||||
|
module_name=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_module_full_path_without_drive(pytester: Pytester) -> None:
|
def test_module_full_path_without_drive(pytester: Pytester) -> None:
|
||||||
|
|
Loading…
Reference in New Issue