Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a16fceb
Support Version specifiers for --python
rahuldevikar Jan 2, 2026
513f2d7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 2, 2026
973ba67
Format file
rahuldevikar Jan 2, 2026
4952bed
Ruff errors showing code errors
rahuldevikar Jan 2, 2026
443dd4e
Fix test
rahuldevikar Jan 2, 2026
3f1b0cf
Fix Workflow
rahuldevikar Jan 2, 2026
c57b37d
Fix: Remove invalid false argument from --skip-missing-interpreters flag
rahuldevikar Jan 2, 2026
685f107
Remove non-existent --skip-missing-interpreters flag from tox commands
rahuldevikar Jan 2, 2026
8070961
Upgrade tox to >=4.0 for Python 3.14 compatibility
rahuldevikar Jan 2, 2026
2a5d588
Remove tox version constraint to resolve circular dependency
rahuldevikar Jan 2, 2026
35075f1
Use Python 3.13 to install tox for Python 3.14 compatibility
rahuldevikar Jan 2, 2026
f4c8cc2
Fix: Allow uv to download Python 3.13 for tox
rahuldevikar Jan 2, 2026
a6db5f4
Pin tox to 4.11.3 for compatibility
rahuldevikar Jan 2, 2026
d00b389
Use tox 4.0.14 for virtualenv compatibility
rahuldevikar Jan 2, 2026
6420ba2
Remove --with . from tox install to avoid version conflict
rahuldevikar Jan 2, 2026
c7998e3
Remove tox-uv dependency to fix version conflict
rahuldevikar Jan 2, 2026
b71cf0e
Use Python 3.14 for tox to avoid download delay
rahuldevikar Jan 2, 2026
413fce1
Add --skip-pkg-install to setup step and add timeout
rahuldevikar Jan 2, 2026
6142917
Remove --skip-pkg-install from setup step to install dependencies
rahuldevikar Jan 2, 2026
3113d0f
Revert Workflow Changes
rahuldevikar Jan 2, 2026
143ec3e
Pin tox v4 in CI
rahuldevikar Jan 2, 2026
0c0fdf5
Pin tox v4 in CI
rahuldevikar Jan 2, 2026
2aea7e7
Pin tox v4 in CI
rahuldevikar Jan 2, 2026
be1f041
Fetch upstream tags in CI
rahuldevikar Jan 2, 2026
84905c3
Merge branch 'main' into users/rahuldevikar/fix2994
rahuldevikar Jan 2, 2026
5ce4fa5
Merge branch 'main' into users/rahuldevikar/fix2994
rahuldevikar Jan 2, 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
20 changes: 14 additions & 6 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🏷️ Fetch upstream tags for versioning
shell: bash
run: |
git fetch --force --tags https://github.com/pypa/virtualenv.git
- name: 🐍 Setup Python for tox
uses: actions/setup-python@v5
with:
Expand All @@ -63,9 +67,9 @@ jobs:
shell: bash
run: |
if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then
uv tool install --no-managed-python --python 3.14 tox --with .
uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with .
else
uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with .
uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with tox-uv --with .
fi
- name: 🐍 Setup Python for test ${{ matrix.py }}
uses: actions/setup-python@v5
Expand Down Expand Up @@ -106,7 +110,7 @@ jobs:
echo "TOXENV=$py" >> "$GITHUB_ENV"
echo "Set TOXENV=$py"
- name: 🏗️ Setup test suite
run: tox run -vvvv --notest --skip-missing-interpreters false
run: tox run -vvvv --notest --skip-missing-interpreters
- name: 🏃 Run test suite
run: tox run --skip-pkg-install
timeout-minutes: 20
Expand Down Expand Up @@ -137,12 +141,16 @@ jobs:
- name: 🚀 Install uv
uses: astral-sh/setup-uv@v4
- name: 📦 Install tox
run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv
run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.32" --with tox-uv
- name: 📥 Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🏷️ Fetch upstream tags for versioning
shell: bash
run: |
git fetch --force --tags https://github.com/pypa/virtualenv.git
- name: 🏗️ Setup check suite
run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }}
run: tox run -vv --notest --skip-missing-interpreters -e ${{ matrix.tox_env }}
- name: 🏃 Run check for ${{ matrix.tox_env }}
run: tox run --skip-pkg-install -e ${{ matrix.tox_env }}
run: tox run --skip-pkg-install -e ${{ matrix.tox_env }}
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
67 changes: 43 additions & 24 deletions src/virtualenv/discovery/py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,27 +393,49 @@ 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 _check_path_match(self, spec):
"""Check if path specification matches. Returns None if no path check needed."""
if not spec.path:
return None
if self.executable == os.path.abspath(spec.path):
return True
if spec.is_abs:
return False
# if path set, and is not our original executable name, this does not match
basename = os.path.basename(self.original_executable)
spec_path = spec.path
if sys.platform == "win32":
basename, suffix = os.path.splitext(basename)
if spec_path.endswith(suffix):
spec_path = spec_path[: -len(suffix)]
return basename == spec_path

