Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/manage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},

Expand Down Expand Up @@ -698,16 +699,22 @@ 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()
if self.default_source:
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()
Expand Down Expand Up @@ -981,7 +988,8 @@ 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

def execute(self):
Expand Down
80 changes: 72 additions & 8 deletions src/manage/firstrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -360,16 +405,19 @@ 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("\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)
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?")
Expand Down Expand Up @@ -399,6 +447,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)
Expand Down
60 changes: 45 additions & 15 deletions src/manage/list_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import sys

from . import logging
from .exceptions import ArgumentError
Expand Down Expand Up @@ -285,36 +284,67 @@ 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)
tags.append(tag_or_range(cmd.default_tag))
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)
Expand Down
1 change: 0 additions & 1 deletion src/manage/uninstall_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions src/manage/verutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<numbers>\d+(\.\d+)*)([\.\-]?(?P<level>" + levels + r")[\.]?(?P<serial>\d*))?$",
r"^(?P<numbers>\d+(\.\d+)*)([\.\-]?(?P<level>" + Version._LEVELS + r")[\.]?(?P<serial>\d*))?$",
s,
re.I,
)
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions tests/test_firstrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions tests/test_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ def __init__(self):
self.captured = []
self.source = None
self.install_dir = "<none>"
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)
Expand Down