Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions _msbuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
202 changes: 138 additions & 64 deletions src/manage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!<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."),
(f"{EXE_NAME} exec !B!<any of the above>!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!<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 @@ -475,74 +516,62 @@
raise NotImplementedError(f"'{type(self).__name__}' does not implement 'execute()'")

@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)
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!<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."
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):
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())
self.show_usage()

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

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L574

Added line #L574 was not covered by tests
LOGGER.print(self.help_text())
try:
LOGGER.print(self.HELP_TEXT.lstrip())
Expand Down Expand Up @@ -616,8 +645,12 @@

class ListCommand(BaseCommand):
CMD = "list"
HELP_LINE = "Shows all installed Python runtimes"
HELP_LINE = ("Show installed Python runtimes, optionally filtering by " +
"!B!<FILTER>!W!.")
USAGE_LINE = "list !B![<FILTER>]!W!"
HELP_TEXT = r"""!G!List command!W!
Shows installed Python runtimes, optionally filtered or formatted.

> py list !B![options] [<FILTER> ...]!W!

!G!Options:!W!
Expand Down Expand Up @@ -691,8 +724,12 @@

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!
Downloads new Python runtimes and sets up shortcuts and other registration.

> py install !B![options] <TAG> [<TAG>] ...!W!

!G!Options:!W!
Expand Down Expand Up @@ -768,14 +805,20 @@

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!
Removes one or more runtimes from your machine.

> py uninstall !B![options] <TAG> [<TAG>] ...!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!<TAG> <TAG>!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!<TAG> <TAG>!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
Expand Down Expand Up @@ -809,7 +852,10 @@
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!
Shows help for specific commands.

> py help !B![<CMD>] ...!W!

!G!Options:!W!
Expand All @@ -823,8 +869,7 @@
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:
Expand All @@ -851,11 +896,40 @@
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!<TAG>!W! ...
> {EXE_NAME} exec -3!B!<VERSION>!W! ...
> {EXE_NAME} exec ...
> py [ -V:!B!<TAG>!W! | -3!B!<VERSION>!W! ] ...

!G!Options:!W!
-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.
-3!B!<VERSION>!W! 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.
"""

def __init__(self, args, root=None):
# Essentially disable argument processing for this command
super().__init__(args[:1], root)
self.args = args[1:]

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

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L929-L930

Added lines #L929 - L930 were not covered by tests


class DefaultConfig(BaseCommand):
CMD = "__no_command"
_create_log_file = False
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
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading