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.