From 48ff714e80ec195d68933c4a9d153d17048cb581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 30 Oct 2026 12:02:38 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(spec):=20accept=20build=20suff?= =?UTF-8?q?ixes=20on=20specifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifiers now take the same trailing t, d/-dbg/-debug, -32/-64 and - parts as plain specs, once after the last item, so >=3.13t asks for a free-threaded 3.13 or newer. Both patterns share one modifier grammar so the two forms cannot drift apart again. A spec that starts with a comparison operator but does not parse now raises ValueError naming it, instead of being looked up as an executable. Closes #175 --- docs/changelog/175.feature.rst | 3 ++ docs/explanation.rst | 13 ++++- src/python_discovery/_py_spec.py | 91 ++++++++++++++++++-------------- tests/test_discovery.py | 7 ++- tests/test_py_info_extra.py | 12 +++++ tests/test_py_spec.py | 53 +++++++++++++++---- tests/test_py_spec_extra.py | 5 -- 7 files changed, 128 insertions(+), 56 deletions(-) create mode 100644 docs/changelog/175.feature.rst diff --git a/docs/changelog/175.feature.rst b/docs/changelog/175.feature.rst new file mode 100644 index 0000000..c40387c --- /dev/null +++ b/docs/changelog/175.feature.rst @@ -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. diff --git a/docs/explanation.rst b/docs/explanation.rst index 125532b..78009ed 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -364,10 +364,19 @@ and machine segments, leaves the string unrecognized, so it falls back to a lite * - ``cpython>=3.11`` - `Version specifier `_ restricted to CPython + * - ``>=3.13t`` + - `Version specifier `_ + restricted to free-threaded builds `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. diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index c263650..8e79c94 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -10,26 +10,29 @@ from ._py_info import normalize_isa from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion -PATTERN = re.compile( - r""" - ^ - (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) - (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1) +_MODIFIERS: Final[str] = r""" (?Pt)? # free-threaded flag (?Pd|(?:-dbg|-debug)(?=-|$))? # debug build flag (d, -dbg or -debug) (?:-(?P32|64))? # architecture bitness (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) $ - """, +""" +PATTERN = re.compile( + r""" + ^ + (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) + (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1) + """ + + _MODIFIERS, re.VERBOSE, ) SPECIFIER_PATTERN = re.compile( r""" ^ (?:(?P[A-Za-z]+)\s*)? # optional implementation prefix - (?P(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier - $ - """, + (?P(?:===|==|~=|!=|<=|>=|<|>).*?) # PEP 440 version specifier, lazy so the modifiers can follow it + """ + + _MODIFIERS, re.VERBOSE, ) @@ -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: @@ -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 `_. + :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) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 5d464ef..c552365 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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) @@ -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( diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 17f0427..b421a05 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -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) diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index 36bacd2..d57c26c 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import sys from typing import TYPE_CHECKING @@ -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: @@ -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 diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index 2250f07..571639a 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -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