Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions docs/changelog/175.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Accept the free-threaded, debug, architecture and machine suffixes after a version specifier, as in ``>=3.13t`` or
``cpython>=3.11,<3.14-64``, and raise ``ValueError`` for a spec that starts with a comparison operator but does not
parse, such as ``>=3.13.``, instead of looking it up as an executable name.
13 changes: 11 additions & 2 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,19 @@ and machine segments, leaves the string unrecognized, so it falls back to a lite
* - ``cpython>=3.11``
- `Version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
restricted to CPython
* - ``>=3.13t``
- `Version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
restricted to free-threaded builds

`Version specifiers <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
(``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple specifiers can be comma-separated,
for example ``>=3.11,<3.13``. Each version is a release of up to three numbers with an optional ``a``, ``b`` or
``rc`` pre-release suffix, such as ``3.12`` or ``3.13.0rc1``; ``==`` and ``!=`` also take a trailing ``.*``
wildcard, as in ``==3.12.*``. A specifier with any item outside that form, such as ``>=3.x``, ``>=3.10.post1``,
``>=3.12 <4`` (missing comma) or ``>=3.12,foo``, falls back to a literal executable-name match.
wildcard, as in ``==3.12.*``.

The ABI flags, ``-arch`` and ``-machine`` parts described above follow the last specifier and apply to the whole set:
``>=3.13t`` asks for a free-threaded 3.13 or newer, ``cpython>=3.11,<3.14-64`` for a 64-bit CPython in that range.
Without ``t`` a specifier accepts any build, while a plain ``3.13`` asks for the default (GIL) build. A string that
starts with a comparison operator but does not parse this way, such as ``>=3.x``, ``>=3.10.post1``, ``>=3.12 <4``
(missing comma) or ``>=3.13t,<3.15`` (a flag on an inner item), raises :exc:`ValueError` instead of being looked up as
an executable name.
91 changes: 52 additions & 39 deletions src/python_discovery/_py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,29 @@
from ._py_info import normalize_isa
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion

PATTERN = re.compile(
r"""
^
(?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
_MODIFIERS: Final[str] = r"""
(?P<threaded>t)? # free-threaded flag
(?P<debug>d|(?:-dbg|-debug)(?=-|$))? # debug build flag (d, -dbg or -debug)
(?:-(?P<arch>32|64))? # architecture bitness
(?:-(?P<machine>[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit)
$
""",
"""
PATTERN = re.compile(
r"""
^
(?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
"""
+ _MODIFIERS,
re.VERBOSE,
)
SPECIFIER_PATTERN = re.compile(
r"""
^
(?:(?P<impl>[A-Za-z]+)\s*)? # optional implementation prefix
(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
$
""",
(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).*?) # PEP 440 version specifier, lazy so the modifiers can follow it
"""
+ _MODIFIERS,
re.VERBOSE,
)

Expand Down Expand Up @@ -69,51 +72,60 @@ def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | No
def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
if not (match := re.match(PATTERN, string_spec)):
return None
groups = match.groupdict()
version = groups["version"]
major, minor, micro, threaded = None, None, None, None
debug = True if groups["debug"] else None # unconstrained unless an explicit d/-dbg/-debug marker is present
if version is not None:
release, threaded = (None, None, None), None
if (version := match["version"]) is not None:
try:
major, minor, micro = _parse_version_parts(version)
release = _parse_version_parts(version)
except ValueError:
return None
threaded = bool(groups["threaded"])
impl = groups["impl"]
threaded = bool(match["threaded"])
return _spec_from_match(string_spec, match, release, threaded=threaded, version_specifier=None)


def _spec_from_match(
string_spec: str,
match: re.Match[str],
release: tuple[int | None, int | None, int | None],
*,
threaded: bool | None,
version_specifier: SpecifierSet | None,
) -> PythonSpec:
impl = match["impl"]
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
impl = "graalpy"
arch = _int_or_none(groups["arch"])
machine = groups.get("machine")
if machine is not None:
machine = normalize_isa(machine)
return PythonSpec(
string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine, debug=debug
string_spec,
impl,
*release,
_int_or_none(match["arch"]),
None,
free_threaded=threaded,
machine=None if (machine := match["machine"]) is None else normalize_isa(machine),
debug=True if match["debug"] else None, # unconstrained unless an explicit d/-dbg/-debug marker is present
version_specifier=version_specifier,
)


def _parse_specifier(string_spec: str) -> PythonSpec | None:
if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
return None
if SpecifierSet is None: # pragma: no cover
if not (match := SPECIFIER_PATTERN.match(string_spec.strip())):
return None
impl = specifier_match.group("impl")
spec_text = specifier_match.group("spec").strip()
try:
version_specifier = SpecifierSet.from_string(spec_text)
except InvalidSpecifier: # pragma: no cover
return None
# the set never raises: it keeps an unparsable version as None and drops items without an operator, so fall back
# to a literal path, like any other unrecognized spec, unless every item parsed into a usable version
spec_text = match["spec"].strip()
version_specifier = SpecifierSet.from_string(spec_text)
# the set never raises: it keeps an unparsable version as None and drops items without an operator
items = [item for item in spec_text.split(",") if item.strip()]
if len(items) != len(version_specifier.specifiers) or any(spec.version is None for spec in version_specifier):
return None
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
impl = "graalpy"
return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
msg = f"invalid version specifier {string_spec!r}"
raise ValueError(msg)
return _spec_from_match(
string_spec,
match,
(None, None, None),
# the version bounds say nothing about the build, so without a trailing t any build qualifies
threaded=True if match["threaded"] else None,
version_specifier=version_specifier,
)


class PythonSpec:
Expand Down Expand Up @@ -169,6 +181,7 @@ def from_string_spec(cls, string_spec: str) -> PythonSpec:

:param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
or a `version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_.
:raises ValueError: if the spec starts with a version specifier operator but does not parse as one.
"""
if pathlib.Path(string_spec).is_absolute():
return cls(string_spec, None, None, None, None, None, string_spec)
Expand Down
7 changes: 6 additions & 1 deletion tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def test_discovery_fallback_fail(session_cache: DiskCache, caplog: pytest.LogCap
assert "accepted" not in caplog.text


@pytest.mark.parametrize("missing", ["magic-one", "python.", ">=3.12 <4"])
@pytest.mark.parametrize("missing", ["magic-one", "python."])
def test_discovery_fallback_ok(missing: str, session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
result = get_interpreter([missing, sys.executable], cache=session_cache)
Expand All @@ -128,6 +128,11 @@ def test_discovery_fallback_ok(missing: str, session_cache: DiskCache, caplog: p
assert "accepted" in caplog.text


def test_discovery_invalid_specifier_raises(session_cache: DiskCache) -> None:
with pytest.raises(ValueError, match=r"^invalid version specifier '>=3\.12 <4'$"):
get_interpreter([">=3.12 <4", sys.executable], cache=session_cache)


@pytest.fixture
def mock_find_interpreter(mocker: MockerFixture) -> None:
mocker.patch(
Expand Down
12 changes: 12 additions & 0 deletions tests/test_py_info_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ def test_satisfies_version_specifier_fails() -> None:
assert CURRENT.satisfies(spec, impl_must_match=False) is False


@pytest.mark.parametrize(
("suffix", "expected"),
[
pytest.param("t", CURRENT.free_threaded, id="free-threaded"),
pytest.param("", True, id="any-build"),
],
)
def test_satisfies_version_specifier_free_threaded(suffix: str, expected: bool) -> None:
spec = PythonSpec.from_string_spec(f">={sys.version_info.major}.{sys.version_info.minor}{suffix}")
assert CURRENT.satisfies(spec, impl_must_match=False) is expected


def test_satisfies_version_specifier_unparsable_version() -> None:
specifier = SimpleSpecifierSet.from_string(">=3.x")
spec = PythonSpec(">=3.x", None, None, None, None, None, None, version_specifier=specifier)
Expand Down
53 changes: 44 additions & 9 deletions tests/test_py_spec.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
import sys
from typing import TYPE_CHECKING

Expand All @@ -23,15 +24,6 @@
"python.",
"cpython..",
"pypy...",
">=3.x",
">=3.12 <4",
">=3.10.post1",
"cpython>=v3.10",
">=3.12,<4.x",
">=3.x,<4",
"<,4",
">=3.12,foo",
">=3.12, 4",
],
)
def test_bad_py_spec(text: str) -> None:
Expand All @@ -45,6 +37,49 @@ def test_bad_py_spec(text: str) -> None:
assert all(v is None for v in content.values())


@pytest.mark.parametrize(
"text",
[
pytest.param(">=3.x", id="non-numeric-version"),
pytest.param(">=3.13.", id="trailing-dot"),
pytest.param(">=3.10.post1", id="post-release"),
pytest.param("cpython>=v3.10", id="v-prefix"),
pytest.param(">=3.12 <4", id="missing-comma"),
pytest.param(">=3.12,<4.x", id="bad-last-item"),
pytest.param(">=3.x,<4", id="bad-first-item"),
pytest.param("<,4", id="operator-without-version"),
pytest.param(">=3.12,foo", id="item-without-operator"),
pytest.param(">=", id="bare-operator"),
pytest.param(">=3.13t,<3.15", id="modifier-before-last-item"),
],
)
def test_invalid_specifier_raises(text: str) -> None:
with pytest.raises(ValueError, match=f"^invalid version specifier {re.escape(repr(text))}$"):
PythonSpec.from_string_spec(text)


@pytest.mark.parametrize(
("text", "expected"),
[
pytest.param(">=3.13t", (">=3.13", True, None, None, None), id="free-threaded"),
pytest.param(">=3.13td", (">=3.13", True, True, None, None), id="free-threaded-debug"),
pytest.param(">=3.13-dbg", (">=3.13", None, True, None, None), id="debug"),
pytest.param(">=3.11,<3.14t", (">=3.11,<3.14", True, None, None, None), id="set"),
pytest.param("cpython>=3.12-64-arm64", (">=3.12", None, None, 64, "arm64"), id="arch-machine"),
pytest.param(">=3.13", (">=3.13", None, None, None, None), id="any-build"),
],
)
def test_specifier_modifiers(text: str, expected: tuple[str, bool | None, bool | None, int | None, str | None]) -> None:
spec = PythonSpec.from_string_spec(text)
assert (
str(spec.version_specifier),
spec.free_threaded,
spec.debug,
spec.architecture,
spec.machine,
) == expected


def test_py_spec_first_digit_only_major() -> None:
spec = PythonSpec.from_string_spec("278")
assert spec.major == 2
Expand Down
5 changes: 0 additions & 5 deletions tests/test_py_spec_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,6 @@ def test_single_digit_version() -> None:
assert spec.minor is None


def test_specifier_with_invalid_inner() -> None:
spec = PythonSpec.from_string_spec(">=not_a_version")
assert spec.path is None or spec.version_specifier is not None or spec.path == ">=not_a_version"


def test_two_digit_version() -> None:
spec = PythonSpec.from_string_spec("python312")
assert spec.major == 3
Expand Down
Loading