Skip to content

Commit f378d2a

Browse files
authored
Improved first-run experience (#190)
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)
1 parent a58994c commit f378d2a

File tree

8 files changed

+156
-27
lines changed

8 files changed

+156
-27
lines changed

src/manage/commands.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ def execute(self):
262262
"check_long_paths": (config_bool, None, "env"),
263263
"check_py_on_path": (config_bool, None, "env"),
264264
"check_any_install": (config_bool, None, "env"),
265+
"check_latest_install": (config_bool, None, "env"),
265266
"check_global_dir": (config_bool, None, "env"),
266267
},
267268

@@ -698,16 +699,22 @@ class ListCommand(BaseCommand):
698699
one = False
699700
unmanaged = True
700701
source = None
702+
fallback_source = None
701703
default_source = False
702704
keep_log = False
703705

706+
# Not settable from the CLI/config, but used internally
707+
formatter_callable = None
708+
fallback_source_only = False
709+
704710
def execute(self):
705711
from .list_command import execute
706712
self.show_welcome()
707713
if self.default_source:
708714
LOGGER.debug("Loading 'install' command to get source")
709715
inst_cmd = COMMANDS["install"](["install"], self.root)
710716
self.source = inst_cmd.source
717+
self.fallback_source = inst_cmd.fallback_source
711718
if self.source and "://" not in str(self.source):
712719
try:
713720
self.source = Path(self.source).absolute().as_uri()
@@ -981,7 +988,8 @@ class FirstRun(BaseCommand):
981988
check_app_alias = True
982989
check_long_paths = True
983990
check_py_on_path = True
984-
check_any_install = True
991+
check_any_install = False
992+
check_latest_install = True
985993
check_global_dir = True
986994

987995
def execute(self):

src/manage/firstrun.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,51 @@ def check_any_install(cmd):
248248
return True
249249

250250

