From e9b488049cf9f81a719e8070f3158af0b4dc9f3d Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Fri, 14 Jul 2023 17:14:18 +0200 Subject: [PATCH 01/13] 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 5ce9f5a02d4..c07a1ce8a62 100644 --- a/src/pip/_internal/cli/main_parser.py +++ b/src/pip/_internal/cli/main_parser.py @@ -6,6 +6,8 @@ import subprocess import sys +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 9311824d750..0512818d243 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -11,6 +11,12 @@ from contextlib import suppress from typing import Any, NoReturn +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 @@ -21,54 +27,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 | None) -> str: # leave full control over description to us @@ -77,13 +81,14 @@ def format_description(self, description: str | None) -> 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 "" @@ -91,10 +96,95 @@ def format_description(self, description: str | None) -> str: def format_epilog(self, epilog: str | None) -> 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) @@ -109,14 +199,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): @@ -127,7 +217,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 c476386294a09928a65af8755c676c8e86255a9f Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Fri, 14 Jul 2023 17:25:09 +0200 Subject: [PATCH 02/13] 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 0512818d243..09a32519b3a 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -27,7 +27,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", @@ -35,7 +35,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 ] @@ -47,7 +47,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.""" @@ -118,7 +118,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: @@ -169,7 +169,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 d49ec29bc11f38e9d3cefcd5f71469b2eafa358b Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Fri, 14 Jul 2023 17:29:31 +0200 Subject: [PATCH 03/13] 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 1cafa1693b4580574580dd98f37d55281149db31 Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Sat, 5 Aug 2023 20:49:39 +0200 Subject: [PATCH 04/13] 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 09a32519b3a..20dd95fce42 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -9,9 +9,9 @@ import textwrap from collections.abc import Generator from contextlib import suppress -from typing import Any, NoReturn +from typing import IO, Any, Dict, List, NoReturn, Optional -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 @@ -244,6 +244,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 From 05dd345cae63115d0e267a6660a2df54a15ca1fa Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sat, 15 Nov 2025 11:00:09 -0500 Subject: [PATCH 05/13] Fix typing --- src/pip/_internal/cli/parser.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index 20dd95fce42..b4e1def145b 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -9,7 +9,7 @@ import textwrap from collections.abc import Generator from contextlib import suppress -from typing import IO, Any, Dict, List, NoReturn, Optional +from typing import IO, Any, NoReturn from pip._vendor.rich.console import Console, RenderableType, detect_legacy_windows from pip._vendor.rich.markup import escape @@ -27,7 +27,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", @@ -35,7 +35,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 ] @@ -47,7 +47,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.""" @@ -118,7 +118,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: @@ -169,7 +169,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")) @@ -244,7 +244,7 @@ def option_list_all(self) -> list[optparse.Option]: return res - def _print_ansi(self, text: str, file: Optional[IO[str]] = None) -> None: + def _print_ansi(self, text: str, file: IO[str] | None = None) -> None: if file is None: file = sys.stdout if detect_legacy_windows(): @@ -253,11 +253,11 @@ def _print_ansi(self, text: str, file: Optional[IO[str]] = None) -> None: else: file.write(text) - def print_usage(self, file: Optional[IO[str]] = None) -> None: + def print_usage(self, file: IO[str] | None = None) -> None: if self.usage: self._print_ansi(self.get_usage(), file=file) - def print_help(self, file: Optional[IO[str]] = None) -> None: + def print_help(self, file: IO[str] | None = None) -> None: self._print_ansi(self.format_help(), file=file) From e4ca82b31a89b5d7be90305e1079633cf928b34f Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sat, 15 Nov 2025 13:18:25 -0500 Subject: [PATCH 06/13] Remove compatibility code for legacy Windows CWD --- src/pip/_internal/cli/parser.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index b4e1def145b..0512818d243 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -9,9 +9,9 @@ import textwrap from collections.abc import Generator from contextlib import suppress -from typing import IO, Any, NoReturn +from typing import Any, NoReturn -from pip._vendor.rich.console import Console, RenderableType, detect_legacy_windows +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 @@ -244,22 +244,6 @@ def option_list_all(self) -> list[optparse.Option]: return res - def _print_ansi(self, text: str, file: IO[str] | None = 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: IO[str] | None = None) -> None: - if self.usage: - self._print_ansi(self.get_usage(), file=file) - - def print_help(self, file: IO[str] | None = None) -> None: - self._print_ansi(self.format_help(), file=file) - class ConfigOptionParser(CustomOptionParser): """Custom option parser which updates its defaults by checking the From 4a665beac2963ae584398e07792cb36386e26a45 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sat, 15 Nov 2025 13:57:31 -0500 Subject: [PATCH 07/13] Further simplify code --- src/pip/_internal/cli/parser.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index 0512818d243..e80ae48d20b 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -102,22 +102,15 @@ def format_epilog(self, epilog: str | None) -> str: 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))) + """Equivalent to HelpFormatter.expand_default() but with Rich support.""" + help = escape(super().expand_default(option)) 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. + """Overridden method with Rich support.""" result: list[Text] = [] opts = self.rich_option_strings[option] opt_width = self.help_position - self.current_indent - 2 @@ -147,7 +140,7 @@ def format_option(self, option: optparse.Option) -> str: return self.stringify(Text().join(result)) def store_option_strings(self, parser: optparse.OptionParser) -> None: - # Overridden to call the rich methods. + """Overridden method with Rich support.""" self.indent() max_len = 0 for opt in parser.option_list: @@ -168,7 +161,7 @@ def store_option_strings(self, parser: optparse.OptionParser) -> None: 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`. + """Equivalent to HelpFormatter.format_option_strings() but with Rich support.""" opts: list[Text] = [] if option._short_opts: From 588c84a49d0c840a4078b6a52eade0a3d89df6be Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sat, 15 Nov 2025 13:59:39 -0500 Subject: [PATCH 08/13] =?UTF-8?q?Reword=20=F0=9F=93=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- news/12134.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/12134.feature.rst b/news/12134.feature.rst index 9e0951837d1..c020d2e7c02 100644 --- a/news/12134.feature.rst +++ b/news/12134.feature.rst @@ -1 +1 @@ -Render the output of pip's command line help using rich. +Display pip's command line help in colour, if possible. From db1ba7f4387227a16b06fb88c9d747fd989bdc48 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sat, 15 Nov 2025 14:21:36 -0500 Subject: [PATCH 09/13] Handle (PIP_)NO_COLOR --- src/pip/_internal/cli/parser.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index e80ae48d20b..a45fa64e8e8 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -4,6 +4,7 @@ import logging import optparse +import os import shutil import sys import textwrap @@ -11,7 +12,7 @@ from contextlib import suppress from typing import Any, NoReturn -from pip._vendor.rich.console import Console, RenderableType +from pip._vendor.rich.console import RenderableType from pip._vendor.rich.markup import escape from pip._vendor.rich.style import StyleType from pip._vendor.rich.text import Text @@ -19,6 +20,7 @@ from pip._internal.cli.status_codes import UNKNOWN_ERROR from pip._internal.configuration import Configuration, ConfigurationError +from pip._internal.utils.logging import PipConsole from pip._internal.utils.misc import redact_auth_from_url, strtobool logger = logging.getLogger(__name__) @@ -46,7 +48,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs["indent_increment"] = 1 kwargs["width"] = shutil.get_terminal_size()[0] - 2 super().__init__(*args, **kwargs) - self.console: Console = Console(theme=Theme(self.styles)) + # This is unfortunate but necessary since arguments may have not been + # parsed yet at this point, so detect --no-color manually. + no_color = ( + "--no-color" in sys.argv + or bool(strtobool(os.environ.get("PIP_NO_COLOR", "no") or "no")) + or "NO_COLOR" in os.environ + ) + self.console = PipConsole(theme=Theme(self.styles), no_color=no_color) self.rich_option_strings: dict[optparse.Option, Text] = {} def stringify(self, text: RenderableType) -> str: From 5ebe046d3b6168f5f551c454b63d86999d607f49 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sun, 16 Nov 2025 13:16:29 -0500 Subject: [PATCH 10/13] Replace metavar colour and colourize colons --- src/pip/_internal/cli/parser.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index a45fa64e8e8..e466ed77584 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -33,7 +33,7 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): "optparse.args": "cyan", "optparse.groups": "dark_orange", "optparse.help": "default", - "optparse.metavar": "dark_cyan", + "optparse.metavar": "yellow", "optparse.syntax": "bold", "optparse.text": "default", } @@ -68,8 +68,7 @@ def stringify(self, text: RenderableType) -> str: def format_heading(self, heading: str) -> str: if heading == "Options": return "" - rich_heading = Text().append(heading, "optparse.groups").append(":\n") - return self.stringify(rich_heading) + return self.stringify(Text(heading + ":\n", "optparse.groups")) def format_usage(self, usage: str) -> str: """ @@ -78,8 +77,8 @@ def format_usage(self, usage: str) -> str: """ rich_usage = ( Text("\n") - .append("Usage", "optparse.groups") - .append(f": {self.indent_lines(textwrap.dedent(usage), ' ')}\n") + .append("Usage:", "optparse.groups") + .append(f" {self.indent_lines(textwrap.dedent(usage), ' ')}\n") ) return self.stringify(rich_usage) @@ -90,14 +89,14 @@ def format_description(self, description: str | None) -> str: label = "Commands" else: label = "Description" - rich_label = self.stringify(Text(label, "optparse.groups")) + 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"{rich_label}:\n{description}\n" + description = f"{rich_label}\n{description}\n" return description else: return "" From dbab7c7cfaa37ba36fdd9c8058206e6aaab9df52 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Sun, 16 Nov 2025 13:33:20 -0500 Subject: [PATCH 11/13] Colorize short/long options differently --- src/pip/_internal/cli/main_parser.py | 2 +- src/pip/_internal/cli/parser.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pip/_internal/cli/main_parser.py b/src/pip/_internal/cli/main_parser.py index c07a1ce8a62..d2b79e1f179 100644 --- a/src/pip/_internal/cli/main_parser.py +++ b/src/pip/_internal/cli/main_parser.py @@ -43,7 +43,7 @@ def create_main_parser() -> ConfigOptionParser: description = [""] + [ parser.formatter.stringify( # type: ignore Text() - .append(name, "optparse.args") + .append(name, "optparse.longargs") .append(" " * (28 - len(name))) .append(command_info.summary, "optparse.help") ) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index e466ed77584..c09698e2c7a 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -30,7 +30,8 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): """A prettier/less verbose help formatter for optparse.""" styles: dict[str, StyleType] = { - "optparse.args": "cyan", + "optparse.shortargs": "green", + "optparse.longargs": "cyan", "optparse.groups": "dark_orange", "optparse.help": "default", "optparse.metavar": "yellow", @@ -38,7 +39,8 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): "optparse.text": "default", } highlights: list[str] = [ - r"(?:^|\s)(?P-{1,2}[\w]+[\w-]*)", # highlight --words-with-dashes as args + r"(?:^|\s)(?P-{1}[\w]+[\w-]*)", # highlight -letter as short args + r"(?:^|\s)(?P-{2}[\w]+[\w-]*)", # highlight --words as long args r"`(?P[^`]*)`", # highlight `text in backquotes` as syntax ] @@ -173,9 +175,9 @@ def rich_format_option_strings(self, option: optparse.Option) -> Text: opts: list[Text] = [] if option._short_opts: - opts.append(Text(option._short_opts[0], "optparse.args")) + opts.append(Text(option._short_opts[0], "optparse.shortargs")) if option._long_opts: - opts.append(Text(option._long_opts[0], "optparse.args")) + opts.append(Text(option._long_opts[0], "optparse.longargs")) if len(opts) > 1: opts.insert(1, Text(", ")) From 9f863f09902a3b97f132706bb2fd81f0c7f88e98 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Mon, 17 Nov 2025 11:34:52 -0500 Subject: [PATCH 12/13] Use blue for headers --- src/pip/_internal/cli/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index c09698e2c7a..3ed8de8900e 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -32,7 +32,7 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): styles: dict[str, StyleType] = { "optparse.shortargs": "green", "optparse.longargs": "cyan", - "optparse.groups": "dark_orange", + "optparse.groups": "blue", "optparse.help": "default", "optparse.metavar": "yellow", "optparse.syntax": "bold", From 1ba8493ed3421ea819dad371fbe5c517104412e3 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Wed, 19 Nov 2025 15:29:37 -0500 Subject: [PATCH 13/13] Use BOLD blue for headers --- src/pip/_internal/cli/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py index 3ed8de8900e..27a0367f70d 100644 --- a/src/pip/_internal/cli/parser.py +++ b/src/pip/_internal/cli/parser.py @@ -32,7 +32,7 @@ class PrettyHelpFormatter(optparse.IndentedHelpFormatter): styles: dict[str, StyleType] = { "optparse.shortargs": "green", "optparse.longargs": "cyan", - "optparse.groups": "blue", + "optparse.groups": "bold blue", "optparse.help": "default", "optparse.metavar": "yellow", "optparse.syntax": "bold",