From 36ec58c661f196e26820fa7cec23fa18f7768124 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Fri, 10 Oct 2025 16:09:46 +0100 Subject: [PATCH 1/3] Improves message for global directory. Adds check for whether the latest Python is installed, separate from any. (Fixes #187) Use default_platform to select best online runtime. (Fixes #186) --- src/manage/commands.py | 8 ++++ src/manage/firstrun.py | 79 +++++++++++++++++++++++++++++---- src/manage/list_command.py | 60 ++++++++++++++++++------- src/manage/uninstall_command.py | 1 - src/manage/verutils.py | 8 +++- tests/conftest.py | 1 + tests/test_firstrun.py | 21 +++++++++ tests/test_list.py | 2 + 8 files changed, 154 insertions(+), 26 deletions(-) diff --git a/src/manage/commands.py b/src/manage/commands.py index ed376f6..190eb49 100644 --- a/src/manage/commands.py +++ b/src/manage/commands.py @@ -262,6 +262,7 @@ def execute(self): "check_long_paths": (config_bool, None, "env"), "check_py_on_path": (config_bool, None, "env"), "check_any_install": (config_bool, None, "env"), + "check_latest_install": (config_bool, None, "env"), "check_global_dir": (config_bool, None, "env"), }, @@ -698,9 +699,14 @@ class ListCommand(BaseCommand): one = False unmanaged = True source = None + fallback_source = None default_source = False keep_log = False + # Not settable from the CLI/config, but used internally + formatter_callable = None + fallback_source_only = False + def execute(self): from .list_command import execute self.show_welcome() @@ -708,6 +714,7 @@ def execute(self): LOGGER.debug("Loading 'install' command to get source") inst_cmd = COMMANDS["install"](["install"], self.root) self.source = inst_cmd.source + self.fallback_source = inst_cmd.fallback_source if self.source and "://" not in str(self.source): try: self.source = Path(self.source).absolute().as_uri() @@ -982,6 +989,7 @@ class FirstRun(BaseCommand): check_long_paths = True check_py_on_path = True check_any_install = True + check_latest_install = True check_global_dir = True def execute(self): diff --git a/src/manage/firstrun.py b/src/manage/firstrun.py index 22bad51..c5de303 100644 --- a/src/manage/firstrun.py +++ b/src/manage/firstrun.py @@ -248,6 +248,51 @@ def check_any_install(cmd): return True +def _list_available_fallback_runtimes(cmd): + from .commands import find_command + + candidates = [] + try: + list_cmd = find_command(["list", "--online", "-1", "default"], cmd.root) + list_cmd.formatter_callable = lambda cmd, installs: candidates.extend(installs) + list_cmd.fallback_source_only = True + list_cmd.execute() + if not candidates: + list_cmd.fallback_source_only = False + list_cmd.execute() + except Exception: + LOGGER.debug("Check skipped: Failed to find 'list' command.", exc_info=True) + return [] + except SystemExit: + LOGGER.debug("Check skipped: Failed to execute 'list' command.") + return [] + + return candidates + + +def check_latest_install(cmd): + LOGGER.debug("Checking if any default runtime is installed") + + available = _list_available_fallback_runtimes(cmd) + if not available: + return "skip" + + installs = cmd.get_installs(include_unmanaged=True, set_default=False) + if not installs: + LOGGER.debug("Check failed: no installs found") + return False + + present = {i.get("tag") for i in installs} + available = set(j for i in available for j in i.get("install-for", [])) + LOGGER.debug("Already installed: %s", sorted(present)) + LOGGER.debug("Available: %s", sorted(available)) + if available & present: + LOGGER.debug("Check passed: installs found") + return True + LOGGER.debug("Check failed: no equivalent 'default' runtime installed") + return False + + def do_install(cmd): from .commands import find_command try: @@ -360,16 +405,18 @@ def first_run(cmd): welcome() line_break() shown_any = True - LOGGER.print("!Y!The directory for versioned Python commands is not " + LOGGER.print("!Y!The global shortcuts directory is not " "configured.!W!", level=logging.WARN) - LOGGER.print("\nThis will prevent commands like !B!python3.14.exe!W! " - "working, but will not affect the !B!python!W! or " - "!B!py!W! commands (for example, !B!py -V:3.14!W!).", + LOGGER.print("\nThis enables commands like !B!python3.14.exe!W!, " + "but is not needed for the !B!python!W! or !B!py!W! " + "commands (for example, !B!py -V:3.14!W!).", + wrap=True) + LOGGER.print("\nWe can add the directory (!B!%s!W!) to PATH now, " + "but you will need to restart your terminal to use " + "it. The entry will be removed if you run !B!py " + "uninstall --purge!W!, or else you can remove it " + "manually when uninstalling Python.\n", cmd.global_dir, wrap=True) - LOGGER.print("\nWe can add the directory to PATH now, but you will " - "need to restart your terminal to see the change, and " - "must manually edit environment variables to later " - "remove the entry.\n", wrap=True) if ( not cmd.confirm or not cmd.ask_ny("Add commands directory to your PATH now?") @@ -399,6 +446,22 @@ def first_run(cmd): elif cmd.explicit: LOGGER.info("Checked for any Python installs") + if cmd.check_latest_install: + if not check_latest_install(cmd): + welcome() + line_break() + shown_any = True + LOGGER.print("!Y!You do not have the latest Python runtime.!W!", + level=logging.WARN) + LOGGER.print("\nInstall the current latest version of CPython? If " + "not, you can use '!B!py install default!W!' later to " + "install.\n", wrap=True) + LOGGER.info("") + if not cmd.confirm or cmd.ask_yn("Install CPython now?"): + do_install(cmd) + elif cmd.explicit: + LOGGER.info("Checked for the latest available Python install") + if shown_any or cmd.explicit: line_break() LOGGER.print("!G!Configuration checks completed.!W!", level=logging.WARN) diff --git a/src/manage/list_command.py b/src/manage/list_command.py index 4801c00..3884398 100644 --- a/src/manage/list_command.py +++ b/src/manage/list_command.py @@ -1,5 +1,4 @@ import json -import sys from . import logging from .exceptions import ArgumentError @@ -285,16 +284,20 @@ def _get_installs_from_index(indexes, filters): def execute(cmd): LOGGER.debug("BEGIN list_command.execute: %r", cmd.args) - try: - LOGGER.debug("Get formatter %s", cmd.format) - formatter = FORMATTERS[cmd.format] - except LookupError: - formatters = FORMATTERS.keys() - {"legacy", "legacy-paths"} - expect = ", ".join(sorted(formatters)) - raise ArgumentError(f"'{cmd.format}' is not a valid format; expected one of: {expect}") from None + if cmd.formatter_callable: + formatter = cmd.formatter_callable + else: + try: + LOGGER.debug("Get formatter %s", cmd.format) + formatter = FORMATTERS[cmd.format] + except LookupError: + formatters = FORMATTERS.keys() - {"legacy", "legacy-paths"} + expect = ", ".join(sorted(formatters)) + raise ArgumentError(f"'{cmd.format}' is not a valid format; expected one of: {expect}") from None from .tagutils import tag_or_range, install_matches_any tags = [] + plat = None for arg in cmd.args: if arg.casefold() == "default".casefold(): LOGGER.debug("Replacing 'default' with '%s'", cmd.default_tag) @@ -302,19 +305,46 @@ def execute(cmd): else: try: tags.append(tag_or_range(arg)) + try: + if not plat: + plat = tags[-1].platform + except AttributeError: + pass except ValueError as ex: LOGGER.warn("%s", ex) + plat = plat or cmd.default_platform if cmd.source: from .indexutils import Index from .urlutils import IndexDownloader - try: - installs = _get_installs_from_index( - IndexDownloader(cmd.source, Index), - tags, - ) - except OSError as ex: - raise SystemExit(1) from ex + installs = [] + first_exc = None + for source in [ + None if cmd.fallback_source_only else cmd.source, + cmd.fallback_source, + ]: + if source: + try: + installs = _get_installs_from_index( + IndexDownloader(source, Index), + tags, + ) + break + except OSError as ex: + if first_exc is None: + first_exc = ex + 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: + installs = best[:1] or installs elif cmd.install_dir: try: installs = cmd.get_installs(include_unmanaged=cmd.unmanaged) diff --git a/src/manage/uninstall_command.py b/src/manage/uninstall_command.py index 85795ac..abaebb6 100644 --- a/src/manage/uninstall_command.py +++ b/src/manage/uninstall_command.py @@ -18,7 +18,6 @@ def _iterdir(p, only_files=False): def _do_purge_global_dir(global_dir, warn_msg, *, hive=None, subkey="Environment"): - import os import winreg if hive is None: diff --git a/src/manage/verutils.py b/src/manage/verutils.py index 2a4cbda..84b72d8 100644 --- a/src/manage/verutils.py +++ b/src/manage/verutils.py @@ -13,15 +13,19 @@ class Version: } _TEXT_UNMAP = {v: k for k, v in TEXT_MAP.items()} + _LEVELS = None # Versions with more fields than this will be truncated. MAX_FIELDS = 8 def __init__(self, s): import re - levels = "|".join(re.escape(k) for k in self.TEXT_MAP if k) + if isinstance(s, Version): + s = s.s + if not Version._LEVELS: + Version._LEVELS = "|".join(re.escape(k) for k in self.TEXT_MAP if k) m = re.match( - r"^(?P\d+(\.\d+)*)([\.\-]?(?P" + levels + r")[\.]?(?P\d*))?$", + r"^(?P\d+(\.\d+)*)([\.\-]?(?P" + Version._LEVELS + r")[\.]?(?P\d*))?$", s, re.I, ) diff --git a/tests/conftest.py b/tests/conftest.py index 6d99e0b..41957ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,6 +149,7 @@ def localserver(): class FakeConfig: def __init__(self, global_dir, installs=[]): + self.root = global_dir.parent if global_dir else None self.global_dir = global_dir self.confirm = False self.installs = list(installs) diff --git a/tests/test_firstrun.py b/tests/test_firstrun.py index f7cfba1..48de68a 100644 --- a/tests/test_firstrun.py +++ b/tests/test_firstrun.py @@ -99,6 +99,27 @@ def test_check_any_install(fake_config): assert firstrun.check_any_install(fake_config) == True +def test_check_latest_install(fake_config, monkeypatch): + fake_config.default_tag = "1" + fake_config.default_platform = "-64" + assert firstrun.check_latest_install(fake_config) == False + + fake_config.installs.append({"tag": "1.0-64"}) + assert firstrun.check_latest_install(fake_config) == False + + def _fallbacks(cmd): + return [{"install-for": ["1.0-64"]}] + + monkeypatch.setattr(firstrun, "_list_available_fallback_runtimes", _fallbacks) + assert firstrun.check_latest_install(fake_config) == True + + def _fallbacks(cmd): + return [{"install-for": ["1.0-32"]}] + + monkeypatch.setattr(firstrun, "_list_available_fallback_runtimes", _fallbacks) + assert firstrun.check_latest_install(fake_config) == False + + def test_welcome(assert_log): welcome = firstrun._Welcome() assert_log(assert_log.end_of_log()) diff --git a/tests/test_list.py b/tests/test_list.py index 03991c7..cddbe79 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -31,7 +31,9 @@ def __init__(self): self.captured = [] self.source = None self.install_dir = "" + self.default_platform = "-64" self.format = "test" + self.formatter_callable = None self.one = False self.unmanaged = True list_command.FORMATTERS["test"] = lambda c, i: self.captured.extend(i) From eae89eb32ddd67e8cacfcd244cb7b50028bd990f Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Fri, 10 Oct 2025 16:10:48 +0100 Subject: [PATCH 2/3] Don't check for any install by default --- src/manage/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manage/commands.py b/src/manage/commands.py index 190eb49..ec57c09 100644 --- a/src/manage/commands.py +++ b/src/manage/commands.py @@ -988,7 +988,7 @@ class FirstRun(BaseCommand): check_app_alias = True check_long_paths = True check_py_on_path = True - check_any_install = True + check_any_install = False check_latest_install = True check_global_dir = True From 04694795b57c76b7075d054bea7d6bb116b4bf97 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Mon, 13 Oct 2025 19:57:51 +0100 Subject: [PATCH 3/3] Clarification --- src/manage/firstrun.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/manage/firstrun.py b/src/manage/firstrun.py index c5de303..bd084b6 100644 --- a/src/manage/firstrun.py +++ b/src/manage/firstrun.py @@ -407,7 +407,8 @@ def first_run(cmd): shown_any = True LOGGER.print("!Y!The global shortcuts directory is not " "configured.!W!", level=logging.WARN) - LOGGER.print("\nThis enables commands like !B!python3.14.exe!W!, " + LOGGER.print("\nConfiguring this enables commands like " + "!B!python3.14.exe!W! to run from your terminal, " "but is not needed for the !B!python!W! or !B!py!W! " "commands (for example, !B!py -V:3.14!W!).", wrap=True)