251+
def _list_available_fallback_runtimes(cmd):
252+
from .commands import find_command
253+
254+
candidates = []
255+
try:
256+
list_cmd = find_command(["list", "--online", "-1", "default"], cmd.root)
257+
list_cmd.formatter_callable = lambda cmd, installs: candidates.extend(installs)
258+
list_cmd.fallback_source_only = True
259+
list_cmd.execute()
260+
if not candidates:
261+
list_cmd.fallback_source_only = False
262+
list_cmd.execute()
263+
except Exception:
264+
LOGGER.debug("Check skipped: Failed to find 'list' command.", exc_info=True)
265+
return []
266+
except SystemExit:
267+
LOGGER.debug("Check skipped: Failed to execute 'list' command.")
268+
return []
269+
270+
return candidates
271+
272+
273+
def check_latest_install(cmd):
274+
LOGGER.debug("Checking if any default runtime is installed")
275+
276+
available = _list_available_fallback_runtimes(cmd)
277+
if not available:
278+
return "skip"
279+
280+
installs = cmd.get_installs(include_unmanaged=True, set_default=False)
281+
if not installs:
282+
LOGGER.debug("Check failed: no installs found")
283+
return False
284+
285+
present = {i.get("tag") for i in installs}
286+
available = set(j for i in available for j in i.get("install-for", []))
287+
LOGGER.debug("Already installed: %s", sorted(present))
288+
LOGGER.debug("Available: %s", sorted(available))
289+
if available & present:
290+
LOGGER.debug("Check passed: installs found")
291+
return True
292+
LOGGER.debug("Check failed: no equivalent 'default' runtime installed")
293+
return False
294+
295+
251296
def do_install(cmd):
252297
from .commands import find_command
253298
try:
@@ -360,16 +405,19 @@ def first_run(cmd):
360405
welcome()
361406
line_break()
362407
shown_any = True
363-
LOGGER.print("!Y!The directory for versioned Python commands is not "
408+
LOGGER.print("!Y!The global shortcuts directory is not "
364409
"configured.!W!", level=logging.WARN)
365-
LOGGER.print("\nThis will prevent commands like !B!python3.14.exe!W! "
366-
"working, but will not affect the !B!python!W! or "
367-
"!B!py!W! commands (for example, !B!py -V:3.14!W!).",
410+
LOGGER.print("\nConfiguring this enables commands like "
411+
"!B!python3.14.exe!W! to run from your terminal, "
412+
"but is not needed for the !B!python!W! or !B!py!W! "
413+
"commands (for example, !B!py -V:3.14!W!).",
414+
wrap=True)
415+
LOGGER.print("\nWe can add the directory (!B!%s!W!) to PATH now, "
416+
"but you will need to restart your terminal to use "
417+
"it. The entry will be removed if you run !B!py "
418+
"uninstall --purge!W!, or else you can remove it "
419+
"manually when uninstalling Python.\n", cmd.global_dir,
368420
wrap=True)
369-
LOGGER.print("\nWe can add the directory to PATH now, but you will "
370-
"need to restart your terminal to see the change, and "
371-
"must manually edit environment variables to later "
372-
"remove the entry.\n", wrap=True)
373421
if (
374422
not cmd.confirm or
375423
not cmd.ask_ny("Add commands directory to your PATH now?")
@@ -399,6 +447,22 @@ def first_run(cmd):
399447
elif cmd.explicit:
400448
LOGGER.info("Checked for any Python installs")
401449

450+
if cmd.check_latest_install:
451+
if not check_latest_install(cmd):
452+
welcome()
453+
line_break()
454+
shown_any = True
455+
LOGGER.print("!Y!You do not have the latest Python runtime.!W!",
456+
level=logging.WARN)
457+
LOGGER.print("\nInstall the current latest version of CPython? If "
458+
"not, you can use '!B!py install default!W!' later to "
459+
"install.\n", wrap=True)
460+
LOGGER.info("")
461+
if not cmd.confirm or cmd.ask_yn("Install CPython now?"):
462+
do_install(cmd)
463+
elif cmd.explicit:
464+
LOGGER.info("Checked for the latest available Python install")
465+
402466
if shown_any or cmd.explicit:
403467
line_break()
404468
LOGGER.print("!G!Configuration checks completed.!W!", level=logging.WARN)

src/manage/list_command.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
import sys
32

43
from . import logging
54
from .exceptions import ArgumentError
@@ -285,36 +284,67 @@ def _get_installs_from_index(indexes, filters):
285284
def execute(cmd):
286285
LOGGER.debug("BEGIN list_command.execute: %r", cmd.args)
287286

288-
try:
289-
LOGGER.debug("Get formatter %s", cmd.format)
290-
formatter = FORMATTERS[cmd.format]
291-
except LookupError:
292-
formatters = FORMATTERS.keys() - {"legacy", "legacy-paths"}
293-
expect = ", ".join(sorted(formatters))
294-
raise ArgumentError(f"'{cmd.format}' is not a valid format; expected one of: {expect}") from None
287+
if cmd.formatter_callable:
288+
formatter = cmd.formatter_callable
289+
else:
290+
try:
291+
LOGGER.debug("Get formatter %s", cmd.format)
292+
formatter = FORMATTERS[cmd.format]
293+
except LookupError:
294+
formatters = FORMATTERS.keys() - {"legacy", "legacy-paths"}
295+
expect = ", ".join(sorted(formatters))
296+
raise ArgumentError(f"'{cmd.format}' is not a valid format; expected one of: {expect}") from None
295297

296298
from .tagutils import tag_or_range, install_matches_any
297299
tags = []
300+
plat = None
298301
for arg in cmd.args:
299302
if arg.casefold() == "default".casefold():
300303
LOGGER.debug("Replacing 'default' with '%s'", cmd.default_tag)
301304
tags.append(tag_or_range(cmd.default_tag))
302305
else:
303306
try:
304307
tags.append(tag_or_range(arg))
308+
try:
309+
if not plat:
310+
plat = tags[-1].platform
311+
except AttributeError:
312+
pass
305313
except ValueError as ex:
306314
LOGGER.warn("%s", ex)
315+
plat = plat or cmd.default_platform
307316

308317
if cmd.source:
309318
from .indexutils import Index
310319
from .urlutils import IndexDownloader
311-
try:
312-
installs = _get_installs_from_index(
313-
IndexDownloader(cmd.source, Index),
314-
tags,
315-
)
316-
except OSError as ex:
317-
raise SystemExit(1) from ex
320+
installs = []
321+
first_exc = None
322+
for source in [
323+
None if cmd.fallback_source_only else cmd.source,
324+
cmd.fallback_source,
325+
]:
326+
if source:
327+
try:
328+
installs = _get_installs_from_index(
329+
IndexDownloader(source, Index),
330+
tags,
331+
)
332+
break
333+
except OSError as ex:
334+
if first_exc is None:
335+
first_exc = ex
336+
if first_exc:
337+
raise SystemExit(1) from first_exc
338+
if cmd.one:
339+
# Pick the first non-prerelease that'll install for our platform
340+
best = [i for i in installs
341+
if any(t.endswith(plat) for t in i.get("install-for", []))]
342+
for i in best:
343+
if not i["sort-version"].is_prerelease:
344+
installs = [i]
345+
break
346+
else:
347+
installs = best[:1] or installs
318348
elif cmd.install_dir:
319349
try:
320350
installs = cmd.get_installs(include_unmanaged=cmd.unmanaged)

src/manage/uninstall_command.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ def _iterdir(p, only_files=False):
1818

1919

2020
def _do_purge_global_dir(global_dir, warn_msg, *, hive=None, subkey="Environment"):
21-
import os
2221
import winreg
2322

2423
if hive is None:

src/manage/verutils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@ class Version:
1313
}
1414

1515
_TEXT_UNMAP = {v: k for k, v in TEXT_MAP.items()}
16+
_LEVELS = None
1617

1718
# Versions with more fields than this will be truncated.
1819
MAX_FIELDS = 8
1920

2021
def __init__(self, s):
2122
import re
22-
levels = "|".join(re.escape(k) for k in self.TEXT_MAP if k)
23+
if isinstance(s, Version):
24+
s = s.s
25+
if not Version._LEVELS:
26+
Version._LEVELS = "|".join(re.escape(k) for k in self.TEXT_MAP if k)
2327
m = re.match(
24-
r"^(?P<numbers>\d+(\.\d+)*)([\.\-]?(?P<level>" + levels + r")[\.]?(?P<serial>\d*))?$",
28+
r"^(?P<numbers>\d+(\.\d+)*)([\.\-]?(?P<level>" + Version._LEVELS + r")[\.]?(?P<serial>\d*))?$",
2529
s,
2630
re.I,
2731
)

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def localserver():
149149

150150
class FakeConfig:
151151
def __init__(self, global_dir, installs=[]):
152+
self.root = global_dir.parent if global_dir else None
152153
self.global_dir = global_dir
153154
self.confirm = False
154155
self.installs = list(installs)

tests/test_firstrun.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,27 @@ def test_check_any_install(fake_config):
9999
assert firstrun.check_any_install(fake_config) == True
100100

101101

102+
def test_check_latest_install(fake_config, monkeypatch):
103+
fake_config.default_tag = "1"
104+
fake_config.default_platform = "-64"
105+
assert firstrun.check_latest_install(fake_config) == False
106+
107+
fake_config.installs.append({"tag": "1.0-64"})
108+
assert firstrun.check_latest_install(fake_config) == False
109+
110+
def _fallbacks(cmd):
111+
return [{"install-for": ["1.0-64"]}]
112+
113+
monkeypatch.setattr(firstrun, "_list_available_fallback_runtimes", _fallbacks)
114+
assert firstrun.check_latest_install(fake_config) == True
115+
116+
def _fallbacks(cmd):
117+
return [{"install-for": ["1.0-32"]}]
118+
119+
monkeypatch.setattr(firstrun, "_list_available_fallback_runtimes", _fallbacks)
120+
assert firstrun.check_latest_install(fake_config) == False
121+
122+
102123
def test_welcome(assert_log):
103124
welcome = firstrun._Welcome()
104125
assert_log(assert_log.end_of_log())

tests/test_list.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ def __init__(self):
3131
self.captured = []
3232
self.source = None
3333
self.install_dir = "<none>"
34+
self.default_platform = "-64"
3435
self.format = "test"
36+
self.formatter_callable = None
3537
self.one = False
3638
self.unmanaged = True
3739
list_command.FORMATTERS["test"] = lambda c, i: self.captured.extend(i)

0 commit comments

Comments
 (0)