Skip to content

Commit 1cd65ce

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 1cd65ce

2 files changed

Lines changed: 38 additions & 23 deletions

File tree

src/python_discovery/_py_info.py

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

479490
_current_system = None
480491
_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)