Skip to content

Commit 5d005b5

Browse files
committed
🐛 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
1 parent c4ec5ca commit 5d005b5

2 files changed

Lines changed: 39 additions & 23 deletions

File tree

src/python_discovery/_py_info.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,12 +469,24 @@ 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+
assert specifier.version is not None # noqa: S101
474+
numeric_version = specifier.version_str
475+
for prefix in ("rc", "b", "a"):
476+
if prefix in numeric_version:
477+
numeric_version = numeric_version.split(prefix)[0]
478+
break
479+
precision = numeric_version.count(".") + 1
480+
release = ".".join(str(c) for c in [version_info.major, version_info.minor, version_info.micro][:precision])
481+
if (
482+
version_info.releaselevel != "final"
483+
and (precision == 3 or specifier.version.pre_type is not None) # noqa: PLR2004
484+
and (suffix := {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel))
485+
):
476486
release = f"{release}{suffix}{version_info.serial}"
477-
return spec.version_specifier.contains(release)
487+
if not specifier.contains(release):
488+
return False
489+
return True
478490

479491
_current_system = None
480492
_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)