diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..f33e00b Binary files /dev/null and b/.coverage differ diff --git a/docs/changelog/+spec-specifier-precision.bugfix.rst b/docs/changelog/+spec-specifier-precision.bugfix.rst new file mode 100644 index 0000000..f9aae1a --- /dev/null +++ b/docs/changelog/+spec-specifier-precision.bugfix.rst @@ -0,0 +1,2 @@ +``PythonSpec.satisfies`` now checks a version specifier at the precision it was written with, so ``3.11`` no longer +satisfies ``>=3.13``. This also lets the Windows registry pre-filter skip interpreters that can't match. diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index f38c129..7bc3674 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,25 @@ 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 + # 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.""" + """Get the number of release parts a specifier item was written with (``>=3.12`` has two).""" 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..312f08a 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,35 @@ def test_specifier_set_parsed_for_valid_format() -> None: assert spec.implementation == "cpython" -def test_get_required_precision_attribute_error() -> None: +@pytest.mark.parametrize( + ("text", "expected"), + [ + pytest.param(">=3", 1, id="major"), + pytest.param(">=3.12", 2, id="minor"), + pytest.param(">=3.12.1", 3, id="micro"), + pytest.param(">=3.14rc1", 2, id="minor-pre"), + pytest.param(">=3.14.0b2", 3, id="micro-pre"), + pytest.param("==3.12.*", 2, id="wildcard"), + ], +) +def test_get_required_precision_counts_written_parts(text: str, expected: int) -> 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 + assert PythonSpec._get_required_precision(SimpleSpecifier.from_string(text)) == expected + + +@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"), + ], +) +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