Skip to content

Commit 0276db6

Browse files
Add support for PEP 440 version specifiers in the --python flag. (#3008)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 4f900c2 commit 0276db6

File tree

8 files changed

+404
-6
lines changed

8 files changed

+404
-6
lines changed

docs/changelog/2994.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` `.

docs/cli_interface.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ To avoid confusion, it's best to think of them as the "rule" and the "hint".
3838
This flag sets the mandatory requirements for the interpreter. The ``<spec>`` can be:
3939

4040
- **A version string** (e.g., ``python3.8``, ``pypy3``). ``virtualenv`` will search for any interpreter that matches this version.
41+
- **A version specifier** using PEP 440 operators (e.g., ``>=3.12``, ``~=3.11.0``, ``python>=3.10``). ``virtualenv`` will search for any interpreter that satisfies the version constraint. You can also specify the implementation: ``cpython>=3.12``.
4142
- **An absolute path** (e.g., ``/usr/bin/python3.8``). This is a *strict* requirement. Only the interpreter at this exact path will be used. If it does not exist or is not a valid interpreter, creation will fail.
4243

4344
**``--try-first-with <path>``: The Hint**

src/virtualenv/discovery/builtin.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ def add_parser_arguments(cls, parser: ArgumentParser) -> None:
4646
type=str,
4747
action="append",
4848
default=[],
49-
help="interpreter based on what to create environment (path/identifier) "
50-
"- by default use the interpreter where the tool is installed - first found wins",
49+
help="interpreter based on what to create environment (path/identifier/version-specifier) "
50+
"- by default use the interpreter where the tool is installed - first found wins. "
51+
"Version specifiers (e.g., >=3.12, ~=3.11.0, ==3.10) are also supported",
5152
)
5253
parser.add_argument(
5354
"--try-first-with",

src/virtualenv/discovery/py_info.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ def clear_cache(cls, app_data):
393393
clear(app_data)
394394
cls._cache_exe_discovery.clear()
395395

396-
def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911
396+
def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911, PLR0912
397397
"""Check if a given specification can be satisfied by the this python interpreter instance."""
398398
if spec.path:
399399
if self.executable == os.path.abspath(spec.path):
@@ -422,6 +422,20 @@ def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911
422422
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
423423
return False
424424

425+
if spec.version_specifier is not None:
426+
version_info = self.version_info
427+
release = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
428+
if version_info.releaselevel != "final":
429+
suffix = {
430+
"alpha": "a",
431+
"beta": "b",
432+
"candidate": "rc",
433+
}.get(version_info.releaselevel)
434+
if suffix is not None:
435+
release = f"{release}{suffix}{version_info.serial}"
436+
if not spec.version_specifier.contains(release):
437+
return False
438+
425439
for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)):
426440
if req is not None and our is not None and our != req:
427441
return False

src/virtualenv/discovery/py_spec.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22

33
from __future__ import annotations
44

5+
import contextlib
56
import os
67
import re
78

9+
from virtualenv.util.specifier import SimpleSpecifierSet, SimpleVersion
10+
811
PATTERN = re.compile(r"^(?P<impl>[a-zA-Z]+)?(?P<version>[0-9.]+)?(?P<threaded>t)?(?:-(?P<arch>32|64))?$")
12+
SPECIFIER_PATTERN = re.compile(r"^(?:(?P<impl>[A-Za-z]+)\s*)?(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+)$")
913

1014

