diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 68120bde6..f796b324f 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -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: @@ -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 @@ -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 @@ -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 }} \ No newline at end of file diff --git a/src/virtualenv/discovery/builtin.py b/src/virtualenv/discovery/builtin.py index e2d193911..ed0829471 100644 --- a/src/virtualenv/discovery/builtin.py +++ b/src/virtualenv/discovery/builtin.py @@ -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", diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index c2310cd7e..f077c0694 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -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: @@ -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 diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index d8519c23d..392e160b8 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -5,6 +5,8 @@ import os import re +from packaging.specifiers import InvalidSpecifier, SpecifierSet + PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") @@ -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 @@ -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: @@ -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.""" @@ -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)})"