Skip to content
Open
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
Binary file added .coverage
Binary file not shown.
2 changes: 2 additions & 0 deletions docs/changelog/+spec-specifier-precision.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 11 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,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]
"""
Expand Down
43 changes: 30 additions & 13 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,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