From 2706bb4d2491b10b6d34e9c9a25048fd546795b1 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 5 Apr 2022 22:30:22 -0400 Subject: [PATCH] refactor: drop path.py Signed-off-by: Henry Schreiner --- nox/command.py | 16 ++-- nox/sessions.py | 22 +++-- nox/virtualenv.py | 29 ++++--- setup.cfg | 1 - tests/test_virtualenv.py | 177 ++++++++++++++++----------------------- 5 files changed, 109 insertions(+), 136 deletions(-) diff --git a/nox/command.py b/nox/command.py index 01bcc0b1..5102d57e 100644 --- a/nox/command.py +++ b/nox/command.py @@ -16,11 +16,10 @@ 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 @@ -42,16 +41,15 @@ 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 + if paths is not None: + full_path = shutil.which(program, path=os.pathsep.join(paths)) + if full_path: + return full_path - full_path = py.path.local.sysfind(program) + full_path = shutil.which(program) if full_path: - return full_path.strpath + return full_path logger.error(f"Program {program} not found.") raise CommandFailed(f"Program {program} not found") diff --git a/nox/sessions.py b/nox/sessions.py index 3cb838c1..c0fcac59 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 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,17 @@ 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 behavior.""" + 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): @@ -713,11 +723,9 @@ def execute(self) -> Result: 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_str = os.path.realpath(os.path.dirname(self.global_config.noxfile)) - with cwd: + with _chdir(cwd_str): self._create_venv() session = Session(self) self.func(session) diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 4233c7b8..c169af18 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -18,12 +18,11 @@ 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.logger import logger @@ -109,12 +108,13 @@ 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() - except py.process.cmdexec.Error: - return None + ret = subprocess.run( + [py_exe, f"-{version}", "-c", script], check=True, text=True + ) + if ret.returncode == 0 and ret.stdout: + return ret.stdout.strip() return None @@ -138,15 +138,14 @@ 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: + prefix = f"{version}." + ret = subprocess.run([path_python, "-c", script], text=True, check=False) + if ret.returncode != 0 or not ret.stdout: return None + if ret.stdout.startswith(prefix): + return str(path_python) return None @@ -424,7 +423,7 @@ def _resolved_interpreter(self) -> str: 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/setup.cfg b/setup.cfg index 00e06b6f..2ad1dc79 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,7 +39,6 @@ install_requires = argcomplete>=1.9.4,<3.0 colorlog>=2.6.1,<7.0.0 packaging>=20.9 - py>=1.4.0,<2.0.0 virtualenv>=14.0.0 importlib-metadata;python_version < '3.8' typing-extensions>=3.7.4;python_version < '3.8' diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index ce5dd232..04a4e466 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -20,6 +20,7 @@ import subprocess import sys from textwrap import dedent +from typing import NamedTuple from unittest import mock import py @@ -32,6 +33,11 @@ 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 factory(*args, **kwargs): @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 @@ -546,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 = py.path.local(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( @@ -610,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) + raise subprocess.CalledProcessError(1, cmd) + + 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): + raise subprocess.CalledProcessError(1, cmd) + + 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") @@ -672,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", @@ -686,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. @@ -699,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 @@ -707,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. @@ -733,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 @@ -756,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