diff --git a/_msbuild.py b/_msbuild.py index 270e099..3ad317a 100644 --- a/_msbuild.py +++ b/_msbuild.py @@ -215,6 +215,9 @@ def get_commands(): # Check if a subclass of BaseCommand if not any(b.id in command_bases for b in cls.bases): continue + # Ignore exec command - it gets handled separately. + if cls.name == "ExecCommand": + continue command_bases.add(cls.name) for a in filter(lambda s: isinstance(s, ast.Assign), cls.body): if not any(t.id == "CMD" for t in a.targets): diff --git a/src/manage/commands.py b/src/manage/commands.py index ce55ef3..083263b 100644 --- a/src/manage/commands.py +++ b/src/manage/commands.py @@ -33,15 +33,56 @@ WELCOME = f"""!B!Python install manager was successfully updated to {__version__}.!W! """ - -# The help text of subcommands is generated below - look for 'subcommands_list' - -GLOBAL_OPTIONS_HELP_TEXT = fr"""!G!Global options:!W! +# The 'py help' or 'pymanager help' output is constructed by these default docs, +# with individual subcommand docs added in usage_text_lines(). +# +# Descriptive text (tuple element 1) will be aligned and rewrapped across all +# commands. +# +# Where a command summary (tuple element 0) ends with a newline, it allows the +# wrapping algorithm to start the description on the following line if the +# command is too long. +PY_USAGE_DOCS = [ + (f"{EXE_NAME} !B!!W!\n", + "Launch the default runtime with specified options. " + + "This is the equivalent of the !G!python!W! command."), + (f"{EXE_NAME} -V:!B!!W!", + "Launch runtime identified by !B!!W!, which should include the " + + "company name if not !B!PythonCore!W!. Regular Python options may " + + "follow this option."), + (f"{EXE_NAME} -3!B!!W!", + r"Equivalent to -V:PythonCore\3!B!!W!. The version must begin " + + "with the digit 3, platform overrides are permitted, and regular Python " + + "options may follow. " + + "!G!py -3!W! is the equivalent of the !G!python3!W! command."), + (f"{EXE_NAME} exec !B!!W!\n", + "Equivalent to any of the above launch options, and the requested runtime " + + "will be installed if needed."), +] + + +PYMANAGER_USAGE_DOCS = [ + (f"{EXE_NAME} exec !B!!W!\n", + "Launch the default runtime with specified options, installing it if needed. " + + "This is the equivalent of the !G!python!W! command, but with auto-install."), + (f"{EXE_NAME} exec -V:!B!!W!", + "Launch runtime identified by !B!!W!, which should include the " + + "company name if not !B!PythonCore!W!. Regular Python options may " + + "follow this option. The runtime will be installed if needed."), + (f"{EXE_NAME} exec -3!B!!W!\n", + r"Equivalent to -V:PythonCore\3!B!!W!. The version must begin " + + "with a '3', platform overrides are permitted, and regular Python " + + "options may follow. The runtime will be installed if needed."), +] + + +GLOBAL_OPTIONS_HELP_TEXT = fr"""!G!Global options: !B!(options must come after a command)!W! -v, --verbose Increased output (!B!log_level={logging.INFO}!W!) -vv Further increased output (!B!log_level={logging.DEBUG}!W!) -q, --quiet Less output (!B!log_level={logging.WARN}!W!) -qq Even less output (!B!log_level={logging.ERROR}!W!) -y, --yes Always confirm prompts (!B!confirm=false!W!) + -h, -?, --help Show help for a specific command --config=!B!!W! Override configuration with JSON file """ @@ -475,65 +516,54 @@ def execute(self): raise NotImplementedError(f"'{type(self).__name__}' does not implement 'execute()'") @classmethod - def usage_text_lines(cls): - usage_docs = [ - (f" {EXE_NAME} -V:!B!!W!", - "Launch runtime identified by !B!!W!, which should include the " + - "company name if not !B!PythonCore!W!. Regular Python options may " + - "follow this option."), - (f" {EXE_NAME} -!B!!W!", - r"Equivalent to -V:PythonCore\!B!!W!. The version must " + - "begin with the digit 3, platform overrides are permitted, " + - "and regular Python options may follow." + - (" !G!py -3!W! is the equivalent of the !G!python3!W! command." if EXE_NAME == "py" else "")), - (f" {EXE_NAME} !B!!W!", - "Run a specific command (see list below)."), - ] - - usage_ljust = max(len(logging.strip_colour(i[0])) for i in usage_docs) + def show_usage(cls): + if EXE_NAME.casefold() in ("py".casefold(), "pyw".casefold()): + usage_docs = PY_USAGE_DOCS + else: + usage_docs = PYMANAGER_USAGE_DOCS + + usage_docs = list(usage_docs) + for cmd in sorted(COMMANDS): + if not cmd[:1].isalpha(): + continue + try: + usage_docs.append( + ( + f"{EXE_NAME} " + getattr(COMMANDS[cmd], "USAGE_LINE", cmd), + COMMANDS[cmd].HELP_LINE + ) + ) + except AttributeError: + pass + + usage_docs = [(f" {x.lstrip()}", y) for x, y in usage_docs] + + usage_ljust = max(len(logging.strip_colour(i[0])) for i in usage_docs if not i[0].endswith("\n")) if usage_ljust % 4: usage_ljust += 4 - (usage_ljust % 4) usage_ljust = max(usage_ljust, 16) + 1 sp = " " * usage_ljust - yield "!G!Usage:!W!" - if EXE_NAME.casefold() in ("py".casefold(), "pyw".casefold()): - yield f" {EXE_NAME} !B!!W!" - yield sp + "Launch the default runtime with specified options." - yield sp + "This is the equivalent of the !G!python!W! command." + LOGGER.print("!G!Usage:!W!") for k, d in usage_docs: - r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k))) + if k.endswith("\n") and len(logging.strip_colour(k)) >= usage_ljust: + LOGGER.print(k.rstrip()) + r = sp + else: + k = k.rstrip() + r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k))) for b in d.split(" "): - if len(r) >= 80: - yield r.rstrip() + if len(r) >= logging.CONSOLE_MAX_WIDTH: + LOGGER.print(r.rstrip()) r = sp r += b + " " if r.rstrip(): - yield r + LOGGER.print(r) - yield "" - yield "Find additional information at !B!https://docs.python.org/using/windows.html!W!." - yield "" - - @classmethod - def usage_text(cls): - return "\n".join(cls.usage_text_lines()) - - @classmethod - def subcommands_list(cls): - usage_ljust = len(EXE_NAME) + 1 + max(len(cmd) for cmd in sorted(COMMANDS) if cmd[:1].isalpha()) - if usage_ljust % 4: - usage_ljust += 4 - (usage_ljust % 4) - usage_ljust = max(usage_ljust, 16) - cmd_help = [ - " {:<{}} {}".format(f"{EXE_NAME} {cmd}", usage_ljust, getattr(COMMANDS[cmd], "HELP_LINE", "")) - for cmd in sorted(COMMANDS) - if cmd[:1].isalpha() - ] - return fr""" -!G!Commands:!W! -{'\n'.join(cmd_help)} -""".lstrip().replace("\r\n", "\n") + LOGGER.print() + # TODO: Remove the /dev/ for stable release + LOGGER.print("Find additional information at !B!https://docs.python.org/dev/using/windows!W!.") + LOGGER.print() @classmethod def help_text(cls): @@ -541,8 +571,7 @@ def help_text(cls): def help(self): if type(self) is BaseCommand: - LOGGER.print(self.usage_text()) - LOGGER.print(self.subcommands_list()) + self.show_usage() LOGGER.print(self.help_text()) try: LOGGER.print(self.HELP_TEXT.lstrip()) @@ -616,8 +645,12 @@ def get_install_to_run(self, tag=None, script=None, *, windowed=False): class ListCommand(BaseCommand): CMD = "list" - HELP_LINE = "Shows all installed Python runtimes" + HELP_LINE = ("Show installed Python runtimes, optionally filtering by " + + "!B!!W!.") + USAGE_LINE = "list !B![]!W!" HELP_TEXT = r"""!G!List command!W! +Shows installed Python runtimes, optionally filtered or formatted. + > py list !B![options] [ ...]!W! !G!Options:!W! @@ -691,8 +724,12 @@ class ListPathsLegacyCommand(ListLegacyCommand): class InstallCommand(BaseCommand): CMD = "install" - HELP_LINE = "Download new Python runtimes" + HELP_LINE = ("Download new Python runtimes, or pass !B!--update!W! to " + + "update existing installs.") + USAGE_LINE = "install !B!!W!" HELP_TEXT = r"""!G!Install command!W! +Downloads new Python runtimes and sets up shortcuts and other registration. + > py install !B![options] [] ...!W! !G!Options:!W! @@ -768,14 +805,20 @@ def execute(self): class UninstallCommand(BaseCommand): CMD = "uninstall" - HELP_LINE = "Remove runtimes from your machine" + HELP_LINE = ("Remove one or more runtimes from your machine. Pass " + + "!B!--purge!W! to clean up all runtimes and cached files.") + USAGE_LINE = "uninstall !B!!W!" HELP_TEXT = r"""!G!Uninstall command!W! +Removes one or more runtimes from your machine. + > py uninstall !B![options] [] ...!W! !G!Options:!W! - --purge Remove all runtimes, shortcuts, and cached files. Ignores tags. - --by-id Require TAG to exactly match the install ID. (For advanced use.) - !B! !W! ... One or more runtimes to uninstall (Company\Tag format) + --purge Remove all runtimes, shortcuts, and cached files. Ignores tags. + --by-id Require TAG to exactly match the install ID. (For advanced use.) + !B! !W! ... One or more runtimes to uninstall (Company\Tag format) + Each tag will only remove a single runtime, even if it matches + more than one. !B!EXAMPLE:!W! Uninstall Python 3.12 32-bit > py uninstall 3.12-32 @@ -809,7 +852,10 @@ def execute(self): class HelpCommand(BaseCommand): CMD = "help" HELP_LINE = "Show help for Python installation manager commands" + USAGE_LINE = "help !B![]!W!" HELP_TEXT = r"""!G!Help command!W! +Shows help for specific commands. + > py help !B![] ...!W! !G!Options:!W! @@ -823,8 +869,7 @@ def execute(self): LOGGER.print(COPYRIGHT) self.show_welcome(copyright=False) if not self.args: - LOGGER.print(BaseCommand.usage_text()) - LOGGER.print(BaseCommand.subcommands_list()) + self.show_usage() LOGGER.print(BaseCommand.help_text()) for a in self.args: try: @@ -851,11 +896,40 @@ def execute(self): LOGGER.print(f"!R!Unknown command: {' '.join(args)}!W!") LOGGER.print(COPYRIGHT) self.show_welcome(copyright=False) - LOGGER.print(BaseCommand.usage_text()) - LOGGER.print(BaseCommand.subcommands_list()) + self.show_usage() LOGGER.print(f"The command !R!{' '.join(args)}!W! was not recognized.") +# This command exists solely to provide help. +# When it is specified, it gets handled in main.cpp +class ExecCommand(BaseCommand): + CMD = "exec" + HELP_TEXT = f"""!G!Execute command!W! +Launches the specified (or default) runtime. This command is optional when +launching through !G!py!W!, as the default behaviour is to launch a runtime. +When used explicitly, this command will automatically install the requested +runtime if it is not available. + +> {EXE_NAME} exec -V:!B!!W! ... +> {EXE_NAME} exec -3!B!!W! ... +> {EXE_NAME} exec ... +> py [ -V:!B!!W! | -3!B!!W! ] ... + +!G!Options:!W! + -V:!B!!W! Launch runtime identified by !B!!W!, which should include + the company name if not !B!PythonCore!W!. Regular Python options + may follow this option. The runtime will be installed if needed. + -3!B!!W! Equivalent to -V:PythonCore\3!B!!W!. The version must + begin with a '3', platform overrides are permitted, and regular + Python options may follow. The runtime will be installed if needed. +""" + + def __init__(self, args, root=None): + # Essentially disable argument processing for this command + super().__init__(args[:1], root) + self.args = args[1:] + + class DefaultConfig(BaseCommand): CMD = "__no_command" _create_log_file = False diff --git a/src/manage/list_command.py b/src/manage/list_command.py index e1c2869..acd9ff8 100644 --- a/src/manage/list_command.py +++ b/src/manage/list_command.py @@ -1,8 +1,10 @@ import json import sys +from . import logging from .exceptions import ArgumentError -from .logging import LOGGER + +LOGGER = logging.LOGGER def _exe_partition(n): @@ -102,6 +104,10 @@ def format_table(cmd, installs): except LookupError: pass + while sum(cwidth.values()) > logging.CONSOLE_MAX_WIDTH: + # TODO: Some kind of algorithm for reducing column widths to fit + break + LOGGER.print("!B!%s!W!", " ".join(columns[c].ljust(cwidth[c]) for c in columns), always=True) any_shown = False diff --git a/src/manage/logging.py b/src/manage/logging.py index 5d0148f..3a51641 100644 --- a/src/manage/logging.py +++ b/src/manage/logging.py @@ -1,6 +1,15 @@ import os import sys +# For convenient changing in the future. With a bit of luck, we will always +# depend on this constant dynamically, and so could update it at runtime, but +# don't assume that if you're adding that feature! +# Note that this only applies to deliberate formatting tasks. In general, we +# write entire lines of text unwrapped and let the console handle it, but some +# tasks (e.g. progress bars, tables) need to know the width. +CONSOLE_MAX_WIDTH = 80 + + DEBUG = 10 VERBOSE = 15 INFO = 20 @@ -174,8 +183,10 @@ def print(self, msg=None, *args, always=False, level=INFO, **kwargs): class ProgressPrinter: - def __init__(self, operation, maxwidth=80): + def __init__(self, operation, maxwidth=...): self.operation = operation or "Progress" + if maxwidth is ...: + maxwidth = CONSOLE_MAX_WIDTH self.width = maxwidth - 3 - len(self.operation) self._dots_shown = 0 self._started = False diff --git a/tests/conftest.py b/tests/conftest.py index 41d993d..fd844dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,14 @@ setattr(_native, k, getattr(_native_test, k)) +import manage +manage.EXE_NAME = "pymanager-pytest" + + +import manage.commands +manage.commands.WELCOME = "" + + from manage.logging import LOGGER, DEBUG LOGGER.level = DEBUG diff --git a/tests/test_commands.py b/tests/test_commands.py index d942597..c273f25 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,19 +3,44 @@ from manage import commands -def test_help_with_error_command(assert_log, monkeypatch): +def test_pymanager_help_command(assert_log): + cmd = commands.HelpCommand([commands.HelpCommand.CMD], None) + cmd.execute() + assert_log( + assert_log.skip_until(r"Python installation manager \d+\.\d+.*"), + assert_log.skip_until(".*pymanager-pytest exec -V.*"), + assert_log.skip_until(".*pymanager-pytest exec -3.*"), + assert_log.skip_until(".*pymanager-pytest install.*"), + assert_log.skip_until(".*pymanager-pytest list.*"), + assert_log.skip_until(".*pymanager-pytest uninstall.*"), + ) + + +def test_py_help_command(assert_log, monkeypatch): + monkeypatch.setattr(commands, "EXE_NAME", "py") + cmd = commands.HelpCommand([commands.HelpCommand.CMD], None) + cmd.execute() + assert_log( + assert_log.skip_until(r"Python installation manager \d+\.\d+.*"), + assert_log.skip_until(".*pymanager-pytest -V.*"), + assert_log.skip_until(".*pymanager-pytest -3.*"), + assert_log.skip_until(".*py install.*"), + assert_log.skip_until(".*py list.*"), + assert_log.skip_until(".*py uninstall.*"), + ) + + +def test_help_with_error_command(assert_log): expect = secrets.token_hex(16) cmd = commands.HelpWithErrorCommand( [commands.HelpWithErrorCommand.CMD, expect, "-v", "-q"], None ) - monkeypatch.setattr(commands, "EXE_NAME", "pymanager-test") - monkeypatch.setattr(commands, "WELCOME", "") cmd.execute() assert_log( - assert_log.skip_until(rf".*Unknown command: pymanager-test {expect} -v -q.*"), + assert_log.skip_until(f".*Unknown command: pymanager-pytest {expect} -v -q.*"), r"Python installation manager \d+\.\d+.*", - assert_log.skip_until(rf"The command .*?pymanager-test {expect} -v -q.*"), + assert_log.skip_until(f"The command .*?pymanager-pytest {expect} -v -q.*"), )