mirror of https://github.com/pypa/pip
Merge b47c078725
into a15dd75d98
This commit is contained in:
commit
5d8d84560c
|
@ -0,0 +1 @@
|
|||
Render the output of pip's command line help using rich.
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue