Repository: wntrblm/nox (python)

Issue:
Remove usage of py.path?
Currently, there is a dependency on `py.path`, which pytest is trying to remove, and it seems like nox should remove it too, now that Python 3 is required (since quite a while, actually). py.path is not recommended for use: https://py.readthedocs.io/en/latest/path.html. This would reduce a dependency, too!

Looking through the usages, I see `py.path.local.sysfind`, which with a little effort could be replaced by Python 3's [shutil.which](https://docs.python.org/3/library/shutil.html#shutil.which). I see `as_cwd`, which could be handled pretty easily (ideally looking like the [3.11 context addition](https://docs.python.org/3.11/library/contextlib.html#contextlib.chdir)). That seems to be it for non-test usages.

Pull request description:
refactor: Drop `py` as a dependency
closes #583 

I picked up @henryiii's excellent work on #592, resolved the conflicts and the issue he described with `shutil.which` expecting a `.exe`.

This should hopefully be everything needed to drop `py.path`, @henryiii I'd be grateful if you could check this out to see if I've solved the issue adequately as you have more of the context than me 🙂 

Patch:
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 918f829..9189189 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -329,7 +329,7 @@ You can also pass the notified session positional arguments:
     def consume_thing(session):
         # The 'consume' command has the arguments
         # sent to it from the 'prepare_thing' session
-        session.run("consume", "thing", session.posargs)
+        session.run("consume", "thing", *session.posargs)
 
 Note that this will only have the desired effect if selecting sessions to run via the ``--session/-s`` flag. If you simply run ``nox``, all selected sessions will be run.
 
diff --git a/nox/command.py b/nox/command.py
index 8a55c1f..1034366 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -16,11 +16,10 @@ from __future__ import annotations
 
 import os
 import shlex
+import shutil
 import sys
 from typing import Any, Iterable, Sequence
 
-import py
-
 from nox.logger import logger
 from nox.popen import popen
 
@@ -40,18 +39,14 @@ class CommandFailed(Exception):
 
 def which(program: str, paths: list[str] | None) -> str:
     """Finds the full path to an executable."""
-    full_path = None
-
-    if paths:
-        full_path = py.path.local.sysfind(program, paths=paths)
-
-    if full_path:
-        return full_path.strpath  # type: ignore[no-any-return]
-
-    full_path = py.path.local.sysfind(program)
+    if paths is not None:
+        full_path = shutil.which(program, path=os.pathsep.join(paths))
+        if full_path:
+            return full_path
 
+    full_path = shutil.which(program)
     if full_path:
-        return full_path.strpath  # type: ignore[no-any-return]
+        return full_path
 
     logger.error(f"Program {program} not found.")
     raise CommandFailed(f"Program {program} not found")
@@ -71,8 +66,7 @@ def _clean_env(env: dict[str, str] | None) -> dict[str, str] | None:
 
 
 def _shlex_join(args: Sequence[str]) -> str:
