From fa7bed5c79c897c98a63b51bbe72b00014c81204 Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Fri, 14 Jul 2023 17:14:18 +0200 Subject: [PATCH 1/4] Colourful pip help --- src/pip/_internal/cli/main_parser.py | 9 +- src/pip/_internal/cli/parser.py | 163 +++++++++++++++++++++------ 2 files changed, 135 insertions(+), 37 deletions(-) diff --git a/src/pip/_internal/cli/main_parser.py b/src/pip/_internal/cli/main_parser.py index 5ade356b9c2..c5b0783eb90 100644 --- a/src/pip/_internal/cli/main_parser.py +++ b/src/pip/_internal/cli/main_parser.py @@ -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 @@ -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) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index c762cf2781d..1629ea97b7f 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -8,6 +8,12 @@ from contextlib import suppress from typing import Any, Dict, Generator, List, Tuple +from pip._vendor.rich.console import Console, RenderableType +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 from pip._internal.utils.misc import redact_auth_from_url, strtobool @@ -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-{1,2}[\w]+[\w-]*)", # highlight --words-with-dashes as args + r"`(?P[^`]*)`", # 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 @@ 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 "" @@ -88,10 +93,95 @@ def format_description(self, description: str) -> str: 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 @@ 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 From 50104248915d691a04e98f34a45162d19ff86552 Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Fri, 14 Jul 2023 17:25:09 +0200 Subject: [PATCH 2/4] Fix annotations on python 3.7 --- src/pip/_internal/cli/parser.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index 1629ea97b7f..154b2f42189 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -24,7 +24,7 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): """A prettier/less verbose help formatter for optparse.""" - styles: dict[str, StyleType] = { + styles: Dict[str, StyleType] = { "optparse.args": "cyan", "optparse.groups": "dark_orange", "optparse.help": "default", @@ -32,7 +32,7 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): "optparse.syntax": "bold", "optparse.text": "default", } - highlights: list[str] = [ + highlights: List[str] = [ r"(?:^|\s)(?P-{1,2}[\w]+[\w-]*)", # highlight --words-with-dashes as args r"`(?P[^`]*)`", # highlight `text in backquotes` as syntax ] @@ -44,7 +44,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: 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] = {} + self.rich_option_strings: Dict[optparse.Option, Text] = {} def stringify(self, text: RenderableType) -> str: """Render a rich object as a string.""" @@ -115,7 +115,7 @@ def rich_expand_default(self, option: optparse.Option) -> Text: def format_option(self, option: optparse.Option) -> str: # Overridden to call the rich methods. - result: list[Text] = [] + result: List[Text] = [] opts = self.rich_option_strings[option] opt_width = self.help_position - self.current_indent - 2 if len(opts) > opt_width: @@ -166,7 +166,7 @@ def store_option_strings(self, parser: optparse.OptionParser) -> None: def rich_format_option_strings(self, option: optparse.Option) -> Text: # `HelpFormatter.format_option_strings()` equivalent that returns a `Text`. - opts: list[Text] = [] + opts: List[Text] = [] if option._short_opts: opts.append(Text(option._short_opts[0], "optparse.args")) From 2c9bbf42523e1134ab77afa56e3ec3ac8e427425 Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Fri, 14 Jul 2023 17:29:31 +0200 Subject: [PATCH 3/4] Add a news entry --- news/12134.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/12134.feature.rst diff --git a/news/12134.feature.rst b/news/12134.feature.rst new file mode 100644 index 00000000000..9e0951837d1 --- /dev/null +++ b/news/12134.feature.rst @@ -0,0 +1 @@ +Render the output of pip's command line help using rich. From b47c07872537e918948bd7f820422003c5178bc4 Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Sat, 5 Aug 2023 20:49:39 +0200 Subject: [PATCH 4/4] Fix ansi codes on legacy windows console --- src/pip/_internal/cli/parser.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index 154b2f42189..176b0bd8d33 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -6,9 +6,9 @@ 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 +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 @@ -241,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