Skip to content

Commit b426bb3

Browse files
committed
Refactor Session._parsearg into a separate function for testing
1 parent 2213016 commit b426bb3

File tree

5 files changed

+206
-94
lines changed

5 files changed

+206
-94
lines changed

src/_pytest/main.py

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,9 @@ def _perform_collect( # noqa: F811
549549
self._initial_parts = [] # type: List[Tuple[py.path.local, List[str]]]
550550
self.items = items = [] # type: List[nodes.Item]
551551
for arg in args:
552-
fspath, parts = self._parsearg(arg)
552+
fspath, parts = resolve_collection_argument(
553+
self.config.invocation_dir, arg, as_pypath=self.config.option.pyargs
554+
)
553555
self._initial_parts.append((fspath, parts))
554556
initialpaths.append(fspath)
555557
self._initialpaths = frozenset(initialpaths)
@@ -673,37 +675,6 @@ def _collect(
673675
return
674676
yield from m
675677

676-
def _tryconvertpyarg(self, x: str) -> str:
677-
"""Convert a dotted module name to path."""
678-
try:
679-
spec = importlib.util.find_spec(x)
680-
# AttributeError: looks like package module, but actually filename
681-
# ImportError: module does not exist
682-
# ValueError: not a module name
683-
except (AttributeError, ImportError, ValueError):
684-
return x
685-
if spec is None or spec.origin is None or spec.origin == "namespace":
686-
return x
687-
elif spec.submodule_search_locations:
688-
return os.path.dirname(spec.origin)
689-
else:
690-
return spec.origin
691-
692-
def _parsearg(self, arg: str) -> Tuple[py.path.local, List[str]]:
693-
"""Return (fspath, names) tuple after checking the file exists."""
694-
strpath, *parts = str(arg).split("::")
695-
if self.config.option.pyargs:
696-
strpath = self._tryconvertpyarg(strpath)
697-
fspath = Path(str(self.config.invocation_dir), strpath)
698-
fspath = absolutepath(fspath)
699-
if not fspath.exists():
700-
if self.config.option.pyargs:
701-
raise UsageError(
702-
"file or package not found: " + arg + " (missing __init__.py?)"
703-
)
704-
raise UsageError("file not found: " + arg)
705-
return py.path.local(str(fspath)), parts
706-
707678
def matchnodes(
708679
self, matching: Sequence[Union[nodes.Item, nodes.Collector]], names: List[str],
709680
) -> Sequence[Union[nodes.Item, nodes.Collector]]:
@@ -770,3 +741,59 @@ def genitems(
770741
for subnode in rep.result:
771742
yield from self.genitems(subnode)
772743
node.ihook.pytest_collectreport(report=rep)
744+
745+
746+
def search_pypath(module_name: str) -> str:
747+
"""Search sys.path for the given a dotted module name, and return its file system path."""
748+
try:
749+
spec = importlib.util.find_spec(module_name)
750+
# AttributeError: looks like package module, but actually filename
751+
# ImportError: module does not exist
752+
# ValueError: not a module name
753+
except (AttributeError, ImportError, ValueError):
754+
return module_name
755+
if spec is None or spec.origin is None or spec.origin == "namespace":
756+
return module_name
757+
elif spec.submodule_search_locations:
758+
return os.path.dirname(spec.origin)
759+
else:
760+
return spec.origin
761+
762+
763+
def resolve_collection_argument(
764+
invocation_dir: py.path.local, arg: str, *, as_pypath: bool = False
765+
) -> Tuple[py.path.local, List[str]]:
766+
"""Parse path arguments optionally containing selection parts and return (fspath, names).
767+
768+
Command-line arguments can point to files and/or directories, and optionally contain
769+
parts for specific tests selection, for example:
770+
771+
"pkg/tests/test_foo.py::TestClass::test_foo"
772+
773+
This function ensures the path exists, and returns a tuple:
774+
775+
(py.path.path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"])
776+
777+
When as_pypath is True, expects that the command-line argument actually contains
778+
module paths instead of file-system paths:
779+
780+
"pkg.tests.test_foo::TestClass::test_foo"
781+
782+
In which case we search sys.path for a matching module, and then return the *path* to the
783+
found module.
784+
785+
If the path doesn't exist, raise UsageError.
786+
"""
787+
strpath, *parts = str(arg).split("::")
788+
if as_pypath:
789+
strpath = search_pypath(strpath)
790+
fspath = Path(str(invocation_dir), strpath)
791+
fspath = absolutepath(fspath)
792+
if not fspath.exists():
793+
msg = (
794+
"module or package not found: {arg} (missing __init__.py?)"
795+
if as_pypath
796+
else "file or directory not found: {arg}"
797+
)
798+
raise UsageError(msg.format(arg=arg))
799+
return py.path.local(str(fspath)), parts

testing/acceptance_test.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def pytest_configure():
7070
def test_file_not_found(self, testdir):
7171
result = testdir.runpytest("asd")
7272
assert result.ret != 0
73-
result.stderr.fnmatch_lines(["ERROR: file not found*asd"])
73+
result.stderr.fnmatch_lines(["ERROR: file or directory not found: asd"])
7474

7575
def test_file_not_found_unconfigure_issue143(self, testdir):
7676
testdir.makeconftest(
@@ -83,7 +83,7 @@ def pytest_unconfigure():
8383
)
8484
result = testdir.runpytest("-s", "asd")
8585
assert result.ret == ExitCode.USAGE_ERROR
86-
result.stderr.fnmatch_lines(["ERROR: file not found*asd"])
86+
result.stderr.fnmatch_lines(["ERROR: file or directory not found: asd"])
8787
result.stdout.fnmatch_lines(["*---configure", "*---unconfigure"])
8888

8989
def test_config_preparse_plugin_option(self, testdir):
@@ -791,7 +791,7 @@ def test_cmdline_python_package_symlink(self, testdir, monkeypatch):
791791
def test_cmdline_python_package_not_exists(self, testdir):
792792
result = testdir.runpytest("--pyargs", "tpkgwhatv")
793793
assert result.ret
794-
result.stderr.fnmatch_lines(["ERROR*file*or*package*not*found*"])
794+
result.stderr.fnmatch_lines(["ERROR*module*or*package*not*found*"])
795795

796796
@pytest.mark.xfail(reason="decide: feature or bug")
797797
def test_noclass_discovery_if_not_testcase(self, testdir):

testing/test_collection.py

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -443,25 +443,6 @@ def pytest_collect_file(path, parent):
443443

444444

445445
class TestSession:
446-
def test_parsearg(self, testdir) -> None:
447-
p = testdir.makepyfile("def test_func(): pass")
448-
subdir = testdir.mkdir("sub")
449-
subdir.ensure("__init__.py")
450-
target = subdir.join(p.basename)
451-
p.move(target)
452-
subdir.chdir()
453-
config = testdir.parseconfig(p.basename)
454-
rcol = Session.from_config(config)
455-
assert rcol.fspath == subdir
456-
fspath, parts = rcol._parsearg(p.basename)
457-
458-
assert fspath == target
459-
assert len(parts) == 0
460-
fspath, parts = rcol._parsearg(p.basename + "::test_func")
461-
assert fspath == target
462-
assert parts[0] == "test_func"
463-
assert len(parts) == 1
464-
465446
def test_collect_topdir(self, testdir):
466447
p = testdir.makepyfile("def test_func(): pass")
467448
id = "::".join([p.basename, "test_func"])
@@ -1426,42 +1407,3 @@ def test_modules_not_importable_as_side_effect(self, testdir):
14261407
"* 1 failed in *",
14271408
]
14281409
)
1429-
1430-
1431-
def test_module_full_path_without_drive(testdir):
1432-
"""Collect and run test using full path except for the drive letter (#7628)
1433-
1434-
Passing a full path without a drive letter would trigger a bug in py.path.local
1435-
where it would keep the full path without the drive letter around, instead of resolving
1436-
to the full path, resulting in fixtures node ids not matching against test node ids correctly.
1437-
"""
1438-
testdir.makepyfile(
1439-
**{
1440-
"project/conftest.py": """
1441-
import pytest
1442-
@pytest.fixture
1443-
def fix(): return 1
1444-
""",
1445-
}
1446-
)
1447-
1448-
testdir.makepyfile(
1449-
**{
1450-
"project/tests/dummy_test.py": """
1451-
def test(fix):
1452-
assert fix == 1
1453-
"""
1454-
}
1455-
)
1456-
fn = testdir.tmpdir.join("project/tests/dummy_test.py")
1457-
assert fn.isfile()
1458-
1459-
drive, path = os.path.splitdrive(str(fn))
1460-
1461-
result = testdir.runpytest(path, "-v")
1462-
result.stdout.fnmatch_lines(
1463-
[
1464-
os.path.join("project", "tests", "dummy_test.py") + "::test PASSED *",
1465-
"* 1 passed in *",
1466-
]
1467-
)

testing/test_main.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import argparse
2+
import os
3+
import re
24
from typing import Optional
35

6+
import py.path
7+
48
import pytest
59
from _pytest.config import ExitCode
10+
from _pytest.config import UsageError
11+
from _pytest.main import resolve_collection_argument
612
from _pytest.main import validate_basetemp
713
from _pytest.pytester import Testdir
814

@@ -98,3 +104,138 @@ def test_validate_basetemp_fails(tmp_path, basetemp, monkeypatch):
98104
def test_validate_basetemp_integration(testdir):
99105
result = testdir.runpytest("--basetemp=.")
100106
result.stderr.fnmatch_lines("*basetemp must not be*")
107+
108+
109+
class TestResolveCollectionArgument:
110+
@pytest.fixture
111+
def root(self, testdir):
112+
testdir.syspathinsert(str(testdir.tmpdir / "src"))
113+
testdir.chdir()
114+
115+
pkg = testdir.tmpdir.join("src/pkg").ensure_dir()
116+
pkg.join("__init__.py").ensure(file=True)
117+
pkg.join("test.py").ensure(file=True)
118+
return testdir.tmpdir
119+
120+
def test_file(self, root):
121+
"""File and parts."""
122+
assert resolve_collection_argument(root, "src/pkg/test.py") == (
123+
root / "src/pkg/test.py",
124+
[],
125+
)
126+
assert resolve_collection_argument(root, "src/pkg/test.py::") == (
127+
root / "src/pkg/test.py",
128+
[""],
129+
)
130+
assert resolve_collection_argument(root, "src/pkg/test.py::foo::bar") == (
131+
root / "src/pkg/test.py",
132+
["foo", "bar"],
133+
)
134+
assert resolve_collection_argument(root, "src/pkg/test.py::foo::bar::") == (
135+
root / "src/pkg/test.py",
136+
["foo", "bar", ""],
137+
)
138+
139+
def test_dir(self, root):
140+
"""Directory and parts."""
141+
assert resolve_collection_argument(root, "src/pkg") == (root / "src/pkg", [])
142+
assert resolve_collection_argument(root, "src/pkg::") == (
143+
root / "src/pkg",
144+
[""],
145+
)
146+
assert resolve_collection_argument(root, "src/pkg::foo::bar") == (
147+
root / "src/pkg",
148+
["foo", "bar"],
149+
)
150+
assert resolve_collection_argument(root, "src/pkg::foo::bar::") == (
151+
root / "src/pkg",
152+
["foo", "bar", ""],
153+
)
154+
155+
def test_pypath(self, root):
156+
"""Dotted name and parts."""
157+
assert resolve_collection_argument(root, "pkg.test", as_pypath=True) == (
158+
root / "src/pkg/test.py",
159+
[],
160+
)
161+
assert resolve_collection_argument(
162+
root, "pkg.test::foo::bar", as_pypath=True
163+
) == (root / "src/pkg/test.py", ["foo", "bar"],)
164+
assert resolve_collection_argument(root, "pkg", as_pypath=True) == (
165+
root / "src/pkg",
166+
[],
167+
)
168+
assert resolve_collection_argument(root, "pkg::foo::bar", as_pypath=True) == (
169+
root / "src/pkg",
170+
["foo", "bar"],
171+
)
172+
173+
def test_does_not_exist(self, root):
174+
"""Given a file/module that does not exist raises UsageError."""
175+
with pytest.raises(
176+
UsageError, match=re.escape("file or directory not found: foobar")
177+
):
178+
resolve_collection_argument(root, "foobar")
179+
180+
with pytest.raises(
181+
UsageError,
182+
match=re.escape(
183+
"module or package not found: foobar (missing __init__.py?)"
184+
),
185+
):
186+
resolve_collection_argument(root, "foobar", as_pypath=True)
187+
188+
def test_absolute_paths_are_resolved_correctly(self, root):
189+
"""Absolute paths resolve back to absolute paths."""
190+
full_path = str(root / "src")
191+
assert resolve_collection_argument(root, full_path) == (
192+
py.path.local(os.path.abspath("src")),
193+
[],
194+
)
195+
196+
# ensure full paths given in the command-line without the drive letter resolve
197+
# to the full path correctly (#7628)
198+
drive, full_path_without_drive = os.path.splitdrive(full_path)
199+
assert resolve_collection_argument(root, full_path_without_drive) == (
200+
py.path.local(os.path.abspath("src")),
201+
[],
202+
)
203+
204+
205+
def test_module_full_path_without_drive(testdir):
206+
"""Collect and run test using full path except for the drive letter (#7628).
207+
208+
Passing a full path without a drive letter would trigger a bug in py.path.local
209+
where it would keep the full path without the drive letter around, instead of resolving
210+
to the full path, resulting in fixtures node ids not matching against test node ids correctly.
211+
"""
212+
testdir.makepyfile(
213+
**{
214+
"project/conftest.py": """
215+
import pytest
216+
@pytest.fixture
217+
def fix(): return 1
218+
""",
219+
}
220+
)
221+
222+
testdir.makepyfile(
223+
**{
224+
"project/tests/dummy_test.py": """
225+
def test(fix):
226+
assert fix == 1
227+
"""
228+
}
229+
)
230+
fn = testdir.tmpdir.join("project/tests/dummy_test.py")
231+
assert fn.isfile()
232+
233+
drive, path = os.path.splitdrive(str(fn))
234+
235+
result = testdir.runpytest(path, "-v")
236+
result.stdout.fnmatch_lines(
237+
[
238+
os.path.join("project", "tests", "dummy_test.py") + "::test PASSED *",
239+
"* 1 passed in *",
240+
]
241+
)

testing/test_terminal.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,9 @@ def test_collectonly_missing_path(self, testdir):
442442
have the items attribute."""
443443
result = testdir.runpytest("--collect-only", "uhm_missing_path")
444444
assert result.ret == 4
445-
result.stderr.fnmatch_lines(["*ERROR: file not found*"])
445+
result.stderr.fnmatch_lines(
446+
["*ERROR: file or directory not found: uhm_missing_path"]
447+
)
446448

447449
def test_collectonly_quiet(self, testdir):
448450
testdir.makepyfile("def test_foo(): pass")

0 commit comments

Comments
 (0)