diff --git a/docs/changelog/2994.feature.rst b/docs/changelog/2994.feature.rst new file mode 100644 index 000000000..d11bc8b05 --- /dev/null +++ b/docs/changelog/2994.feature.rst @@ -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`` `. diff --git a/docs/cli_interface.rst b/docs/cli_interface.rst index 27ea231ef..fd3543b35 100644 --- a/docs/cli_interface.rst +++ b/docs/cli_interface.rst @@ -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 ```` 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 ``: The Hint** diff --git a/src/virtualenv/discovery/builtin.py b/src/virtualenv/discovery/builtin.py index e2d193911..43e48fa87 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..e72c82dd6 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -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): @@ -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 diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index d8519c23d..4bd8b4209 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -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[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") +SPECIFIER_PATTERN = re.compile(r"^(?:(?P[A-Za-z]+)\s*)?(?P(?:===|==|~=|!=|<=|>=|<|>).+)$") class PythonSpec: @@ -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 @@ -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: @@ -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.""" @@ -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 @@ -113,6 +181,9 @@ 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 @@ -120,10 +191,29 @@ 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)})" +# Create aliases for backward compatibility +SpecifierSet = SimpleSpecifierSet +Version = SimpleVersion +InvalidSpecifier = ValueError +InvalidVersion = ValueError + __all__ = [ + "InvalidSpecifier", + "InvalidVersion", "PythonSpec", + "SpecifierSet", + "Version", ] diff --git a/src/virtualenv/util/specifier.py b/src/virtualenv/util/specifier.py new file mode 100644 index 000000000..eca5b3389 --- /dev/null +++ b/src/virtualenv/util/specifier.py @@ -0,0 +1,237 @@ +"""Version specifier support using only standard library (PEP 440 compatible).""" + +from __future__ import annotations + +import contextlib +import operator +import re + + +class SimpleVersion: + """Simple PEP 440-like version parser using only standard library.""" + + def __init__(self, version_str: str) -> None: + self.version_str = version_str + # Parse version string into components + # Support formats like: "3.11", "3.11.0", "3.11.0a1", "3.11.0b2", "3.11.0rc1" + match = re.match( + r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(a|b|rc)(\d+))?$", + version_str.strip(), + ) + if not match: + msg = f"Invalid version: {version_str}" + raise ValueError(msg) + + self.major = int(match.group(1)) + self.minor = int(match.group(2)) if match.group(2) else 0 + self.micro = int(match.group(3)) if match.group(3) else 0 + self.pre_type = match.group(4) # a, b, rc or None + self.pre_num = int(match.group(5)) if match.group(5) else None + self.release = (self.major, self.minor, self.micro) + + def __eq__(self, other): + if not isinstance(other, SimpleVersion): + return NotImplemented + return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num + + def __hash__(self): + return hash((self.release, self.pre_type, self.pre_num)) + + def __lt__(self, other): + if not isinstance(other, SimpleVersion): + return NotImplemented + # Compare release tuples first + if self.release != other.release: + return self.release < other.release + return self._compare_prerelease(other) + + def _compare_prerelease(self, other): + """Compare pre-release versions.""" + # If releases are equal, compare pre-release + # No pre-release is greater than any pre-release + if self.pre_type is None and other.pre_type is None: + return False + if self.pre_type is None: + return False # self is final, other is pre-release + if other.pre_type is None: + return True # self is pre-release, other is final + # Both are pre-releases, compare type then number + pre_order = {"a": 1, "b": 2, "rc": 3} + if pre_order[self.pre_type] != pre_order[other.pre_type]: + return pre_order[self.pre_type] < pre_order[other.pre_type] + return (self.pre_num or 0) < (other.pre_num or 0) + + def __le__(self, other): + return self == other or self < other + + def __gt__(self, other): + if not isinstance(other, SimpleVersion): + return NotImplemented + return not self <= other + + def __ge__(self, other): + return not self < other + + def __str__(self): + return self.version_str + + def __repr__(self): + return f"SimpleVersion('{self.version_str}')" + + +class SimpleSpecifier: + """Simple PEP 440-like version specifier using only standard library.""" + + __slots__ = ( + "is_wildcard", + "operator", + "spec_str", + "version", + "version_str", + "wildcard_precision", + "wildcard_version", + ) + + def __init__(self, spec_str: str) -> None: + self.spec_str = spec_str.strip() + # Parse operator and version + match = re.match(r"^(===|==|~=|!=|<=|>=|<|>)\s*(.+)$", self.spec_str) + if not match: + msg = f"Invalid specifier: {spec_str}" + raise ValueError(msg) + + self.operator = match.group(1) + self.version_str = match.group(2).strip() + + # Handle wildcard versions like "3.11.*" + if self.version_str.endswith(".*"): + self.is_wildcard = True + self.wildcard_version = self.version_str[:-2] + # Count the precision for wildcard matching + self.wildcard_precision = len(self.wildcard_version.split(".")) + self.version_str = self.wildcard_version + else: + self.is_wildcard = False + self.wildcard_precision = None + + try: + self.version = SimpleVersion(self.version_str) + except ValueError: + # If version parsing fails, store as string for prefix matching + self.version = None + + def contains(self, version_str: str) -> bool: + """Check if a version string satisfies this specifier.""" + try: + candidate = SimpleVersion(version_str) if isinstance(version_str, str) else version_str + except ValueError: + return False + + if self.version is None: + return False + + if self.is_wildcard: + return self._check_wildcard(candidate) + return self._check_standard(candidate) + + def _check_wildcard(self, candidate): + """Check wildcard version matching.""" + if self.operator == "==": + return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision] + if self.operator == "!=": + return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision] + # Other operators with wildcards are not standard + return False + + def _check_standard(self, candidate): + """Check standard version comparisons.""" + if self.operator == "===": + return str(candidate) == str(self.version) + if self.operator == "~=": + return self._check_compatible_release(candidate) + # Use operator module for comparisons + cmp_ops = { + "==": operator.eq, + "!=": operator.ne, + "<": operator.lt, + "<=": operator.le, + ">": operator.gt, + ">=": operator.ge, + } + if self.operator in cmp_ops: + return cmp_ops[self.operator](candidate, self.version) + return False + + def _check_compatible_release(self, candidate): + """Check compatible release version (~=).""" + if candidate < self.version: + return False + if len(self.version.release) >= 2: # noqa: PLR2004 + upper_parts = list(self.version.release[:-1]) + upper_parts[-1] += 1 + upper = SimpleVersion(".".join(str(p) for p in upper_parts)) + return candidate < upper + return True + + def __eq__(self, other): + if not isinstance(other, SimpleSpecifier): + return NotImplemented + return self.spec_str == other.spec_str + + def __hash__(self): + return hash(self.spec_str) + + def __str__(self): + return self.spec_str + + def __repr__(self): + return f"SimpleSpecifier('{self.spec_str}')" + + +class SimpleSpecifierSet: + """Simple PEP 440-like specifier set using only standard library.""" + + __slots__ = ("specifiers", "specifiers_str") + + def __init__(self, specifiers_str: str = "") -> None: + self.specifiers_str = specifiers_str.strip() + self.specifiers = [] + + if self.specifiers_str: + # Split by comma for compound specifiers + for spec_item in self.specifiers_str.split(","): + stripped = spec_item.strip() + if stripped: + with contextlib.suppress(ValueError): + self.specifiers.append(SimpleSpecifier(stripped)) + + def contains(self, version_str: str) -> bool: + """Check if a version satisfies all specifiers in the set.""" + if not self.specifiers: + return True + # All specifiers must be satisfied + return all(spec.contains(version_str) for spec in self.specifiers) + + def __iter__(self): + return iter(self.specifiers) + + def __eq__(self, other): + if not isinstance(other, SimpleSpecifierSet): + return NotImplemented + return self.specifiers_str == other.specifiers_str + + def __hash__(self): + return hash(self.specifiers_str) + + def __str__(self): + return self.specifiers_str + + def __repr__(self): + return f"SimpleSpecifierSet('{self.specifiers_str}')" + + +__all__ = [ + "SimpleSpecifier", + "SimpleSpecifierSet", + "SimpleVersion", +] diff --git a/tests/unit/discovery/test_discovery.py b/tests/unit/discovery/test_discovery.py index e6c78e2e3..06fdfd1ab 100644 --- a/tests/unit/discovery/test_discovery.py +++ b/tests/unit/discovery/test_discovery.py @@ -329,3 +329,30 @@ def test_returns_first_python_specified_when_no_env_var_is_specified(mocker, mon result = builtin.run() assert result == mocker.sentinel.python_from_cli + + +def test_discovery_via_version_specifier(session_app_data): + """Test that version specifiers like >=3.11 work correctly.""" + current = PythonInfo.current_system(session_app_data) + major, minor = current.version_info.major, current.version_info.minor + + # Test with >= specifier that should match current Python + spec = f">={major}.{minor}" + interpreter = get_interpreter(spec, [], session_app_data) + assert interpreter is not None + assert interpreter.version_info.major == major + assert interpreter.version_info.minor >= minor + + # Test with compound specifier + spec = f">={major}.{minor},<{major}.{minor + 10}" + interpreter = get_interpreter(spec, [], session_app_data) + assert interpreter is not None + assert interpreter.version_info.major == major + assert minor <= interpreter.version_info.minor < minor + 10 + + # Test with implementation prefix + spec = f"cpython>={major}.{minor}" + interpreter = get_interpreter(spec, [], session_app_data) + if current.implementation == "CPython": + assert interpreter is not None + assert interpreter.implementation == "CPython" diff --git a/tests/unit/discovery/test_py_spec.py b/tests/unit/discovery/test_py_spec.py index 0841019ec..2c037b6bb 100644 --- a/tests/unit/discovery/test_py_spec.py +++ b/tests/unit/discovery/test_py_spec.py @@ -6,6 +6,7 @@ import pytest from virtualenv.discovery.py_spec import PythonSpec +from virtualenv.util.specifier import SimpleSpecifierSet as SpecifierSet def test_bad_py_spec(): @@ -132,3 +133,29 @@ def test_relative_spec(tmp_path, monkeypatch): a_relative_path = str((tmp_path / "a" / "b").relative_to(tmp_path)) spec = PythonSpec.from_string_spec(a_relative_path) assert spec.path == a_relative_path + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + (">=3.12", ">=3.12"), + ("python>=3.12", ">=3.12"), + ("cpython!=3.11.*", "!=3.11.*"), + ("<=3.13,>=3.12", "<=3.13,>=3.12"), + ], +) +def test_specifier_parsing(text, expected): + spec = PythonSpec.from_string_spec(text) + assert spec.version_specifier == SpecifierSet(expected) + + +def test_specifier_with_implementation(): + spec = PythonSpec.from_string_spec("cpython>=3.12") + assert spec.implementation == "cpython" + assert spec.version_specifier == SpecifierSet(">=3.12") + + +def test_specifier_satisfies_with_partial_information(): + spec = PythonSpec.from_string_spec(">=3.12") + candidate = PythonSpec.from_string_spec("python3.12") + assert candidate.satisfies(spec) is True