Skip to content

Commit e9b4880

Browse files
hamdanalichard26
authored andcommitted
Colourful pip help
1 parent ebaa70b commit e9b4880

File tree

2 files changed

+135
-37
lines changed

2 files changed

+135
-37
lines changed

src/pip/_internal/cli/main_parser.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import subprocess
77
import sys
88

9+
from pip._vendor.rich.text import Text
10+
911
from pip._internal.build_env import get_runnable_pip
1012
from pip._internal.cli import cmdoptions
1113
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
@@ -39,7 +41,12 @@ def create_main_parser() -> ConfigOptionParser:
3941

4042
# create command listing for description
4143
description = [""] + [
42-
f"{name:27} {command_info.summary}"
44+
parser.formatter.stringify( # type: ignore
45+
Text()
46+
.append(name, "optparse.args")
47+
.append(" " * (28 - len(name)))
48+
.append(command_info.summary, "optparse.help")
49+
)
4350
for name, command_info in commands_dict.items()
4451
]
4552
parser.description = "\n".join(description)

src/pip/_internal/cli/parser.py

Lines changed: 127 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
from contextlib import suppress
1212
from typing import Any, NoReturn
1313

14+
from pip._vendor.rich.console import Console, RenderableType
15+
from pip._vendor.rich.markup import escape
16+
from pip._vendor.rich.style import StyleType
17+
from pip._vendor.rich.text import Text
18+
from pip._vendor.rich.theme import Theme
19+
1420
from pip._internal.cli.status_codes import UNKNOWN_ERROR
1521
from pip._internal.configuration import Configuration, ConfigurationError
1622
from pip._internal.utils.misc import redact_auth_from_url, strtobool
@@ -21,54 +27,52 @@
2127
class PrettyHelpFormatter(optparse.IndentedHelpFormatter):
2228
"""A prettier/less verbose help formatter for optparse."""
2329

30+
styles: dict[str, StyleType] = {
31+
"optparse.args": "cyan",
32+
"optparse.groups": "dark_orange",
33+
"optparse.help": "default",
34+
"optparse.metavar": "dark_cyan",
35+
"optparse.syntax": "bold",
36+
"optparse.text": "default",
37+
}
38+
highlights: list[str] = [
39+
r"(?:^|\s)(?P<args>-{1,2}[\w]+[\w-]*)", # highlight --words-with-dashes as args
40+
r"`(?P<syntax>[^`]*)`", # highlight `text in backquotes` as syntax
41+
]
42+
2443
def __init__(self, *args: Any, **kwargs: Any) -> None:
2544
# help position must be aligned with __init__.parseopts.description
2645
kwargs["max_help_position"] = 30
2746
kwargs["indent_increment"] = 1
2847
kwargs["width"] = shutil.get_terminal_size()[0] - 2
2948
super().__init__(*args, **kwargs)
49+
self.console: Console = Console(theme=Theme(self.styles))
50+
self.rich_option_strings: dict[optparse.Option, Text] = {}
3051

31-
def format_option_strings(self, option: optparse.Option) -> str:
32-
return self._format_option_strings(option)
33-
34-
def _format_option_strings(
35-
self, option: optparse.Option, mvarfmt: str = " <{}>", optsep: str = ", "
36-
) -> str:
37-
"""
38-
Return a comma-separated list of option strings and metavars.
39-
40-
:param option: tuple of (short opt, long opt), e.g: ('-f', '--format')
41-
:param mvarfmt: metavar format string
42-
:param optsep: separator
43-
"""
44-
opts = []
45-
46-
if option._short_opts:
47-
opts.append(option._short_opts[0])
48-
if option._long_opts:
49-
opts.append(option._long_opts[0])
50-
if len(opts) > 1:
51-
opts.insert(1, optsep)
52-
53-
if option.takes_value():
54-
assert option.dest is not None
55-
metavar = option.metavar or option.dest.lower()
56-
opts.append(mvarfmt.format(metavar.lower()))
57-
58-
return "".join(opts)
52+
def stringify(self, text: RenderableType) -> str:
53+
"""Render a rich object as a string."""
54+
with self.console.capture() as capture:
55+
self.console.print(text, highlight=False, soft_wrap=True, end="")
56+
help = capture.get()
57+
return "\n".join(line.rstrip() for line in help.split("\n"))
5958

6059
def format_heading(self, heading: str) -> str:
6160
if heading == "Options":
6261
return ""
63-
return heading + ":\n"
62+
rich_heading = Text().append(heading, "optparse.groups").append(":\n")
63+
return self.stringify(rich_heading)
6464

6565
def format_usage(self, usage: str) -> str:
6666
"""
6767
Ensure there is only one newline between usage and the first heading
6868
if there is no description.
6969
"""
70-
msg = "\nUsage: {}\n".format(self.indent_lines(textwrap.dedent(usage), " "))
71-
return msg
70+
rich_usage = (
71+
Text("\n")
72+
.append("Usage", "optparse.groups")
73+
.append(f": {self.indent_lines(textwrap.dedent(usage), ' ')}\n")
74+
)
75+
return self.stringify(rich_usage)
7276

7377
def format_description(self, description: str | None) -> str:
7478
# leave full control over description to us
@@ -77,24 +81,110 @@ def format_description(self, description: str | None) -> str:
7781
label = "Commands"
7882
else:
7983
label = "Description"
84+
rich_label = self.stringify(Text(label, "optparse.groups"))
8085
# some doc strings have initial newlines, some don't
8186
description = description.lstrip("\n")
8287
# some doc strings have final newlines and spaces, some don't
8388
description = description.rstrip()
8489
# dedent, then reindent
8590
description = self.indent_lines(textwrap.dedent(description), " ")
86-
description = f"{label}:\n{description}\n"
91+
description = f"{rich_label}:\n{description}\n"
8792
return description
8893
else:
8994
return ""
9095

