Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
128 changes: 80 additions & 48 deletions src/manage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,53 @@
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!<regular Python options>!W!\n",
"Launch the default runtime with specified options. " +
"This is the equivalent of the !G!python!W! command."),
(f"{EXE_NAME} -V:!B!<TAG>!W!",
"Launch runtime identified by !B!<TAG>!W!, which should include the " +
"company name if not !B!PythonCore!W!. Regular Python options may " +
"follow this option."),
(f"{EXE_NAME} -3!B!<VERSION>!W!",
r"Equivalent to -V:PythonCore\3!B!<VERSION>!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."),
]


PYMANAGER_USAGE_DOCS = [
(f"{EXE_NAME} exec !B!<regular Python options>!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!<TAG>!W!",
"Launch runtime identified by !B!<TAG>!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!<VERSION>!W!\n",
r"Equivalent to -V:PythonCore\3!B!<VERSION>!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!<PATH>!W! Override configuration with JSON file
"""

Expand Down Expand Up @@ -476,73 +514,62 @@

@classmethod
def usage_text_lines(cls):
usage_docs = [
(f" {EXE_NAME} -V:!B!<TAG>!W!",
"Launch runtime identified by !B!<TAG>!W!, which should include the " +
"company name if not !B!PythonCore!W!. Regular Python options may " +
"follow this option."),
(f" {EXE_NAME} -!B!<VERSION>!W!",
r"Equivalent to -V:PythonCore\!B!<VERSION>!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!<COMMAND>!W!",
"Run a specific command (see list below)."),
]

usage_ljust = max(len(logging.strip_colour(i[0])) for i in usage_docs)
if EXE_NAME.casefold() in ("py".casefold(), "pyw".casefold()):
usage_docs = PY_USAGE_DOCS

Check warning on line 518 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L518

Added line #L518 was not covered by tests
else:
usage_docs = PYMANAGER_USAGE_DOCS

usage_docs.extend(
[
(
f"{EXE_NAME} " + getattr(COMMANDS[cmd], "USAGE_LINE", cmd),
getattr(COMMANDS[cmd], "HELP_LINE", "")
)
for cmd in sorted(COMMANDS)
if cmd[:1].isalpha()
]
)

usage_docs = [(" " + 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!<regular Python options>!W!"
yield sp + "Launch the default runtime with specified options."
yield sp + "This is the equivalent of the !G!python!W! command."
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:
yield 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:
if len(r) >= logging.CONSOLE_MAX_WIDTH:
yield r.rstrip()
r = sp
r += b + " "
if r.rstrip():
yield r

yield ""
yield "Find additional information at !B!https://docs.python.org/using/windows.html!W!."
# TODO: Remove the 3.14 for stable release
yield "Find additional information at !B!https://docs.python.org/3.14/using/windows!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")

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

def help(self):
if type(self) is BaseCommand:
LOGGER.print(self.usage_text())
LOGGER.print(self.subcommands_list())
LOGGER.print(self.help_text())
try:
LOGGER.print(self.HELP_TEXT.lstrip())
Expand Down Expand Up @@ -616,7 +643,9 @@

class ListCommand(BaseCommand):
CMD = "list"
HELP_LINE = "Shows all installed Python runtimes"
HELP_LINE = ("Shows installed Python runtimes, optionally filtering by " +
"!B!<FILTER>!W!.")
USAGE_LINE = "list !B![<FILTER>]!W!"
HELP_TEXT = r"""!G!List command!W!
> py list !B![options] [<FILTER> ...]!W!
Expand Down Expand Up @@ -691,7 +720,9 @@

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!<TAG>!W!"
HELP_TEXT = r"""!G!Install command!W!
> py install !B![options] <TAG> [<TAG>] ...!W!
Expand Down Expand Up @@ -768,7 +799,9 @@

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!<TAG>!W!"
HELP_TEXT = r"""!G!Uninstall command!W!
> py uninstall !B![options] <TAG> [<TAG>] ...!W!
Expand Down Expand Up @@ -809,6 +842,7 @@
class HelpCommand(BaseCommand):
CMD = "help"
HELP_LINE = "Show help for Python installation manager commands"
USAGE_LINE = "help !B![<CMD>]!W!"
HELP_TEXT = r"""!G!Help command!W!
> py help !B![<CMD>] ...!W!
Expand All @@ -824,7 +858,6 @@
self.show_welcome(copyright=False)
if not self.args:
LOGGER.print(BaseCommand.usage_text())
LOGGER.print(BaseCommand.subcommands_list())
LOGGER.print(BaseCommand.help_text())
for a in self.args:
try:
Expand Down Expand Up @@ -852,7 +885,6 @@
LOGGER.print(COPYRIGHT)
self.show_welcome(copyright=False)
LOGGER.print(BaseCommand.usage_text())
LOGGER.print(BaseCommand.subcommands_list())
LOGGER.print(f"The command !R!{' '.join(args)}!W! was not recognized.")


Expand Down
8 changes: 7 additions & 1 deletion src/manage/list_command.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/manage/logging.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -174,8 +183,10 @@


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

Check warning on line 189 in src/manage/logging.py

View check run for this annotation

Codecov / codecov/patch

src/manage/logging.py#L189

Added line #L189 was not covered by tests
self.width = maxwidth - 3 - len(self.operation)
self._dots_shown = 0
self._started = False
Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
setattr(_native, k, getattr(_native_test, k))


import manage
manage.EXE_NAME = "pymanager-pytest"


from manage.logging import LOGGER, DEBUG
LOGGER.level = DEBUG

Expand Down
5 changes: 2 additions & 3 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ def test_help_with_error_command(assert_log, monkeypatch):
[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(rf".*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(rf"The command .*?pymanager-pytest {expect} -v -q.*"),
)