1115
class PythonSpec:
@@ -22,6 +26,7 @@ def __init__( # noqa: PLR0913
2226
path: str | None,
2327
*,
2428
free_threaded: bool | None = None,
29+
version_specifier: SpecifierSet | None = None,
2530
) -> None:
2631
self.str_spec = str_spec
2732
self.implementation = implementation
@@ -31,10 +36,12 @@ def __init__( # noqa: PLR0913
3136
self.free_threaded = free_threaded
3237
self.architecture = architecture
3338
self.path = path
39+
self.version_specifier = version_specifier
3440

3541
@classmethod
3642
def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912
3743
impl, major, minor, micro, threaded, arch, path = None, None, None, None, None, None, None
44+
version_specifier = None
3845
if os.path.isabs(string_spec): # noqa: PLR1702
3946
path = string_spec
4047
else:
@@ -72,9 +79,41 @@ def _int_or_none(val):
7279
arch = _int_or_none(groups["arch"])
7380

7481
if not ok:
82+
specifier_match = SPECIFIER_PATTERN.match(string_spec.strip())
83+
if specifier_match and SpecifierSet is not None:
84+
impl = specifier_match.group("impl")
85+
spec_text = specifier_match.group("spec").strip()
86+
try:
87+
version_specifier = SpecifierSet(spec_text)
88+
except InvalidSpecifier:
89+
pass
90+
else:
91+
if impl in {"py", "python"}:
92+
impl = None
93+
return cls(
94+
string_spec,
95+
impl,
96+
None,
97+
None,
98+
None,
99+
None,
100+
None,
101+
free_threaded=None,
102+
version_specifier=version_specifier,
103+
)
75104
path = string_spec
76105

77-
return cls(string_spec, impl, major, minor, micro, arch, path, free_threaded=threaded)
106+
return cls(
107+
string_spec,
108+
impl,
109+
major,
110+
minor,
111+
micro,
112+
arch,
113+
path,
114+
free_threaded=threaded,
115+
version_specifier=version_specifier,
116+
)
78117

79118
def generate_re(self, *, windows: bool) -> re.Pattern:
80119
"""Generate a regular expression for matching against a filename."""
@@ -102,7 +141,36 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
102141
def is_abs(self):
103142
return self.path is not None and os.path.isabs(self.path)
104143

105-
def satisfies(self, spec):
144+
def _check_version_specifier(self, spec):
145+
"""Check if version specifier is satisfied."""
146+
components: list[int] = []
147+
for part in (self.major, self.minor, self.micro):
148+
if part is None:
149+
break
150+
components.append(part)
151+
if not components:
152+
return True
153+
154+
version_str = ".".join(str(part) for part in components)
155+
with contextlib.suppress(InvalidVersion):
156+
Version(version_str)
157+
for item in spec.version_specifier:
158+
# Check precision requirements
159+
required_precision = self._get_required_precision(item)
160+
if required_precision is None or len(components) < required_precision:
161+
continue
162+
if not item.contains(version_str):
163+
return False
164+
return True
165+
166+
@staticmethod
167+
def _get_required_precision(item):
168+
"""Get the required precision for a specifier item."""
169+
with contextlib.suppress(AttributeError, ValueError):
170+
return len(item.version.release)
171+
return None
172+
173+
def satisfies(self, spec): # noqa: PLR0911
106174
"""Called when there's a candidate metadata spec to see if compatible - e.g. PEP-514 on Windows."""
107175
if spec.is_abs and self.is_abs and self.path != spec.path:
108176
return False
@@ -113,17 +181,39 @@ def satisfies(self, spec):
113181
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
114182
return False
115183

184+
if spec.version_specifier is not None and not self._check_version_specifier(spec):
185+
return False
186+
116187
for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)):
117188
if req is not None and our is not None and our != req:
118189
return False
119190
return True
120191

121192
def __repr__(self) -> str:
122193
name = type(self).__name__
123-
params = "implementation", "major", "minor", "micro", "architecture", "path", "free_threaded"
194+
params = (
195+
"implementation",
196+
"major",
197+
"minor",
198+
"micro",
199+
"architecture",
200+
"path",
201+
"free_threaded",
202+
"version_specifier",
203+
)
124204
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
125205

126206

207+
# Create aliases for backward compatibility
208+
SpecifierSet = SimpleSpecifierSet
209+
Version = SimpleVersion
210+
InvalidSpecifier = ValueError
211+
InvalidVersion = ValueError
212+
127213
__all__ = [
214+
"InvalidSpecifier",
215+
"InvalidVersion",
128216
"PythonSpec",
217+
"SpecifierSet",
218+
"Version",
129219
]

0 commit comments

Comments
 (0)