9196
def format_epilog(self, epilog: str | None) -> str:
9297
# leave full control over epilog to us
9398
if epilog:
94-
return epilog
99+
rich_epilog = Text(epilog, style="optparse.text")
100+
return self.stringify(rich_epilog)
95101
else:
96102
return ""
97103

104+
def rich_expand_default(self, option: optparse.Option) -> Text:
105+
# `HelpFormatter.expand_default()` equivalent that returns a `Text`.
106+
assert option.help is not None
107+
if self.parser is None or not self.default_tag:
108+
help = option.help
109+
else:
110+
default_value = self.parser.defaults.get(option.dest) # type: ignore
111+
if default_value is optparse.NO_DEFAULT or default_value is None:
112+
default_value = self.NO_DEFAULT_VALUE
113+
help = option.help.replace(self.default_tag, escape(str(default_value)))
114+
rich_help = Text.from_markup(help, style="optparse.help")
115+
for highlight in self.highlights:
116+
rich_help.highlight_regex(highlight, style_prefix="optparse.")
117+
return rich_help
118+
119+
def format_option(self, option: optparse.Option) -> str:
120+
# Overridden to call the rich methods.
121+
result: list[Text] = []
122+
opts = self.rich_option_strings[option]
123+
opt_width = self.help_position - self.current_indent - 2
124+
if len(opts) > opt_width:
125+
opts.append("\n")
126+
indent_first = self.help_position
127+
else: # start help on same line as opts
128+
opts.set_length(opt_width + 2)
129+
indent_first = 0
130+
opts.pad_left(self.current_indent)
131+
result.append(opts)
132+
if option.help:
133+
help_text = self.rich_expand_default(option)
134+
help_text.expand_tabs(8) # textwrap expands tabs first
135+
help_text.plain = help_text.plain.translate(
136+
textwrap.TextWrapper.unicode_whitespace_trans
137+
) # textwrap converts whitespace to " " second
138+
help_lines = help_text.wrap(self.console, self.help_width)
139+
result.append(Text(" " * indent_first) + help_lines[0] + "\n")
140+
indent = Text(" " * self.help_position)
141+
for line in help_lines[1:]:
142+
result.append(indent + line + "\n")
143+
elif opts.plain[-1] != "\n":
144+
result.append(Text("\n"))
145+
else:
146+
pass # pragma: no cover
147+
return self.stringify(Text().join(result))
148+
149+
def store_option_strings(self, parser: optparse.OptionParser) -> None:
150+
# Overridden to call the rich methods.
151+
self.indent()
152+
max_len = 0
153+
for opt in parser.option_list:
154+
strings = self.rich_format_option_strings(opt)
155+
self.option_strings[opt] = strings.plain
156+
self.rich_option_strings[opt] = strings
157+
max_len = max(max_len, len(strings) + self.current_indent)
158+
self.indent()
159+
for group in parser.option_groups:
160+
for opt in group.option_list:
161+
strings = self.rich_format_option_strings(opt)
162+
self.option_strings[opt] = strings.plain
163+
self.rich_option_strings[opt] = strings
164+
max_len = max(max_len, len(strings) + self.current_indent)
165+
self.dedent()
166+
self.dedent()
167+
self.help_position = min(max_len + 2, self.max_help_position)
168+
self.help_width = max(self.width - self.help_position, 11)
169+
170+
def rich_format_option_strings(self, option: optparse.Option) -> Text:
171+
# `HelpFormatter.format_option_strings()` equivalent that returns a `Text`.
172+
opts: list[Text] = []
173+
174+
if option._short_opts:
175+
opts.append(Text(option._short_opts[0], "optparse.args"))
176+
if option._long_opts:
177+
opts.append(Text(option._long_opts[0], "optparse.args"))
178+
if len(opts) > 1:
179+
opts.insert(1, Text(", "))
180+
181+
if option.takes_value():
182+
assert option.dest is not None
183+
metavar = option.metavar or option.dest.lower()
184+
opts.append(Text(" ").append(f"<{metavar.lower()}>", "optparse.metavar"))
185+
186+
return Text().join(opts)
187+
98188
def indent_lines(self, text: str, indent: str) -> str:
99189
new_lines = [indent + line for line in text.split("\n")]
100190
return "\n".join(new_lines)
@@ -109,14 +199,14 @@ class UpdatingDefaultsHelpFormatter(PrettyHelpFormatter):
109199
Also redact auth from url type options
110200
"""
111201

112-
def expand_default(self, option: optparse.Option) -> str:
202+
def rich_expand_default(self, option: optparse.Option) -> Text:
113203
default_values = None
114204
if self.parser is not None:
115205
assert isinstance(self.parser, ConfigOptionParser)
116206
self.parser._update_defaults(self.parser.defaults)
117207
assert option.dest is not None
118208
default_values = self.parser.defaults.get(option.dest)
119-
help_text = super().expand_default(option)
209+
help_text = super().rich_expand_default(option)
120210

121211
if default_values and option.metavar == "URL":
122212
if isinstance(default_values, str):
@@ -127,7 +217,8 @@ def expand_default(self, option: optparse.Option) -> str:
127217
default_values = []
128218

129219
for val in default_values:
130-
help_text = help_text.replace(val, redact_auth_from_url(val))
220+
new_val = escape(redact_auth_from_url(val))
221+
help_text = Text(new_val).join(help_text.split(val))
131222

132223
return help_text
133224

0 commit comments

Comments
 (0)