Skip to content

Commit e58e20a

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

File tree

4 files changed

+232
-90
lines changed

4 files changed

+232
-90
lines changed

src/_pytest/main.py

Lines changed: 89 additions & 31 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_path_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,36 +675,36 @@ 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
678+
# def _tryconvertpyarg(self, x: str) -> str:
679+
# """Convert a dotted module name to path."""
680+
# try:
681+
# spec = importlib.util.find_spec(x)
682+
# # AttributeError: looks like package module, but actually filename
683+
# # ImportError: module does not exist
684+
# # ValueError: not a module name
685+
# except (AttributeError, ImportError, ValueError):
686+
# return x
687+
# if spec is None or spec.origin is None or spec.origin == "namespace":
688+
# return x
689+
# elif spec.submodule_search_locations:
690+
# return os.path.dirname(spec.origin)
691+
# else:
692+
# return spec.origin
693+
#
694+
# def _parsearg(self, arg: str) -> Tuple[py.path.local, List[str]]:
695+
# """Return (fspath, names) tuple after checking the file exists."""
696+
# strpath, *parts = str(arg).split("::")
697+
# if self.config.option.pyargs:
698+
# strpath = self._tryconvertpyarg(strpath)
699+
# fspath = Path(str(self.config.invocation_dir), strpath)
700+
# fspath = absolutepath(fspath)
701+
# if not fspath.exists():
702+
# if self.config.option.pyargs:
703+
# raise UsageError(
704+
# "file or package not found: " + arg + " (missing __init__.py?)"
705+
# )
706+
# raise UsageError("file not found: " + arg)
707+
# return py.path.local(str(fspath)), parts
706708

707709
def matchnodes(
708710
self, matching: Sequence[Union[nodes.Item, nodes.Collector]], names: List[str],
@@ -770,3 +772,59 @@ def genitems(
770772
for subnode in rep.result:
771773
yield from self.genitems(subnode)
772774
node.ihook.pytest_collectreport(report=rep)
775+
776+
777+
def search_pypath(module_name: str) -> str:
778+
"""Search sys.path for the given a dotted module name, and return its file system path."""
779+
try:
780+
spec = importlib.util.find_spec(module_name)
781+
# AttributeError: looks like package module, but actually filename
782+
# ImportError: module does not exist
783+
# ValueError: not a module name
784+
except (AttributeError, ImportError, ValueError):
785+
return module_name
786+
if spec is None or spec.origin is None or spec.origin == "namespace":
787+
return module_name
788+
elif spec.submodule_search_locations:
789+
return os.path.dirname(spec.origin)
790+
else:
791+
return spec.origin
792+
793+
794+
def resolve_path_argument(
795+
invocation_dir: py.path.local, arg: str, *, as_pypath=False
796+
) -> Tuple[py.path.local, List[str]]:
797+
"""Parse path arguments optionally containing selection parts and return (fspath, names).
798+
799+
Command-line arguments can point to files and/or directories, and optionally contain
800+
parts for specific tests selection, for example:
801+
802+
"pkg/tests/test_foo.py::TestClass::test_foo"
803+
804+
This function ensures the path exists, and returns a tuple:
805+
806+
(py.path.path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"])
807+
808+
When as_pypath is True, expects that the command-line argument actually contains
809+
module paths instead of file-system paths:
810+
811+
"pkg.tests.test_foo::TestClass::test_foo"
812+
813+
In which case we search sys.path for a matching module, and then return the *path* to the
814+
found module.
815+
816+
If the path doesn't exist, raise UsageError.
817+
"""
818+
strpath, *parts = str(arg).split("::")
819+
if as_pypath:
820+
strpath = search_pypath(strpath)
821+
fspath = Path(str(invocation_dir), strpath)
822+
fspath = absolutepath(fspath)
823+
if not fspath.exists():
824+
msg = (
825+
"module or package not found: {arg} (missing __init__.py?)"
826+
if as_pypath
827+
else "file or directory not found: {arg}"
828+
)
829+
raise UsageError(msg.format(arg=arg))
830+
return py.path.local(str(fspath)), parts

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: 140 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_path_argument
612
from _pytest.main import validate_basetemp
713
from _pytest.pytester import Testdir
814

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

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)