diff --git a/src/manage/indexutils.py b/src/manage/indexutils.py index 5169373..44bf5c5 100644 --- a/src/manage/indexutils.py +++ b/src/manage/indexutils.py @@ -231,9 +231,11 @@ def find_all(self, tags, *, seen_ids=None, loose_company=False, with_prerelease= seen_ids.add(i["id"].casefold()) yield i - def find_to_install(self, tag, *, loose_company=False, prefer_prerelease=False): - tag_list = [tag] if tag else [] + def find_to_install(self, tag, *, loose_company=False, prefer_prerelease=None): + tag_list = [tag_or_range(tag)] if tag else [] LOGGER.debug("Finding %s to install", tag_list) + if prefer_prerelease is None: + prefer_prerelease = any(t.prefers_prerelease for t in tag_list) for i in self.find_all(tag_list, loose_company=loose_company, with_prerelease=prefer_prerelease): return {**i, "source": self.source_url} if not loose_company: diff --git a/src/manage/installs.py b/src/manage/installs.py index 225b41e..06158fd 100644 --- a/src/manage/installs.py +++ b/src/manage/installs.py @@ -17,6 +17,14 @@ def _make_sort_key(install): ) +def _make_sort_key_ignore_pre(install): + # Alternate sort key for "-dev" that treats prerelease as equal to release + return ( + CompanyTag(install.get("company"), install.get("tag")), + Version(install["sort-version"]), + ) + + def _get_installs(install_dir): for d in Path(install_dir).iterdir(): p = d / "__install__.json" @@ -258,8 +266,6 @@ def get_matching_install_tags( else: matches.append((i, t)) matched_any = True - if single_tag and matched_any: - break if LOGGER.would_log_to_console(DEBUG): # Don't bother listing all installs unless the user has asked # for console output. @@ -298,6 +304,8 @@ def get_matching_install_tags( # If none or only prereleases, keep them all if default_platform: default_platform = default_platform.casefold() + # Remember whether all source versions were prereleases, so we don't + # reset our results if all post-filter versions are prereleases. all_pre = all(Version(i["sort-version"]).is_prerelease for i, t in best) best2 = best best = [(i, t) for i, t in best @@ -305,13 +313,24 @@ def get_matching_install_tags( or t["tag"].casefold().endswith(default_platform)] LOGGER.debug("default_platform '%s' matched %s %s", default_platform, len(best), "install" if len(best) == 1 else "installs") - if ( - not best or - (not all_pre and all(Version(i["sort-version"]).is_prerelease for i, t in best)) - ): + if not best: + LOGGER.debug("Reusing unfiltered list because all candidates were " + "excluded") + best = best2 + elif (not all_pre + and (not tag or not tag.prefers_prerelease) + and all(Version(i["sort-version"]).is_prerelease for i, t in best)): LOGGER.debug("Reusing unfiltered list") best = best2 + if best and single_tag: + if tag.prefers_prerelease: + best2 = sorted(best, key=lambda i_t: _make_sort_key_ignore_pre(i_t[0])) + for i_t in best2: + if Version(i_t[0]["sort-version"]).is_prerelease: + return [i_t] + return best[:1] + return best @@ -357,6 +376,11 @@ def get_install_to_run( default_platform=default_platform, ) + if tag and tag.prefers_prerelease: + best2 = sorted(best, key=lambda i_t: _make_sort_key_ignore_pre(i_t[0])) + for i, t in best2: + if Version(i["sort-version"]).is_prerelease: + return _patch_install_to_run(i, t) if best: return _patch_install_to_run(*best[0]) diff --git a/src/manage/list_command.py b/src/manage/list_command.py index 4b27736..c01e806 100644 --- a/src/manage/list_command.py +++ b/src/manage/list_command.py @@ -354,15 +354,19 @@ def execute(cmd): if first_exc: raise SystemExit(1) from first_exc if cmd.one: - # Pick the first non-prerelease that'll install for our platform best = [i for i in installs if any(t.endswith(plat) for t in i.get("install-for", []))] - for i in best: - if not i["sort-version"].is_prerelease: - installs = [i] - break - else: + if any(t.prefers_prerelease for t in tags): + # First result will be the latest prerelease (or release) installs = best[:1] or installs + else: + # Pick the first non-prerelease that'll install for our platform + for i in best: + if not i["sort-version"].is_prerelease: + installs = [i] + break + else: + installs = best[:1] or installs elif cmd.install_dir: try: installs = cmd.get_installs(include_unmanaged=cmd.unmanaged) diff --git a/src/manage/tagutils.py b/src/manage/tagutils.py index e2a22cd..63d289d 100644 --- a/src/manage/tagutils.py +++ b/src/manage/tagutils.py @@ -67,6 +67,13 @@ def startswith(self, other): return not self.s return self.s.startswith(other.s) + def endswith(self, other): + if not isinstance(other, type(self)): + return False + if not other.s: + return True + return self.s.endswith(other.s) + def __hash__(self): return hash(self.s) @@ -213,6 +220,12 @@ def match(self, pattern): def satisfied_by(self, tag): return tag.match(self) + @property + def prefers_prerelease(self, dev_text=_AscendingText("dev")): + if self._sortkey and self._sortkey[-1] == dev_text: + return True + return False + def __str__(self): if self.is_core: return f"{self.tag}{self.platform}" @@ -340,6 +353,10 @@ def __init__(self, spec): def __repr__(self): return "".format(", ".join(repr(r) for r in self.ranges)) + @property + def prefers_prerelease(self): + return any(r.prefers_prerelease for r in self.ranges) + def satisfied_by(self, tag): return all(r(tag) for r in self.ranges) @@ -359,6 +376,10 @@ def __init__(self, tag): assert isinstance(tag, CompanyTag) self.tag = tag + @property + def prefers_prerelease(self): + return self.tag.prefers_prerelease + def __repr__(self): return f"{self.OP}{self.tag}" @@ -404,6 +425,9 @@ class RangeExclude(Range): def __call__(self, other): return not other.matches_bound(self.tag) + @property + def prefers_prerelease(self): + return False def tag_or_range(tag): if not isinstance(tag, str): diff --git a/src/manage/verutils.py b/src/manage/verutils.py index 84b72d8..b4a2a6c 100644 --- a/src/manage/verutils.py +++ b/src/manage/verutils.py @@ -18,6 +18,11 @@ class Version: # Versions with more fields than this will be truncated. MAX_FIELDS = 8 + def __new__(self, s): + if isinstance(s, Version): + return s + return object.__new__(self) + def __init__(self, s): import re if isinstance(s, Version): diff --git a/tests/conftest.py b/tests/conftest.py index 189129a..5105120 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -276,25 +276,45 @@ def make_install(tag, **kwargs): def fake_get_installs(install_dir): - yield make_install("1.0") - yield make_install("1.0-32", sort_version="1.0", alias=[dict(name="py1.0.exe"), dict(name="py1.0-32.exe")]) - yield make_install("1.0-64", sort_version="1.0", alias=[dict(name="py1.0.exe"), dict(name="py1.0-64.exe")]) - yield make_install("2.0-64", sort_version="2.0") - yield make_install("2.0-arm64", sort_version="2.0") - yield make_install("3.0a1-32", sort_version="3.0a1") - yield make_install("3.0a1-64", sort_version="3.0a1") - yield make_install("1.1", company="Company", target="company.exe", targetw="companyw.exe") - yield make_install("1.1-64", sort_version="1.1", company="Company", target="company.exe", targetw="companyw.exe") - yield make_install("1.1-arm64", sort_version="1.1", company="Company", target="company.exe", targetw="companyw.exe") - yield make_install("2.1", sort_version="2.1", company="Company", target="company.exe", targetw="companyw.exe") - yield make_install("2.1-64", sort_version="2.1", company="Company", target="company.exe", targetw="companyw.exe") + installs = [ + make_install("1.0"), + make_install("1.0-32", sort_version="1.0", alias=[dict(name="py1.0.exe"), dict(name="py1.0-32.exe")]), + make_install("1.0-64", sort_version="1.0", alias=[dict(name="py1.0.exe"), dict(name="py1.0-64.exe")]), + make_install("2.0-64", sort_version="2.0"), + make_install("2.0-arm64", sort_version="2.0"), + make_install("3.0a1-32", sort_version="3.0a1"), + make_install("3.0a1-64", sort_version="3.0a1"), + make_install("1.1", company="Company", target="company.exe", targetw="companyw.exe"), + make_install("1.1-64", sort_version="1.1", company="Company", target="company.exe", targetw="companyw.exe"), + make_install("1.1-arm64", sort_version="1.1", company="Company", target="company.exe", targetw="companyw.exe"), + make_install("2.1", sort_version="2.1", company="Company", target="company.exe", targetw="companyw.exe"), + make_install("2.1-64", sort_version="2.1", company="Company", target="company.exe", targetw="companyw.exe"), + ] + installs.sort(key=manage.installs._make_sort_key) + return installs def fake_get_installs2(install_dir): - yield make_install("1.0-32", sort_version="1.0") - yield make_install("3.0a1-32", sort_version="3.0a1", run_for=["3.0.1a1-32", "3.0-32", "3-32"]) - yield make_install("3.0a1-64", sort_version="3.0a1", run_for=["3.0.1a1-64", "3.0-64", "3-64"]) - yield make_install("3.0a1-arm64", sort_version="3.0a1", run_for=["3.0.1a1-arm64", "3.0-arm64", "3-arm64"]) + installs = [ + make_install("1.0-32", sort_version="1.0"), + make_install("3.0a1-32", sort_version="3.0a1", run_for=["3.0.1a1-32", "3.0-32", "3-32"]), + make_install("3.0a1-64", sort_version="3.0a1", run_for=["3.0.1a1-64", "3.0-64", "3-64"]), + make_install("3.0a1-arm64", sort_version="3.0a1", run_for=["3.0.1a1-arm64", "3.0-arm64", "3-arm64"]), + ] + installs.sort(key=manage.installs._make_sort_key) + return installs + + +def fake_get_installs3(install_dir): + installs = [ + make_install("1.0-32", sort_version="1.0"), + make_install("3.0-64", sort_version="3.0", run_for=["3.0.1-64", "3.0-64", "3-64", "3-dev-64"]), + make_install("3.0a1-32", sort_version="3.0a1", run_for=["3.0.1a1-32", "3.0-32", "3-32", "3-dev-32"]), + make_install("3.0a1-arm64", sort_version="3.0a1", run_for=["3.0.1a1-arm64", "3.0-arm64", "3-arm64", "3-dev-arm64"]), + make_install("3.1a1-64", sort_version="3.1a1", run_for=["3.1a1-64", "3.1-64", "3-64", "3-dev-64"]), + ] + installs.sort(key=manage.installs._make_sort_key) + return installs def fake_get_unmanaged_installs(): @@ -317,3 +337,10 @@ def patched_installs2(monkeypatch): monkeypatch.setattr(manage.installs, "_get_installs", fake_get_installs2) monkeypatch.setattr(manage.installs, "_get_unmanaged_installs", fake_get_unmanaged_installs) monkeypatch.setattr(manage.installs, "_get_venv_install", fake_get_venv_install) + + +@pytest.fixture +def patched_installs3(monkeypatch): + monkeypatch.setattr(manage.installs, "_get_installs", fake_get_installs3) + monkeypatch.setattr(manage.installs, "_get_unmanaged_installs", fake_get_unmanaged_installs) + monkeypatch.setattr(manage.installs, "_get_venv_install", fake_get_venv_install) diff --git a/tests/test_installs.py b/tests/test_installs.py index b8ca808..8a4f4c6 100644 --- a/tests/test_installs.py +++ b/tests/test_installs.py @@ -51,6 +51,40 @@ def test_get_default_install_prerelease(patched_installs2): assert m[0][0]["id"] == "PythonCore-3.0a1-32" +def test_get_default_install_prerelease_dev(patched_installs3): + inst = list(installs._get_installs("")) + # Installs contains 3.0 for -64, and 3.0a1 for -32, -64 and -arm64 + + # Passing "-V:3" should always prefer a final release, rather than a + # prerelease, even if the platform isn't the default. + m = installs.get_matching_install_tags(inst, "3", None, "-32", single_tag=True) + assert m and m[0] + assert m[0][0]["id"] == "PythonCore-3.0-64" + + m = installs.get_matching_install_tags(inst, "3", None, "-64", single_tag=True) + assert m and m[0] + assert m[0][0]["id"] == "PythonCore-3.0-64" + + # Passing "-V:3-dev" should prefer a prerelease for the default platform, + # even if a final release for a different platform exists. + m = installs.get_matching_install_tags(inst, "3-dev", None, "-32", single_tag=True) + assert m and m[0] + assert m[0][0]["id"] == "PythonCore-3.0a1-32" + + m = installs.get_matching_install_tags(inst, "3-dev", None, "-64", single_tag=True) + assert m and m[0] + assert m[0][0]["id"] == "PythonCore-3.1a1-64" + + # Sanity check that explicit platforms always choose something matching + m = installs.get_matching_install_tags(inst, "3-32", None, "-32", single_tag=True) + assert m and m[0] + assert m[0][0]["id"] == "PythonCore-3.0a1-32" + + m = installs.get_matching_install_tags(inst, "3-64", None, "-64", single_tag=True) + assert m and m[0] + assert m[0][0]["id"] == "PythonCore-3.0-64" + + def test_get_install_to_run(patched_installs): i = installs.get_install_to_run("", None, "1.0") assert i["id"] == "PythonCore-1.0" diff --git a/tests/test_tagutils.py b/tests/test_tagutils.py index 9f5a5f8..ead3963 100644 --- a/tests/test_tagutils.py +++ b/tests/test_tagutils.py @@ -1,6 +1,6 @@ import pytest -from manage.tagutils import CompanyTag, TagRange +from manage.tagutils import CompanyTag, TagRange, tag_or_range @pytest.mark.parametrize("tag_str", [ @@ -42,6 +42,24 @@ def test_tag_match(): assert not CompanyTag("PythonCore\\4.56").match(CompanyTag("", "3")) +def test_prefer_prerelease(): + assert not tag_or_range("3").prefers_prerelease + assert not tag_or_range("3-64").prefers_prerelease + assert not tag_or_range("3.13").prefers_prerelease + assert tag_or_range("3-dev").prefers_prerelease + assert tag_or_range("3.13-DEV").prefers_prerelease + assert tag_or_range("3.13-dev-64").prefers_prerelease + + assert not tag_or_range(">3").prefers_prerelease + assert not tag_or_range("<3-64").prefers_prerelease + assert not tag_or_range("==3.13").prefers_prerelease + assert not tag_or_range("!=3.13").prefers_prerelease + assert not tag_or_range("!=3.13-dev").prefers_prerelease + assert tag_or_range(">3-dev").prefers_prerelease + assert tag_or_range("<3.13-DEV").prefers_prerelease + assert tag_or_range("==3.13-dev-64").prefers_prerelease + + def test_tag_platform_match(): assert CompanyTag("3.10-64").match(CompanyTag("3.10")) assert CompanyTag("3.10-64").match(CompanyTag("3.10-64")) diff --git a/tests/test_verutils.py b/tests/test_verutils.py index acc1983..d9751ac 100644 --- a/tests/test_verutils.py +++ b/tests/test_verutils.py @@ -3,6 +3,15 @@ from manage.verutils import Version +def test_version_identity(): + v1 = Version("1.0") + v2 = Version(v1) + v3 = Version("1.0") + assert v1 is v2 + assert v1 is not v3 + assert v1 == v3 + + @pytest.mark.parametrize("ver_str", [ "3", "3.1", "3.10", "3.1.2", "3.1.2.3", "3a1", "3.1a1", "3.1.2a2", "3.1.2-a2", "3.1.a.4",