diff --git a/docs/changelog/159.bugfix.rst b/docs/changelog/159.bugfix.rst new file mode 100644 index 0000000..3bf9421 --- /dev/null +++ b/docs/changelog/159.bugfix.rst @@ -0,0 +1,2 @@ +Match ``~=X.Y`` compatible-release specifiers against later minor versions, as PEP 440 defines, instead of only +the same minor; ``~=3.10`` now accepts 3.11 and newer 3.x. diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py index 2607631..f9f05ea 100644 --- a/src/python_discovery/_specifier.py +++ b/src/python_discovery/_specifier.py @@ -227,12 +227,10 @@ def _check_compatible_release(self, candidate: SimpleVersion) -> bool: return False if candidate < self.version: return False - if len(self.version.release) >= 2: # ruff:ignore[magic-value-comparison] # pragma: no branch # SimpleVersion always has 3-part release - upper_parts = list(self.version.release[:-1]) - upper_parts[-1] += 1 - upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts)) - return candidate < upper - return True # pragma: no cover + # ~=X.Y is >=X.Y, ==X.*: compare all but the last part the spec wrote (release is padded to three). + # A bare ~=X still compares its one part, so it stays ==X.*. + prefix = max(self.version_str.count("."), 1) + return candidate.release[:prefix] == self.version.release[:prefix] def __eq__(self, other: object) -> bool: if not isinstance(other, SimpleSpecifier): diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index c348b71..3452f92 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -297,6 +297,19 @@ def test_iter_interpreters_uv_layout( ["cpython-3.12.13-macos-aarch64-none/bin/python"], id="specifier-rules-out-the-rest", ), + pytest.param( + ( + "cpython-3.9.25-macos-aarch64-none/bin/python", + "cpython-3.10.19-macos-aarch64-none/bin/python", + "cpython-3.13.7-macos-aarch64-none/bin/python", + ), + "~=3.10", + [ + "cpython-3.13.7-macos-aarch64-none/bin/python", + "cpython-3.10.19-macos-aarch64-none/bin/python", + ], + id="compatible-release-keeps-later-minors", + ), pytest.param( ( "cpython-3.14.6-macos-aarch64-none/bin/python", diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 4995b86..b2e5bd4 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -211,6 +211,9 @@ def test_satisfies_version_specifier_fails() -> None: pytest.param(VersionInfo(3, 15, 0, "final", 0), "<3.15.0a6", False, id="final_not_less_than_prerelease"), pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15", True, id="final_match_major_minor"), pytest.param(VersionInfo(3, 15, 1, "alpha", 1), ">=3.15.0", True, id="later_micro_prerelease_match"), + pytest.param(VersionInfo(3, 13, 7, "final", 0), "~=3.10", True, id="compatible_release_later_minor"), + pytest.param(VersionInfo(4, 0, 0, "final", 0), "~=3.10", False, id="compatible_release_next_major"), + pytest.param(VersionInfo(3, 13, 0, "alpha", 1), "~=3.12.0", False, id="compatible_release_next_minor_alpha"), ], ) def test_satisfies_version_specifier_prerelease(version_info: VersionInfo, spec_str: str, expected: bool) -> None: diff --git a/tests/test_specifier.py b/tests/test_specifier.py index 6091452..2c3d218 100644 --- a/tests/test_specifier.py +++ b/tests/test_specifier.py @@ -149,6 +149,12 @@ def test_specifier_parse_wildcard() -> None: pytest.param("~=3.12.0", "3.13.0", False, id="compatible-next-minor"), pytest.param("~=3.12.0", "3.11.0", False, id="compatible-below"), pytest.param("~=3.12.0", "3.11.9", False, id="compatible-just-below"), + pytest.param("~=3.12.0", "3.13.0a1", False, id="compatible-next-minor-prerelease"), + pytest.param("~=3.10", "3.13.7", True, id="compatible-two-part-next-minor"), + pytest.param("~=3.10", "3.9.25", False, id="compatible-two-part-below"), + pytest.param("~=3.10", "4.0.0", False, id="compatible-two-part-next-major"), + pytest.param("~=3.10", "4.0.0a1", False, id="compatible-two-part-next-major-prerelease"), + pytest.param("~=3.13rc1", "3.14.0", True, id="compatible-two-part-prerelease-next-minor"), pytest.param(">=3.12", "not_a_version", False, id="invalid-version"), ], ) @@ -297,3 +303,5 @@ def test_specifier_compatible_release_major_only() -> None: spec = SimpleSpecifier.from_string("~=3") assert spec.contains("3.0.0") is True assert spec.contains("3.0.5") is True + assert spec.contains("3.13.7") is True + assert spec.contains("4.0.0") is False