Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions 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 @@ -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):
Expand Down
79 changes: 71 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,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!, "
Comment thread
zooba marked this conversation as resolved.
Outdated
"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,
Comment thread
zooba marked this conversation as resolved.
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 +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)
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