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