Skip to content

Commit 140733c

Browse files
✨ feat(virtualenv): auto-pin virtualenv for end-of-life Python (#3967)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
1 parent bfbc876 commit 140733c

12 files changed

Lines changed: 239 additions & 49 deletions

File tree

docs/changelog/3960.doc.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix nested list items being rendered with continued numbering instead of as sub-items in ``development.rst``,
2+
``onboarding.rst`` and ``explanation.rst``.

docs/changelog/3965.feature.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Derive :ref:`virtualenv_spec` automatically: when an environment targets a Python version the installed
2+
:pypi:`virtualenv` can no longer create (e.g. ``py38`` with virtualenv ``21.5+``), tox now pins the newest virtualenv
3+
that still supports it and bootstraps it for that environment only. The downgrade happens only when every
4+
``base_python`` candidate is unsupported, so environments targeting current interpreters are unaffected. This fixes
5+
``py38`` (and other end-of-life interpreters) being silently skipped after a virtualenv upgrade - by
6+
:user:`gaborbernat`. (:issue:`3965`)

docs/development.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,10 @@ Navigate to `Actions > Prepare Release <https://github.com/tox-dev/tox/actions/w
304304
2. Select the branch (usually ``main``).
305305
3. Choose the version bump type:
306306

307-
4. ``auto`` (default) - Automatically bump minor if feature changelogs exist, otherwise bump patch.
308-
5. ``major`` - Bump major version (e.g., 4.0.0 → 5.0.0).
309-
6. ``minor`` - Bump minor version (e.g., 4.27.0 → 4.28.0).
310-
7. ``patch`` - Bump patch version (e.g., 4.27.0 → 4.27.1).
307+
- ``auto`` (default) - Automatically bump minor if feature changelogs exist, otherwise bump patch.
308+
- ``major`` - Bump major version (e.g., 4.0.0 → 5.0.0).
309+
- ``minor`` - Bump minor version (e.g., 4.27.0 → 4.28.0).
310+
- ``patch`` - Bump patch version (e.g., 4.27.0 → 4.27.1).
311311

312312
4. Click "Run workflow".
313313

docs/explanation.rst

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,10 @@ The primary tox states are:
9595

9696
Steps 2 and 3 can be selectively skipped with CLI flags:
9797

98-
3. ``--skip-pkg-install`` skips step 3 only (packaging and package installation), while still installing
99-
dependencies.
100-
4. ``--skip-env-install`` skips both steps 2 and 3 entirely, reusing the environment as-is. This is useful when
101-
working offline or when the environment is already fully set up from a previous run. See :ref:`skip-env-install`
102-
for practical usage.
98+
- ``--skip-pkg-install`` skips step 3 only (packaging and package installation), while still installing dependencies.
99+
- ``--skip-env-install`` skips both steps 2 and 3 entirely, reusing the environment as-is. This is useful when
100+
working offline or when the environment is already fully set up from a previous run. See :ref:`skip-env-install`
101+
for practical usage.
103102

104103
4. **Extra setup commands** (optional): run the :ref:`extra_setup_commands` specified. These execute after all
105104
installations complete but before test commands, and run during the ``--notest`` phase.
@@ -677,8 +676,14 @@ installed virtualenv supports all target Python versions, but breaks down at the
677676
only import one virtualenv version per process, projects that need both old and new Pythons in a single ``tox.toml`` hit
678677
a wall.
679678

680-
The :ref:`virtualenv_spec` setting resolves this by decoupling the virtualenv used for environment creation from the one
681-
tox imports. When set, tox:
679+
tox closes this gap automatically: for each environment it checks the targeted :ref:`base_python` against the installed
680+
virtualenv's `version support timeline <https://virtualenv.pypa.io/en/latest/reference/compatibility.html>`_, and when
681+
that interpreter can no longer be created, it derives a :ref:`virtualenv_spec` pinning the newest virtualenv that still
682+
can. To stay conservative it only downgrades when *every* ``base_python`` candidate is unsupported -- otherwise tox may
683+
resolve to a creatable interpreter and no bootstrap is needed.
684+
685+
The :ref:`virtualenv_spec` setting (whether derived or set explicitly) resolves this by decoupling the virtualenv used
686+
for environment creation from the one tox imports. When non-empty, tox:
682687

683688
1. Creates a bootstrap venv (using the stdlib ``venv`` module) in ``.tox/.virtualenv-bootstrap/``.
684689
2. Installs the specified virtualenv version into that bootstrap venv via pip.
@@ -688,9 +693,9 @@ The bootstrap is content-addressed by a hash of the spec string, so different sp
688693
file lock protects against concurrent bootstrap creation (relevant in parallel mode). Once bootstrapped, subsequent runs
689694
skip directly to step 3.
690695

691-
When ``virtualenv_spec`` is empty (the default), tox uses the imported virtualenv with zero overhead -- the subprocess
692-
path only activates when explicitly configured. The spec is included in the environment cache key, so changing it
693-
triggers automatic recreation.
696+
When ``virtualenv_spec`` resolves to empty, tox uses the imported virtualenv with zero overhead -- the subprocess path
697+
only activates for a non-empty spec. The spec is included in the environment cache key, so changing it (or a virtualenv
698+
upgrade that changes the derived value) triggers automatic recreation.
694699

695700
This design mirrors tox's own auto-provisioning mechanism (``requires`` / ``min_version``), where tox bootstraps itself
696701
into a separate environment when the running installation doesn't meet the declared requirements.

docs/how-to/usage.rst

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -985,7 +985,8 @@ logged as warnings and never block the recreation itself.
985985
*****************************************
986986

987987
When a project must support both very old (e.g. Python 3.6) and very new (e.g. Python 3.15) interpreters, no single
988-
virtualenv release covers both. Use :ref:`virtualenv_spec` to pin a different virtualenv version per environment:
988+
virtualenv release covers both. tox handles this automatically -- each environment whose target Python the installed
989+
virtualenv can no longer create transparently bootstraps a compatible older virtualenv (see :ref:`virtualenv_spec`):
989990

990991
.. tab:: TOML
991992

@@ -997,9 +998,6 @@ virtualenv release covers both. Use :ref:`virtualenv_spec` to pin a different vi
997998
deps = ["pytest"]
998999
commands = [["pytest"]]
9991000
1000-
[env."3.6"]
1001-
virtualenv_spec = "virtualenv<20.22.0"
1002-
10031001
.. tab:: INI
10041002

10051003
.. code-block:: ini
@@ -1011,11 +1009,9 @@ virtualenv release covers both. Use :ref:`virtualenv_spec` to pin a different vi
10111009
deps = pytest
10121010
commands = pytest
10131011
1014-
[testenv:3.6]
1015-
virtualenv_spec = virtualenv<20.22.0
1016-
1017-
The ``3.6`` environment uses an older virtualenv that still supports Python 3.6, while other environments use the
1012+
The ``3.6`` environment automatically uses an older virtualenv that still supports Python 3.6, while the others use the
10181013
default (imported) virtualenv. The first run bootstraps the pinned version; subsequent runs reuse the cached bootstrap.
1014+
Set :ref:`virtualenv_spec` on an environment to override the version tox picks.
10191015

10201016
.. _howto_generate_matrix:
10211017

@@ -1748,26 +1744,35 @@ See :ref:`generative-environment-list` for the full range syntax reference.
17481744
Test end-of-life Python versions
17491745
**********************************
17501746

1751-
tox uses :pypi:`virtualenv` under the hood. Newer virtualenv versions drop support for older Python interpreters:
1747+
tox uses :pypi:`virtualenv` under the hood, and newer virtualenv versions drop the ability to create environments for
1748+
older Python interpreters:
17521749

17531750
- `virtualenv 20.22.0 <https://virtualenv.pypa.io/en/latest/changelog.html#v20-22-0-2023-04-19>`_ dropped Python 3.6 and
17541751
earlier
1755-
- `virtualenv 20.27.0 <https://virtualenv.pypa.io/en/latest/changelog.html#v20-27-0-2024-10-17>`_ dropped Python 3.7
1752+
- `virtualenv 21.5.0 <https://virtualenv.pypa.io/en/latest/changelog.html#v21-5-0-2026-06-13>`_ dropped Python 3.8 and
1753+
earlier
17561754

1757-
To test against these versions, pin virtualenv:
1755+
You do not need to configure anything for this: tox inspects the :ref:`base_python` of each environment and, when the
1756+
installed virtualenv can no longer create that interpreter, automatically pins a compatible older virtualenv for that
1757+
environment only (see :ref:`virtualenv_spec`). Environments targeting supported Pythons keep using the installed
1758+
virtualenv, so a single ``tox.toml`` can mix end-of-life and current interpreters:
17581759

17591760
.. tab:: TOML
17601761

17611762
.. code-block:: toml
17621763
1763-
requires = ["virtualenv<20.22.0"]
1764+
env_list = ["py38", "py313"] # py38 transparently bootstraps an older virtualenv, py313 does not
17641765
17651766
.. tab:: INI
17661767

17671768
.. code-block:: ini
17681769
17691770
[tox]
1770-
requires = virtualenv<20.22.0
1771+
env_list = py38, py313
1772+
1773+
Set :ref:`virtualenv_spec` explicitly only to override the automatically chosen pin. Prefer it over pinning virtualenv
1774+
through the top-level ``requires``: ``requires`` swaps the virtualenv for the whole tox process, which then cannot
1775+
create the newer interpreters, whereas ``virtualenv_spec`` is resolved per environment.
17711776

17721777
***************************************
17731778
Use tox with different build backends

docs/onboarding.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -546,12 +546,12 @@ for configuration in this order:
546546
1. If ``--conf`` is specified, it uses that file directly.
547547
2. Otherwise, it walks up from CWD looking for:
548548

549-
3. ``tox.toml`` which uses `TomlSource <https://github.com/tox-dev/tox/blob/main/src/tox/config/source/toml_.py>`_.
550-
4. ``pyproject.toml`` (with ``[tool.tox]``) which uses `PyProjectTomlSource
551-
<https://github.com/tox-dev/tox/blob/main/src/tox/config/source/pyproject.py>`_.
552-
5. ``tox.ini`` which uses `IniSource <https://github.com/tox-dev/tox/blob/main/src/tox/config/source/ini.py>`_.
553-
6. ``setup.cfg`` (with ``[tox:tox]``) which uses `SetupCfgSource
554-
<https://github.com/tox-dev/tox/blob/main/src/tox/config/source/setup_cfg.py>`_.
549+
- ``tox.toml`` which uses `TomlSource <https://github.com/tox-dev/tox/blob/main/src/tox/config/source/toml_.py>`_.
550+
- ``pyproject.toml`` (with ``[tool.tox]``) which uses `PyProjectTomlSource
551+
<https://github.com/tox-dev/tox/blob/main/src/tox/config/source/pyproject.py>`_.
552+
- ``tox.ini`` which uses `IniSource <https://github.com/tox-dev/tox/blob/main/src/tox/config/source/ini.py>`_.
553+
- ``setup.cfg`` (with ``[tox:tox]``) which uses `SetupCfgSource
554+
<https://github.com/tox-dev/tox/blob/main/src/tox/config/source/setup_cfg.py>`_.
555555

556556
3. If nothing is found, it creates an empty `IniSource
557557
<https://github.com/tox-dev/tox/blob/main/src/tox/config/source/ini.py>`_ at CWD.

docs/reference/config.rst

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,15 +1907,22 @@ Python virtual environment
19071907
:keys: virtualenv_spec
19081908
:default: ""
19091909
:version_added: 4.42
1910+
:version_changed: 4.56
19101911

19111912
A :pep:`440` version specifier for virtualenv (e.g. ``virtualenv<20.22.0``). When set, tox bootstraps the specified
19121913
virtualenv version into an isolated environment and drives it via subprocess, instead of using the imported
19131914
virtualenv library. This enables environments targeting Python versions that are incompatible with the virtualenv
19141915
installed alongside tox.
19151916

1917+
Left empty (the default), tox derives the value automatically: it inspects the environment's :ref:`base_python` and,
1918+
only when the installed virtualenv can no longer *create* that interpreter, pins the newest virtualenv that still
1919+
can. The pin is applied for that environment alone, so environments targeting supported Pythons keep using the
1920+
imported virtualenv with zero overhead. tox downgrades only when every ``base_python`` candidate is unsupported by
1921+
the installed virtualenv -- if any candidate would resolve to a creatable interpreter, no pin is applied. Set the
1922+
value explicitly to override this choice.
1923+
19161924
The bootstrap environment is cached under ``.tox/.virtualenv-bootstrap/`` (keyed by a hash of the spec string) and
1917-
reused across runs. Concurrent access is protected by a file lock. When the spec is empty (the default), tox uses
1918-
the imported virtualenv with zero overhead.
1925+
reused across runs. Concurrent access is protected by a file lock.
19191926

19201927
.. tab:: TOML
19211928

docs/tox_conf.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ToxConfig(SphinxDirective):
2424
option_spec: ClassVar[dict[str, Callable[[str], Any]]] = {
2525
"keys": unchanged_required,
2626
"version_added": unchanged,
27+
"version_changed": unchanged,
2728
"version_deprecated": unchanged,
2829
"default": unchanged,
2930
"constant": flag,
@@ -71,6 +72,10 @@ def run(self) -> list[Node]:
7172
line += Text(" 📢 added in ")
7273
ver = self.options["version_added"]
7374
line += literal(ver, ver)
75+
if "version_changed" in self.options:
76+
line += Text(" 🔄 changed in ")
77+
ver = self.options["version_changed"]
78+
line += literal(ver, ver)
7479
if "version_deprecated" in self.options:
7580
line += Text(" ⚠️ deprecated in ")
7681
ver = self.options["version_deprecated"]

src/tox/tox.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@
504504
},
505505
"virtualenv_spec": {
506506
"type": "string",
507-
"description": "PEP 440 version spec for virtualenv (e.g. virtualenv<20.22.0). When set, tox bootstraps this version in an isolated environment and runs it via subprocess, enabling Python versions incompatible with the installed virtualenv."
507+
"description": "PEP 440 version spec for virtualenv (e.g. virtualenv<20.22.0). When set, tox bootstraps this version in an isolated environment and runs it via subprocess, enabling Python versions incompatible with the installed virtualenv. Left empty it is derived automatically: tox pins an older virtualenv only when the installed one can no longer create the targeted Python version."
508508
},
509509
"skip_install": {
510510
"type": "boolean",

src/tox/tox_env/python/virtual_env/api.py

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import sys
77
from abc import ABC
88
from contextlib import redirect_stderr
9+
from dataclasses import dataclass
910
from io import StringIO
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING, Any, cast
1213

14+
from packaging.version import Version
15+
from python_discovery import get_interpreter
1316
from virtualenv import __version__ as virtualenv_version
1417
from virtualenv import app_data, session_via_cli
1518
from virtualenv.discovery.py_spec import PythonSpec
@@ -19,14 +22,16 @@
1922
from tox.tox_env.errors import Skip
2023
from tox.tox_env.python.api import Python, PythonInfo, VersionInfo
2124
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
2326

2427
if TYPE_CHECKING:
28+
from python_discovery import PyInfoCache
2529
from virtualenv.create.creator import Creator
2630
from virtualenv.create.describe import Describe
2731
from virtualenv.discovery.py_info import PythonInfo as VirtualenvPythonInfo
2832
from virtualenv.run.session import Session
2933

34+
from tox.config.main import Config
3035
from tox.execute.api import Execute
3136
from tox.tox_env.api import ToxEnvCreateArgs
3237

@@ -72,12 +77,16 @@ def register_config(self) -> None:
7277
self.conf.add_config(
7378
keys=["virtualenv_spec"],
7479
of_type=str,
75-
default="",
80+
default=self._default_virtualenv_spec,
7681
desc="PEP 440 version spec for virtualenv (e.g. virtualenv<20.22.0). When set, tox bootstraps this "
7782
"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.",
7985
)
8086

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+
8190
@property
8291
def executor(self) -> Execute:
8392
if self._executor is None:
@@ -128,10 +137,18 @@ def session(self) -> Session | SubprocessSession:
128137
def _create_subprocess_session(self, spec: str, env: dict[str, str]) -> SubprocessSession:
129138
from .subprocess_adapter import ensure_bootstrap, probe_python # noqa: PLC0415
130139

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)
135152

136153
def _create_imported_session(self, env: dict[str, str]) -> Session:
137154
env_dir = [str(self.env_dir)]
@@ -253,12 +270,61 @@ def get_virtualenv_py_info(path: Path) -> VirtualenvPythonInfo:
253270
from virtualenv.discovery import cached_py_info # noqa: PLC0415
254271
from virtualenv.discovery.py_info import PythonInfo as VirtualenvPythonInfo # noqa: PLC0415
255272

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))
261274
if result is None:
262275
msg = f"could not query python information for {path}"
263276
raise RuntimeError(msg)
264277
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

Comments
 (0)