1"""Base option parser setup"""
2
3from __future__ import annotations
4
5import logging
6import optparse
7import os
8import re
9import shutil
10import sys
11import textwrap
12from collections.abc import Generator
13from contextlib import suppress
14from typing import TYPE_CHECKING, Any, NoReturn
15
16from pip._vendor.rich.markup import escape
17from pip._vendor.rich.theme import Theme
18
19from pip._internal.cli.status_codes import UNKNOWN_ERROR
20from pip._internal.configuration import Configuration, ConfigurationError
21from pip._internal.utils.misc import redact_auth_from_url, strtobool
22
23if TYPE_CHECKING:
24 from pip._internal.utils.logging import PipConsole
25
26logger = logging.getLogger(__name__)
27
28
29class PrettyHelpFormatter(optparse.IndentedHelpFormatter):
30 """A prettier/less verbose help formatter for optparse."""
31
32 styles = {
33 "optparse.shortargs": "green",
34 "optparse.longargs": "cyan",
35 "optparse.groups": "bold blue",
36 "optparse.metavar": "yellow",
37 }
38 highlights = {
39 r"\s(-{1}[\w]+[\w-]*)": "shortargs", # highlight -letter as short args
40 r"\s(-{2}[\w]+[\w-]*)": "longargs", # highlight --words as long args
41 }
42
43 def __init__(self, *args: Any, **kwargs: Any) -> None:
44 # help position must be aligned with __init__.parseopts.description
45 kwargs["max_help_position"] = 30
46 kwargs["indent_increment"] = 1
47 kwargs["width"] = shutil.get_terminal_size()[0] - 2
48 super().__init__(*args, **kwargs)
49
50 def format_option_strings(self, option: optparse.Option) -> str:
51 """Return a comma-separated list of option strings and metavars."""
52 opts = []
53
54 if option._short_opts:
55 opts.append(f"[optparse.shortargs]{option._short_opts[0]}[/]")
56 if option._long_opts:
57 opts.append(f"[optparse.longargs]{option._long_opts[0]}[/]")
58 if len(opts) > 1:
59 opts.insert(1, ", ")
60
61 if option.takes_value():
62 assert option.dest is not None
63 metavar = option.metavar or option.dest.lower()
64 opts.append(f" [optparse.metavar]<{escape(metavar.lower())}>[/]")
65
66 return "".join(opts)
67
68 def format_option(self, option: optparse.Option) -> str:
69 """Overridden method with Rich support."""
70 # fmt: off
71 result = []
72 opts = self.option_strings[option]
73 opt_width = self.help_position - self.current_indent - 2
74 # Remove the rich style tags before calculating width during
75 # text wrap calculations. Also store the length removed to adjust
76 # the padding in the else branch.
77 stripped = re.sub(r"(\[[a-z.]+\])|(\[\/\])", "", opts)
78 style_tag_length = len(opts) - len(stripped)
79 if len(stripped) > opt_width:
80 opts = "%*s%s\n" % (self.current_indent, "", opts) # noqa: UP031
81 indent_first = self.help_position
82 else: # start help on same line as opts
83 opts = "%*s%-*s " % (self.current_indent, "", # noqa: UP031
84 opt_width + style_tag_length, opts)
85 indent_first = 0
86 result.append(opts)
87 if option.help:
88 help_text = self.expand_default(option)
89 help_lines = textwrap.wrap(help_text, self.help_width)
90 result.append("%*s%s\n" % (indent_first, "", help_lines[0])) # noqa: UP031
91 result.extend(["%*s%s\n" % (self.help_position, "", line) # noqa: UP031
92 for line in help_lines[1:]])
93 elif opts[-1] != "\n":
94 result.append("\n")
95 return "".join(result)
96 # fmt: on
97
98 def format_heading(self, heading: str) -> str:
99 if heading == "Options":
100 return ""
101 return "[optparse.groups]" + escape(heading) + ":[/]\n"
102
103 def format_usage(self, usage: str) -> str:
104 """
105 Ensure there is only one newline between usage and the first heading
106 if there is no description.
107 """
108 contents = self.indent_lines(textwrap.dedent(usage), " ")
109 msg = f"\n[optparse.groups]Usage:[/] {escape(contents)}\n"
110 return msg
111
112 def format_description(self, description: str | None) -> str:
113 # leave full control over description to us
114 if description:
115 if hasattr(self.parser, "main"):
116 label = "[optparse.groups]Commands:[/]"
117 else:
118 label = "[optparse.groups]Description:[/]"
119
120 # some doc strings have initial newlines, some don't
121 description = description.lstrip("\n")
122 # some doc strings have final newlines and spaces, some don't
123 description = description.rstrip()
124 # dedent, then reindent
125 description = self.indent_lines(textwrap.dedent(description), " ")
126 description = f"{label}\n{description}\n"
127 return description
128 else:
129 return ""
130
131 def format_epilog(self, epilog: str | None) -> str:
132 # leave full control over epilog to us
133 if epilog:
134 return escape(epilog)
135 else:
136 return ""
137
138 def expand_default(self, option: optparse.Option) -> str:
139 """Overridden HelpFormatter.expand_default() which colorizes flags."""
140 help = escape(super().expand_default(option))
141 for regex, style in self.highlights.items():
142 help = re.sub(regex, rf"[optparse.{style}] \1[/]", help)
143 return help
144
145 def indent_lines(self, text: str, indent: str) -> str:
146 new_lines = [indent + line for line in text.split("\n")]
147 return "\n".join(new_lines)
148
149
150class UpdatingDefaultsHelpFormatter(PrettyHelpFormatter):
151 """Custom help formatter for use in ConfigOptionParser.
152
153 This is updates the defaults before expanding them, allowing
154 them to show up correctly in the help listing.
155
156 Also redact auth from url type options
157 """
158
159 def expand_default(self, option: optparse.Option) -> str:
160 default_values = None
161 if self.parser is not None:
162 assert isinstance(self.parser, ConfigOptionParser)
163 self.parser._update_defaults(self.parser.defaults)
164 assert option.dest is not None
165 default_values = self.parser.defaults.get(option.dest)
166 help_text = super().expand_default(option)
167
168 if default_values and option.metavar == "URL":
169 if isinstance(default_values, str):
170 default_values = [default_values]
171
172 # If its not a list, we should abort and just return the help text
173 if not isinstance(default_values, list):
174 default_values = []
175
176 for val in default_values:
177 help_text = help_text.replace(val, redact_auth_from_url(val))
178
179 return help_text
180
181
182class CustomOptionParser(optparse.OptionParser):
183 def insert_option_group(
184 self, idx: int, *args: Any, **kwargs: Any
185 ) -> optparse.OptionGroup:
186 """Insert an OptionGroup at a given position."""
187 group = self.add_option_group(*args, **kwargs)
188
189 self.option_groups.pop()
190 self.option_groups.insert(idx, group)
191
192 return group
193
194 @property
195 def option_list_all(self) -> list[optparse.Option]:
196 """Get a list of all options, including those in option groups."""
197 res = self.option_list[:]
198 for i in self.option_groups:
199 res.extend(i.option_list)
200
201 return res
202
203
204class ConfigOptionParser(CustomOptionParser):
205 """Custom option parser which updates its defaults by checking the
206 configuration files and environmental variables"""
207
208 def __init__(
209 self,
210 *args: Any,
211 name: str,
212 isolated: bool = False,
213 **kwargs: Any,
214 ) -> None:
215 self.name = name
216 self.config = Configuration(isolated)
217 assert self.name
218 super().__init__(*args, **kwargs)
219
220 def check_default(self, option: optparse.Option, key: str, val: Any) -> Any:
221 try:
222 return option.check_value(key, val)
223 except optparse.OptionValueError as exc:
224 logger.error(
225 "An error occurred during configuration: %s. "
226 "Check your pip configuration files or "
227 "environment variables for this value. "
228 "See https://pip.pypa.io/en/stable/topics/configuration/ "
229 "for more information.",
230 exc,
231 )
232 sys.exit(3)
233
234 def _get_ordered_configuration_items(
235 self,
236 ) -> Generator[tuple[str, Any], None, None]:
237 # Configuration gives keys in an unordered manner. Order them.
238 override_order = ["global", self.name, ":env:"]
239
240 # Pool the options into different groups
241 # Use a dict because we need to implement the fallthrough logic after PR 12201
242 # was merged which removed the fallthrough logic for options
243 section_items_dict: dict[str, dict[str, Any]] = {
244 name: {} for name in override_order
245 }
246
247 for _, value in self.config.items():
248 for section_key, val in value.items():
249
250 section, key = section_key.split(".", 1)
251 if section in override_order:
252 section_items_dict[section][key] = val
253
254 # Now that we a dict of items per section, convert to list of tuples
255 # Make sure we completely remove empty values again
256 section_items = {
257 name: [(k, v) for k, v in section_items_dict[name].items() if v]
258 for name in override_order
259 }
260
261 # Yield each group in their override order
262 for section in override_order:
263 yield from section_items[section]
264
265 def _update_defaults(self, defaults: dict[str, Any]) -> dict[str, Any]:
266 """Updates the given defaults with values from the config files and
267 the environ. Does a little special handling for certain types of
268 options (lists)."""
269
270 # Accumulate complex default state.
271 self.values = optparse.Values(self.defaults)
272 late_eval = set()
273 # Then set the options with those values
274 for key, val in self._get_ordered_configuration_items():
275 # '--' because configuration supports only long names
276 option = self.get_option("--" + key)
277
278 # Ignore options not present in this parser. E.g. non-globals put
279 # in [global] by users that want them to apply to all applicable
280 # commands.
281 if option is None:
282 continue
283
284 assert option.dest is not None
285
286 if option.action in ("store_true", "store_false"):
287 try:
288 val = strtobool(val)
289 except ValueError:
290 self.error(
291 f"{val} is not a valid value for {key} option, "
292 "please specify a boolean value like yes/no, "
293 "true/false or 1/0 instead."
294 )
295 elif option.action == "count":
296 with suppress(ValueError):
297 val = strtobool(val)
298 with suppress(ValueError):
299 val = int(val)
300 if not isinstance(val, int) or val < 0:
301 self.error(
302 f"{val} is not a valid value for {key} option, "
303 "please instead specify either a non-negative integer "
304 "or a boolean value like yes/no or false/true "
305 "which is equivalent to 1/0."
306 )
307 elif option.action == "append":
308 val = val.split()
309 val = [self.check_default(option, key, v) for v in val]
310 elif option.action == "callback":
311 assert option.callback is not None
312 late_eval.add(option.dest)
313 opt_str = option.get_opt_string()
314 val = option.convert_value(opt_str, val)
315 # From take_action
316 args = option.callback_args or ()
317 kwargs = option.callback_kwargs or {}
318 option.callback(option, opt_str, val, self, *args, **kwargs)
319 else:
320 val = self.check_default(option, key, val)
321
322 defaults[option.dest] = val
323
324 for key in late_eval:
325 defaults[key] = getattr(self.values, key)
326 self.values = None
327 return defaults
328
329 def get_default_values(self) -> optparse.Values:
330 """Overriding to make updating the defaults after instantiation of
331 the option parser possible, _update_defaults() does the dirty work."""
332 if not self.process_default_values:
333 # Old, pre-Optik 1.5 behaviour.
334 return optparse.Values(self.defaults)
335
336 # Load the configuration, or error out in case of an error
337 try:
338 self.config.load()
339 except ConfigurationError as err:
340 self.exit(UNKNOWN_ERROR, str(err))
341
342 defaults = self._update_defaults(self.defaults.copy()) # ours
343 for option in self._get_all_options():
344 assert option.dest is not None
345 default = defaults.get(option.dest)
346 if isinstance(default, str):
347 opt_str = option.get_opt_string()
348 defaults[option.dest] = option.check_value(opt_str, default)
349 return optparse.Values(defaults)
350
351 def error(self, msg: str) -> NoReturn:
352 self.print_usage(sys.stderr)
353 self.exit(UNKNOWN_ERROR, f"{msg}\n")
354
355 def _create_console_for_help(self, file: Any = None) -> PipConsole:
356 # This is unfortunate but necessary since arguments may have not been
357 # parsed yet at this point, so detect --no-color manually.
358 no_color = (
359 "--no-color" in sys.argv
360 or bool(strtobool(os.environ.get("PIP_NO_COLOR", "no") or "no"))
361 or "NO_COLOR" in os.environ
362 )
363 # Imported lazily: rich's console stack is only needed when help is
364 # actually rendered, not when this module is imported for `--version`.
365 from pip._internal.utils.logging import PipConsole
366
367 return PipConsole(
368 theme=Theme(PrettyHelpFormatter.styles), no_color=no_color, file=file
369 )
370
371 def print_usage(self, file: Any = None) -> None:
372 if self.usage:
373 console = self._create_console_for_help(file)
374 console.print(self.get_usage(), highlight=False)
375
376 def print_help(self, file: Any = None) -> None:
377 console = self._create_console_for_help(file)
378 console.print(self.format_help().rstrip(), highlight=False)