diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5ff8ba3ca..b0a895a04 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -670,7 +670,7 @@ class FixtureRequest: "\n\nRequested here:\n{}:{}".format( funcitem.nodeid, fixturedef.argname, - getlocation(fixturedef.func, funcitem.config.rootdir), + getlocation(fixturedef.func, funcitem.config.rootpath), source_path_str, source_lineno, ) @@ -728,7 +728,7 @@ class FixtureRequest: fs, lineno = getfslineno(factory) if isinstance(fs, Path): session: Session = self._pyfuncitem.session - p = bestrelpath(Path(session.fspath), fs) + p = bestrelpath(session.path, fs) else: p = fs args = _format_args(factory) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index b6de7a8dd..06cfb1fd5 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -25,6 +25,7 @@ import attr import _pytest._code from _pytest import nodes from _pytest.compat import final +from _pytest.compat import LEGACY_PATH from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.config import directory_arg @@ -301,7 +302,7 @@ def wrap_session( finally: # Explicitly break reference cycle. excinfo = None # type: ignore - session.startdir.chdir() + os.chdir(session.startpath) if initstate >= 2: try: config.hook.pytest_sessionfinish( @@ -476,7 +477,6 @@ class Session(nodes.FSCollector): self.shouldstop: Union[bool, str] = False self.shouldfail: Union[bool, str] = False self.trace = config.trace.root.get("collection") - self.startdir = config.invocation_dir self._initialpaths: FrozenSet[Path] = frozenset() self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) @@ -497,6 +497,24 @@ class Session(nodes.FSCollector): self.testscollected, ) + @property + def startpath(self) -> Path: + """The path from which pytest was invoked. + + .. versionadded:: 6.3.0 + """ + return self.config.invocation_params.dir + + @property + def stardir(self) -> LEGACY_PATH: + """The path from which pytest was invoked. + + Prefer to use ``startpath`` which is a :class:`pathlib.Path`. + + :type: LEGACY_PATH + """ + return legacy_path(self.startpath) + def _node_location_to_relpath(self, node_path: Path) -> str: # bestrelpath is a quite slow function. return self._bestrelpathcache[node_path] diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 0e23c7330..99b7eb1a6 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -32,6 +32,7 @@ from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords from _pytest.outcomes import fail from _pytest.pathlib import absolutepath +from _pytest.pathlib import commonpath from _pytest.store import Store if TYPE_CHECKING: @@ -517,13 +518,11 @@ class Collector(Node): excinfo.traceback = ntraceback.filter() -def _check_initialpaths_for_relpath( - session: "Session", fspath: LEGACY_PATH -) -> Optional[str]: +def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[str]: for initial_path in session._initialpaths: - initial_path_ = legacy_path(initial_path) - if fspath.common(initial_path_) == initial_path_: - return fspath.relto(initial_path_) + if commonpath(path, initial_path) == initial_path: + rel = str(path.relative_to(initial_path)) + return "" if rel == "." else rel return None @@ -538,7 +537,7 @@ class FSCollector(Collector): nodeid: Optional[str] = None, ) -> None: path, fspath = _imply_path(path, fspath=fspath) - name = fspath.basename + name = path.name if parent is not None and parent.path != path: try: rel = path.relative_to(parent.path) @@ -547,7 +546,7 @@ class FSCollector(Collector): else: name = str(rel) name = name.replace(os.sep, SEP) - self.path = Path(fspath) + self.path = path session = session or parent.session @@ -555,7 +554,7 @@ class FSCollector(Collector): try: nodeid = str(self.path.relative_to(session.config.rootpath)) except ValueError: - nodeid = _check_initialpaths_for_relpath(session, fspath) + nodeid = _check_initialpaths_for_relpath(session, path) if nodeid and os.sep != SEP: nodeid = nodeid.replace(os.sep, SEP) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index d3908a3fd..63764b341 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -583,7 +583,7 @@ def resolve_package_path(path: Path) -> Optional[Path]: def visit( - path: str, recurse: Callable[["os.DirEntry[str]"], bool] + path: Union[str, "os.PathLike[str]"], recurse: Callable[["os.DirEntry[str]"], bool] ) -> Iterator["os.DirEntry[str]"]: """Walk a directory recursively, in breadth-first order. @@ -657,3 +657,21 @@ def bestrelpath(directory: Path, dest: Path) -> str: # Forward from base to dest. *reldest.parts, ) + + +# Originates from py. path.local.copy(), with siginficant trims and adjustments. +# TODO(py38): Replace with shutil.copytree(..., symlinks=True, dirs_exist_ok=True) +def copytree(source: Path, target: Path) -> None: + """Recursively copy a source directory to target.""" + assert source.is_dir() + for entry in visit(source, recurse=lambda entry: not entry.is_symlink()): + x = Path(entry) + relpath = x.relative_to(source) + newx = target / relpath + newx.parent.mkdir(exist_ok=True) + if x.is_symlink(): + newx.symlink_to(os.readlink(x)) + elif x.is_file(): + shutil.copyfile(x, newx) + elif x.is_dir(): + newx.mkdir(exist_ok=True) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index febae0785..968a53651 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -63,6 +63,7 @@ from _pytest.outcomes import fail from _pytest.outcomes import importorskip from _pytest.outcomes import skip from _pytest.pathlib import bestrelpath +from _pytest.pathlib import copytree from _pytest.pathlib import make_numbered_dir from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -935,10 +936,7 @@ class Pytester: example_path = example_dir.joinpath(name) if example_path.is_dir() and not example_path.joinpath("__init__.py").is_file(): - # TODO: legacy_path.copy can copy files to existing directories, - # while with shutil.copytree the destination directory cannot exist, - # we will need to roll our own in order to drop legacy_path completely - legacy_path(example_path).copy(legacy_path(self.path)) + copytree(example_path, self.path) return self.path elif example_path.is_file(): result = self.path.joinpath(example_path.name) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 905b40d89..04fbb4570 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -645,7 +645,7 @@ class Package(Module): session=session, nodeid=nodeid, ) - self.name = os.path.basename(str(fspath.dirname)) + self.name = path.parent.name def setup(self) -> None: # Not using fixtures to call setup_module here because autouse fixtures diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 657e06833..b4013f6a2 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -1,5 +1,5 @@ +import os from io import StringIO -from pathlib import Path from pprint import pprint from typing import Any from typing import cast @@ -29,7 +29,6 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import final -from _pytest.compat import LEGACY_PATH from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item @@ -500,8 +499,8 @@ def _report_to_json(report: BaseReport) -> Dict[str, Any]: else: d["longrepr"] = report.longrepr for name in d: - if isinstance(d[name], (LEGACY_PATH, Path)): - d[name] = str(d[name]) + if isinstance(d[name], os.PathLike): + d[name] = os.fspath(d[name]) elif name == "result": d[name] = None # for now return d diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index eea9214e7..2c95113e5 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -37,6 +37,8 @@ from _pytest._code import ExceptionInfo from _pytest._code.code import ExceptionRepr from _pytest._io.wcwidth import wcswidth from _pytest.compat import final +from _pytest.compat import LEGACY_PATH +from _pytest.compat import legacy_path from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config import ExitCode @@ -318,7 +320,6 @@ class TerminalReporter: self.stats: Dict[str, List[Any]] = {} self._main_color: Optional[str] = None self._known_types: Optional[List[str]] = None - self.startdir = config.invocation_dir self.startpath = config.invocation_params.dir if file is None: file = sys.stdout @@ -381,6 +382,16 @@ class TerminalReporter: def showlongtestinfo(self) -> bool: return self.verbosity > 0 + @property + def startdir(self) -> LEGACY_PATH: + """The directory from which pytest was invoked. + + Prefer to use ``startpath`` which is a :class:`pathlib.Path`. + + :type: LEGACY_PATH + """ + return legacy_path(self.startpath) + def hasopt(self, char: str) -> bool: char = {"xfailed": "x", "skipped": "s"}.get(char, char) return char in self.reportchars diff --git a/testing/python/collect.py b/testing/python/collect.py index 633212d95..0edb4452e 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -933,11 +933,11 @@ def test_setup_only_available_in_subdir(pytester: Pytester) -> None: """\ import pytest def pytest_runtest_setup(item): - assert item.fspath.purebasename == "test_in_sub1" + assert item.path.stem == "test_in_sub1" def pytest_runtest_call(item): - assert item.fspath.purebasename == "test_in_sub1" + assert item.path.stem == "test_in_sub1" def pytest_runtest_teardown(item): - assert item.fspath.purebasename == "test_in_sub1" + assert item.path.stem == "test_in_sub1" """ ) ) @@ -946,11 +946,11 @@ def test_setup_only_available_in_subdir(pytester: Pytester) -> None: """\ import pytest def pytest_runtest_setup(item): - assert item.fspath.purebasename == "test_in_sub2" + assert item.path.stem == "test_in_sub2" def pytest_runtest_call(item): - assert item.fspath.purebasename == "test_in_sub2" + assert item.path.stem == "test_in_sub2" def pytest_runtest_teardown(item): - assert item.fspath.purebasename == "test_in_sub2" + assert item.path.stem == "test_in_sub2" """ ) ) @@ -1125,8 +1125,7 @@ class TestReportInfo: def test_func_reportinfo(self, pytester: Pytester) -> None: item = pytester.getitem("def test_func(): pass") fspath, lineno, modpath = item.reportinfo() - with pytest.warns(DeprecationWarning): - assert fspath == item.fspath + assert str(fspath) == str(item.path) assert lineno == 0 assert modpath == "test_func" @@ -1141,8 +1140,7 @@ class TestReportInfo: classcol = pytester.collect_by_name(modcol, "TestClass") assert isinstance(classcol, Class) fspath, lineno, msg = classcol.reportinfo() - with pytest.warns(DeprecationWarning): - assert fspath == modcol.fspath + assert str(fspath) == str(modcol.path) assert lineno == 1 assert msg == "TestClass" diff --git a/testing/test_nodes.py b/testing/test_nodes.py index dde161777..fbdbce395 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -5,7 +5,6 @@ from typing import Type import pytest from _pytest import nodes -from _pytest.compat import legacy_path from _pytest.pytester import Pytester from _pytest.warning_types import PytestWarning @@ -76,7 +75,7 @@ def test__check_initialpaths_for_relpath() -> None: session = cast(pytest.Session, FakeSession1) - assert nodes._check_initialpaths_for_relpath(session, legacy_path(cwd)) == "" + assert nodes._check_initialpaths_for_relpath(session, cwd) == "" sub = cwd / "file" @@ -85,9 +84,9 @@ def test__check_initialpaths_for_relpath() -> None: session = cast(pytest.Session, FakeSession2) - assert nodes._check_initialpaths_for_relpath(session, legacy_path(sub)) == "file" + assert nodes._check_initialpaths_for_relpath(session, sub) == "file" - outside = legacy_path("/outside") + outside = Path("/outside") assert nodes._check_initialpaths_for_relpath(session, outside) is None diff --git a/testing/test_reports.py b/testing/test_reports.py index 3da63c2c8..31b6cf1af 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -4,7 +4,6 @@ from typing import Union import pytest from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionRepr -from _pytest.compat import legacy_path from _pytest.config import Config from _pytest.pytester import Pytester from _pytest.reports import CollectReport @@ -225,18 +224,26 @@ class TestReportSerialization: assert newrep.longrepr == str(rep.longrepr) def test_paths_support(self, pytester: Pytester) -> None: - """Report attributes which are py.path or pathlib objects should become strings.""" + """Report attributes which are path-like should become strings.""" pytester.makepyfile( """ def test_a(): assert False """ ) + + class MyPathLike: + def __init__(self, path: str) -> None: + self.path = path + + def __fspath__(self) -> str: + return self.path + reprec = pytester.inline_run() reports = reprec.getreports("pytest_runtest_logreport") assert len(reports) == 3 test_a_call = reports[1] - test_a_call.path1 = legacy_path(pytester.path) # type: ignore[attr-defined] + test_a_call.path1 = MyPathLike(str(pytester.path)) # type: ignore[attr-defined] test_a_call.path2 = pytester.path # type: ignore[attr-defined] data = test_a_call._to_json() assert data["path1"] == str(pytester.path)