1111from contextlib import suppress
1212from 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+
1420from pip ._internal .cli .status_codes import UNKNOWN_ERROR
1521from pip ._internal .configuration import Configuration , ConfigurationError
1622from pip ._internal .utils .misc import redact_auth_from_url , strtobool
2127class 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 = "\n Usage: {}\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