diff --git a/docs/changelog/+spec-specifier-precision.bugfix.rst b/docs/changelog/+spec-specifier-precision.bugfix.rst new file mode 100644 index 0000000..8561ec8 --- /dev/null +++ b/docs/changelog/+spec-specifier-precision.bugfix.rst @@ -0,0 +1,3 @@ +``PythonSpec.satisfies`` now checks a version specifier at the precision it was written with, so ``3.11`` no longer +satisfies ``>=3.13``. The Windows registry pre-filter skips mismatched releases and interrogates candidates whose +prerelease status is unknown. diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index f38c129..ca17bfc 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib import pathlib import re from typing import Final @@ -235,27 +234,31 @@ def _check_version_specifier(self, spec: PythonSpec) -> bool: if not components: return True - version_str = ".".join(str(part) for part in components) if spec.version_specifier is None: return True - with contextlib.suppress(InvalidVersion): - Version.from_string(version_str) - for item in spec.version_specifier: - required_precision = self._get_required_precision(item) - if required_precision is None or len(components) < required_precision: - continue - if not item.contains(version_str): - return False + for item in spec.version_specifier: + required_precision = self._get_required_precision(item) + if required_precision is None or len(components) < required_precision: + continue + # Registry versions omit prerelease status, so a matching release still needs interrogation. + if ( + (version := item.version) is not None + and version.pre_type is not None + and tuple(components[:required_precision]) == version.release[:required_precision] + ): + continue + # compare at the precision the specifier was written with, as PythonInfo.satisfies does + if not item.contains(".".join(str(part) for part in components[:required_precision])): + return False return True @staticmethod def _get_required_precision(item: SimpleSpecifier) -> int | None: - """Get the required precision for a specifier item.""" if item.version is None: return None - with contextlib.suppress(AttributeError, ValueError): - return len(item.version.release) - return None + if item.wildcard_precision is not None: + return item.wildcard_precision + return re.split(r"a|b|rc", item.version_str, maxsplit=1)[0].count(".") + 1 def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-statements] """ diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index 3adc020..e8ab73f 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -1,7 +1,5 @@ from __future__ import annotations -from unittest.mock import MagicMock - import pytest from python_discovery import PythonSpec @@ -165,16 +163,22 @@ def test_specifier_set_parsed_for_valid_format() -> None: assert spec.implementation == "cpython" -def test_get_required_precision_attribute_error() -> None: - from python_discovery._specifier import SimpleSpecifier - - mock_version = MagicMock(spec=[]) - specifier = SimpleSpecifier( - spec_str=">=3.12", - operator=">=", - version_str="3.12", - is_wildcard=False, - wildcard_precision=None, - version=mock_version, - ) - assert PythonSpec._get_required_precision(specifier) is None +@pytest.mark.parametrize( + ("candidate", "text", "expected"), + [ + pytest.param("3.11", ">=3.13", False, id="minor-too-old"), + pytest.param("3.14", "<3.13", False, id="minor-too-new"), + pytest.param("3.11", "==3.12.*", False, id="minor-wildcard-miss"), + pytest.param("3.12", "==3.12.*", True, id="minor-wildcard-hit"), + pytest.param("3.13", ">=3.12,<3.14", True, id="minor-in-range"), + pytest.param("3", ">=4", False, id="major-too-old"), + pytest.param("3.12", ">=3.12.5", True, id="micro-unknown"), + pytest.param("3.12.1", "==3.12", True, id="micro-at-minor-precision"), + pytest.param("3.14", "==3.14rc1", True, id="prerelease-unknown"), + pytest.param("3.14", "<=3.14rc1", True, id="prerelease-upper-bound-unknown"), + pytest.param("3.14.0", "==3.14.0rc1", True, id="prerelease-unknown-at-micro-precision"), + pytest.param("3.13", ">=3.14rc1", False, id="earlier-release-still-filtered"), + ], +) +def test_version_specifier_compared_at_written_precision(candidate: str, text: str, expected: bool) -> None: + assert PythonSpec.from_string_spec(candidate).satisfies(PythonSpec.from_string_spec(text)) is expected