Skip to content

Commit 330ff65

Browse files
authored
Fixes #29: Improved help output for all commands (#36)
1 parent e366c2e commit 330ff65

File tree

6 files changed

+198
-71
lines changed

6 files changed

+198
-71
lines changed

_msbuild.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ def get_commands():
215215
# Check if a subclass of BaseCommand
216216
if not any(b.id in command_bases for b in cls.bases):
217217
continue
218+
# Ignore exec command - it gets handled separately.
219+
if cls.name == "ExecCommand":
220+
continue
218221
command_bases.add(cls.name)
219222
for a in filter(lambda s: isinstance(s, ast.Assign), cls.body):
220223
if not any(t.id == "CMD" for t in a.targets):

src/manage/commands.py

Lines changed: 138 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,56 @@
3333
WELCOME = f"""!B!Python install manager was successfully updated to {__version__}.!W!
3434
"""
3535

36-
37-
# The help text of subcommands is generated below - look for 'subcommands_list'
38-
39-
GLOBAL_OPTIONS_HELP_TEXT = fr"""!G!Global options:!W!
36+
# The 'py help' or 'pymanager help' output is constructed by these default docs,
37+
# with individual subcommand docs added in usage_text_lines().
38+
#
39+
# Descriptive text (tuple element 1) will be aligned and rewrapped across all
40+
# commands.
41+
#
42+
# Where a command summary (tuple element 0) ends with a newline, it allows the
43+
# wrapping algorithm to start the description on the following line if the
44+
# command is too long.
45+
PY_USAGE_DOCS = [
46+
(f"{EXE_NAME} !B!<regular Python options>!W!\n",
47+
"Launch the default runtime with specified options. " +
48+
"This is the equivalent of the !G!python!W! command."),
49+
(f"{EXE_NAME} -V:!B!<TAG>!W!",
50+
"Launch runtime identified by !B!<TAG>!W!, which should include the " +
51+
"company name if not !B!PythonCore!W!. Regular Python options may " +
52+
"follow this option."),
53+
(f"{EXE_NAME} -3!B!<VERSION>!W!",
54+
r"Equivalent to -V:PythonCore\3!B!<VERSION>!W!. The version must begin " +
55+
"with the digit 3, platform overrides are permitted, and regular Python " +
56+
"options may follow. " +
57+
"!G!py -3!W! is the equivalent of the !G!python3!W! command."),
58+
(f"{EXE_NAME} exec !B!<any of the above>!W!\n",
59+
"Equivalent to any of the above launch options, and the requested runtime " +
60+
"will be installed if needed."),
61+
]
62+
63+
64+
PYMANAGER_USAGE_DOCS = [
65+
(f"{EXE_NAME} exec !B!<regular Python options>!W!\n",
66+
"Launch the default runtime with specified options, installing it if needed. " +
67+
"This is the equivalent of the !G!python!W! command, but with auto-install."),
68+
(f"{EXE_NAME} exec -V:!B!<TAG>!W!",
69+
"Launch runtime identified by !B!<TAG>!W!, which should include the " +
70+
"company name if not !B!PythonCore!W!. Regular Python options may " +
71+
"follow this option. The runtime will be installed if needed."),
72+
(f"{EXE_NAME} exec -3!B!<VERSION>!W!\n",
73+
r"Equivalent to -V:PythonCore\3!B!<VERSION>!W!. The version must begin " +
74+
"with a '3', platform overrides are permitted, and regular Python " +
75+
"options may follow. The runtime will be installed if needed."),
76+
]
77+
78+
79+
GLOBAL_OPTIONS_HELP_TEXT = fr"""!G!Global options: !B!(options must come after a command)!W!
4080
-v, --verbose Increased output (!B!log_level={logging.INFO}!W!)
4181
-vv Further increased output (!B!log_level={logging.DEBUG}!W!)
4282
-q, --quiet Less output (!B!log_level={logging.WARN}!W!)
4383
-qq Even less output (!B!log_level={logging.ERROR}!W!)
4484
-y, --yes Always confirm prompts (!B!confirm=false!W!)
85+
-h, -?, --help Show help for a specific command
4586
--config=!B!<PATH>!W! Override configuration with JSON file
4687
"""
4788

@@ -475,74 +516,62 @@ def execute(self):
475516
raise NotImplementedError(f"'{type(self).__name__}' does not implement 'execute()'")
476517

477518
@classmethod
478-
def usage_text_lines(cls):
479-
usage_docs = [
480-
(f" {EXE_NAME} -V:!B!<TAG>!W!",
481-
"Launch runtime identified by !B!<TAG>!W!, which should include the " +
482-
"company name if not !B!PythonCore!W!. Regular Python options may " +
483-
"follow this option."),
484-
(f" {EXE_NAME} -!B!<VERSION>!W!",
485-
r"Equivalent to -V:PythonCore\!B!<VERSION>!W!. The version must " +
486-
"begin with the digit 3, platform overrides are permitted, " +
487-
"and regular Python options may follow." +
488-
(" !G!py -3!W! is the equivalent of the !G!python3!W! command." if EXE_NAME == "py" else "")),
489-
(f" {EXE_NAME} !B!<COMMAND>!W!",
490-
"Run a specific command (see list below)."),
491-
]
492-
493-
usage_ljust = max(len(logging.strip_colour(i[0])) for i in usage_docs)
519+
def show_usage(cls):
520+
if EXE_NAME.casefold() in ("py".casefold(), "pyw".casefold()):
521+
usage_docs = PY_USAGE_DOCS
522+
else:
523+
usage_docs = PYMANAGER_USAGE_DOCS
524+
525+
usage_docs = list(usage_docs)
526+
for cmd in sorted(COMMANDS):
527+
if not cmd[:1].isalpha():
528+
continue
529+
try:
530+
usage_docs.append(
531+
(
532+
f"{EXE_NAME} " + getattr(COMMANDS[cmd], "USAGE_LINE", cmd),
533+
COMMANDS[cmd].HELP_LINE
534+
)
535+
)
536+
except AttributeError:
537+
pass
538+
539+
usage_docs = [(f" {x.lstrip()}", y) for x, y in usage_docs]
540+
541+
usage_ljust = max(len(logging.strip_colour(i[0])) for i in usage_docs if not i[0].endswith("\n"))
494542
if usage_ljust % 4:
495543
usage_ljust += 4 - (usage_ljust % 4)
496544
usage_ljust = max(usage_ljust, 16) + 1
497545
sp = " " * usage_ljust
498546

499-
yield "!G!Usage:!W!"
500-
if EXE_NAME.casefold() in ("py".casefold(), "pyw".casefold()):
501-
yield f" {EXE_NAME} !B!<regular Python options>!W!"
502-
yield sp + "Launch the default runtime with specified options."
503-
yield sp + "This is the equivalent of the !G!python!W! command."
547+
LOGGER.print("!G!Usage:!W!")
504548
for k, d in usage_docs:
505-
r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k)))
549+
if k.endswith("\n") and len(logging.strip_colour(k)) >= usage_ljust:
550+
LOGGER.print(k.rstrip())
551+
r = sp
552+
else:
553+
k = k.rstrip()
554+
r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k)))
506555
for b in d.split(" "):
507-
if len(r) >= 80:
508-
yield r.rstrip()
556+
if len(r) >= logging.CONSOLE_MAX_WIDTH:
557+
LOGGER.print(r.rstrip())
509558
r = sp
510559
r += b + " "
511560
if r.rstrip():
512-
yield r
561+
LOGGER.print(r)
513562

514-
yield ""
515-
yield "Find additional information at !B!https://docs.python.org/using/windows.html!W!."
516-
yield ""
517-
518-
@classmethod
519-
def usage_text(cls):
520-
return "\n".join(cls.usage_text_lines())
521-
522-
@classmethod
523-
def subcommands_list(cls):
524-
usage_ljust = len(EXE_NAME) + 1 + max(len(cmd) for cmd in sorted(COMMANDS) if cmd[:1].isalpha())
525-
if usage_ljust % 4:
526-
usage_ljust += 4 - (usage_ljust % 4)
527-
usage_ljust = max(usage_ljust, 16)
528-
cmd_help = [
529-
" {:<{}} {}".format(f"{EXE_NAME} {cmd}", usage_ljust, getattr(COMMANDS[cmd], "HELP_LINE", ""))
530-
for cmd in sorted(COMMANDS)
531-
if cmd[:1].isalpha()
532-
]
533-
return fr"""
534-
!G!Commands:!W!
535-
{'\n'.join(cmd_help)}
536-
""".lstrip().replace("\r\n", "\n")
563+
LOGGER.print()
564+
# TODO: Remove the /dev/ for stable release
565+
LOGGER.print("Find additional information at !B!https://docs.python.org/dev/using/windows!W!.")
566+
LOGGER.print()
537567

538568
@classmethod
539569
def help_text(cls):
540570
return GLOBAL_OPTIONS_HELP_TEXT.replace("\r\n", "\n")
541571

542572
def help(self):
543573
if type(self) is BaseCommand:
544-
LOGGER.print(self.usage_text())
545-
LOGGER.print(self.subcommands_list())
574+
self.show_usage()
546575
LOGGER.print(self.help_text())
547576
try:
548577
LOGGER.print(self.HELP_TEXT.lstrip())
@@ -616,8 +645,12 @@ def get_install_to_run(self, tag=None, script=None, *, windowed=False):
616645

617646
class ListCommand(BaseCommand):
618647
CMD = "list"
619-
HELP_LINE = "Shows all installed Python runtimes"
648+
HELP_LINE = ("Show installed Python runtimes, optionally filtering by " +
649+
"!B!<FILTER>!W!.")
650+
USAGE_LINE = "list !B![<FILTER>]!W!"
620651
HELP_TEXT = r"""!G!List command!W!
652+
Shows installed Python runtimes, optionally filtered or formatted.
653+
621654
> py list !B![options] [<FILTER> ...]!W!
622655
623656
!G!Options:!W!
@@ -691,8 +724,12 @@ class ListPathsLegacyCommand(ListLegacyCommand):
691724