-    # shlex.join() was added in Python 3.8
-    return " ".join(shlex.quote(arg) for arg in args)
+    return " ".join(shlex.quote(os.fspath(arg)) for arg in args)
 
 
 def run(
diff --git a/nox/sessions.py b/nox/sessions.py
index bae3074..483c34d 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -15,6 +15,7 @@
 from __future__ import annotations
 
 import argparse
+import contextlib
 import enum
 import hashlib
 import os
@@ -23,9 +24,7 @@ import re
 import sys
 import unicodedata
 from types import TracebackType
-from typing import Any, Callable, Iterable, Mapping, Sequence
-
-import py
+from typing import Any, Callable, Generator, Iterable, Mapping, Sequence
 
 import nox.command
 from nox import _typing
@@ -37,6 +36,20 @@ if _typing.TYPE_CHECKING:
     from nox.manifest import Manifest
 
 
+@contextlib.contextmanager
+def _chdir(path: str) -> Generator[None, None, None]:
+    """
+    Change the current working directory to the given path.
+    Follows python 3.11's chdir behaviour.
+    """
+    cwd = os.getcwd()
+    try:
+        os.chdir(path)
+        yield
+    finally:
+        os.chdir(cwd)
+
+
 def _normalize_path(envdir: str, path: str | bytes) -> str:
     """Normalizes a string to be a "safe" filesystem path for a virtualenv."""
     if isinstance(path, bytes):
@@ -305,6 +318,16 @@ class Session:
                # Display coverage report even when tests fail.
                session.run("coverage", "report")
 
+        If you pass ``silent=True``, you can capture the output of a command that would
+        otherwise be shown to the user. For example to get the current Git commit ID::
+
+            out = session.run(
+                "git", "rev-parse", "--short", "HEAD",
+                external=True, silent=True
+            )
+
+            print("Current Git commit is", out.strip())
+
         :param env: A dictionary of environment variables to expose to the
             command. By default, all environment variables are passed.
         :type env: dict or None
@@ -330,6 +353,12 @@ class Session:
             them. Set to ``None`` to never send a kill signal.
             Default: ``0.2``
         :type terminate_timeout: float or None
+        :param stdout: Redirect standard output of the command into a file. Can't be
+            combined with *silent*.
+        :type stdout: file or file descriptor
+        :param stderr: Redirect standard output of the command into a file. Can't be
+            combined with *silent*.
+        :type stderr: file or file descriptor
         """
         if not args:
             raise ValueError("At least one argument required to run().")
@@ -717,13 +746,9 @@ class SessionRunner:
         logger.warning(f"Running session {self.friendly_name}")
 
         try:
-            # By default, Nox should quietly change to the directory where
-            # the noxfile.py file is located.
-            cwd = py.path.local(
-                os.path.realpath(os.path.dirname(self.global_config.noxfile))
-            ).as_cwd()
+            cwd = os.path.realpath(os.path.dirname(self.global_config.noxfile))
 
-            with cwd:
+            with _chdir(cwd):
                 self._create_venv()
                 session = Session(self)
                 session.env["NOX_CURRENT_SESSION"] = session.name
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 8c5b05c..07fae2b 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -18,18 +18,16 @@ import os
 import platform
 import re
 import shutil
+import subprocess
 import sys
 from socket import gethostbyname
 from typing import Any, Mapping
 
-import py
-
 import nox
 import nox.command
+from nox import _typing
 from nox.logger import logger
 
-from . import _typing
-
 # Problematic environment variables that are stripped from all commands inside
 # of a virtualenv. See https://github.com/theacodes/nox/issues/44
 _BLACKLISTED_ENV_VARS = frozenset(
@@ -109,12 +107,16 @@ def locate_via_py(version: str) -> str | None:
         if it is found.
     """
     script = "import sys; print(sys.executable)"
-    py_exe = py.path.local.sysfind("py")
+    py_exe = shutil.which("py")
     if py_exe is not None:
-        try:
-            return py_exe.sysexec("-" + version, "-c", script).strip()  # type: ignore[no-any-return]
-        except py.process.cmdexec.Error:
-            return None
+        ret = subprocess.run(
+            [py_exe, f"-{version}", "-c", script],
+            check=False,
+            text=True,
+            capture_output=True,
+        )
+        if ret.returncode == 0 and ret.stdout:
+            return ret.stdout.strip()
     return None
 
 
@@ -138,15 +140,17 @@ def locate_using_path_and_version(version: str) -> str | None:
         return None
 
     script = "import platform; print(platform.python_version())"
-    path_python = py.path.local.sysfind("python")
+    path_python = shutil.which("python")
     if path_python:
-        try:
-            prefix = f"{version}."
-            version_string = path_python.sysexec("-c", script).strip()
-            if version_string.startswith(prefix):
-                return str(path_python)
-        except py.process.cmdexec.Error:
-            return None
+        prefix = f"{version}"
+        ret = subprocess.run(
+            [path_python, "-c", script],
+            check=False,
+            text=True,
+            capture_output=True,
+        )
+        if ret.returncode == 0 and ret.stdout and ret.stdout.strip().startswith(prefix):
+            return path_python
 
     return None
 
@@ -424,7 +428,7 @@ class VirtualEnv(ProcessEnv):
             cleaned_interpreter = f"python{xy_version}"
 
         # If the cleaned interpreter is on the PATH, go ahead and return it.
-        if py.path.local.sysfind(cleaned_interpreter):
+        if shutil.which(cleaned_interpreter):
             self._resolved = cleaned_interpreter
             return self._resolved
 
diff --git a/pyproject.toml b/pyproject.toml
index ae2cccb..8842712 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,6 @@ dependencies = [
   "colorlog<7.0.0,>=2.6.1",
   'importlib-metadata; python_version < "3.8"',
   "packaging>=20.9",
-  "py<2.0.0,>=1.4",
   'typing-extensions>=3.7.4; python_version < "3.8"',
   "virtualenv>=14",
 ]


Test patch:
diff --git a/tests/test_command.py b/tests/test_command.py
index a05ab82..f649459 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -18,10 +18,12 @@ import ctypes
 import logging
 import os
 import platform
+import shutil
 import signal
 import subprocess
 import sys
 import time
+from pathlib import Path
 from textwrap import dedent
 from unittest import mock
 
@@ -57,6 +59,13 @@ def test_run_silent(capsys):
     assert out == ""
 
 
+@pytest.mark.skipif(shutil.which("git") is None, reason="Needs git")
+def test_run_not_in_path(capsys):
+    # Paths falls back on the environment PATH if the command is not found.
+    result = nox.command.run(["git", "--version"], paths=["."])
+    assert result is True
+
+
 def test_run_verbosity(capsys, caplog):
     caplog.clear()
     with caplog.at_level(logging.DEBUG):
@@ -112,6 +121,19 @@ def test_run_verbosity_failed_command(capsys, caplog):
     assert not logs
 
 
+@pytest.mark.skipif(
+    platform.system() == "Windows",
+    reason="See https://github.com/python/cpython/issues/85815",
+)
+def test_run_non_str():
+    result = nox.command.run(
+        [Path(PYTHON), "-c", "import sys; print(sys.argv)", Path(PYTHON)],
+        silent=True,
+    )
+
+    assert PYTHON in result
+
+
 def test_run_env_unicode():
     result = nox.command.run(
         [PYTHON, "-c", 'import os; print(os.environ["SIGIL"])'],
@@ -147,15 +169,19 @@ def test_run_path_nonexistent():
     assert "/non/existent" not in result
 
 
-def test_run_path_existent(tmpdir, monkeypatch):
-    executable = tmpdir.join("testexc")
-    executable.ensure("")
+def test_run_path_existent(tmp_path: Path):
+    executable_name = (
+        "testexc.exe" if "windows" in platform.platform().lower() else "testexc"
+    )
+    tmp_path.touch()
+    executable = tmp_path.joinpath(executable_name)
+    executable.touch()
     executable.chmod(0o700)
 
     with mock.patch("nox.command.popen") as mock_command:
         mock_command.return_value = (0, "")
-        nox.command.run(["testexc"], silent=True, paths=[tmpdir.strpath])
-        mock_command.assert_called_with([executable.strpath], env=None, silent=True)
+        nox.command.run([executable_name], silent=True, paths=[str(tmp_path)])
+        mock_command.assert_called_with([str(executable)], env=None, silent=True)
 
 
 def test_run_external_warns(tmpdir, caplog):
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 6070bec..cb776ea 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -20,6 +20,7 @@ import shutil
 import subprocess
 import sys
 from textwrap import dedent
+from typing import NamedTuple
 from unittest import mock
 
 import py
@@ -32,6 +33,11 @@ HAS_CONDA = shutil.which("conda") is not None
 RAISE_ERROR = "RAISE_ERROR"
 
 
+class TextProcessResult(NamedTuple):
+    stdout: str
+    returncode: int = 0
+
+
 @pytest.fixture
 def make_one(tmpdir):
     def factory(*args, **kwargs):
@@ -53,73 +59,38 @@ def make_conda(tmpdir):
 
 
 @pytest.fixture
-def make_mocked_interpreter_path():
-    """Provides a factory to create a mocked ``path`` object pointing
-    to a python interpreter.
-
-    This mocked ``path`` provides
-        - a ``__str__`` which is equal to the factory's ``path`` parameter
-        - a ``sysexec`` method which returns the value of the
-          factory's ``sysexec_result`` parameter.
-          (the ``sysexec_result`` parameter can be a version string
-          or ``RAISE_ERROR``).
-    """
-
-    def factory(path, sysexec_result):
-        def mock_sysexec(*_):
-            if sysexec_result == RAISE_ERROR:
-                raise py.process.cmdexec.Error(1, 1, "", "", "")
-            else:
-                return sysexec_result
-
-        attrs = {
-            "sysexec.side_effect": mock_sysexec,
-            "__str__": mock.Mock(return_value=path),
-        }
-        mock_python = mock.Mock()
-        mock_python.configure_mock(**attrs)
-
-        return mock_python
-
-    return factory
-
-
-@pytest.fixture
-def patch_sysfind(make_mocked_interpreter_path):
+def patch_sysfind(monkeypatch):
     """Provides a function to patch ``sysfind`` with parameters for tests related
     to locating a Python interpreter in the system ``PATH``.
     """
 
-    def patcher(sysfind, only_find, sysfind_result, sysexec_result):
-        """Returns an extended ``sysfind`` patch object for tests related to locating a
-        Python interpreter in the system ``PATH``.
+    def patcher(only_find, sysfind_result, sysexec_result):
+        """Monkeypatches python discovery, causing specific results to be found.
 
         Args:
-            sysfind: The original sysfind patch object
-            only_find (Tuple[str]): The strings for which ``sysfind`` should be successful,
+            only_find (Tuple[str]): The strings for which ``shutil.which`` should be successful,
                 e.g. ``("python", "python.exe")``
             sysfind_result (Optional[str]): The ``path`` string to create the returned
                 mocked ``path`` object with which will represent the found Python interpreter,
                 or ``None``.
-                This parameter is passed on to ``make_mocked_interpreter_path``.
             sysexec_result (str): A string that should be returned when executing the
                 mocked ``path`` object. Usually a Python version string.
                 Use the global ``RAISE_ERROR`` to have ``sysexec`` fail.
-                This parameter is passed on to ``make_mocked_interpreter_path``.
         """
-        mock_python = make_mocked_interpreter_path(sysfind_result, sysexec_result)
 
-        def mock_sysfind(arg):
+        def special_which(name, path=None):
             if sysfind_result is None:
                 return None
-            elif arg.lower() in only_find:
-                return mock_python
-            else:
-                return None
+            if name.lower() in only_find:
+                return sysfind_result or name
+            return None
+
+        monkeypatch.setattr(shutil, "which", special_which)
 
-        sysfind.side_effect = mock_sysfind
+        def special_run(cmd, *args, **kwargs):
+            return TextProcessResult(sysexec_result)
 
-        return sysfind
+        monkeypatch.setattr(subprocess, "run", special_run)
 
     return patcher
 
@@ -256,7 +227,7 @@ def test_condaenv_detection(make_conda):
     venv.create()
 
     proc_result = subprocess.run(
-        [py.path.local.sysfind("conda").strpath, "list"],
+        [shutil.which("conda"), "list"],
         env=venv.env,
         check=True,
         capture_output=True,
@@ -502,7 +473,6 @@ def test_create_reuse_oldstyle_virtualenv_environment(make_one):
 
 
 @enable_staleness_check
-@pytest.mark.skipif(IS_WINDOWS, reason="Avoid spurious failure on Windows.")
 def test_create_reuse_python2_environment(make_one):
     venv, location = make_one(reuse_existing=True, interpreter="2.7")
 
@@ -547,59 +517,57 @@ def test__resolved_interpreter_none(make_one):
     ],
 )
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=True)
-def test__resolved_interpreter_numerical_non_windows(
-    sysfind, make_one, input_, expected
-):
+@mock.patch.object(shutil, "which", return_value=True)
+def test__resolved_interpreter_numerical_non_windows(which, make_one, input_, expected):
     venv, _ = make_one(interpreter=input_)
 
     assert venv._resolved_interpreter == expected
-    sysfind.assert_called_once_with(expected)
+    which.assert_called_once_with(expected)
 
 
 @pytest.mark.parametrize("input_", ["2.", "2.7."])
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=False)
-def test__resolved_interpreter_invalid_numerical_id(sysfind, make_one, input_):
+@mock.patch.object(shutil, "which", return_value=False)
+def test__resolved_interpreter_invalid_numerical_id(which, make_one, input_):
     venv, _ = make_one(interpreter=input_)
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
         venv._resolved_interpreter
 
-    sysfind.assert_called_once_with(input_)
+    which.assert_called_once_with(input_)
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=False)
-def test__resolved_interpreter_32_bit_non_windows(sysfind, make_one):
+@mock.patch.object(shutil, "which", return_value=False)
+def test__resolved_interpreter_32_bit_non_windows(which, make_one):
     venv, _ = make_one(interpreter="3.6-32")
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
         venv._resolved_interpreter
-    sysfind.assert_called_once_with("3.6-32")
+    which.assert_called_once_with("3.6-32")
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=True)
-def test__resolved_interpreter_non_windows(sysfind, make_one):
+@mock.patch.object(shutil, "which", return_value=True)
+def test__resolved_interpreter_non_windows(which, make_one):
     # Establish that the interpreter is simply passed through resolution
     # on non-Windows.
     venv, _ = make_one(interpreter="python3.6")
 
     assert venv._resolved_interpreter == "python3.6"
-    sysfind.assert_called_once_with("python3.6")
+    which.assert_called_once_with("python3.6")
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_full_path(sysfind, make_one):
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_windows_full_path(which, make_one):
     # Establish that if we get a fully-qualified system path (on Windows
     # or otherwise) and the path exists, that we accept it.
     venv, _ = make_one(interpreter=r"c:\Python36\python.exe")
 
-    sysfind.return_value = py.path.local(venv.interpreter)
+    which.return_value = venv.interpreter
     assert venv._resolved_interpreter == r"c:\Python36\python.exe"
-    sysfind.assert_called_once_with(r"c:\Python36\python.exe")
+    which.assert_called_once_with(r"c:\Python36\python.exe")
 
 
 @pytest.mark.parametrize(
@@ -611,57 +579,59 @@ def test__resolved_interpreter_windows_full_path(sysfind, make_one):
     ],
 )
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_pyexe(sysfind, make_one, input_, expected):
+@mock.patch.object(subprocess, "run")
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_windows_pyexe(which, run, make_one, input_, expected):
     # Establish that if we get a standard pythonX.Y path, we look it
     # up via the py launcher on Windows.
     venv, _ = make_one(interpreter=input_)
 
+    if input_ == "3.7":
+        input_ = "python3.7"
+
     # Trick the system into thinking that it cannot find python3.6
     # (it likely will on Unix). Also, when the system looks for the
     # py launcher, give it a dummy that returns our test value when
     # run.
-    attrs = {"sysexec.return_value": expected}
-    mock_py = mock.Mock()
-    mock_py.configure_mock(**attrs)
-    sysfind.side_effect = lambda arg: mock_py if arg == "py" else None
+    def special_run(cmd, *args, **kwargs):
+        if cmd[0] == "py":
+            return TextProcessResult(expected)
+        return TextProcessResult("", 1)
+
+    run.side_effect = special_run
+    which.side_effect = lambda x: "py" if x == "py" else None
 
     # Okay now run the test.
     assert venv._resolved_interpreter == expected
-    assert sysfind.call_count == 2
-    if input_ == "3.7":
-        sysfind.assert_has_calls([mock.call("python3.7"), mock.call("py")])
-    else:
-        sysfind.assert_has_calls([mock.call(input_), mock.call("py")])
+    assert which.call_count == 2
+    which.assert_has_calls([mock.call(input_), mock.call("py")])
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_pyexe_fails(sysfind, make_one):
+@mock.patch.object(subprocess, "run")
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_windows_pyexe_fails(which, run, make_one):
     # Establish that if the py launcher fails, we give the right error.
     venv, _ = make_one(interpreter="python3.6")
 
     # Trick the nox.virtualenv._SYSTEM into thinking that it cannot find python3.6
     # (it likely will on Unix). Also, when the nox.virtualenv._SYSTEM looks for the
     # py launcher, give it a dummy that fails.
-    attrs = {"sysexec.side_effect": py.process.cmdexec.Error(1, 1, "", "", "")}
-    mock_py = mock.Mock()
-    mock_py.configure_mock(**attrs)
-    sysfind.side_effect = lambda arg: mock_py if arg == "py" else None
+    def special_run(cmd, *args, **kwargs):
+        return TextProcessResult("", 1)
+
+    run.side_effect = special_run
+    which.side_effect = lambda x: "py" if x == "py" else None
 
     # Okay now run the test.
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
         venv._resolved_interpreter
 
-    sysfind.assert_any_call("python3.6")
-    sysfind.assert_any_call("py")
+    which.assert_has_calls([mock.call("python3.6"), mock.call("py")])
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_path_and_version(
-    sysfind, make_one, patch_sysfind
-):
+def test__resolved_interpreter_windows_path_and_version(make_one, patch_sysfind):
     # Establish that if we get a standard pythonX.Y path, we look it
     # up via the path on Windows.
     venv, _ = make_one(interpreter="3.7")
@@ -673,7 +643,6 @@ def test__resolved_interpreter_windows_path_and_version(
     # in the system path.
     correct_path = r"c:\python37-x64\python.exe"
     patch_sysfind(
-        sysfind,
         only_find=("python", "python.exe"),
         sysfind_result=correct_path,
         sysexec_result="3.7.3\\n",
@@ -687,9 +656,8 @@ def test__resolved_interpreter_windows_path_and_version(
 @pytest.mark.parametrize("sysfind_result", [r"c:\python37-x64\python.exe", None])
 @pytest.mark.parametrize("sysexec_result", ["3.7.3\\n", RAISE_ERROR])
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
 def test__resolved_interpreter_windows_path_and_version_fails(
-    sysfind, input_, sysfind_result, sysexec_result, make_one, patch_sysfind
+    input_, sysfind_result, sysexec_result, make_one, patch_sysfind
 ):
     # Establish that if we get a standard pythonX.Y path, we look it
     # up via the path on Windows.
@@ -700,7 +668,7 @@ def test__resolved_interpreter_windows_path_and_version_fails(
     # Also, we don't give it a mock py launcher.
     # But we give it a mock python interpreter to find
     # in the system path.
-    patch_sysfind(sysfind, ("python", "python.exe"), sysfind_result, sysexec_result)
+    patch_sysfind(("python", "python.exe"), sysfind_result, sysexec_result)
 
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
         venv._resolved_interpreter
@@ -708,14 +676,14 @@ def test__resolved_interpreter_windows_path_and_version_fails(
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
 @mock.patch.object(py._path.local.LocalPath, "check")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_not_found(sysfind, check, make_one):
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_not_found(which, check, make_one):
     # Establish that if an interpreter cannot be found at a standard
     # location on Windows, we raise a useful error.
     venv, _ = make_one(interpreter="python3.6")
 
     # We are on Windows, and nothing can be found.
-    sysfind.return_value = None
+    which.return_value = None
     check.return_value = False
 
     # Run the test.
@@ -734,22 +702,22 @@ def test__resolved_interpreter_nonstandard(make_one):
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=True)
-def test__resolved_interpreter_cache_result(sysfind, make_one):
+@mock.patch.object(shutil, "which", return_value=True)
+def test__resolved_interpreter_cache_result(which, make_one):
     venv, _ = make_one(interpreter="3.6")
 
     assert venv._resolved is None
     assert venv._resolved_interpreter == "python3.6"
-    sysfind.assert_called_once_with("python3.6")
+    which.assert_called_once_with("python3.6")
     # Check the cache and call again to make sure it is used.
     assert venv._resolved == "python3.6"
     assert venv._resolved_interpreter == "python3.6"
-    assert sysfind.call_count == 1
+    assert which.call_count == 1
 
 
 @mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=None)
-def test__resolved_interpreter_cache_failure(sysfind, make_one):
+@mock.patch.object(shutil, "which", return_value=None)
+def test__resolved_interpreter_cache_failure(which, make_one):
     venv, _ = make_one(interpreter="3.7-32")
 
     assert venv._resolved is None
@@ -757,9 +725,9 @@ def test__resolved_interpreter_cache_failure(sysfind, make_one):
         venv._resolved_interpreter
     caught = exc_info.value
 
-    sysfind.assert_called_once_with("3.7-32")
+    which.assert_called_once_with("3.7-32")
     # Check the cache and call again to make sure it is used.
     assert venv._resolved is caught
     with pytest.raises(nox.virtualenv.InterpreterNotFound):
         venv._resolved_interpreter
-    assert sysfind.call_count == 1
+    assert which.call_count == 1