From e515264eb1ed6505a6b130b278e7496c7bfda152 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Mon, 15 Mar 2021 16:01:58 +0200
Subject: [PATCH 1/5] Remove yet more unnecessary py.path uses

---
 src/_pytest/fixtures.py   |  4 ++--
 src/_pytest/nodes.py      | 17 ++++++++---------
 src/_pytest/python.py     |  2 +-
 testing/python/collect.py | 18 ++++++++----------
 testing/test_nodes.py     |  7 +++----
 5 files changed, 22 insertions(+), 26 deletions(-)

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/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/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/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
 
 

From 6a174afdfe64f89cc1e8399f7c713a0f505080b6 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Mon, 15 Mar 2021 10:15:34 +0200
Subject: [PATCH 2/5] terminal: move startdir attribute to a property that can
 be deprecated

Same as in Config.
---
 src/_pytest/terminal.py | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

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

From ccdadb64ea328d16860e7a68fb6768d5bfa8d5a4 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Mon, 15 Mar 2021 15:42:45 +0200
Subject: [PATCH 3/5] main: add Session.startpath, make Session.startdir a
 property that can be deprecated

Same as in Config.
---
 src/_pytest/main.py | 22 ++++++++++++++++++++--
 1 file changed, 20 insertions(+), 2 deletions(-)

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]

From 202dd9f423b766b54465068acc47d39a571af2f6 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Mon, 15 Mar 2021 16:37:38 +0200
Subject: [PATCH 4/5] pytester: add & use our own copytree instead of py.path's

Fixes the TODO note.
---
 src/_pytest/pathlib.py  | 20 +++++++++++++++++++-
 src/_pytest/pytester.py |  6 ++----
 2 files changed, 21 insertions(+), 5 deletions(-)

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)

From 4690e4c510b170d152551ce796eae94bf81c164f Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Mon, 15 Mar 2021 17:20:55 +0200
Subject: [PATCH 5/5] reports: support any PathLike instead of only Path,
 py.path

The goal is to avoid referring to the legacy py.path.
---
 src/_pytest/reports.py  |  7 +++----
 testing/test_reports.py | 13 ++++++++++---
 2 files changed, 13 insertions(+), 7 deletions(-)

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/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)