This commit is contained in:
Ali Hamdan 2023-12-05 10:10:30 -05:00 committed by GitHub
commit 5d8d84560c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 153 additions and 38 deletions

1
news/12134.feature.rst Normal file
View File

@ -0,0 +1 @@
Render the output of pip's command line help using rich.

View File

@ -6,6 +6,8 @@ import subprocess
import sys
from typing import List, Optional, Tuple
from pip._vendor.rich.text import Text
from pip._internal.build_env import get_runnable_pip
from pip._internal.cli import cmdoptions
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
@ -39,7 +41,12 @@ def create_main_parser() -> ConfigOptionParser:
# create command listing for description
description = [""] + [
f"{name:27} {command_info.summary}"
parser.formatter.stringify( # type: ignore
Text()
.append(name, "optparse.args")
.append(" " * (28 - len(name)))
.append(command_info.summary, "optparse.help")
)
for name, command_info in commands_dict.items()
]
parser.description = "\n".join(description)

View File

@ -6,7 +6,13 @@ import shutil
import sys
import textwrap
from contextlib import suppress
from typing import Any, Dict, Generator, List, Tuple
from typing import IO, Any, Dict, Generator, List, Optional, Tuple
from pip._vendor.rich.console import Console, RenderableType, detect_legacy_windows
from pip._vendor.rich.markup import escape
from pip._vendor.rich.style import StyleType
from pip._vendor.rich.text import Text
from pip._vendor.rich.theme import Theme
from pip._internal.cli.status_codes import UNKNOWN_ERROR
from pip._internal.configuration import Configuration, ConfigurationError
@ -18,54 +24,52 @@ logger = logging.getLogger(__name__)
class PrettyHelpFormatter(optparse.IndentedHelpFormatter):
"""A prettier/less verbose help formatter for optparse."""
styles: Dict[str, StyleType] = {
"optparse.args": "cyan",
"optparse.groups": "dark_orange",
"optparse.help": "default",
"optparse.metavar": "dark_cyan",
"optparse.syntax": "bold",
"optparse.text": "default",
}
highlights: List[str] = [
r"(?:^|\s)(?P<args>-{1,2}[\w]+[\w-]*)", # highlight --words-with-dashes as args
r"`(?P<syntax>[^`]*)`", # highlight `text in backquotes` as syntax
]
def __init__(self, *args: Any, **kwargs: Any) -> None:
# help position must be aligned with __init__.parseopts.description
kwargs["max_help_position"] = 30
kwargs["indent_increment"] = 1
kwargs["width"] = shutil.get_terminal_size()[0] - 2
super().__init__(*args, **kwargs)
self.console: Console = Console(theme=Theme(self.styles))
self.rich_option_strings: Dict[optparse.Option, Text] = {}
def format_option_strings(self, option: optparse.Option) -> str:
return self._format_option_strings(option)
def _format_option_strings(
self, option: optparse.Option, mvarfmt: str = " <{}>", optsep: str = ", "
) -> str:
"""
Return a comma-separated list of option strings and metavars.
:param option: tuple of (short opt, long opt), e.g: ('-f', '--format')
:param mvarfmt: metavar format string
:param optsep: separator
"""
opts = []
if option._short_opts:
opts.append(option._short_opts[0])
if option._long_opts:
opts.append(option._long_opts[0])
if len(opts) > 1:
opts.insert(1, optsep)
if option.takes_value():
assert option.dest is not None
metavar = option.metavar or option.dest.lower()
opts.append(mvarfmt.format(metavar.lower()))
return "".join(opts)
def stringify(self, text: RenderableType) -> str:
"""Render a rich object as a string."""
with self.console.capture() as capture:
self.console.print(text, highlight=False, soft_wrap=True, end="")
help = capture.get()
return "\n".join(line.rstrip() for line in help.split("\n"))
def format_heading(self, heading: str) -> str:
if heading == "Options":
return ""
return heading + ":\n"
rich_heading = Text().append(heading, "optparse.groups").append(":\n")
return self.stringify(rich_heading)
def format_usage(self, usage: str) -> str:
"""
Ensure there is only one newline between usage and the first heading
if there is no description.
"""
msg = "\nUsage: {}\n".format(self.indent_lines(textwrap.dedent(usage), " "))
return msg
rich_usage = (
Text("\n")
.append("Usage", "optparse.groups")
.append(f": {self.indent_lines(textwrap.dedent(usage), ' ')}\n")
)
return self.stringify(rich_usage)
def format_description(self, description: str) -> str:
# leave full control over description to us
@ -74,13 +78,14 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter):
label = "Commands"
else:
label = "Description"
rich_label = self.stringify(Text(label, "optparse.groups"))
# some doc strings have initial newlines, some don't
description = description.lstrip("\n")
# some doc strings have final newlines and spaces, some don't
description = description.rstrip()
# dedent, then reindent
description = self.indent_lines(textwrap.dedent(description), " ")
description = f"{label}:\n{description}\n"
description = f"{rich_label}:\n{description}\n"
return description
else:
return ""
@ -88,10 +93,95 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter):
def format_epilog(self, epilog: str) -> str:
# leave full control over epilog to us
if epilog:
return epilog
rich_epilog = Text(epilog, style="optparse.text")
return self.stringify(rich_epilog)
else:
return ""
def rich_expand_default(self, option: optparse.Option) -> Text:
# `HelpFormatter.expand_default()` equivalent that returns a `Text`.
assert option.help is not None
if self.parser is None or not self.default_tag:
help = option.help
else:
default_value = self.parser.defaults.get(option.dest) # type: ignore
if default_value is optparse.NO_DEFAULT or default_value is None:
default_value = self.NO_DEFAULT_VALUE
help = option.help.replace(self.default_tag, escape(str(default_value)))
rich_help = Text.from_markup(help, style="optparse.help")
for highlight in self.highlights:
rich_help.highlight_regex(highlight, style_prefix="optparse.")
return rich_help
def format_option(self, option: optparse.Option) -> str:
# Overridden to call the rich methods.
result: List[Text] = []
opts = self.rich_option_strings[option]
opt_width = self.help_position - self.current_indent - 2
if len(opts) > opt_width:
opts.append("\n")
indent_first = self.help_position
else: # start help on same line as opts
opts.set_length(opt_width + 2)
indent_first = 0
opts.pad_left(self.current_indent)
result.append(opts)
if option.help:
help_text = self.rich_expand_default(option)
help_text.expand_tabs(8) # textwrap expands tabs first
help_text.plain = help_text.plain.translate(
textwrap.TextWrapper.unicode_whitespace_trans
) # textwrap converts whitespace to " " second
help_lines = help_text.wrap(self.console, self.help_width)
result.append(Text(" " * indent_first) + help_lines[0] + "\n")
indent = Text(" " * self.help_position)
for line in help_lines[1:]:
result.append(indent + line + "\n")
elif opts.plain[-1] != "\n":
result.append(Text("\n"))
else:
pass # pragma: no cover
return self.stringify(Text().join(result))
def store_option_strings(self, parser: optparse.OptionParser) -> None:
# Overridden to call the rich methods.
self.indent()
max_len = 0
for opt in parser.option_list:
strings = self.rich_format_option_strings(opt)
self.option_strings[opt] = strings.plain
self.rich_option_strings[opt] = strings
max_len = max(max_len, len(strings) + self.current_indent)
self.indent()
for group in parser.option_groups:
for opt in group.option_list:
strings = self.rich_format_option_strings(opt)
self.option_strings[opt] = strings.plain
self.rich_option_strings[opt] = strings
max_len = max(max_len, len(strings) + self.current_indent)
self.dedent()
self.dedent()
self.help_position = min(max_len + 2, self.max_help_position)
self.help_width = max(self.width - self.help_position, 11)
def rich_format_option_strings(self, option: optparse.Option) -> Text:
# `HelpFormatter.format_option_strings()` equivalent that returns a `Text`.
opts: List[Text] = []
if option._short_opts:
opts.append(Text(option._short_opts[0], "optparse.args"))
if option._long_opts:
opts.append(Text(option._long_opts[0], "optparse.args"))
if len(opts) > 1:
opts.insert(1, Text(", "))
if option.takes_value():
assert option.dest is not None
metavar = option.metavar or option.dest.lower()
opts.append(Text(" ").append(f"<{metavar.lower()}>", "optparse.metavar"))
return Text().join(opts)
def indent_lines(self, text: str, indent: str) -> str:
new_lines = [indent + line for line in text.split("\n")]
return "\n".join(new_lines)
@ -106,14 +196,14 @@ class UpdatingDefaultsHelpFormatter(PrettyHelpFormatter):
Also redact auth from url type options
"""
def expand_default(self, option: optparse.Option) -> str:
def rich_expand_default(self, option: optparse.Option) -> Text:
default_values = None
if self.parser is not None:
assert isinstance(self.parser, ConfigOptionParser)
self.parser._update_defaults(self.parser.defaults)
assert option.dest is not None
default_values = self.parser.defaults.get(option.dest)
help_text = super().expand_default(option)
help_text = super().rich_expand_default(option)
if default_values and option.metavar == "URL":
if isinstance(default_values, str):
@ -124,7 +214,8 @@ class UpdatingDefaultsHelpFormatter(PrettyHelpFormatter):
default_values = []
for val in default_values:
help_text = help_text.replace(val, redact_auth_from_url(val))
new_val = escape(redact_auth_from_url(val))
help_text = Text(new_val).join(help_text.split(val))
return help_text
@ -150,6 +241,22 @@ class CustomOptionParser(optparse.OptionParser):
return res
def _print_ansi(self, text: str, file: Optional[IO[str]] = None) -> None:
if file is None:
file = sys.stdout
if detect_legacy_windows():
console = Console(file=file)
console.print(Text.from_ansi(text), soft_wrap=True)
else:
file.write(text)
def print_usage(self, file: Optional[IO[str]] = None) -> None:
if self.usage:
self._print_ansi(self.get_usage(), file=file)
def print_help(self, file: Optional[IO[str]] = None) -> None:
self._print_ansi(self.format_help(), file=file)
class ConfigOptionParser(CustomOptionParser):
"""Custom option parser which updates its defaults by checking the