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