Skip to content

Commit e7358a9

Browse files
committed
Fixes #29: Improved help output for all commands
1 parent 1e94063 commit e7358a9

File tree

4 files changed

+103
-50
lines changed

4 files changed

+103
-50
lines changed

src/manage/commands.py

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,53 @@
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+
]
59+
60+
61+
PYMANAGER_USAGE_DOCS = [
62+
(f"{EXE_NAME} exec !B!<regular Python options>!W!\n",
63+
"Launch the default runtime with specified options, installing it if needed. " +
64+
"This is the equivalent of the !G!python!W! command, but with auto-install."),
65+
(f"{EXE_NAME} exec -V:!B!<TAG>!W!",
66+
"Launch runtime identified by !B!<TAG>!W!, which should include the " +
67+
"company name if not !B!PythonCore!W!. Regular Python options may " +
68+
"follow this option. The runtime will be installed if needed."),
69+
(f"{EXE_NAME} exec -3!B!<VERSION>!W!\n",
70+
r"Equivalent to -V:PythonCore\3!B!<VERSION>!W!. The version must begin " +
71+
"with a '3', platform overrides are permitted, and regular Python " +
72+
"options may follow. The runtime will be installed if needed."),
73+
]
74+
75+
76+
GLOBAL_OPTIONS_HELP_TEXT = fr"""!G!Global options: !B!(options must come after a command)!W!
4077
-v, --verbose Increased output (!B!log_level={logging.INFO}!W!)
4178
-vv Further increased output (!B!log_level={logging.DEBUG}!W!)
4279
-q, --quiet Less output (!B!log_level={logging.WARN}!W!)
4380
-qq Even less output (!B!log_level={logging.ERROR}!W!)
4481
-y, --yes Always confirm prompts (!B!confirm=false!W!)
82+
-h, -?, --help Show help for a specific command
4583
--config=!B!<PATH>!W! Override configuration with JSON file
4684
"""
4785

@@ -476,73 +514,62 @@ def execute(self):
476514

477515
@classmethod
478516
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)
517+
if EXE_NAME.casefold() in ("py".casefold(), "pyw".casefold()):
518+
usage_docs = PY_USAGE_DOCS
519+
else:
520+
usage_docs = PYMANAGER_USAGE_DOCS
521+
522+
usage_docs.extend(
523+
[
524+
(
525+
f"{EXE_NAME} " + getattr(COMMANDS[cmd], "USAGE_LINE", cmd),
526+
getattr(COMMANDS[cmd], "HELP_LINE", "")
527+
)
528+
for cmd in sorted(COMMANDS)
529+
if cmd[:1].isalpha()
530+
]
531+
)
532+
533+
usage_docs = [(" " + x.lstrip(), y) for x, y in usage_docs]
534+
535+
usage_ljust = max(len(logging.strip_colour(i[0])) for i in usage_docs if not i[0].endswith("\n"))
494536
if usage_ljust % 4:
495537
usage_ljust += 4 - (usage_ljust % 4)
496538
usage_ljust = max(usage_ljust, 16) + 1
497539
sp = " " * usage_ljust
498540

499541
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."
504542
for k, d in usage_docs:
505-
r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k)))
543+
if k.endswith("\n") and len(logging.strip_colour(k)) >= usage_ljust:
544+
yield k.rstrip()
545+
r = sp
546+
else:
547+
k = k.rstrip()
548+
r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k)))
506549
for b in d.split(" "):
507-
if len(r) >= 80:
550+
if len(r) >= logging.CONSOLE_MAX_WIDTH:
508551
yield r.rstrip()
509552
r = sp
510553
r += b + " "
511554
if r.rstrip():
512555
yield r
513556

514557
yield ""
515-
yield "Find additional information at !B!https://docs.python.org/using/windows.html!W!."
558+
# TODO: Remove the 3.14 for stable release
559+
yield "Find additional information at !B!https://docs.python.org/3.14/using/windows!W!."
516560
yield ""
517561

