Skip to content

Commit 2bbb35c

Browse files
🐛 fix(seed): refuse to seed unsupported Python versions (#3173)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 7042705 commit 2bbb35c

11 files changed

Lines changed: 226 additions & 34 deletions

File tree

docs/changelog/3171.bugfix.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Refuse to create environments whose Python the bundled wheels no longer cover (currently below 3.9). virtualenv used to
2+
substitute the newest bundled ``pip``, which cannot run on such a target, leaving a broken environment; seeder selection
3+
now rejects it up front with a clear error. ``--no-seed`` and third-party seeders that ship compatible wheels still work
4+
- by :user:`gaborbernat`.

docs/explanation.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,10 @@ it can discover on the system, provided a matching creator exists.
116116
- Linux, macOS, Windows
117117
- Minimal test coverage, marked experimental.
118118

119-
Seed packages (``pip``, ``setuptools``) are bundled for CPython 3.9 through 3.16. Target interpreters outside this range
120-
use the highest available bundled version as a fallback, which may or may not be compatible.
119+
Seed packages (``pip``, ``setuptools``) are bundled for CPython 3.9 through 3.16. A target newer than the highest
120+
bundled version reuses the newest bundle. A target older than the oldest bundled version has no compatible bundled
121+
wheel, so the bundled seeders refuse it before the environment is created; pass ``--no-seed`` for an empty environment,
122+
or select a seeder that ships wheels for that version.
121123

122124
**********************
123125
How virtualenv works
@@ -448,7 +450,9 @@ uses. They want to align virtualenv's bundled packages with system package versi
448450

449451
Distributions can patch the ``virtualenv.seed.wheels.embed`` module, replacing the ``get_embed_wheel`` function with
450452
their own implementation that returns distribution-provided wheels. If they want to use virtualenv's test suite for
451-
validation, they should also provide the ``BUNDLE_FOLDER``, ``BUNDLE_SUPPORT``, and ``MAX`` variables.
453+
validation, they should also provide the ``BUNDLE_FOLDER``, ``BUNDLE_SUPPORT``, ``MIN``, ``MAX``, and
454+
``OLDEST_SUPPORTED`` variables. ``OLDEST_SUPPORTED`` (the parsed form of ``MIN``) sets the floor below which the bundled
455+
seeders refuse to seed, so a distribution that bundles wheels for an older Python must lower it to match.
452456

453457
Distributions should also consider patching ``virtualenv.seed.embed.base_embed.PERIODIC_UPDATE_ON_BY_DEFAULT`` to
454458
``False``, allowing the system package manager to control seed package updates rather than virtualenv's periodic update

docs/plugin/how-to.rst

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,31 @@ Implement the ``Discover`` interface:
1414

1515
.. code-block:: python
1616
17+
from __future__ import annotations
18+
19+
from argparse import ArgumentParser
20+
21+
from virtualenv.config.cli.parser import VirtualEnvOptions
1722
from virtualenv.discovery.discover import Discover
1823
from virtualenv.discovery.py_info import PythonInfo
1924
2025
2126
class CustomDiscovery(Discover):
2227
@classmethod
23-
def add_parser_arguments(cls, parser):
28+
def add_parser_arguments(cls, parser: ArgumentParser) -> None:
2429
parser.add_argument("--custom-opt", help="custom discovery option")
2530
26-
def __init__(self, options):
31+
def __init__(self, options: VirtualEnvOptions) -> None:
2732
super().__init__(options)
2833
self.custom_opt = options.custom_opt
2934
30-
def run(self):
35+
def run(self) -> PythonInfo | None:
3136
# Locate Python interpreter and return PythonInfo
32-
python_exe = self._find_python()
33-
return PythonInfo.from_exe(str(python_exe))
37+
return PythonInfo.from_exe(str(self._find_python()))
3438
35-
def _find_python(self):
39+
def _find_python(self) -> str:
3640
# Implementation-specific logic
37-
pass
41+
...
3842
3943
Register the entry point:
4044

@@ -53,19 +57,32 @@ Implement the ``Creator`` interface:
5357

5458
.. code-block:: python
5559
56-
from virtualenv.create.creator import Creator
60+
from __future__ import annotations
61+
62+
from argparse import ArgumentParser
63+
64+
from virtualenv.app_data.base import AppData
65+
from virtualenv.config.cli.parser import VirtualEnvOptions
66+
from virtualenv.create.creator import Creator, CreatorMeta
67+
from virtualenv.discovery.py_info import PythonInfo
5768
5869
5970
class CustomCreator(Creator):
6071
@classmethod
61-
def add_parser_arguments(cls, parser, interpreter):
72+
def add_parser_arguments(
73+
cls,
74+
parser: ArgumentParser,
75+
interpreter: PythonInfo,
76+
meta: CreatorMeta,
77+
app_data: AppData,
78+
) -> None:
6279
parser.add_argument("--custom-creator-opt", help="custom creator option")
6380
64-
def __init__(self, options, interpreter):
81+
def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> None:
6582
super().__init__(options, interpreter)
6683
self.custom_opt = options.custom_creator_opt
6784
68-
def create(self):
85+
def create(self) -> None:
6986
# Create directory structure
7087
self.bin_dir.mkdir(parents=True, exist_ok=True)
7188
# Copy or symlink Python executable
@@ -89,29 +106,51 @@ Register the entry point using a naming pattern that matches platform and Python
89106

90107
Seeder plugins install initial packages into the virtual environment. Register under ``virtualenv.seed``.
91108

109+
Override ``cannot_seed`` to reject target interpreters the seeder does not support. The base returns ``None`` for every
110+
interpreter; return a message instead and selection rejects the seeder before creating the environment, surfacing your
111+
message to the user. A plugin can therefore serve Python versions the bundled seeders no longer ship wheels for, such as
112+
a version past its support window.
113+
92114
Implement the ``Seeder`` interface:
93115

94116
.. code-block:: python
95117
118+
from __future__ import annotations
119+
120+
from argparse import ArgumentParser
121+
122+
from virtualenv.app_data.base import AppData
123+
from virtualenv.config.cli.parser import VirtualEnvOptions
124+
from virtualenv.create.creator import Creator
125+
from virtualenv.discovery.py_info import PythonInfo
96126
from virtualenv.seed.seeder import Seeder
97127
98128
99129
class CustomSeeder(Seeder):
100130
@classmethod
101-
def add_parser_arguments(cls, parser, interpreter, app_data):
131+
def add_parser_arguments(
132+
cls, parser: ArgumentParser, interpreter: PythonInfo, app_data: AppData
133+
) -> None:
102134
parser.add_argument("--custom-seed-opt", help="custom seeder option")
103135
104-
def __init__(self, options, enabled, app_data):
105-
super().__init__(options, enabled, app_data)
136+
@classmethod
137+
def cannot_seed(cls, interpreter: PythonInfo) -> str | None:
138+
# ship wheels down to Python 3.6, for example
139+
if interpreter.version_info[:2] >= (3, 6):
140+
return None
141+
return "custom seeder ships wheels only for Python 3.6 and later"
142+
143+
def __init__(self, options: VirtualEnvOptions, enabled: bool) -> None:
144+
super().__init__(options, enabled)
106145
self.custom_opt = options.custom_seed_opt
107146
108-
def run(self, creator):
147+
def run(self, creator: Creator) -> None:
109148
# Install packages into creator.bin_dir / creator.script("pip")
110149
self._install_packages(creator)
111150
112-
def _install_packages(self, creator):
151+
def _install_packages(self, creator: Creator) -> None:
113152
# Implementation-specific logic
114-
pass
153+
...
115154
116155
Register the entry point:
117156

@@ -130,18 +169,24 @@ Implement the ``Activator`` interface:
130169

131170
.. code-block:: python
132171
172+
from __future__ import annotations
173+
174+
from pathlib import Path
175+
133176
from virtualenv.activation.activator import Activator
177+
from virtualenv.create.creator import Creator
134178
135179
136180
class CustomShellActivator(Activator):
137-
def generate(self, creator):
181+
def generate(self, creator: Creator) -> list[Path]:
138182
# Generate activation script content
139183
script_content = self._render_template(creator)
140184
# Write to activation directory
141185
dest = creator.bin_dir / self.script_name
142186
dest.write_text(script_content)
187+
return [dest]
143188
144-
def _render_template(self, creator):
189+
def _render_template(self, creator: Creator) -> str:
145190
# Return activation script content
146191
return f"""
147192
# Custom shell activation script
@@ -150,7 +195,7 @@ Implement the ``Activator`` interface:
150195
"""
151196
152197
@property
153-
def script_name(self):
198+
def script_name(self) -> str:
154199
return "activate.custom"
155200
156201
Register the entry point:

docs/plugin/tutorial.rst

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,21 @@ In ``src/virtualenv_pyenv/__init__.py``, implement the discovery plugin by subcl
4949
from __future__ import annotations
5050
5151
import subprocess
52+
from argparse import ArgumentParser
5253
from pathlib import Path
5354
55+
from virtualenv.config.cli.parser import VirtualEnvOptions
5456
from virtualenv.discovery.discover import Discover
5557
from virtualenv.discovery.py_info import PythonInfo
5658
5759
5860
class PyEnvDiscovery(Discover):
59-
def __init__(self, options):
61+
def __init__(self, options: VirtualEnvOptions) -> None:
6062
super().__init__(options)
6163
self.python_spec = options.python if options.python else "python"
6264
6365
@classmethod
64-
def add_parser_arguments(cls, parser):
66+
def add_parser_arguments(cls, parser: ArgumentParser) -> None:
6567
parser.add_argument(
6668
"--python",
6769
dest="python",
@@ -71,7 +73,7 @@ In ``src/virtualenv_pyenv/__init__.py``, implement the discovery plugin by subcl
7173
help="pyenv Python version to use (e.g., 3.11.0)",
7274
)
7375
74-
def run(self):
76+
def run(self) -> PythonInfo | None:
7577
try:
7678
result = subprocess.run(
7779
["pyenv", "which", "python"],
@@ -82,7 +84,7 @@ In ``src/virtualenv_pyenv/__init__.py``, implement the discovery plugin by subcl
8284
python_path = Path(result.stdout.strip())
8385
return PythonInfo.from_exe(str(python_path))
8486
except (subprocess.CalledProcessError, FileNotFoundError) as e:
85-
raise RuntimeError(f"Failed to locate pyenv Python: {e}")
87+
raise RuntimeError(f"Failed to locate pyenv Python: {e}") from e
8688
8789
********************
8890
Install the plugin

docs/reference/compatibility.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Python 3.14.
4646

4747
Major version support changes:
4848

49+
- **21.5.0** (2026-06-13): dropped support for running under and creating environments for Python 3.8 and earlier.
4950
- **20.27.0** (2024-10-17): dropped support for running under Python 3.7 and earlier.
5051
- **20.22.0** (2023-04-19): dropped support for creating environments for Python 3.6 and earlier.
5152
- **20.18.0** (2023-02-06): dropped support for running under Python 3.6 and earlier.

src/virtualenv/run/plugin/seeders.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ def handle_selected_arg_parse(self, options: VirtualEnvOptions) -> str:
4343

4444
def create(self, options: VirtualEnvOptions) -> Seeder:
4545
assert self._impl_class is not None # noqa: S101 # Set by handle_selected_arg_parse
46-
return self._impl_class(options)
46+
seeder = self._impl_class(options)
47+
if seeder.enabled and (reason := seeder.cannot_seed(self.interpreter)) is not None:
48+
raise RuntimeError(reason)
49+
return seeder
4750

4851

4952
__all__ = [

src/virtualenv/seed/embed/base_embed.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from virtualenv.seed.seeder import Seeder
1010
from virtualenv.seed.wheels import Version
11+
from virtualenv.seed.wheels.embed import MIN, OLDEST_SUPPORTED
1112

1213
if TYPE_CHECKING:
1314
from argparse import ArgumentParser
@@ -65,6 +66,28 @@ def distribution_to_versions(self) -> dict[str, str]:
6566
if getattr(self, f"no_{distribution}", None) is False and getattr(self, f"{distribution}_version") != "none"
6667
}
6768

69+
@classmethod
70+
def cannot_seed(cls, interpreter: PythonInfo) -> str | None:
71+
"""Explain why the bundled wheels cannot seed the target Python version.
72+
73+
The embedded pip/setuptools stopped shipping for Pythons below :data:`OLDEST_SUPPORTED`, so seeding one would
74+
install an incompatible wheel.
75+
76+
:param interpreter: the interpreter to be seeded
77+
78+
:returns: ``None`` when the bundled wheels still support the target, otherwise a message naming the target and
79+
the remedies
80+
81+
"""
82+
if interpreter.version_info[:2] >= OLDEST_SUPPORTED:
83+
return None
84+
target = f"{interpreter.version_info.major}.{interpreter.version_info.minor}"
85+
return (
86+
f"the bundled seeder no longer ships pip/setuptools for Python {target}; the oldest supported target is "
87+
f"Python {MIN} - pass --no-seed for an empty environment, use a seeder that provides Python {target} "
88+
f"wheels, or install an older virtualenv release"
89+
)
90+
6891
@classmethod
6992
def add_parser_arguments(cls, parser: ArgumentParser, interpreter: PythonInfo, app_data: AppData) -> None: # noqa: ARG003
7093
group = parser.add_mutually_exclusive_group()

src/virtualenv/seed/seeder.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ def __init__(self, options: VirtualEnvOptions, enabled: bool) -> None:
2626
self.enabled = enabled
2727
self.env = options.env
2828

29+
@classmethod
30+
def cannot_seed(cls, interpreter: PythonInfo) -> str | None: # noqa: ARG003
31+
"""Explain why this seeder cannot install seed packages for the given interpreter.
32+
33+
:param interpreter: the interpreter the environment is based on
34+
35+
:returns: ``None`` when the seeder supports the interpreter, otherwise a message describing why it cannot;
36+
selection rejects a seeder that returns a message and surfaces it to the user
37+
38+
"""
39+
return None
40+
2941
@classmethod
3042
def add_parser_arguments(cls, parser: ArgumentParser, interpreter: PythonInfo, app_data: AppData) -> None:
3143
"""Add CLI arguments for this seed mechanisms.

src/virtualenv/seed/wheels/embed/__init__.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@
4343
},
4444
}
4545
MAX = next(reversed(BUNDLE_SUPPORT))
46+
MIN = next(iter(BUNDLE_SUPPORT))
47+
48+
49+
def _release_tuple(version: str) -> tuple[int, ...]:
50+
return tuple(int(part) for part in version.split("."))
51+
52+
53+
# oldest target Python version virtualenv still bundles seed wheels for; anything below this has no embedded pip
54+
OLDEST_SUPPORTED = _release_tuple(MIN)
4655

4756
# SHA-256 of every bundled wheel. Verified on load so a corrupted or tampered wheel on disk fails loud instead of
4857
# being handed to pip. Generated together with ``BUNDLE_SUPPORT`` by ``tasks/upgrade_wheels.py``.
@@ -55,19 +64,26 @@
5564
_VERIFIED_WHEELS: set[str] = set()
5665

5766

58-
def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None:
67+
def get_embed_wheel(distribution: str, for_py_version: str | None) -> Wheel | None:
5968
"""Return the bundled wheel that ships with virtualenv for a given distribution and Python version.
6069
6170
:param distribution: project name of the seed package, for example ``pip`` or ``setuptools``.
62-
:param for_py_version: major.minor Python version string the environment will be created for.
71+
:param for_py_version: major.minor Python version string the environment will be created for, or ``None`` to use the
72+
newest bundle.
6373
6474
:returns: a :class:`Wheel` pointing at the verified bundled file, or ``None`` when no wheel is bundled for the
65-
requested combination.
75+
requested combination, including target versions below the oldest bundled one.
6676
6777
:raises RuntimeError: if the bundled wheel on disk fails SHA-256 verification.
6878
6979
"""
70-
mapping = BUNDLE_SUPPORT.get(for_py_version, {}) or BUNDLE_SUPPORT[MAX]
80+
if for_py_version is None or _release_tuple(for_py_version) > _release_tuple(MAX):
81+
# no specific target, or a Python newer than anything bundled: reuse the newest bundle
82+
mapping = BUNDLE_SUPPORT[MAX]
83+
else: # versions below the oldest bundled one fall through to None instead of an incompatible newer wheel
84+
mapping = BUNDLE_SUPPORT.get(for_py_version)
85+
if not mapping:
86+
return None
7187
wheel_file = mapping.get(distribution)
7288
if wheel_file is None:
7389
return None
@@ -112,5 +128,7 @@ def _hash_bundled_wheel(path: Path) -> str:
112128
"BUNDLE_SHA256",
113129
"BUNDLE_SUPPORT",
114130
"MAX",
131+
"MIN",
132+
"OLDEST_SUPPORTED",
115133
"get_embed_wheel",
116134
]

0 commit comments

Comments
 (0)