|
6 | 6 | import sys |
7 | 7 | from abc import ABC |
8 | 8 | from contextlib import redirect_stderr |
| 9 | +from dataclasses import dataclass |
9 | 10 | from io import StringIO |
10 | 11 | from pathlib import Path |
11 | 12 | from typing import TYPE_CHECKING, Any, cast |
12 | 13 |
|
| 14 | +from packaging.version import Version |
| 15 | +from python_discovery import get_interpreter |
13 | 16 | from virtualenv import __version__ as virtualenv_version |
14 | 17 | from virtualenv import app_data, session_via_cli |
15 | 18 | from virtualenv.discovery.py_spec import PythonSpec |
|
19 | 22 | from tox.tox_env.errors import Skip |
20 | 23 | from tox.tox_env.python.api import Python, PythonInfo, VersionInfo |
21 | 24 | from tox.tox_env.python.pip.pip_install import Pip |
22 | | -from tox.tox_env.python.virtual_env.subprocess_adapter import SubprocessCreator, SubprocessSession |
| 25 | +from tox.tox_env.python.virtual_env.subprocess_adapter import SubprocessCreator, SubprocessPythonInfo, SubprocessSession |
23 | 26 |
|
24 | 27 | if TYPE_CHECKING: |
| 28 | + from python_discovery import PyInfoCache |
25 | 29 | from virtualenv.create.creator import Creator |
26 | 30 | from virtualenv.create.describe import Describe |
27 | 31 | from virtualenv.discovery.py_info import PythonInfo as VirtualenvPythonInfo |
28 | 32 | from virtualenv.run.session import Session |
29 | 33 |
|
| 34 | + from tox.config.main import Config |
30 | 35 | from tox.execute.api import Execute |
31 | 36 | from tox.tox_env.api import ToxEnvCreateArgs |
32 | 37 |
|
@@ -72,12 +77,16 @@ def register_config(self) -> None: |
72 | 77 | self.conf.add_config( |
73 | 78 | keys=["virtualenv_spec"], |
74 | 79 | of_type=str, |
75 | | - default="", |
| 80 | + default=self._default_virtualenv_spec, |
76 | 81 | desc="PEP 440 version spec for virtualenv (e.g. virtualenv<20.22.0). When set, tox bootstraps this " |
77 | 82 | "version in an isolated environment and runs it via subprocess, enabling Python versions " |
78 | | - "incompatible with the installed virtualenv.", |
| 83 | + "incompatible with the installed virtualenv. Left empty it is derived automatically: tox pins an " |
| 84 | + "older virtualenv only when the installed one can no longer create the targeted Python version.", |
79 | 85 | ) |
80 | 86 |
|
| 87 | + def _default_virtualenv_spec(self, conf: Config, name: str | None) -> str: # noqa: ARG002 |
| 88 | + return _auto_virtualenv_spec(self.conf["base_python"], virtualenv_version) |
| 89 | + |
81 | 90 | @property |
82 | 91 | def executor(self) -> Execute: |
83 | 92 | if self._executor is None: |
@@ -128,10 +137,18 @@ def session(self) -> Session | SubprocessSession: |
128 | 137 | def _create_subprocess_session(self, spec: str, env: dict[str, str]) -> SubprocessSession: |
129 | 138 | from .subprocess_adapter import ensure_bootstrap, probe_python # noqa: PLC0415 |
130 | 139 |
|
131 | | - bootstrap_python = ensure_bootstrap(cast("Path", self.core["work_dir"]), spec) |
132 | | - base_pythons: list[str] = self.conf["base_python"] |
133 | | - interpreter = next((info for bp in base_pythons if (info := probe_python(bp)) is not None), None) |
134 | | - return SubprocessSession(self.env_dir, bootstrap_python, env, interpreter) |
| 140 | + try_first_with = getattr(self.options, "discover", None) |
| 141 | + cache = _shared_app_data() |
| 142 | + interpreter: SubprocessPythonInfo | None = None |
| 143 | + for base_python in cast("list[str]", self.conf["base_python"]): |
| 144 | + resolved = get_interpreter(base_python, try_first_with=try_first_with, cache=cache, env=env) |
| 145 | + if resolved is None or (executable := resolved.system_executable) is None: |
| 146 | + continue |
| 147 | + if (interpreter := probe_python(executable)) is not None: |
| 148 | + break |
| 149 | + # only pay the bootstrap cost once an interpreter is found; a missing one skips without it |
| 150 | + bootstrap = ensure_bootstrap(cast("Path", self.core["work_dir"]), spec) if interpreter is not None else None |
| 151 | + return SubprocessSession(self.env_dir, bootstrap, env, interpreter) |
135 | 152 |
|
136 | 153 | def _create_imported_session(self, env: dict[str, str]) -> Session: |
137 | 154 | env_dir = [str(self.env_dir)] |
@@ -253,12 +270,61 @@ def get_virtualenv_py_info(path: Path) -> VirtualenvPythonInfo: |
253 | 270 | from virtualenv.discovery import cached_py_info # noqa: PLC0415 |
254 | 271 | from virtualenv.discovery.py_info import PythonInfo as VirtualenvPythonInfo # noqa: PLC0415 |
255 | 272 |
|
256 | | - result = cached_py_info.from_exe( |
257 | | - VirtualenvPythonInfo, |
258 | | - app_data.make_app_data(None, read_only=False, env=os.environ), |
259 | | - str(path), |
260 | | - ) |
| 273 | + result = cached_py_info.from_exe(VirtualenvPythonInfo, _shared_app_data(), str(path)) |
261 | 274 | if result is None: |
262 | 275 | msg = f"could not query python information for {path}" |
263 | 276 | raise RuntimeError(msg) |
264 | 277 | return result |
| 278 | + |
| 279 | + |
| 280 | +def _shared_app_data() -> PyInfoCache: |
| 281 | + """Interpreter metadata cache, shared so tox discovery reuses what virtualenv already probed (and vice versa).""" |
| 282 | + return app_data.make_app_data(None, read_only=False, env=os.environ) |
| 283 | + |
| 284 | + |
| 285 | +def _auto_virtualenv_spec(base_pythons: list[str], installed: str) -> str: |
| 286 | + """Pin an older virtualenv when the installed one cannot create the targeted Python. |
| 287 | +
|
| 288 | + Returns an empty string unless *every* candidate in ``base_pythons`` targets a Python the installed virtualenv can |
| 289 | + no longer create an environment for -- only then is a downgrade guaranteed to be required, since tox picks the first |
| 290 | + candidate that resolves on the host and any supported candidate offers a path to success without bootstrapping. |
| 291 | +
|
| 292 | + """ |
| 293 | + installed_version = Version(installed) |
| 294 | + floors: list[Version] = [] |
| 295 | + for base_python in base_pythons: |
| 296 | + spec = PythonSpec.from_string_spec(base_python) |
| 297 | + if spec.major is None or spec.minor is None: |
| 298 | + return "" # target version is unknown, so we cannot be sure creation would fail |
| 299 | + target = _PyVersion(major=spec.major, minor=spec.minor) |
| 300 | + floor = min((d.dropped_in for d in _VIRTUALENV_DROPS if target <= d.newest_unsupported), default=None) |
| 301 | + if floor is None or installed_version < floor: |
| 302 | + return "" # the installed virtualenv can still create this target |
| 303 | + floors.append(floor) |
| 304 | + if not floors: |
| 305 | + return "" |
| 306 | + return f"virtualenv<{min(floors)}" |
| 307 | + |
| 308 | + |
| 309 | +@dataclass(frozen=True, kw_only=True, order=True) |
| 310 | +class _PyVersion: |
| 311 | + """A ``(major, minor)`` Python version, ordered oldest-to-newest.""" |
| 312 | + |
| 313 | + major: int |
| 314 | + minor: int |
| 315 | + |
| 316 | + |
| 317 | +@dataclass(frozen=True, kw_only=True) |
| 318 | +class _VirtualenvDrop: |
| 319 | + """A virtualenv release that dropped the ability to create environments for older Python versions.""" |
| 320 | + |
| 321 | + newest_unsupported: _PyVersion |
| 322 | + dropped_in: Version |
| 323 | + |
| 324 | + |
| 325 | +# virtualenv releases that dropped the ability to *create* environments for a target Python, with the newest |
| 326 | +# (major, minor) each stopped supporting -- https://virtualenv.pypa.io/en/latest/reference/compatibility.html |
| 327 | +_VIRTUALENV_DROPS: tuple[_VirtualenvDrop, ...] = ( |
| 328 | + _VirtualenvDrop(newest_unsupported=_PyVersion(major=3, minor=8), dropped_in=Version("21.5.0")), |
| 329 | + _VirtualenvDrop(newest_unsupported=_PyVersion(major=3, minor=6), dropped_in=Version("20.22.0")), |
| 330 | +) |
0 commit comments