518562
@classmethod
519563
def usage_text(cls):
520564
return "\n".join(cls.usage_text_lines())
521565

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")
537-
538566
@classmethod
539567
def help_text(cls):
540568
return GLOBAL_OPTIONS_HELP_TEXT.replace("\r\n", "\n")
541569

542570
def help(self):
543571
if type(self) is BaseCommand:
544572
LOGGER.print(self.usage_text())
545-
LOGGER.print(self.subcommands_list())
546573
LOGGER.print(self.help_text())
547574
try:
548575
LOGGER.print(self.HELP_TEXT.lstrip())
@@ -616,7 +643,9 @@ def get_install_to_run(self, tag=None, script=None, *, windowed=False):
616643

617644
class ListCommand(BaseCommand):
618645
CMD = "list"
619-
HELP_LINE = "Shows all installed Python runtimes"
646+
HELP_LINE = ("Shows installed Python runtimes, optionally filtering by " +
647+
"!B!<FILTER>!W!.")
648+
USAGE_LINE = "list !B![<FILTER>]!W!"
620649
HELP_TEXT = r"""!G!List command!W!
621650
> py list !B![options] [<FILTER> ...]!W!
622651
@@ -691,7 +720,9 @@ class ListPathsLegacyCommand(ListLegacyCommand):
691720

692721
class InstallCommand(BaseCommand):
693722
CMD = "install"
694-
HELP_LINE = "Download new Python runtimes"
723+
HELP_LINE = ("Download new Python runtimes, or pass !B!--update!W! to " +
724+
"update existing installs.")
725+
USAGE_LINE = "install !B!<TAG>!W!"
695726
HELP_TEXT = r"""!G!Install command!W!
696727
> py install !B![options] <TAG> [<TAG>] ...!W!
697728
@@ -768,7 +799,9 @@ def execute(self):
768799

769800
class UninstallCommand(BaseCommand):
770801
CMD = "uninstall"
771-
HELP_LINE = "Remove runtimes from your machine"
802+
HELP_LINE = ("Remove one or more runtimes from your machine. Pass " +
803+
"!B!--purge!W! to clean up all runtimes and cached files.")
804+
USAGE_LINE = "uninstall !B!<TAG>!W!"
772805
HELP_TEXT = r"""!G!Uninstall command!W!
773806
> py uninstall !B![options] <TAG> [<TAG>] ...!W!
774807
@@ -809,6 +842,7 @@ def execute(self):
809842
class HelpCommand(BaseCommand):
810843
CMD = "help"
811844
HELP_LINE = "Show help for Python installation manager commands"
845+
USAGE_LINE = "help !B![<CMD>]!W!"
812846
HELP_TEXT = r"""!G!Help command!W!
813847
> py help !B![<CMD>] ...!W!
814848
@@ -824,7 +858,6 @@ def execute(self):
824858
self.show_welcome(copyright=False)
825859
if not self.args:
826860
LOGGER.print(BaseCommand.usage_text())
827-
LOGGER.print(BaseCommand.subcommands_list())
828861
LOGGER.print(BaseCommand.help_text())
829862
for a in self.args:
830863
try:
@@ -846,7 +879,6 @@ def execute(self):
846879
LOGGER.print(COPYRIGHT)
847880
self.show_welcome(copyright=False)
848881
LOGGER.print(BaseCommand.usage_text())
849-
LOGGER.print(BaseCommand.subcommands_list())
850882
LOGGER.print(f"The command !R!{EXE_NAME} {' '.join(self.args)}!W! was not recognized.")
851883

852884

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
setattr(_native, k, getattr(_native_test, k))
1818

1919

20+
import manage
21+
manage.EXE_NAME = "pymanager-pytest"
22+
23+
2024
from manage.logging import LOGGER, DEBUG
2125
LOGGER.level = DEBUG
2226

0 commit comments

Comments
 (0)