Skip to content

Commit 4d539c4

Browse files
gaborbernatclaude
andcommitted
🐛 fix(discovery): match prerelease versions against major.minor specs
Python prerelease versions like 3.15.0a6 were incorrectly failing to match version specifiers like >=3.15. This broke testing of prereleases in Fedora and other environments that build libraries against alpha/beta Python versions. The root cause was comparing the full version string "3.15.0a6" against normalized specifier versions without accounting for precision. For a spec like >=3.15 (two components), comparing against "3.15.0a6" failed because PEP 440 defines prereleases as less than final releases. The fix determines precision from the specifier's version string by counting dots, then only includes the prerelease suffix when either the precision is 3 (full version like >=3.15.0) or the specifier itself contains a prerelease marker (like >=3.15.0a1). This allows >=3.15 to match 3.15.0a6 by comparing "3.15" to "3.15", while >=3.15.0 correctly rejects it by comparing "3.15.0a6" to "3.15.0". Fixes #45 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c4ec5ca commit 4d539c4

2 files changed

Lines changed: 40 additions & 23 deletions

File tree

src/python_discovery/_py_info.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,12 +469,25 @@ def _satisfies_version_specifier(self, spec: PythonSpec) -> bool:
469469
if spec.version_specifier is None: # pragma: no cover
470470
return True
471471
version_info = self.version_info
472-
release = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
473-
if version_info.releaselevel != "final":
474-
suffix = {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel)
475-
if suffix is not None: # pragma: no branch # releaselevel is always alpha/beta/candidate here
472+
for specifier in spec.version_specifier:
473+
if specifier.version is None:
474+
continue
475+
numeric_version = specifier.version_str
476+
for prefix in ("rc", "b", "a"):
477+
if prefix in numeric_version:
478+
numeric_version = numeric_version.split(prefix)[0]
479+
break
480+
precision = numeric_version.count(".") + 1
481+
release = ".".join(str(c) for c in [version_info.major, version_info.minor, version_info.micro][:precision])
482+
if (
483+
version_info.releaselevel != "final"
484+
and (precision == 3 or specifier.version.pre_type is not None) # noqa: PLR2004
485+
and (suffix := {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel))
486+
):
476487
release = f"{release}{suffix}{version_info.serial}"
477-
return spec.version_specifier.contains(release)
488+
if not specifier.contains(release):
489+
return False
490+
return True
478491

479492
_current_system = None
480493
_current = None

tests/test_py_info_extra.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -276,25 +276,29 @@ def test_satisfies_version_specifier_fails() -> None:
276276
assert CURRENT.satisfies(spec, impl_must_match=False) is False
277277

278278

279-
def test_satisfies_prerelease_version() -> None:
279+
@pytest.mark.parametrize(
280+
("version_info", "spec_str", "expected"),
281+
[
282+
pytest.param(VersionInfo(3, 14, 0, "alpha", 1), ">=3.14.0a1", True, id="alpha_match_exact"),
283+
pytest.param(VersionInfo(3, 14, 0, "beta", 1), ">=3.14.0b1", True, id="beta_match_exact"),
284+
pytest.param(VersionInfo(3, 14, 0, "candidate", 1), ">=3.14.0rc1", True, id="rc_match_exact"),
285+
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15", True, id="prerelease_match_major_minor"),
286+
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0", False, id="prerelease_not_match_full_precision"),
287+
pytest.param(VersionInfo(3, 15, 0, "alpha", 5), "<3.15.0a6", True, id="earlier_prerelease_less_than"),
288+
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), "<3.15.0a6", False, id="prerelease_not_less_than_itself"),
289+
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0a6", True, id="prerelease_match_itself"),
290+
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0a7", False, id="prerelease_not_match_later"),
291+
pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15.0a6", True, id="final_greater_than_prerelease"),
292+
pytest.param(VersionInfo(3, 15, 0, "final", 0), "<3.15.0a6", False, id="final_not_less_than_prerelease"),
293+
pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15", True, id="final_match_major_minor"),
294+
pytest.param(VersionInfo(3, 15, 1, "alpha", 1), ">=3.15.0", True, id="later_micro_prerelease_match"),
295+
],
296+
)
297+
def test_satisfies_version_specifier_prerelease(version_info: VersionInfo, spec_str: str, expected: bool) -> None:
280298
info = copy.deepcopy(CURRENT)
281-
info.version_info = VersionInfo(3, 14, 0, "alpha", 1)
282-
spec = PythonSpec.from_string_spec(">=3.14.0a1")
283-
assert info.satisfies(spec, impl_must_match=False) is True
284-
285-
286-
def test_satisfies_prerelease_beta() -> None:
287-
info = copy.deepcopy(CURRENT)
288-
info.version_info = VersionInfo(3, 14, 0, "beta", 1)
289-
spec = PythonSpec.from_string_spec(">=3.14.0b1")
290-
assert info.satisfies(spec, impl_must_match=False) is True
291-
292-
293-
def test_satisfies_prerelease_candidate() -> None:
294-
info = copy.deepcopy(CURRENT)
295-
info.version_info = VersionInfo(3, 14, 0, "candidate", 1)
296-
spec = PythonSpec.from_string_spec(">=3.14.0rc1")
297-
assert info.satisfies(spec, impl_must_match=False) is True
299+
info.version_info = version_info
300+
spec = PythonSpec.from_string_spec(spec_str)
301+
assert info.satisfies(spec, impl_must_match=False) is expected
298302

299303

300304
def test_satisfies_path_not_abs_basename_match() -> None:

0 commit comments

Comments
 (0)