Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/+spec-specifier-precision.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 17 additions & 14 deletions src/python_discovery/_py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import contextlib
import pathlib
import re
from typing import Final
Expand Down Expand Up @@ -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]
"""
Expand Down
34 changes: 19 additions & 15 deletions tests/test_py_spec_extra.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from python_discovery import PythonSpec
Expand Down Expand Up @@ -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