def _check_impl_match(self, spec, impl_must_match):
"""Check if implementation matches."""
return (
not impl_must_match
or spec.implementation is None
or spec.implementation.lower() == self.implementation.lower()
)

def _check_version_match(self, spec):
"""Check if version matches spec (handles both specifiers and exact versions)."""
if spec.version_specifier is not None:
version_string = f"{self.version_info.major}.{self.version_info.minor}.{self.version_info.micro}"
return version_string in spec.version_specifier
# Check exact version match (backward compatibility)
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
return True

def satisfies(self, spec, impl_must_match):
"""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):
return True # if the path is a our own executable path we're done
if not spec.is_abs:
# if path set, and is not our original executable name, this does not match
basename = os.path.basename(self.original_executable)
spec_path = spec.path
if sys.platform == "win32":
basename, suffix = os.path.splitext(basename)
if spec_path.endswith(suffix):
spec_path = spec_path[: -len(suffix)]
if basename != spec_path:
return False

if (
impl_must_match
and spec.implementation is not None
and spec.implementation.lower() != self.implementation.lower()
):
path_match = self._check_path_match(spec)
if path_match is False:
return False

if not self._check_impl_match(spec, impl_must_match):
return False

if spec.architecture is not None and spec.architecture != self.architecture:
Expand All @@ -422,10 +444,7 @@ 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

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
return True
return self._check_version_match(spec)

_current_system = None
_current = None
Expand Down
44 changes: 41 additions & 3 deletions src/virtualenv/discovery/py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import os
import re

from packaging.specifiers import InvalidSpecifier, SpecifierSet

PATTERN = re.compile(r"^(?P<impl>[a-zA-Z]+)?(?P<version>[0-9.]+)?(?P<threaded>t)?(?:-(?P<arch>32|64))?$")


Expand All @@ -22,6 +24,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,13 +34,29 @@ 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

# Check if this looks like a version specifier (contains comparison operators)
# Version specifiers start with >=, <=, ==, ~=, !=, >, <, or ===
specifier_operators = (">=", "<=", "==", "~=", "!=", ">", "<", "===")
is_specifier = any(string_spec.lstrip().startswith(op) for op in specifier_operators)

if is_specifier:
try:
version_specifier = SpecifierSet(string_spec)
# Extract the base version from the specifier for display purposes
# We'll match any version that satisfies the specifier
except InvalidSpecifier: # If it fails to parse as a specifier, treat it as a regular spec
is_specifier = False

if os.path.isabs(string_spec): # noqa: PLR1702
path = string_spec
else:
elif not is_specifier:
ok = False
match = re.match(PATTERN, string_spec)
if match:
Expand Down Expand Up @@ -74,7 +93,17 @@ def _int_or_none(val):
if not ok:
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 @@ -120,7 +149,16 @@ def satisfies(self, spec):

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)})"


Expand Down
Loading