692725
class InstallCommand(BaseCommand):
693726
CMD = "install"
694-
HELP_LINE = "Download new Python runtimes"
727+
HELP_LINE = ("Download new Python runtimes, or pass !B!--update!W! to " +
728+
"update existing installs.")
729+
USAGE_LINE = "install !B!<TAG>!W!"
695730
HELP_TEXT = r"""!G!Install command!W!
731+
Downloads new Python runtimes and sets up shortcuts and other registration.
732+
696733
> py install !B![options] <TAG> [<TAG>] ...!W!
697734
698735
!G!Options:!W!
@@ -768,14 +805,20 @@ def execute(self):
768805

769806
class UninstallCommand(BaseCommand):
770807
CMD = "uninstall"
771-
HELP_LINE = "Remove runtimes from your machine"
808+
HELP_LINE = ("Remove one or more runtimes from your machine. Pass " +
809+
"!B!--purge!W! to clean up all runtimes and cached files.")
810+
USAGE_LINE = "uninstall !B!<TAG>!W!"
772811
HELP_TEXT = r"""!G!Uninstall command!W!
812+
Removes one or more runtimes from your machine.
813+
773814
> py uninstall !B![options] <TAG> [<TAG>] ...!W!
774815
775816
!G!Options:!W!
776-
--purge Remove all runtimes, shortcuts, and cached files. Ignores tags.
777-
--by-id Require TAG to exactly match the install ID. (For advanced use.)
778-
!B!<TAG> <TAG>!W! ... One or more runtimes to uninstall (Company\Tag format)
817+
--purge Remove all runtimes, shortcuts, and cached files. Ignores tags.
818+
--by-id Require TAG to exactly match the install ID. (For advanced use.)
819+
!B!<TAG> <TAG>!W! ... One or more runtimes to uninstall (Company\Tag format)
820+
Each tag will only remove a single runtime, even if it matches
821+
more than one.
779822
780823
!B!EXAMPLE:!W! Uninstall Python 3.12 32-bit
781824
> py uninstall 3.12-32
@@ -809,7 +852,10 @@ def execute(self):
809852
class HelpCommand(BaseCommand):
810853
CMD = "help"
811854
HELP_LINE = "Show help for Python installation manager commands"
855+
USAGE_LINE = "help !B![<CMD>]!W!"
812856
HELP_TEXT = r"""!G!Help command!W!
857+
Shows help for specific commands.
858+
813859
> py help !B![<CMD>] ...!W!
814860
815861
!G!Options:!W!
@@ -823,8 +869,7 @@ def execute(self):
823869
LOGGER.print(COPYRIGHT)
824870
self.show_welcome(copyright=False)
825871
if not self.args:
826-
LOGGER.print(BaseCommand.usage_text())
827-
LOGGER.print(BaseCommand.subcommands_list())
872+
self.show_usage()
828873
LOGGER.print(BaseCommand.help_text())
829874
for a in self.args:
830875
try:
@@ -851,11 +896,40 @@ def execute(self):
851896
LOGGER.print(f"!R!Unknown command: {' '.join(args)}!W!")
852897
LOGGER.print(COPYRIGHT)
853898
self.show_welcome(copyright=False)
854-
LOGGER.print(BaseCommand.usage_text())
855-
LOGGER.print(BaseCommand.subcommands_list())
899+
self.show_usage()
856900
LOGGER.print(f"The command !R!{' '.join(args)}!W! was not recognized.")
857901

858902

903+
# This command exists solely to provide help.
904+
# When it is specified, it gets handled in main.cpp
905+
class ExecCommand(BaseCommand):
906+
CMD = "exec"
907+
HELP_TEXT = f"""!G!Execute command!W!
908+
Launches the specified (or default) runtime. This command is optional when
909+
launching through !G!py!W!, as the default behaviour is to launch a runtime.
910+
When used explicitly, this command will automatically install the requested
911+
runtime if it is not available.
912+
913+
> {EXE_NAME} exec -V:!B!<TAG>!W! ...
914+
> {EXE_NAME} exec -3!B!<VERSION>!W! ...
915+
> {EXE_NAME} exec ...
916+
> py [ -V:!B!<TAG>!W! | -3!B!<VERSION>!W! ] ...
917+
918+
!G!Options:!W!
919+
-V:!B!<TAG>!W! Launch runtime identified by !B!<TAG>!W!, which should include
920+
the company name if not !B!PythonCore!W!. Regular Python options
921+
may follow this option. The runtime will be installed if needed.
922+
-3!B!<VERSION>!W! Equivalent to -V:PythonCore\3!B!<VERSION>!W!. The version must
923+
begin with a '3', platform overrides are permitted, and regular
924+
Python options may follow. The runtime will be installed if needed.
925+
"""
926+
927+
def __init__(self, args, root=None):
928+
# Essentially disable argument processing for this command
929+
super().__init__(args[:1], root)
930+
self.args = args[1:]
931+
932+
859933
class DefaultConfig(BaseCommand):
860934
CMD = "__no_command"
861935
_create_log_file = False

src/manage/list_command.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import json
22
import sys
33

4+
from . import logging
45
from .exceptions import ArgumentError
5-
from .logging import LOGGER
6+
7+
LOGGER = logging.LOGGER
68

79

810
def _exe_partition(n):
@@ -102,6 +104,10 @@ def format_table(cmd, installs):
102104
except LookupError:
103105
pass
104106

107+
while sum(cwidth.values()) > logging.CONSOLE_MAX_WIDTH:
108+
# TODO: Some kind of algorithm for reducing column widths to fit
109+
break
110+
105111
LOGGER.print("!B!%s!W!", " ".join(columns[c].ljust(cwidth[c]) for c in columns), always=True)
106112

107113
any_shown = False

src/manage/logging.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import os
22
import sys
33

4+
# For convenient changing in the future. With a bit of luck, we will always
5+
# depend on this constant dynamically, and so could update it at runtime, but
6+
# don't assume that if you're adding that feature!
7+
# Note that this only applies to deliberate formatting tasks. In general, we
8+
# write entire lines of text unwrapped and let the console handle it, but some
9+
# tasks (e.g. progress bars, tables) need to know the width.
10+
CONSOLE_MAX_WIDTH = 80
11+
12+
413
DEBUG = 10
514
VERBOSE = 15
615
INFO = 20
@@ -174,8 +183,10 @@ def print(self, msg=None, *args, always=False, level=INFO, **kwargs):
174183

175184

176185
class ProgressPrinter:
177-
def __init__(self, operation, maxwidth=80):
186+
def __init__(self, operation, maxwidth=...):
178187
self.operation = operation or "Progress"
188+
if maxwidth is ...:
189+
maxwidth = CONSOLE_MAX_WIDTH
179190
self.width = maxwidth - 3 - len(self.operation)
180191
self._dots_shown = 0
181192
self._started = False

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
setattr(_native, k, getattr(_native_test, k))
1818

1919

20+
import manage
21+
manage.EXE_NAME = "pymanager-pytest"
22+
23+
24+
import manage.commands
25+
manage.commands.WELCOME = ""
26+
27+
2028
from manage.logging import LOGGER, DEBUG
2129
LOGGER.level = DEBUG
2230

0 commit comments

Comments
 (0)