From 76cffc6e08174af5e36fd5bd90a0c3b161f49451 Mon Sep 17 00:00:00 2001 From: hippi345 <13539685+hippi345@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:37:38 +0000 Subject: [PATCH] Allow a major-only -dev tag to select a prerelease. Final releases publish the same -dev tags as prereleases, and install lookup only considered prereleases after no final match. `py install 3-dev` therefore resolved to the latest final instead of 3.16.0a1. Treat a requested prerelease tag as preferring prereleases on the first pass. Fixes #428 --- src/manage/indexutils.py | 39 ++++++++++++++++++++++++++- tests/test_indexutils.py | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/manage/indexutils.py b/src/manage/indexutils.py index 5169373..42b3982 100644 --- a/src/manage/indexutils.py +++ b/src/manage/indexutils.py @@ -1,6 +1,6 @@ from .exceptions import InvalidFeedError from .logging import LOGGER -from .tagutils import tag_or_range, install_matches_any +from .tagutils import CompanyTag, tag_or_range, install_matches_any from .verutils import Version SCHEMA = { @@ -195,6 +195,37 @@ def _patch(source_url, v): return _SCHEMA_PATCHES.get(v.get("schema"), lambda _, v: v)(source_url, v) +def _tag_prefers_prerelease(tag): + """True when *tag* asks for a prerelease, such as '3-dev' or '3.16.0a1'. + + Tag sorting splits '-dev' and 'a1' off the numeric version, so the version + component alone is not a prerelease. The suffix is what the request means. + """ + if not isinstance(tag, str): + return False + stripped = tag.strip() + if not stripped or stripped.lstrip(" =")[:1] in "<>!": + return False + try: + parsed = tag_or_range(stripped) + except ValueError: + return False + if not isinstance(parsed, CompanyTag): + return False + for part in parsed._sortkey: + text = str(part).casefold() + if text == "dev": + return True + # Free-threaded tags keep the 't' ABI marker on the suffix ('a1t'). + if len(text) > 1 and text.endswith("t"): + text = text[:-1] + if len(text) >= 2 and text.startswith("rc") and text[2:].isdigit(): + return True + if text[:1] in "abc" and text[1:].isdigit(): + return True + return False + + class Index: def __init__(self, source_url, d): try: @@ -232,6 +263,12 @@ def find_all(self, tags, *, seen_ids=None, loose_company=False, with_prerelease= yield i def find_to_install(self, tag, *, loose_company=False, prefer_prerelease=False): + # Final releases publish the same '-dev' tags as prereleases, so a + # request such as '3-dev' matches an older final on the first pass and + # the prerelease fallback never runs. A tag that is itself a + # prerelease has to include prereleases while scanning newest-first. + if not prefer_prerelease and _tag_prefers_prerelease(tag): + prefer_prerelease = True tag_list = [tag] if tag else [] LOGGER.debug("Finding %s to install", tag_list) for i in self.find_all(tag_list, loose_company=loose_company, with_prerelease=prefer_prerelease): diff --git a/tests/test_indexutils.py b/tests/test_indexutils.py index b60f40c..4a172be 100644 --- a/tests/test_indexutils.py +++ b/tests/test_indexutils.py @@ -170,6 +170,64 @@ def test_install_lookup(): assert index.find_to_install("<3.12,!=3.10")["tag"] == "3.11.3" +def _feed_package(sort, tag, install_for): + data = fake_install_data(sort, sort_version=sort) + data["tag"] = tag + data["install-for"] = list(install_for) + return data + + +def test_find_to_install_dev_tag_prefers_prerelease(): + # gh-428: finals publish the same '-dev' tags as prereleases, so '3-dev' + # used to stop at 3.15.0. '3.16-dev' already worked because no final + # matched that tag and the prerelease fallback ran. + def listed(full, minor): + major = minor.partition(".")[0] + return [ + full, minor, major, + f"{minor}-dev", f"{major}-dev", + f"{full}-64", f"{minor}-64", f"{major}-64", + f"{minor}-dev-64", f"{major}-dev-64", + ] + + only_major = iu.Index("https://localhost/", { + "versions": [ + _feed_package( + "3.16.0a1", "3.16-dev", + listed("3.16.0a1", "3.16"), + ), + _feed_package( + "3.15.0", "3.15", + listed("3.15.0", "3.15"), + ), + ], + }) + assert str(only_major.find_to_install("3-dev")["sort-version"]) == "3.16.0a1" + assert str(only_major.find_to_install(r"PythonCore\3-dev")["sort-version"]) == "3.16.0a1" + assert str(only_major.find_to_install("3-dev-64")["sort-version"]) == "3.16.0a1" + assert str(only_major.find_to_install("3.16-dev")["sort-version"]) == "3.16.0a1" + assert str(only_major.find_to_install("3")["sort-version"]) == "3.15.0" + assert str(only_major.find_to_install("3.15-dev")["sort-version"]) == "3.15.0" + + # A newer prerelease of an already-final minor must win for '-dev' only. + same_minor = iu.Index("https://localhost/", { + "versions": [ + _feed_package( + "3.16.1a1", "3.16-dev", + listed("3.16.1a1", "3.16"), + ), + _feed_package( + "3.16.0", "3.16", + listed("3.16.0", "3.16"), + ), + ], + }) + assert str(same_minor.find_to_install("3-dev")["sort-version"]) == "3.16.1a1" + assert str(same_minor.find_to_install("3.16-dev")["sort-version"]) == "3.16.1a1" + assert str(same_minor.find_to_install("3")["sort-version"]) == "3.16.0" + assert str(same_minor.find_to_install("3.16")["sort-version"]) == "3.16.0" + + def test_select_package(): from manage.install_command import select_package