Skip to content
Open
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
1 change: 1 addition & 0 deletions news/12134.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Render the output of pip's command line help using rich.
9 changes: 8 additions & 1 deletion src/pip/_internal/cli/main_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
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
Expand Down Expand Up @@ -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)
Expand Down
181 changes: 144 additions & 37 deletions src/pip/_internal/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
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
Expand All @@ -18,54 +24,52 @@
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
Expand All @@ -74,24 +78,110 @@ def format_description(self, description: str) -> str:
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 ""

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)
Expand All @@ -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):
Expand All @@ -124,7 +214,8 @@ def expand_default(self, option: optparse.Option) -> str:
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

Expand All @@ -150,6 +241,22 @@ def option_list_all(self) -> List[optparse.Option]:

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
Expand Down