From e6716489594627f5df57ae62638554be0567c6ff Mon Sep 17 00:00:00 2001 From: harbinresearcher Date: Thu, 15 Oct 2026 13:27:54 +0800 Subject: [PATCH 1/3] fix(spec): handle dot-only version components --- docs/changelog/+dot-only-version.bugfix.rst | 1 + src/python_discovery/_py_spec.py | 5 +++++ tests/test_py_spec.py | 17 ++++++++++++++--- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 docs/changelog/+dot-only-version.bugfix.rst diff --git a/docs/changelog/+dot-only-version.bugfix.rst b/docs/changelog/+dot-only-version.bugfix.rst new file mode 100644 index 0000000..8bd58e3 --- /dev/null +++ b/docs/changelog/+dot-only-version.bugfix.rst @@ -0,0 +1 @@ +Handle dot-only version components without ``IndexError``, preserving the normal executable-path fallback. diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index d389ad6..3838462 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -50,6 +50,9 @@ def _int_or_none(val: str | None) -> int | None: def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]: versions = tuple(int(i) for i in version.split(".") if i) + if not versions: + msg = "version must contain a numeric component" + raise ValueError(msg) if len(versions) > _MAX_VERSION_PARTS: msg = "too many version parts" raise ValueError(msg) @@ -161,6 +164,8 @@ 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 `_. + + A version with no numeric components, such as ``python.``, falls back to a literal executable path. """ if pathlib.Path(string_spec).is_absolute(): return cls(string_spec, None, None, None, None, None, string_spec) diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index d4e6b1f..4b4f1c2 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -1,11 +1,12 @@ from __future__ import annotations +import os import sys from typing import TYPE_CHECKING import pytest -from python_discovery import PythonSpec +from python_discovery import PythonSpec, get_interpreter from python_discovery._py_info import normalize_isa from python_discovery._specifier import SimpleSpecifierSet as SpecifierSet @@ -13,8 +14,8 @@ from pathlib import Path -def test_bad_py_spec() -> None: - text = "python2.3.4.5" +@pytest.mark.parametrize("text", ["python2.3.4.5", ".", "..", "...", "python.", "cpython..", "pypy..."]) +def test_bad_py_spec(text: str) -> None: spec = PythonSpec.from_string_spec(text) assert text in repr(spec) assert spec.str_spec == text @@ -25,6 +26,16 @@ def test_bad_py_spec() -> None: assert all(v is None for v in content.values()) +def test_bad_py_spec_falls_back_to_next_interpreter(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + env = {**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")} + + interpreter = get_interpreter(["python.", sys.executable], env=env) + + assert interpreter is not None + assert interpreter.version_info[:3] == sys.version_info[:3] + + def test_py_spec_first_digit_only_major() -> None: spec = PythonSpec.from_string_spec("278") assert spec.major == 2 From 1cfa8ab07f5d465c688e3395877623bb33fec81f Mon Sep 17 00:00:00 2001 From: harbinresearcher Date: Thu, 15 Oct 2026 13:28:15 +0800 Subject: [PATCH 2/3] docs: name changelog fragment for PR 147 --- docs/changelog/{+dot-only-version.bugfix.rst => 147.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changelog/{+dot-only-version.bugfix.rst => 147.bugfix.rst} (100%) diff --git a/docs/changelog/+dot-only-version.bugfix.rst b/docs/changelog/147.bugfix.rst similarity index 100% rename from docs/changelog/+dot-only-version.bugfix.rst rename to docs/changelog/147.bugfix.rst From ac6684ebb37389bb1fd47f8fc5387b6d1abfe8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 15 Oct 2026 00:08:57 -0700 Subject: [PATCH 3/3] test(spec): fold discovery regression into fallback test --- docs/changelog/147.bugfix.rst | 3 ++- src/python_discovery/_py_spec.py | 2 -- tests/test_discovery.py | 5 +++-- tests/test_py_spec.py | 13 +------------ 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/docs/changelog/147.bugfix.rst b/docs/changelog/147.bugfix.rst index 8bd58e3..8ada1d9 100644 --- a/docs/changelog/147.bugfix.rst +++ b/docs/changelog/147.bugfix.rst @@ -1 +1,2 @@ -Handle dot-only version components without ``IndexError``, preserving the normal executable-path fallback. +Treat specs with dot-only version components, such as ``python.``, as literal executable paths instead of raising +``IndexError`` - by :user:`harbinresearcher`. diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 3838462..8928318 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -164,8 +164,6 @@ 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 `_. - - A version with no numeric components, such as ``python.``, falls back to a literal executable path. """ 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 ff278a8..af13847 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -100,9 +100,10 @@ def test_discovery_fallback_fail(session_cache: DiskCache, caplog: pytest.LogCap assert "accepted" not in caplog.text -def test_discovery_fallback_ok(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None: +@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(["magic-one", sys.executable], cache=session_cache) + result = get_interpreter([missing, sys.executable], cache=session_cache) assert result is not None, caplog.text assert result.executable == sys.executable, caplog.text assert "accepted" in caplog.text diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index 4b4f1c2..e7f4d83 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -1,12 +1,11 @@ from __future__ import annotations -import os import sys from typing import TYPE_CHECKING import pytest -from python_discovery import PythonSpec, get_interpreter +from python_discovery import PythonSpec from python_discovery._py_info import normalize_isa from python_discovery._specifier import SimpleSpecifierSet as SpecifierSet @@ -26,16 +25,6 @@ def test_bad_py_spec(text: str) -> None: assert all(v is None for v in content.values()) -def test_bad_py_spec_falls_back_to_next_interpreter(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) - env = {**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")} - - interpreter = get_interpreter(["python.", sys.executable], env=env) - - assert interpreter is not None - assert interpreter.version_info[:3] == sys.version_info[:3] - - def test_py_spec_first_digit_only_major() -> None: spec = PythonSpec.from_string_spec("278") assert spec.major == 2