From ab257ae0228aadff20d2a3d9c91c672b4cb43716 Mon Sep 17 00:00:00 2001
From: darrenhuai <60621295+darrenhuai@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:30:30 -0700
Subject: [PATCH 1/4] fix(spec): treat unparsable version specifiers as literal
paths
A version specifier whose version SimpleVersion cannot read, such as
`>=3.x`, `>=3.10.post1` or the missing-comma typo `>=3.12 <4`, made
get_interpreter and iter_interpreters fail with a bare AssertionError on
the first interpreter they checked, so later keys in a list were never
tried. SimpleSpecifierSet never raises: it stores an unparsable version
as None and drops items without an operator. The literal-path fallback
in _parse_specifier therefore never ran, and the None reached the
type-narrowing assert in PythonInfo._satisfies_version_specifier. Under
-O the assert is gone and the same specs quietly match nothing, and some
of them raise AttributeError on a prerelease interpreter.
_parse_specifier now falls back to a literal path when an item has no
parsed version or no item is left, the same way #147 handles malformed
version specs. That also stops a spec like `<,4` from matching every
interpreter. The assert becomes a no-match check, as in
SimpleSpecifier.contains, so a PythonSpec built by hand with such a
specifier set no longer crashes either.
---
docs/explanation.rst | 5 ++++-
src/python_discovery/_py_info.py | 3 ++-
src/python_discovery/_py_spec.py | 4 ++++
tests/test_discovery.py | 2 +-
tests/test_py_info_extra.py | 8 +++++++-
tests/test_py_spec.py | 20 +++++++++++++++++++-
6 files changed, 37 insertions(+), 5 deletions(-)
diff --git a/docs/explanation.rst b/docs/explanation.rst
index 59b7d3d..d6a7532 100644
--- a/docs/explanation.rst
+++ b/docs/explanation.rst
@@ -367,4 +367,7 @@ and machine segments, leaves the string unrecognized, so it falls back to a lite
`Version specifiers `_
(``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple specifiers can be comma-separated,
-for example ``>=3.11,<3.13``.
+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 a version outside that form, such as
+``>=3.x``, ``>=3.10.post1`` or ``>=3.12 <4`` (missing comma), falls back to a literal executable-name match.
diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py
index 15bbdc6..3dc6161 100644
--- a/src/python_discovery/_py_info.py
+++ b/src/python_discovery/_py_info.py
@@ -295,7 +295,8 @@ def _satisfies_version_specifier(self, spec: PythonSpec) -> bool:
return True
version_info = self.version_info
for specifier in spec.version_specifier:
- assert specifier.version is not None # ruff:ignore[assert]
+ if specifier.version is None: # a hand-built spec can carry an unparsable version, which matches nothing
+ return False
numeric_version = specifier.version_str
for prefix in ("rc", "b", "a"):
if prefix in numeric_version:
diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py
index 8928318..f760b21 100644
--- a/src/python_discovery/_py_spec.py
+++ b/src/python_discovery/_py_spec.py
@@ -104,6 +104,10 @@ def _parse_specifier(string_spec: str) -> PythonSpec | None:
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 treat the
+ # spec as a literal path, like any other unrecognized spec, when a version is unusable or no item survives
+ if not version_specifier.specifiers or any(item.version is None for item in version_specifier):
+ return None
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index af13847..b425abe 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -100,7 +100,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."])
+@pytest.mark.parametrize("missing", ["magic-one", "python.", ">=3.12 <4"])
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)
diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py
index 4995b86..1d5aad2 100644
--- a/tests/test_py_info_extra.py
+++ b/tests/test_py_info_extra.py
@@ -10,7 +10,7 @@
import pytest
-from python_discovery import DiskCache, PythonInfo, PythonSpec
+from python_discovery import DiskCache, PythonInfo, PythonSpec, SimpleSpecifierSet
from python_discovery._py_info import VersionInfo
if TYPE_CHECKING:
@@ -195,6 +195,12 @@ def test_satisfies_version_specifier_fails() -> None:
assert CURRENT.satisfies(spec, impl_must_match=False) is False
+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)
+ assert CURRENT.satisfies(spec, impl_must_match=False) is False
+
+
@pytest.mark.parametrize(
("version_info", "spec_str", "expected"),
[
diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py
index e7f4d83..62751e2 100644
--- a/tests/test_py_spec.py
+++ b/tests/test_py_spec.py
@@ -13,7 +13,25 @@
from pathlib import Path
-@pytest.mark.parametrize("text", ["python2.3.4.5", ".", "..", "...", "python.", "cpython..", "pypy..."])
+@pytest.mark.parametrize(
+ "text",
+ [
+ "python2.3.4.5",
+ ".",
+ "..",
+ "...",
+ "python.",
+ "cpython..",
+ "pypy...",
+ ">=3.x",
+ ">=3.12 <4",
+ ">=3.10.post1",
+ "cpython>=v3.10",
+ ">=3.12,<4.x",
+ ">=3.x,<4",
+ "<,4",
+ ],
+)
def test_bad_py_spec(text: str) -> None:
spec = PythonSpec.from_string_spec(text)
assert text in repr(spec)
From 836c15b92bc2984aa5fed17f9470d289aa72680c Mon Sep 17 00:00:00 2001
From: darrenhuai <60621295+darrenhuai@users.noreply.github.com>
Date: Wed, 21 Oct 2026 23:04:48 -0700
Subject: [PATCH 2/4] docs: add changelog fragment for 157
---
docs/changelog/157.bugfix.rst | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 docs/changelog/157.bugfix.rst
diff --git a/docs/changelog/157.bugfix.rst b/docs/changelog/157.bugfix.rst
new file mode 100644
index 0000000..77d00a0
--- /dev/null
+++ b/docs/changelog/157.bugfix.rst
@@ -0,0 +1,2 @@
+Treat version specifiers whose version cannot be parsed, such as ``>=3.x`` or ``>=3.12 <4``, as literal executable
+paths instead of raising a bare ``AssertionError`` during discovery - by :user:`darrenhuai`.
From bd7964a828db637460928aebb75a295d8acfb168 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?=
Date: Tue, 27 Oct 2026 12:32:00 -0700
Subject: [PATCH 3/4] fix(spec): reject sets with dropped items
SimpleSpecifierSet drops items it cannot parse, so ">=3.12,foo" and
">=3.12, 4" were read as ">=3.12". Fall back to a literal path unless
every comma-separated item parsed, which also covers "<,4".
---
docs/explanation.rst | 4 ++--
src/python_discovery/_py_spec.py | 7 ++++---
tests/test_py_spec.py | 2 ++
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/docs/explanation.rst b/docs/explanation.rst
index d6a7532..125532b 100644
--- a/docs/explanation.rst
+++ b/docs/explanation.rst
@@ -369,5 +369,5 @@ and machine segments, leaves the string unrecognized, so it falls back to a lite
(``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) 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 a version outside that form, such as
-``>=3.x``, ``>=3.10.post1`` or ``>=3.12 <4`` (missing comma), falls back to a literal executable-name match.
+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.
diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py
index f760b21..c263650 100644
--- a/src/python_discovery/_py_spec.py
+++ b/src/python_discovery/_py_spec.py
@@ -104,9 +104,10 @@ def _parse_specifier(string_spec: str) -> PythonSpec | None:
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 treat the
- # spec as a literal path, like any other unrecognized spec, when a version is unusable or no item survives
- if not version_specifier.specifiers or any(item.version is None for item in version_specifier):
+ # 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
+ 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
diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py
index 62751e2..36bacd2 100644
--- a/tests/test_py_spec.py
+++ b/tests/test_py_spec.py
@@ -30,6 +30,8 @@
">=3.12,<4.x",
">=3.x,<4",
"<,4",
+ ">=3.12,foo",
+ ">=3.12, 4",
],
)
def test_bad_py_spec(text: str) -> None:
From e0f50244e0d3d8dd6775a2dd634c94906c6fdbf4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?=
Date: Tue, 27 Oct 2026 12:33:21 -0700
Subject: [PATCH 4/4] docs(changelog): drop hand-written author credit
---
docs/changelog/157.bugfix.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/changelog/157.bugfix.rst b/docs/changelog/157.bugfix.rst
index 77d00a0..0360f84 100644
--- a/docs/changelog/157.bugfix.rst
+++ b/docs/changelog/157.bugfix.rst
@@ -1,2 +1,2 @@
-Treat version specifiers whose version cannot be parsed, such as ``>=3.x`` or ``>=3.12 <4``, as literal executable
-paths instead of raising a bare ``AssertionError`` during discovery - by :user:`darrenhuai`.
+Treat version specifiers with an item that cannot be parsed, such as ``>=3.x``, ``>=3.12 <4`` or ``>=3.12,foo``, as
+literal executable paths instead of raising a bare ``AssertionError`` or ignoring the item during discovery.