From e4fe342272ad658ddef6a6c5f31fea9155a92d3b Mon Sep 17 00:00:00 2001 From: Arun Babu Neelicattu Date: Thu, 23 Jan 2025 14:45:50 +0100 Subject: [PATCH] feat(python): use findpython --- poetry.lock | 17 ++- pyproject.toml | 2 + src/poetry/utils/env/env_manager.py | 49 +++---- src/poetry/utils/env/python_manager.py | 177 +++++++++++-------------- tests/conftest.py | 175 +++++++++++++++++++++++- tests/console/commands/env/test_use.py | 18 ++- tests/console/commands/test_init.py | 22 +-- tests/console/commands/test_new.py | 24 +--- tests/types.py | 11 ++ tests/utils/env/test_env_manager.py | 158 +++++++++------------- tests/utils/test_python_manager.py | 61 ++++++--- 11 files changed, 419 insertions(+), 295 deletions(-) diff --git a/poetry.lock b/poetry.lock index 87941004c83..4791bb8cd83 100644 --- a/poetry.lock +++ b/poetry.lock @@ -598,6 +598,21 @@ docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2. testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] typing = ["typing-extensions (>=4.12.2)"] +[[package]] +name = "findpython" +version = "0.6.2" +description = "A utility to find python versions on your system" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "findpython-0.6.2-py3-none-any.whl", hash = "sha256:bda62477f858ea623ef2269f5e734469a018104a5f6c0fd9317ba238464ddb76"}, + {file = "findpython-0.6.2.tar.gz", hash = "sha256:e0c75ba9f35a7f9bb4423eb31bd17358cccf15761b6837317719177aeff46723"}, +] + +[package.dependencies] +packaging = ">=20" + [[package]] name = "httpretty" version = "1.1.4" @@ -1739,4 +1754,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "e12eda5cc53996f463234f41ff013a8b0aa972ffbcbe210197327660d88b0af1" +content-hash = "aa47753624aadb8e4086016ef73a316474c4b83c58d57c0fe978bec0bb00eab6" diff --git a/pyproject.toml b/pyproject.toml index f86eee304d6..179653e087d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "trove-classifiers (>=2022.5.19)", "virtualenv (>=20.26.6,<21.0.0)", "xattr (>=1.0.0,<2.0.0) ; sys_platform == 'darwin'", + "findpython (>=0.6.2,<0.7.0)", ] authors = [ { name = "SeĢbastien Eustace", email = "sebastien@eustace.io" } @@ -182,6 +183,7 @@ warn_unused_ignores = false module = [ 'deepdiff.*', 'fastjsonschema.*', + 'findpython.*', 'httpretty.*', 'requests_toolbelt.*', 'shellingham.*', diff --git a/src/poetry/utils/env/env_manager.py b/src/poetry/utils/env/env_manager.py index 0a5cba440e2..ac318397f67 100644 --- a/src/poetry/utils/env/env_manager.py +++ b/src/poetry/utils/env/env_manager.py @@ -114,17 +114,8 @@ def base_env_name(self) -> str: def activate(self, python: str) -> Env: venv_path = self._poetry.config.virtualenvs_path - try: - python_version = Version.parse(python) - python = f"python{python_version.major}" - if python_version.precision > 1: - python += f".{python_version.minor}" - except ValueError: - # Executable in PATH or full executable path - pass - - python_ = Python.get_by_name(python) - if python_ is None: + python_instance = Python.get_by_name(python) + if python_instance is None: raise PythonVersionNotFoundError(python) create = False @@ -138,10 +129,10 @@ def activate(self, python: str) -> Env: _venv = VirtualEnv(venv) current_patch = ".".join(str(v) for v in _venv.version_info[:3]) - if python_.patch_version.to_string() != current_patch: + if python_instance.patch_version.to_string() != current_patch: create = True - self.create_venv(executable=python_.executable, force=create) + self.create_venv(python=python_instance, force=create) return self.get(reload=True) @@ -154,14 +145,16 @@ def activate(self, python: str) -> Env: current_patch = current_env["patch"] if ( - current_minor == python_.minor_version.to_string() - and current_patch != python_.patch_version.to_string() + current_minor == python_instance.minor_version.to_string() + and current_patch != python_instance.patch_version.to_string() ): # We need to recreate create = True - name = f"{self.base_env_name}-py{python_.minor_version.to_string()}" - venv = venv_path / name + venv = ( + venv_path + / f"{self.base_env_name}-py{python_instance.minor_version.to_string()}" + ) # Create if needed if not venv.exists() or create: @@ -174,15 +167,15 @@ def activate(self, python: str) -> Env: _venv = VirtualEnv(venv) current_patch = ".".join(str(v) for v in _venv.version_info[:3]) - if python_.patch_version.to_string() != current_patch: + if python_instance.patch_version.to_string() != current_patch: create = True - self.create_venv(executable=python_.executable, force=create) + self.create_venv(python=python_instance, force=create) # Activate envs[self.base_env_name] = { - "minor": python_.minor_version.to_string(), - "patch": python_.patch_version.to_string(), + "minor": python_instance.minor_version.to_string(), + "patch": python_instance.patch_version.to_string(), } self.envs_file.write(envs) @@ -372,7 +365,7 @@ def in_project_venv_exists(self) -> bool: def create_venv( self, name: str | None = None, - executable: Path | None = None, + python: Python | None = None, force: bool = False, ) -> Env: if self._env is not None and not force: @@ -400,11 +393,11 @@ def create_venv( use_poetry_python = self._poetry.config.get("virtualenvs.use-poetry-python") venv_prompt = self._poetry.config.get("virtualenvs.prompt") - python = ( - Python(executable) - if executable - else Python.get_preferred_python(config=self._poetry.config, io=self._io) - ) + specific_python_requested = python is not None + if not python: + python = Python.get_preferred_python( + config=self._poetry.config, io=self._io + ) venv_path = ( self.in_project_venv @@ -422,7 +415,7 @@ def create_venv( # If an executable has been specified, we stop there # and notify the user of the incompatibility. # Otherwise, we try to find a compatible Python version. - if executable and use_poetry_python: + if specific_python_requested and use_poetry_python: raise NoCompatiblePythonVersionFoundError( self._poetry.package.python_versions, python.patch_version.to_string(), diff --git a/src/poetry/utils/env/python_manager.py b/src/poetry/utils/env/python_manager.py index 1779087f02c..bc6de47b55c 100644 --- a/src/poetry/utils/env/python_manager.py +++ b/src/poetry/utils/env/python_manager.py @@ -1,21 +1,21 @@ from __future__ import annotations -import shutil -import subprocess import sys from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING +from typing import cast +from typing import overload + +import findpython +import packaging.version from cleo.io.null_io import NullIO from cleo.io.outputs.output import Verbosity from poetry.core.constraints.version import Version -from poetry.core.constraints.version import parse_constraint -from poetry.utils._compat import decode from poetry.utils.env.exceptions import NoCompatiblePythonVersionFoundError -from poetry.utils.env.script_strings import GET_PYTHON_VERSION_ONELINER if TYPE_CHECKING: @@ -26,27 +26,48 @@ class Python: - def __init__(self, executable: str | Path, version: Version | None = None) -> None: - self.executable = Path(executable) - self._version = version + @overload + def __init__(self, *, python: findpython.PythonVersion) -> None: ... + + @overload + def __init__( + self, executable: str | Path, version: Version | None = None + ) -> None: ... + + # we overload __init__ to ensure we do not break any downstream plugins + # that use the this + def __init__( + self, + executable: str | Path | None = None, + version: Version | None = None, + python: findpython.PythonVersion | None = None, + ) -> None: + if python and (executable or version): + ValueError( + "When python is provided, neither executable or version must be specified" + ) + + if python: + self._python = python + elif executable: + self._python = findpython.PythonVersion( + executable=Path(executable), + _version=packaging.version.Version(str(version)) if version else None, + ) + else: + raise ValueError("Either python or executable must be provided") @property - def version(self) -> Version: - if not self._version: - if self.executable == Path(sys.executable): - python_version = ".".join(str(v) for v in sys.version_info[:3]) - else: - encoding = "locale" if sys.version_info >= (3, 10) else None - python_version = decode( - subprocess.check_output( - [str(self.executable), "-c", GET_PYTHON_VERSION_ONELINER], - text=True, - encoding=encoding, - ).strip() - ) - self._version = Version.parse(python_version) + def python(self) -> findpython.PythonVersion: + return self._python - return self._version + @property + def executable(self) -> Path: + return cast(Path, self._python.executable) + + @property + def version(self) -> Version: + return Version.parse(str(self._python.version)) @cached_property def patch_version(self) -> Version: @@ -60,66 +81,47 @@ def patch_version(self) -> Version: def minor_version(self) -> Version: return Version.from_parts(major=self.version.major, minor=self.version.minor) - @staticmethod - def _full_python_path(python: str) -> Path | None: - # eg first find pythonXY.bat on windows. - path_python = shutil.which(python) - if path_python is None: - return None + @classmethod + def get_active_python(cls) -> Python | None: + if python := findpython.find(): + return cls(python=python) + return None + @classmethod + def from_executable(cls, path: Path | str) -> Python: try: - encoding = "locale" if sys.version_info >= (3, 10) else None - executable = subprocess.check_output( - [path_python, "-c", "import sys; print(sys.executable)"], - text=True, - encoding=encoding, - ).strip() - return Path(executable) - - except subprocess.CalledProcessError: - return None - - @staticmethod - def _detect_active_python(io: IO) -> Path | None: - io.write_error_line( - "Trying to detect current active python executable as specified in" - " the config.", - verbosity=Verbosity.VERBOSE, - ) - - executable = Python._full_python_path("python") - - if executable is not None: - io.write_error_line(f"Found: {executable}", verbosity=Verbosity.VERBOSE) - else: - io.write_error_line( - "Unable to detect the current active python executable. Falling" - " back to default.", - verbosity=Verbosity.VERBOSE, - ) - - return executable + return cls(python=findpython.PythonVersion(executable=Path(path))) + except (FileNotFoundError, NotADirectoryError, ValueError): + raise ValueError(f"{path} is not a valid Python executable") @classmethod def get_system_python(cls) -> Python: - return cls(executable=sys.executable) + return cls( + python=findpython.PythonVersion( + executable=Path(sys.executable), + _version=packaging.version.Version( + ".".join(str(v) for v in sys.version_info[:3]) + ), + ) + ) @classmethod def get_by_name(cls, python_name: str) -> Python | None: - executable = cls._full_python_path(python_name) - if not executable: - return None - - return cls(executable=executable) + if python := findpython.find(python_name): + return cls(python=python) + return None @classmethod def get_preferred_python(cls, config: Config, io: IO | None = None) -> Python: io = io or NullIO() if not config.get("virtualenvs.use-poetry-python") and ( - active_python := Python._detect_active_python(io) + active_python := Python.get_active_python() ): - return cls(executable=active_python) + io.write_error_line( + f"Found: {active_python.executable}", verbosity=Verbosity.VERBOSE + ) + return active_python return cls.get_system_python() @@ -129,39 +131,12 @@ def get_compatible_python(cls, poetry: Poetry, io: IO | None = None) -> Python: supported_python = poetry.package.python_constraint python = None - for suffix in [ - *sorted( - poetry.package.AVAILABLE_PYTHONS, - key=lambda v: (v.startswith("3"), -len(v), v), - reverse=True, - ), - "", - ]: - if len(suffix) == 1: - if not parse_constraint(f"^{suffix}.0").allows_any(supported_python): - continue - elif suffix and not supported_python.allows_any( - parse_constraint(suffix + ".*") - ): - continue - - python_name = f"python{suffix}" - if io.is_debug(): - io.write_error_line(f"Trying {python_name}") - - executable = cls._full_python_path(python_name) - if executable is None: - continue - - candidate = cls(executable) - if supported_python.allows(candidate.patch_version): - python = candidate + for candidate in findpython.find_all(): + python = cls(python=candidate) + if python.version.allows_any(supported_python): io.write_error_line( - f"Using {python_name} ({python.patch_version})" + f"Using {candidate.name} ({python.patch_version})" ) - break - - if not python: - raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions) + return python - return python + raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions) diff --git a/tests/conftest.py b/tests/conftest.py index d69dc6bd12d..f370e5b4b47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,10 @@ from pathlib import Path from typing import TYPE_CHECKING +import findpython import httpretty import keyring +import packaging.version import pytest from jaraco.classes import properties @@ -35,6 +37,7 @@ from poetry.utils.env import EnvManager from poetry.utils.env import SystemEnv from poetry.utils.env import VirtualEnv +from poetry.utils.env.python_manager import Python from tests.helpers import MOCK_DEFAULT_GIT_REVISION from tests.helpers import TestLocker from tests.helpers import TestRepository @@ -51,6 +54,7 @@ from collections.abc import Mapping from typing import Any from typing import Callable + from unittest.mock import MagicMock from cleo.io.inputs.argument import Argument from cleo.io.inputs.option import Option @@ -64,10 +68,10 @@ from tests.types import CommandFactory from tests.types import FixtureCopier from tests.types import FixtureDirGetter + from tests.types import MockedPythonRegister from tests.types import ProjectFactory from tests.types import SetProjectContext - pytest_plugins = [ "tests.repositories.fixtures", ] @@ -627,3 +631,172 @@ def handle(self) -> int: return MockCommand() return _command_factory + + +@pytest.fixture +def mocked_pythons() -> list[findpython.PythonVersion]: + """ + Fixture that provides a mock representation of Python versions that are registered. + + This fixture returns a list of `findpython.PythonVersion` objects. Typically, + it is used in test scenarios to replace actual Python version discovery with + mocked data. By default, this fixture returns an empty list to simulate an + environment without any Python installations. + + :return: Mocked list of Python versions with the type of + `findpython.PythonVersion`. + """ + return [] + + +@pytest.fixture +def mocked_pythons_version_map() -> dict[str, findpython.PythonVersion]: + """ + Create a mocked Python version map for testing purposes. This serves as a + quick lookup for exact version matches. + + This function provides a fixture that returns a dictionary containing a + mapping of specific keys to corresponding instances of the + `findpython.PythonVersion` class. This is primarily used for testing + scenarios involving multiple Python interpreters. If the key is an + empty string, it maps to the system Python interpreter as used by the + `with_mocked_findpython` fixture. + + :return: A dictionary mapping string keys to `findpython.PythonVersion` + instances. A default key "" (empty string) is pre-set to match the + current system environment. + """ + return { + # add the system python if key is empty + "": Python.get_system_python().python + } + + +@pytest.fixture +def mock_findpython_find( + mocked_pythons: list[findpython.PythonVersion], + mocked_pythons_version_map: dict[str, findpython.PythonVersion], + mocker: MockerFixture, +) -> MagicMock: + def _find( + name: str | None = None, + ) -> findpython.PythonVersion | None: + # find exact version matches + # the default key is an empty string in mocked_pythons_version_map + if python := mocked_pythons_version_map.get(name or ""): + return python + + if name is None: + return None + + candidates: list[findpython.PythonVersion] = [] + + # iterate through to find executable name match + for python in mocked_pythons: + if python.executable.name == name: + return python + elif str(python.executable).endswith(name): + candidates.append(python) + + if candidates: + candidates.sort(key=lambda p: p.executable.name) + return candidates[0] + + return None + + return mocker.patch( + "findpython.find", + side_effect=_find, + ) + + +@pytest.fixture +def mock_findpython_find_all( + mocked_pythons: list[findpython.PythonVersion], + mocker: MockerFixture, +) -> MagicMock: + return mocker.patch( + "findpython.find_all", + return_value=mocked_pythons, + ) + + +@pytest.fixture +def without_mocked_findpython( + mock_findpython_find: MagicMock, + mock_findpython_find_all: MagicMock, +) -> None: + mock_findpython_find_all.stop() + mock_findpython_find.stop() + + +@pytest.fixture(autouse=True) +def with_mocked_findpython( + mock_findpython_find: MagicMock, + mock_findpython_find_all: MagicMock, +) -> None: + """ + Fixture that mocks the `findpython` library functions `find` and `find_all`. + + This fixture enables controlled testing of Python version discovery by providing + mocked data for `findpython.PythonVersion` objects and behavior. It patches + the `findpython.find` and `findpython.find_all` methods using the given mock + data to simulate real functionality. + + This function mock behavior includes: + - Finding Python versions by an exact match of executable name or selectable from + candidates whose executable names end with the provided input. + - Returning all mocked Python versions through the `findpython.find_all`. + + See also the `mocked_python_register` fixture. + """ + return + + +@pytest.fixture +def mocked_python_register( + with_mocked_findpython: None, + mocked_pythons: list[findpython.PythonVersion], + mocked_pythons_version_map: dict[str, findpython.PythonVersion], + mocker: MockerFixture, +) -> MockedPythonRegister: + """ + Fixture to provide a mocked registration mechanism for PythonVersion objects. The + fixture interacts with mocked versions of Python, allowing test cases to register + and manage Python versions under controlled conditions. The provided register + function enables the dynamic registration of Python versions, executable, + and optional system designation. + + :return: A function to register a Python version with configurable options. + """ + + def register( + version: str, + executable_name: str | Path | None = None, + parent: str | Path | None = None, + make_system: bool = False, + ) -> Python: + # we allow this to let windows specific tests setup special cases + parent = Path(parent or "/usr/bin") + + if not executable_name: + info = version.split(".") + executable_name = f"python{info[0]}.{info[1]}" + + python = findpython.PythonVersion( + executable=parent / executable_name, + _version=packaging.version.Version(version), + ) + mocked_pythons.append(python) + mocked_pythons_version_map[version] = python + + if make_system: + mocker.patch( + "poetry.utils.env.python_manager.Python.get_system_python", + return_value=Python(python=python), + ) + mocked_pythons_version_map[""] = python + + return Python(python=python) + + return register diff --git a/tests/console/commands/env/test_use.py b/tests/console/commands/env/test_use.py index 478ef414ef8..ec969e3e9e1 100644 --- a/tests/console/commands/env/test_use.py +++ b/tests/console/commands/env/test_use.py @@ -22,11 +22,11 @@ from pytest_mock import MockerFixture from tests.types import CommandTesterFactory + from tests.types import MockedPythonRegister @pytest.fixture(autouse=True) def setup(mocker: MockerFixture) -> None: - mocker.stopall() if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -56,13 +56,9 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( venv_cache: Path, venv_name: str, venvs_in_cache_config: None, + mocked_python_register: MockedPythonRegister, ) -> None: - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) - + mocked_python_register("3.7.1") mock_build_env = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=build_venv ) @@ -95,12 +91,12 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( def test_get_prefers_explicitly_activated_virtualenvs_over_env_var( - mocker: MockerFixture, tester: CommandTester, current_python: tuple[int, int, int], venv_cache: Path, venv_name: str, venvs_in_cache_config: None, + mocked_python_register: MockedPythonRegister, ) -> None: os.environ["VIRTUAL_ENV"] = "/environment/prefix" @@ -114,7 +110,7 @@ def test_get_prefers_explicitly_activated_virtualenvs_over_env_var( doc[venv_name] = {"minor": python_minor, "patch": python_patch} envs_file.write(doc) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") + mocked_python_register(python_patch) tester.execute(python_minor) @@ -132,13 +128,15 @@ def test_get_prefers_explicitly_activated_non_existing_virtualenvs_over_env_var( venv_cache: Path, venv_name: str, venvs_in_cache_config: None, + mocked_python_register: MockedPythonRegister, ) -> None: os.environ["VIRTUAL_ENV"] = "/environment/prefix" python_minor = ".".join(str(v) for v in current_python[:2]) venv_dir = venv_cache / f"{venv_name}-py{python_minor}" - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") + mocked_python_register(python_minor) + mocker.patch( "poetry.utils.env.EnvManager._env", new_callable=mocker.PropertyMock, diff --git a/tests/console/commands/test_init.py b/tests/console/commands/test_init.py index 68f3e39faa3..a796b02b10c 100644 --- a/tests/console/commands/test_init.py +++ b/tests/console/commands/test_init.py @@ -2,13 +2,11 @@ import os import shutil -import subprocess import sys import textwrap from pathlib import Path from typing import TYPE_CHECKING -from typing import Any import pytest @@ -34,6 +32,7 @@ from tests.helpers import PoetryTestApplication from tests.helpers import TestRepository from tests.types import FixtureDirGetter + from tests.types import MockedPythonRegister @pytest.fixture @@ -1110,26 +1109,11 @@ def test_respect_use_poetry_python_on_init( use_poetry_python: bool, python: str, config: Config, - mocker: MockerFixture, tester: CommandTester, source_dir: Path, + mocked_python_register: MockedPythonRegister, ) -> None: - from poetry.utils.env import GET_PYTHON_VERSION_ONELINER - - orig_check_output = subprocess.check_output - - def mock_check_output(cmd: str, *_: Any, **__: Any) -> str: - if GET_PYTHON_VERSION_ONELINER in cmd: - return "1.1.1" - - result: str = orig_check_output(cmd, *_, **__) - return result - - mocker.patch("subprocess.check_output", side_effect=mock_check_output) - mocker.patch( - "poetry.utils.env.python_manager.Python._full_python_path", - return_value=Path(f"/usr/bin/python{python}"), - ) + mocked_python_register(f"{python}.1", make_system=True) config.config["virtualenvs"]["use-poetry-python"] = use_poetry_python pyproject_file = source_dir / "pyproject.toml" diff --git a/tests/console/commands/test_new.py b/tests/console/commands/test_new.py index fdad9e6620b..0ea4b81f95b 100644 --- a/tests/console/commands/test_new.py +++ b/tests/console/commands/test_new.py @@ -1,11 +1,9 @@ from __future__ import annotations -import subprocess import sys from pathlib import Path from typing import TYPE_CHECKING -from typing import Any import pytest @@ -16,11 +14,11 @@ if TYPE_CHECKING: from cleo.testers.command_tester import CommandTester - from pytest_mock import MockerFixture from poetry.config.config import Config from poetry.poetry import Poetry from tests.types import CommandTesterFactory + from tests.types import MockedPythonRegister @pytest.fixture @@ -199,27 +197,11 @@ def test_respect_use_poetry_python_on_new( use_poetry_python: bool, python: str, config: Config, - mocker: MockerFixture, tester: CommandTester, tmp_path: Path, + mocked_python_register: MockedPythonRegister, ) -> None: - from poetry.utils.env import GET_PYTHON_VERSION_ONELINER - - orig_check_output = subprocess.check_output - - def mock_check_output(cmd: str, *_: Any, **__: Any) -> str: - if GET_PYTHON_VERSION_ONELINER in cmd: - return "1.1.1" - - output: str = orig_check_output(cmd, *_, **__) - return output - - mocker.patch("subprocess.check_output", side_effect=mock_check_output) - mocker.patch( - "poetry.utils.env.python_manager.Python._full_python_path", - return_value=Path(f"/usr/bin/python{python}"), - ) - + mocked_python_register(f"{python}.1", make_system=True) config.config["virtualenvs"]["use-poetry-python"] = use_poetry_python package = "package" diff --git a/tests/types.py b/tests/types.py index 7027be6af79..f4f94aef1d3 100644 --- a/tests/types.py +++ b/tests/types.py @@ -25,6 +25,7 @@ from poetry.poetry import Poetry from poetry.repositories.legacy_repository import LegacyRepository from poetry.utils.env import Env + from poetry.utils.env.python_manager import Python from tests.repositories.fixtures.distribution_hashes import DistributionHash HTTPrettyResponse = tuple[int, dict[str, Any], bytes] # status code, headers, body @@ -125,3 +126,13 @@ class SetProjectContext(Protocol): def __call__( self, project: str | Path, in_place: bool = False ) -> AbstractContextManager[Path]: ... + + +class MockedPythonRegister(Protocol): + def __call__( + self, + version: str, + executable_name: str | Path | None = None, + parent: str | Path | None = None, + make_system: bool = False, + ) -> Python: ... diff --git a/tests/utils/env/test_env_manager.py b/tests/utils/env/test_env_manager.py index 6b476b37456..3a00f0c6f93 100644 --- a/tests/utils/env/test_env_manager.py +++ b/tests/utils/env/test_env_manager.py @@ -35,9 +35,9 @@ from poetry.poetry import Poetry from tests.conftest import Config from tests.types import FixtureDirGetter + from tests.types import MockedPythonRegister from tests.types import ProjectFactory - VERSION_3_7_1 = Version.parse("3.7.1") @@ -130,12 +130,9 @@ def test_activate_in_project_venv_no_explicit_config( mocker: MockerFixture, venv_name: str, in_project_venv_dir: Path, + mocked_python_register: MockedPythonRegister, ) -> None: - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) + mocked_python_register("3.7.1") m = mocker.patch("poetry.utils.env.EnvManager.build_venv", side_effect=build_venv) env = manager.activate("python3.7") @@ -166,17 +163,14 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file( mocker: MockerFixture, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) + mocked_python_register("3.7.1") m = mocker.patch("poetry.utils.env.EnvManager.build_venv", side_effect=build_venv) env = manager.activate("python3.7") @@ -206,6 +200,7 @@ def test_activate_fails_when_python_cannot_be_found( config: Config, mocker: MockerFixture, venv_name: str, + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -214,7 +209,7 @@ def test_activate_fails_when_python_cannot_be_found( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", return_value=None) + mocked_python_register("2.7.1") with pytest.raises(PythonVersionNotFoundError) as e: manager.activate("python3.7") @@ -230,6 +225,7 @@ def test_activate_activates_existing_virtualenv_no_envs_file( config: Config, mocker: MockerFixture, venv_name: str, + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -238,11 +234,7 @@ def test_activate_activates_existing_virtualenv_no_envs_file( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) + mocked_python_register("3.7.1") m = mocker.patch("poetry.utils.env.EnvManager.build_venv", side_effect=build_venv) env = manager.activate("python3.7") @@ -266,6 +258,7 @@ def test_activate_activates_same_virtualenv_with_envs_file( config: Config, mocker: MockerFixture, venv_name: str, + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -279,11 +272,7 @@ def test_activate_activates_same_virtualenv_with_envs_file( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) + mocked_python_register("3.7.1") m = mocker.patch("poetry.utils.env.EnvManager.create_venv") env = manager.activate("python3.7") @@ -307,6 +296,7 @@ def test_activate_activates_different_virtualenv_with_envs_file( mocker: MockerFixture, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -320,11 +310,9 @@ def test_activate_activates_different_virtualenv_with_envs_file( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(Version.parse("3.6.6")), - ) + mocked_python_register("3.6.6") + mocked_python_register("3.7.1") + m = mocker.patch("poetry.utils.env.EnvManager.build_venv", side_effect=build_venv) env = manager.activate("python3.6") @@ -353,6 +341,7 @@ def test_activate_activates_recreates_for_different_patch( mocker: MockerFixture, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -366,11 +355,7 @@ def test_activate_activates_recreates_for_different_patch( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) + mocked_python_register("3.7.1") build_venv_m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=build_venv ) @@ -405,6 +390,7 @@ def test_activate_does_not_recreate_when_switching_minor( config: Config, mocker: MockerFixture, venv_name: str, + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -419,11 +405,9 @@ def test_activate_does_not_recreate_when_switching_minor( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(Version.parse("3.6.6")), - ) + mocked_python_register("3.7.1") + mocked_python_register("3.6.6") + build_venv_m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=build_venv ) @@ -453,6 +437,7 @@ def test_activate_with_in_project_setting_does_not_fail_if_no_venvs_dir( tmp_path: Path, mocker: MockerFixture, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -466,11 +451,7 @@ def test_activate_with_in_project_setting_does_not_fail_if_no_venvs_dir( } ) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) + mocked_python_register("3.7.1") m = mocker.patch("poetry.utils.env.EnvManager.build_venv") manager.activate("python3.7") @@ -491,7 +472,6 @@ def test_deactivate_non_activated_but_existing( manager: EnvManager, poetry: Poetry, config: Config, - mocker: MockerFixture, venv_name: str, ) -> None: config.config["virtualenvs"]["use-poetry-python"] = True @@ -503,11 +483,6 @@ def test_deactivate_non_activated_but_existing( config.merge({"virtualenvs": {"path": str(tmp_path)}}) - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(), - ) - manager.deactivate() env = manager.get() @@ -895,6 +870,7 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_ config_virtualenvs_path: Path, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: config.config["virtualenvs"]["use-poetry-python"] = True if "VIRTUAL_ENV" in os.environ: @@ -902,12 +878,9 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_ poetry.package.python_versions = "^3.6" - mocker.patch("sys.version_info", (2, 7, 16)) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(Version.parse("3.7.5")), - ) + mocked_python_register("2.7.16", make_system=True) + mocked_python_register("3.7.16", "python3") + m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" ) @@ -954,6 +927,7 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific config_virtualenvs_path: Path, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: config.config["virtualenvs"]["use-poetry-python"] = True if "VIRTUAL_ENV" in os.environ: @@ -961,19 +935,15 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific poetry.package.python_versions = "^3.6" - mocker.patch("sys.version_info", (2, 7, 16)) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") + mocked_python_register("3.5.3") + mocked_python_register("3.9.0") + mocker.patch( - "subprocess.check_output", - side_effect=[ - sys.base_prefix, - "/usr/bin/python3", - "3.5.3", - "/usr/bin/python3.9", - "3.9.0", - sys.base_prefix, - ], + "poetry.utils.env.python_manager.Python.get_system_python", + return_value=mocked_python_register("2.7.16", make_system=True), ) + mocked_python_register("3.5.3") + mocked_python_register("3.9.0") m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" ) @@ -1019,7 +989,11 @@ def test_create_venv_fails_if_no_compatible_python_version_could_be_found( def test_create_venv_does_not_try_to_find_compatible_versions_with_executable( - manager: EnvManager, poetry: Poetry, config: Config, mocker: MockerFixture + manager: EnvManager, + poetry: Poetry, + config: Config, + mocker: MockerFixture, + mocked_python_register: MockedPythonRegister, ) -> None: config.config["virtualenvs"]["use-poetry-python"] = True if "VIRTUAL_ENV" in os.environ: @@ -1027,13 +1001,12 @@ def test_create_venv_does_not_try_to_find_compatible_versions_with_executable( poetry.package.python_versions = "^4.8" - mocker.patch("subprocess.check_output", side_effect=[sys.base_prefix, "3.8.0"]) m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" ) with pytest.raises(NoCompatiblePythonVersionFoundError) as e: - manager.create_venv(executable=Path("python3.8")) + manager.create_venv(python=mocked_python_register("3.8.0")) expected_message = ( "The specified Python version (3.8.0) is not supported by the project (^4.8).\n" @@ -1053,6 +1026,7 @@ def test_create_venv_uses_patch_version_to_detect_compatibility( config_virtualenvs_path: Path, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: config.config["virtualenvs"]["use-poetry-python"] = True if "VIRTUAL_ENV" in os.environ: @@ -1064,10 +1038,12 @@ def test_create_venv_uses_patch_version_to_detect_compatibility( ) assert version.patch is not None - mocker.patch("sys.version_info", (version.major, version.minor, version.patch + 1)) + python = mocked_python_register( + f"{version.major}.{version.minor}.{version.patch + 1}" + ) mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(Version.parse("3.6.9")), + "poetry.utils.env.python_manager.Python.get_system_python", + return_value=python, ) m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" @@ -1077,7 +1053,7 @@ def test_create_venv_uses_patch_version_to_detect_compatibility( m.assert_called_with( config_virtualenvs_path / f"{venv_name}-py{version.major}.{version.minor}", - executable=Path(sys.executable), + executable=python.executable, flags=venv_flags_default, prompt=f"simple-project-py{version.major}.{version.minor}", ) @@ -1091,35 +1067,30 @@ def test_create_venv_uses_patch_version_to_detect_compatibility_with_executable( config_virtualenvs_path: Path, venv_name: str, venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] version = Version.from_parts(*sys.version_info[:3]) assert version.minor is not None - poetry.package.python_versions = f"~{version.major}.{version.minor - 1}.0" + poetry.package.python_versions = "~3.6.0" venv_name = manager.generate_env_name( "simple-project", str(poetry.file.path.parent) ) - check_output = mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper( - Version.parse(f"{version.major}.{version.minor - 1}.0") - ), - ) + mocked_python_register("3.6.0") m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" ) - manager.create_venv(executable=Path(f"python{version.major}.{version.minor - 1}")) + manager.create_venv(python=mocked_python_register("3.6.0")) - assert check_output.called m.assert_called_with( - config_virtualenvs_path / f"{venv_name}-py{version.major}.{version.minor - 1}", - executable=Path(f"python{version.major}.{version.minor - 1}"), + config_virtualenvs_path / f"{venv_name}-py3.6", + executable=Path("/usr/bin/python3.6"), flags=venv_flags_default, - prompt=f"simple-project-py{version.major}.{version.minor - 1}", + prompt="simple-project-py3.6", ) @@ -1157,6 +1128,7 @@ def test_create_venv_project_name_empty_sets_correct_prompt( config: Config, mocker: MockerFixture, config_virtualenvs_path: Path, + mocked_python_register: MockedPythonRegister, ) -> None: config.config["virtualenvs"]["use-poetry-python"] = True if "VIRTUAL_ENV" in os.environ: @@ -1170,12 +1142,9 @@ def test_create_venv_project_name_empty_sets_correct_prompt( "non-package-mode", str(poetry.file.path.parent) ) - mocker.patch("sys.version_info", (2, 7, 16)) - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - mocker.patch( - "subprocess.check_output", - side_effect=check_output_wrapper(Version.parse("3.7.5")), - ) + mocked_python_register("2.7.16", make_system=True) + mocked_python_register("3.7.1", "python3") + m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" ) @@ -1201,6 +1170,7 @@ def test_create_venv_accepts_fallback_version_w_nonzero_patchlevel( mocker: MockerFixture, config_virtualenvs_path: Path, venv_name: str, + mocked_python_register: MockedPythonRegister, ) -> None: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] @@ -1219,18 +1189,14 @@ def mock_check_output(cmd: str, *args: Any, **kwargs: Any) -> str: return "/usr/bin/python3.5" - mocker.patch("shutil.which", side_effect=lambda py: f"/usr/bin/{py}") - check_output = mocker.patch( - "subprocess.check_output", - side_effect=mock_check_output, - ) + mocked_python_register("3.5.12") + m = mocker.patch( "poetry.utils.env.EnvManager.build_venv", side_effect=lambda *args, **kwargs: "" ) manager.create_venv() - assert check_output.called m.assert_called_with( config_virtualenvs_path / f"{venv_name}-py3.5", executable=Path("/usr/bin/python3.5"), diff --git a/tests/utils/test_python_manager.py b/tests/utils/test_python_manager.py index 9113bd1bf4d..fed1d0e81c5 100644 --- a/tests/utils/test_python_manager.py +++ b/tests/utils/test_python_manager.py @@ -3,13 +3,15 @@ import os import subprocess import sys +import textwrap from pathlib import Path from typing import TYPE_CHECKING +import findpython +import packaging.version import pytest -from cleo.io.null_io import NullIO from poetry.core.constraints.version import Version from poetry.utils.env.python_manager import Python @@ -20,13 +22,13 @@ from pytest_mock import MockerFixture from poetry.config.config import Config + from tests.types import MockedPythonRegister from tests.types import ProjectFactory def test_python_get_version_on_the_fly() -> None: - python = Python(executable=sys.executable) + python = Python.get_system_python() - assert python.executable == Path(sys.executable) assert python.version == Version.parse( ".".join([str(s) for s in sys.version_info[:3]]) ) @@ -41,7 +43,7 @@ def test_python_get_version_on_the_fly() -> None: def test_python_get_system_python() -> None: python = Python.get_system_python() - assert python.executable == Path(sys.executable) + assert python.executable == findpython.find().executable assert python.version == Version.parse( ".".join(str(v) for v in sys.version_info[:3]) ) @@ -59,6 +61,13 @@ def test_python_get_preferred_default(config: Config) -> None: def test_get_preferred_python_use_poetry_python_disabled( config: Config, mocker: MockerFixture ) -> None: + mocker.patch( + "findpython.find", + return_value=findpython.PythonVersion( + executable=Path("/usr/bin/python3.7"), + _version=packaging.version.Version("3.7.1"), + ), + ) mocker.patch( "subprocess.check_output", side_effect=check_output_wrapper(Version.parse("3.7.1")), @@ -85,32 +94,47 @@ def test_get_preferred_python_use_poetry_python_disabled_fallback( def test_fallback_on_detect_active_python(mocker: MockerFixture) -> None: m = mocker.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "some command"), + "findpython.find", + return_value=None, ) - - active_python = Python._detect_active_python(NullIO()) - + active_python = Python.get_active_python() assert active_python is None assert m.call_count == 1 @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_detect_active_python_with_bat(tmp_path: Path) -> None: +def test_detect_active_python_with_bat( + tmp_path: Path, without_mocked_findpython: None +) -> None: """On Windows pyenv uses batch files for python management.""" python_wrapper = tmp_path / "python.bat" - wrapped_python = Path(r"C:\SpecialPython\python.exe") - encoding = "locale" if sys.version_info >= (3, 10) else None - with python_wrapper.open("w", encoding=encoding) as f: - f.write(f"@echo {wrapped_python}") - os.environ["PATH"] = str(python_wrapper.parent) + os.pathsep + os.environ["PATH"] - active_python = Python._detect_active_python(NullIO()) + os.environ["PATH"] = "" + assert Python.get_active_python() is None - assert active_python == wrapped_python + encoding = "locale" if sys.version_info >= (3, 10) else None + with python_wrapper.open("w", encoding=encoding) as f: + f.write( + textwrap.dedent(f""" + @echo off + SET PYTHON_EXE="{sys.executable}" + %PYTHON_EXE% %* + """) + ) + os.environ["PATH"] = str(python_wrapper.parent) + + active_python = Python.get_active_python() + + assert active_python is not None + assert ( + active_python.python.real_path.as_posix() + == Path(sys.executable).resolve().as_posix() + ) -def test_python_find_compatible(project_factory: ProjectFactory) -> None: +def test_python_find_compatible( + project_factory: ProjectFactory, mocked_python_register: MockedPythonRegister +) -> None: # Note: This test may fail on Windows systems using Python from the Microsoft Store, # as the executable is named `py.exe`, which is not currently recognized by # Python.get_compatible_python. This issue will be resolved in #2117. @@ -118,6 +142,7 @@ def test_python_find_compatible(project_factory: ProjectFactory) -> None: # Python interpreter is used before attempting to find another compatible version. fixture = Path(__file__).parent.parent / "fixtures" / "simple_project" poetry = project_factory("simple-project", source=fixture) + mocked_python_register("3.12") python = Python.get_compatible_python(poetry) assert Version.from_parts(3, 4) <= python.version <= Version.from_parts(4, 0)