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)