Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
527ee7b
Fix: Remove invalid false argument from --skip-missing-interpreters flag
rahuldevikar Jan 2, 2026
c48910c
Remove non-existent --skip-missing-interpreters flag from tox commands
rahuldevikar Jan 2, 2026
90b4efb
Upgrade tox to >=4.0 for Python 3.14 compatibility
rahuldevikar Jan 2, 2026
57adcd0
Remove tox version constraint to resolve circular dependency
rahuldevikar Jan 2, 2026
6d9f352
Use Python 3.13 to install tox for Python 3.14 compatibility
rahuldevikar Jan 2, 2026
9d5532b
Fix: Allow uv to download Python 3.13 for tox
rahuldevikar Jan 2, 2026
8c647a5
Pin tox to 4.11.3 for compatibility
rahuldevikar Jan 2, 2026
5c8bede
Use tox 4.0.14 for virtualenv compatibility
rahuldevikar Jan 2, 2026
483572f
Remove --with . from tox install to avoid version conflict
rahuldevikar Jan 2, 2026
6609545
Remove tox-uv dependency to fix version conflict
rahuldevikar Jan 2, 2026
b09281e
Use Python 3.14 for tox to avoid download delay
rahuldevikar Jan 2, 2026
54e3972
Add --skip-pkg-install to setup step and add timeout
rahuldevikar Jan 2, 2026
ac36051
Remove --skip-pkg-install from setup step to install dependencies
rahuldevikar Jan 2, 2026
4e71d01
Revert Workflow Changes
rahuldevikar Jan 2, 2026
55e1e84
Pin tox v4 in CI
rahuldevikar Jan 2, 2026
9c56d75
Pin tox v4 in CI
rahuldevikar Jan 2, 2026
60e3deb
Pin tox v4 in CI
rahuldevikar Jan 2, 2026
4ddf0b8
Fetch upstream tags in CI
rahuldevikar Jan 2, 2026
1ce27ed
Add PEP 440 version specifier support for --python flag
rahuldevikar Jan 3, 2026
a5d3f70
Make packaging library optional in py_spec.py for zipapp compatibility
rahuldevikar Jan 3, 2026
64c256e
increase test timeout
rahuldevikar Jan 3, 2026
6bc406e
Add linter fixes, changelog entry, and documentation
rahuldevikar Jan 3, 2026
cf36c32
Update documentation
rahuldevikar Jan 3, 2026
b504535
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 3, 2026
4afaf6c
Retrigger CI checks
rahuldevikar Jan 3, 2026
7faed93
revert check.yml and increase timeout
rahuldevikar Jan 3, 2026
92e977f
Merge branch 'pypa:main' into users/rahuldevikar/fix2994_clean
rahuldevikar Jan 5, 2026
4c6a1ad
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 5, 2026
e79ed17
Merge branch 'main' into users/rahuldevikar/fix2994_clean
rahuldevikar Jan 5, 2026
b1ac11f
Merge branch 'users/rahuldevikar/fix2994_clean' of https://github.com…
rahuldevikar Jan 5, 2026
a006810
Add Version Specifier class
rahuldevikar Jan 5, 2026
66f700a
Break into multiple methods
rahuldevikar Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog/2994.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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`` `.
1 change: 1 addition & 0 deletions docs/cli_interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ To avoid confusion, it's best to think of them as the "rule" and the "hint".
This flag sets the mandatory requirements for the interpreter. The ``<spec>`` can be:

- **A version string** (e.g., ``python3.8``, ``pypy3``). ``virtualenv`` will search for any interpreter that matches this version.
- **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``.
- **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.

**``--try-first-with <path>``: The Hint**
Expand Down
5 changes: 3 additions & 2 deletions src/virtualenv/discovery/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ def add_parser_arguments(cls, parser: ArgumentParser) -> None:
type=str,
action="append",
default=[],
help="interpreter based on what to create environment (path/identifier) "
"- by default use the interpreter where the tool is installed - first found wins",
help="interpreter based on what to create environment (path/identifier/version-specifier) "
"- by default use the interpreter where the tool is installed - first found wins. "
"Version specifiers (e.g., >=3.12, ~=3.11.0, ==3.10) are also supported",
)
parser.add_argument(
"--try-first-with",
Expand Down
16 changes: 15 additions & 1 deletion src/virtualenv/discovery/py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ def clear_cache(cls, app_data):
clear(app_data)
cls._cache_exe_discovery.clear()

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

if spec.version_specifier is not None:
version_info = self.version_info
release = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
if version_info.releaselevel != "final":
suffix = {
"alpha": "a",
"beta": "b",
"candidate": "rc",
}.get(version_info.releaselevel)
if suffix is not None:
release = f"{release}{suffix}{version_info.serial}"
if not spec.version_specifier.contains(release):
return False

for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)):
if req is not None and our is not None and our != req:
return False
Expand Down
96 changes: 93 additions & 3 deletions src/virtualenv/discovery/py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

from __future__ import annotations

import contextlib
import os
import re

from virtualenv.util.specifier import SimpleSpecifierSet, SimpleVersion

PATTERN = re.compile(r"^(?P<impl>[a-zA-Z]+)?(?P<version>[0-9.]+)?(?P<threaded>t)?(?:-(?P<arch>32|64))?$")
SPECIFIER_PATTERN = re.compile(r"^(?:(?P<impl>[A-Za-z]+)\s*)?(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+)$")


class PythonSpec:
Expand All @@ -22,6 +26,7 @@ def __init__( # noqa: PLR0913
path: str | None,
*,
free_threaded: bool | None = None,
version_specifier: SpecifierSet | None = None,
) -> None:
self.str_spec = str_spec
self.implementation = implementation
Expand All @@ -31,10 +36,12 @@ def __init__( # noqa: PLR0913
self.free_threaded = free_threaded
self.architecture = architecture
self.path = path
self.version_specifier = version_specifier

@classmethod
def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912
impl, major, minor, micro, threaded, arch, path = None, None, None, None, None, None, None
version_specifier = None
if os.path.isabs(string_spec): # noqa: PLR1702
path = string_spec
else:
Expand Down Expand Up @@ -72,9 +79,41 @@ def _int_or_none(val):
arch = _int_or_none(groups["arch"])

if not ok:
specifier_match = SPECIFIER_PATTERN.match(string_spec.strip())
if specifier_match and SpecifierSet is not None:
impl = specifier_match.group("impl")
spec_text = specifier_match.group("spec").strip()
try:
version_specifier = SpecifierSet(spec_text)
except InvalidSpecifier:
pass
else:
if impl in {"py", "python"}:
impl = None
return cls(
string_spec,
impl,
None,
None,
None,
None,
None,
free_threaded=None,
version_specifier=version_specifier,
)
path = string_spec

return cls(string_spec, impl, major, minor, micro, arch, path, free_threaded=threaded)
return cls(
string_spec,
impl,
major,
minor,
micro,
arch,
path,
free_threaded=threaded,
version_specifier=version_specifier,
)

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

def satisfies(self, spec):
def _check_version_specifier(self, spec):
"""Check if version specifier is satisfied."""
components: list[int] = []
for part in (self.major, self.minor, self.micro):
if part is None:
break
components.append(part)
if not components:
return True

version_str = ".".join(str(part) for part in components)
with contextlib.suppress(InvalidVersion):
Version(version_str)
for item in spec.version_specifier:
# Check precision requirements
required_precision = self._get_required_precision(item)
if required_precision is None or len(components) < required_precision:
continue
if not item.contains(version_str):
return False
return True

@staticmethod
def _get_required_precision(item):
"""Get the required precision for a specifier item."""
with contextlib.suppress(AttributeError, ValueError):
return len(item.version.release)
return None

def satisfies(self, spec): # noqa: PLR0911
"""Called when there's a candidate metadata spec to see if compatible - e.g. PEP-514 on Windows."""
if spec.is_abs and self.is_abs and self.path != spec.path:
return False
Expand All @@ -113,17 +181,39 @@ def satisfies(self, spec):
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False

if spec.version_specifier is not None and not self._check_version_specifier(spec):
return False

for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)):
if req is not None and our is not None and our != req:
return False
return True

def __repr__(self) -> str:
name = type(self).__name__
params = "implementation", "major", "minor", "micro", "architecture", "path", "free_threaded"
params = (
"implementation",
"major",
"minor",
"micro",
"architecture",
"path",
"free_threaded",
"version_specifier",
)
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"


# Create aliases for backward compatibility
SpecifierSet = SimpleSpecifierSet
Version = SimpleVersion
InvalidSpecifier = ValueError
InvalidVersion = ValueError

__all__ = [
"InvalidSpecifier",
"InvalidVersion",
"PythonSpec",
"SpecifierSet",
"Version",
]
Loading
Loading