From 508f2a4c808865cf5551ac8092165ace7dd28156 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:29:56 -0700 Subject: [PATCH 1/3] fix(spec): let ~=X.Y match later minor versions SimpleVersion pads every release to three parts, and the compatible release check built its upper bound by dropping the last part of that padded tuple. That is only right when the specifier itself has three parts. For ~=3.10 it gave >=3.10,<3.11 instead of the >=3.10,==3.* that PEP 440 defines, so get_interpreter("~=3.10") returned None on a machine whose Pythons are all 3.11 or newer, and the uv store filter and PythonSpec.satisfies with a full X.Y.Z spec skipped the same interpreters. virtualenv -p "~=3.10" fails the same way. The check now compares every release part the specifier wrote except the last, the same prefix match the wildcard check uses. Comparing the prefix also stops ~=3.12.0 from accepting 3.13.0a1, which sorted below the old 3.13 bound. A bare ~=3 is not valid PEP 440, but it was accepted before, so it keeps working and now means ==3.* in both SimpleSpecifier.contains and PythonInfo.satisfies, which used to disagree on it. Anyone who used ~=3.12 to mean 3.12 only now gets later 3.x releases too; PEP 440 spells that ~=3.12.0 or ==3.12.*. --- src/python_discovery/_specifier.py | 10 ++++------ tests/test_iter_interpreters.py | 13 +++++++++++++ tests/test_py_info_extra.py | 3 +++ tests/test_specifier.py | 8 ++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) 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 From 202ee2a763b2c72b6c302992ecd96ec52cf690ec Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:54:06 -0700 Subject: [PATCH 2/3] docs: add changelog fragment for 159 --- docs/changelog/159.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/159.bugfix.rst diff --git a/docs/changelog/159.bugfix.rst b/docs/changelog/159.bugfix.rst new file mode 100644 index 0000000..6ad75ba --- /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 - by :user:`darrenhuai`. From a1ca02120f4a0f0560f2bd2e712894227da6ff77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 12:33:21 -0700 Subject: [PATCH 3/3] docs(changelog): drop hand-written author credit --- docs/changelog/159.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/159.bugfix.rst b/docs/changelog/159.bugfix.rst index 6ad75ba..3bf9421 100644 --- a/docs/changelog/159.bugfix.rst +++ b/docs/changelog/159.bugfix.rst @@ -1,2 +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 - by :user:`darrenhuai`. +the same minor; ``~=3.10`` now accepts 3.11 and newer 3.x.