Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/config/argparsing.py: 69%
259 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-05-04 13:14 +0700
1import argparse
2import os
3import sys
4import warnings
5from gettext import gettext
6from typing import Any
7from typing import Callable
8from typing import cast
9from typing import Dict
10from typing import List
11from typing import Mapping
12from typing import NoReturn
13from typing import Optional
14from typing import Sequence
15from typing import Tuple
16from typing import TYPE_CHECKING
17from typing import Union
19import _pytest._io
20from _pytest.compat import final
21from _pytest.config.exceptions import UsageError
22from _pytest.deprecated import ARGUMENT_PERCENT_DEFAULT
23from _pytest.deprecated import ARGUMENT_TYPE_STR
24from _pytest.deprecated import ARGUMENT_TYPE_STR_CHOICE
25from _pytest.deprecated import check_ispytest
27if TYPE_CHECKING:
28 from typing_extensions import Literal
30FILE_OR_DIR = "file_or_dir"
33@final
34class Parser:
35 """Parser for command line arguments and ini-file values.
37 :ivar extra_info: Dict of generic param -> value to display in case
38 there's an error processing the command line arguments.
39 """
41 prog: Optional[str] = None
43 def __init__(
44 self,
45 usage: Optional[str] = None,
46 processopt: Optional[Callable[["Argument"], None]] = None,
47 *,
48 _ispytest: bool = False,
49 ) -> None:
50 check_ispytest(_ispytest)
51 self._anonymous = OptionGroup("Custom options", parser=self, _ispytest=True)
52 self._groups: List[OptionGroup] = []
53 self._processopt = processopt
54 self._usage = usage
55 self._inidict: Dict[str, Tuple[str, Optional[str], Any]] = {}
56 self._ininames: List[str] = []
57 self.extra_info: Dict[str, Any] = {}
59 def processoption(self, option: "Argument") -> None:
60 if self._processopt:
61 if option.dest:
62 self._processopt(option)
64 def getgroup(
65 self, name: str, description: str = "", after: Optional[str] = None
66 ) -> "OptionGroup":
67 """Get (or create) a named option Group.
69 :param name: Name of the option group.
70 :param description: Long description for --help output.
71 :param after: Name of another group, used for ordering --help output.
72 :returns: The option group.
74 The returned group object has an ``addoption`` method with the same
75 signature as :func:`parser.addoption <pytest.Parser.addoption>` but
76 will be shown in the respective group in the output of
77 ``pytest --help``.
78 """
79 for group in self._groups:
80 if group.name == name:
81 return group
82 group = OptionGroup(name, description, parser=self, _ispytest=True)
83 i = 0
84 for i, grp in enumerate(self._groups):
85 if grp.name == after:
86 break
87 self._groups.insert(i + 1, group)
88 return group
90 def addoption(self, *opts: str, **attrs: Any) -> None:
91 """Register a command line option.
93 :param opts:
94 Option names, can be short or long options.
95 :param attrs:
96 Same attributes as the argparse library's :py:func:`add_argument()
97 <argparse.ArgumentParser.add_argument>` function accepts.
99 After command line parsing, options are available on the pytest config
100 object via ``config.option.NAME`` where ``NAME`` is usually set
101 by passing a ``dest`` attribute, for example
102 ``addoption("--long", dest="NAME", ...)``.
103 """
104 self._anonymous.addoption(*opts, **attrs)
106 def parse(
107 self,
108 args: Sequence[Union[str, "os.PathLike[str]"]],
109 namespace: Optional[argparse.Namespace] = None,
110 ) -> argparse.Namespace:
111 from _pytest._argcomplete import try_argcomplete
113 self.optparser = self._getparser()
114 try_argcomplete(self.optparser)
115 strargs = [os.fspath(x) for x in args]
116 return self.optparser.parse_args(strargs, namespace=namespace)
118 def _getparser(self) -> "MyOptionParser":
119 from _pytest._argcomplete import filescompleter
121 optparser = MyOptionParser(self, self.extra_info, prog=self.prog)
122 groups = self._groups + [self._anonymous]
123 for group in groups:
124 if group.options:
125 desc = group.description or group.name
126 arggroup = optparser.add_argument_group(desc)
127 for option in group.options:
128 n = option.names()
129 a = option.attrs()
130 arggroup.add_argument(*n, **a)
131 file_or_dir_arg = optparser.add_argument(FILE_OR_DIR, nargs="*")
132 # bash like autocompletion for dirs (appending '/')
133 # Type ignored because typeshed doesn't know about argcomplete.
134 file_or_dir_arg.completer = filescompleter # type: ignore
135 return optparser
137 def parse_setoption(
138 self,
139 args: Sequence[Union[str, "os.PathLike[str]"]],
140 option: argparse.Namespace,
141 namespace: Optional[argparse.Namespace] = None,
142 ) -> List[str]:
143 parsedoption = self.parse(args, namespace=namespace)
144 for name, value in parsedoption.__dict__.items():
145 setattr(option, name, value)
146 return cast(List[str], getattr(parsedoption, FILE_OR_DIR))
148 def parse_known_args(
149 self,
150 args: Sequence[Union[str, "os.PathLike[str]"]],
151 namespace: Optional[argparse.Namespace] = None,
152 ) -> argparse.Namespace:
153 """Parse the known arguments at this point.
155 :returns: An argparse namespace object.
156 """
157 return self.parse_known_and_unknown_args(args, namespace=namespace)[0]
159 def parse_known_and_unknown_args(
160 self,
161 args: Sequence[Union[str, "os.PathLike[str]"]],
162 namespace: Optional[argparse.Namespace] = None,
163 ) -> Tuple[argparse.Namespace, List[str]]:
164 """Parse the known arguments at this point, and also return the
165 remaining unknown arguments.
167 :returns:
168 A tuple containing an argparse namespace object for the known
169 arguments, and a list of the unknown arguments.
170 """
171 optparser = self._getparser()
172 strargs = [os.fspath(x) for x in args]
173 return optparser.parse_known_args(strargs, namespace=namespace)
175 def addini(
176 self,
177 name: str,
178 help: str,
179 type: Optional[
180 "Literal['string', 'paths', 'pathlist', 'args', 'linelist', 'bool']"
181 ] = None,
182 default: Any = None,
183 ) -> None:
184 """Register an ini-file option.
186 :param name:
187 Name of the ini-variable.
188 :param type:
189 Type of the variable. Can be:
191 * ``string``: a string
192 * ``bool``: a boolean
193 * ``args``: a list of strings, separated as in a shell
194 * ``linelist``: a list of strings, separated by line breaks
195 * ``paths``: a list of :class:`pathlib.Path`, separated as in a shell
196 * ``pathlist``: a list of ``py.path``, separated as in a shell
198 .. versionadded:: 7.0
199 The ``paths`` variable type.
201 Defaults to ``string`` if ``None`` or not passed.
202 :param default:
203 Default value if no ini-file option exists but is queried.
205 The value of ini-variables can be retrieved via a call to
206 :py:func:`config.getini(name) <pytest.Config.getini>`.
207 """
208 assert type in (None, "string", "paths", "pathlist", "args", "linelist", "bool")
209 self._inidict[name] = (help, type, default)
210 self._ininames.append(name)
213class ArgumentError(Exception):
214 """Raised if an Argument instance is created with invalid or
215 inconsistent arguments."""
217 def __init__(self, msg: str, option: Union["Argument", str]) -> None:
218 self.msg = msg
219 self.option_id = str(option)
221 def __str__(self) -> str:
222 if self.option_id:
223 return f"option {self.option_id}: {self.msg}"
224 else:
225 return self.msg
228class Argument:
229 """Class that mimics the necessary behaviour of optparse.Option.
231 It's currently a least effort implementation and ignoring choices
232 and integer prefixes.
234 https://docs.python.org/3/library/optparse.html#optparse-standard-option-types
235 """
237 _typ_map = {"int": int, "string": str, "float": float, "complex": complex}
239 def __init__(self, *names: str, **attrs: Any) -> None:
240 """Store params in private vars for use in add_argument."""
241 self._attrs = attrs
242 self._short_opts: List[str] = []
243 self._long_opts: List[str] = []
244 if "%default" in (attrs.get("help") or ""):
245 warnings.warn(ARGUMENT_PERCENT_DEFAULT, stacklevel=3)
246 try:
247 typ = attrs["type"]
248 except KeyError:
249 pass
250 else:
251 # This might raise a keyerror as well, don't want to catch that.
252 if isinstance(typ, str):
253 if typ == "choice":
254 warnings.warn(
255 ARGUMENT_TYPE_STR_CHOICE.format(typ=typ, names=names),
256 stacklevel=4,
257 )
258 # argparse expects a type here take it from
259 # the type of the first element
260 attrs["type"] = type(attrs["choices"][0])
261 else:
262 warnings.warn(
263 ARGUMENT_TYPE_STR.format(typ=typ, names=names), stacklevel=4
264 )
265 attrs["type"] = Argument._typ_map[typ]
266 # Used in test_parseopt -> test_parse_defaultgetter.
267 self.type = attrs["type"]
268 else:
269 self.type = typ
270 try:
271 # Attribute existence is tested in Config._processopt.
272 self.default = attrs["default"]
273 except KeyError:
274 pass
275 self._set_opt_strings(names)
276 dest: Optional[str] = attrs.get("dest")
277 if dest:
278 self.dest = dest
279 elif self._long_opts:
280 self.dest = self._long_opts[0][2:].replace("-", "_")
281 else:
282 try:
283 self.dest = self._short_opts[0][1:]
284 except IndexError as e:
285 self.dest = "???" # Needed for the error repr.
286 raise ArgumentError("need a long or short option", self) from e
288 def names(self) -> List[str]:
289 return self._short_opts + self._long_opts
291 def attrs(self) -> Mapping[str, Any]:
292 # Update any attributes set by processopt.
293 attrs = "default dest help".split()
294 attrs.append(self.dest)
295 for attr in attrs:
296 try:
297 self._attrs[attr] = getattr(self, attr)
298 except AttributeError:
299 pass
300 if self._attrs.get("help"):
301 a = self._attrs["help"]
302 a = a.replace("%default", "%(default)s")
303 # a = a.replace('%prog', '%(prog)s')
304 self._attrs["help"] = a
305 return self._attrs
307 def _set_opt_strings(self, opts: Sequence[str]) -> None:
308 """Directly from optparse.
310 Might not be necessary as this is passed to argparse later on.
311 """
312 for opt in opts:
313 if len(opt) < 2:
314 raise ArgumentError(
315 "invalid option string %r: "
316 "must be at least two characters long" % opt,
317 self,
318 )
319 elif len(opt) == 2:
320 if not (opt[0] == "-" and opt[1] != "-"):
321 raise ArgumentError(
322 "invalid short option string %r: "
323 "must be of the form -x, (x any non-dash char)" % opt,
324 self,
325 )
326 self._short_opts.append(opt)
327 else:
328 if not (opt[0:2] == "--" and opt[2] != "-"):
329 raise ArgumentError(
330 "invalid long option string %r: "
331 "must start with --, followed by non-dash" % opt,
332 self,
333 )
334 self._long_opts.append(opt)
336 def __repr__(self) -> str:
337 args: List[str] = []
338 if self._short_opts:
339 args += ["_short_opts: " + repr(self._short_opts)]
340 if self._long_opts:
341 args += ["_long_opts: " + repr(self._long_opts)]
342 args += ["dest: " + repr(self.dest)]
343 if hasattr(self, "type"):
344 args += ["type: " + repr(self.type)]
345 if hasattr(self, "default"):
346 args += ["default: " + repr(self.default)]
347 return "Argument({})".format(", ".join(args))
350class OptionGroup:
351 """A group of options shown in its own section."""
353 def __init__(
354 self,
355 name: str,
356 description: str = "",
357 parser: Optional[Parser] = None,
358 *,
359 _ispytest: bool = False,
360 ) -> None:
361 check_ispytest(_ispytest)
362 self.name = name
363 self.description = description
364 self.options: List[Argument] = []
365 self.parser = parser
367 def addoption(self, *opts: str, **attrs: Any) -> None:
368 """Add an option to this group.
370 If a shortened version of a long option is specified, it will
371 be suppressed in the help. ``addoption('--twowords', '--two-words')``
372 results in help showing ``--two-words`` only, but ``--twowords`` gets
373 accepted **and** the automatic destination is in ``args.twowords``.
375 :param opts:
376 Option names, can be short or long options.
377 :param attrs:
378 Same attributes as the argparse library's :py:func:`add_argument()
379 <argparse.ArgumentParser.add_argument>` function accepts.
380 """
381 conflict = set(opts).intersection(
382 name for opt in self.options for name in opt.names()
383 )
384 if conflict:
385 raise ValueError("option names %s already added" % conflict)
386 option = Argument(*opts, **attrs)
387 self._addoption_instance(option, shortupper=False)
389 def _addoption(self, *opts: str, **attrs: Any) -> None:
390 option = Argument(*opts, **attrs)
391 self._addoption_instance(option, shortupper=True)
393 def _addoption_instance(self, option: "Argument", shortupper: bool = False) -> None:
394 if not shortupper:
395 for opt in option._short_opts:
396 if opt[0] == "-" and opt[1].islower():
397 raise ValueError("lowercase shortoptions reserved")
398 if self.parser:
399 self.parser.processoption(option)
400 self.options.append(option)
403class MyOptionParser(argparse.ArgumentParser):
404 def __init__(
405 self,
406 parser: Parser,
407 extra_info: Optional[Dict[str, Any]] = None,
408 prog: Optional[str] = None,
409 ) -> None:
410 self._parser = parser
411 super().__init__(
412 prog=prog,
413 usage=parser._usage,
414 add_help=False,
415 formatter_class=DropShorterLongHelpFormatter,
416 allow_abbrev=False,
417 )
418 # extra_info is a dict of (param -> value) to display if there's
419 # an usage error to provide more contextual information to the user.
420 self.extra_info = extra_info if extra_info else {}
422 def error(self, message: str) -> NoReturn:
423 """Transform argparse error message into UsageError."""
424 msg = f"{self.prog}: error: {message}"
426 if hasattr(self._parser, "_config_source_hint"):
427 # Type ignored because the attribute is set dynamically.
428 msg = f"{msg} ({self._parser._config_source_hint})" # type: ignore
430 raise UsageError(self.format_usage() + msg)
432 # Type ignored because typeshed has a very complex type in the superclass.
433 def parse_args( # type: ignore
434 self,
435 args: Optional[Sequence[str]] = None,
436 namespace: Optional[argparse.Namespace] = None,
437 ) -> argparse.Namespace:
438 """Allow splitting of positional arguments."""
439 parsed, unrecognized = self.parse_known_args(args, namespace)
440 if unrecognized:
441 for arg in unrecognized:
442 if arg and arg[0] == "-":
443 lines = ["unrecognized arguments: %s" % (" ".join(unrecognized))]
444 for k, v in sorted(self.extra_info.items()):
445 lines.append(f" {k}: {v}")
446 self.error("\n".join(lines))
447 getattr(parsed, FILE_OR_DIR).extend(unrecognized)
448 return parsed
450 if sys.version_info[:2] < (3, 9): # pragma: no cover
451 # Backport of https://github.com/python/cpython/pull/14316 so we can
452 # disable long --argument abbreviations without breaking short flags.
453 def _parse_optional(
454 self, arg_string: str
455 ) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]:
456 if not arg_string:
457 return None
458 if not arg_string[0] in self.prefix_chars:
459 return None
460 if arg_string in self._option_string_actions:
461 action = self._option_string_actions[arg_string]
462 return action, arg_string, None
463 if len(arg_string) == 1:
464 return None
465 if "=" in arg_string:
466 option_string, explicit_arg = arg_string.split("=", 1)
467 if option_string in self._option_string_actions:
468 action = self._option_string_actions[option_string]
469 return action, option_string, explicit_arg
470 if self.allow_abbrev or not arg_string.startswith("--"):
471 option_tuples = self._get_option_tuples(arg_string)
472 if len(option_tuples) > 1:
473 msg = gettext(
474 "ambiguous option: %(option)s could match %(matches)s"
475 )
476 options = ", ".join(option for _, option, _ in option_tuples)
477 self.error(msg % {"option": arg_string, "matches": options})
478 elif len(option_tuples) == 1:
479 (option_tuple,) = option_tuples
480 return option_tuple
481 if self._negative_number_matcher.match(arg_string):
482 if not self._has_negative_number_optionals:
483 return None
484 if " " in arg_string:
485 return None
486 return None, arg_string, None
489class DropShorterLongHelpFormatter(argparse.HelpFormatter):
490 """Shorten help for long options that differ only in extra hyphens.
492 - Collapse **long** options that are the same except for extra hyphens.
493 - Shortcut if there are only two options and one of them is a short one.
494 - Cache result on the action object as this is called at least 2 times.
495 """
497 def __init__(self, *args: Any, **kwargs: Any) -> None:
498 # Use more accurate terminal width.
499 if "width" not in kwargs:
500 kwargs["width"] = _pytest._io.get_terminal_width()
501 super().__init__(*args, **kwargs)
503 def _format_action_invocation(self, action: argparse.Action) -> str:
504 orgstr = super()._format_action_invocation(action)
505 if orgstr and orgstr[0] != "-": # only optional arguments
506 return orgstr
507 res: Optional[str] = getattr(action, "_formatted_action_invocation", None)
508 if res:
509 return res
510 options = orgstr.split(", ")
511 if len(options) == 2 and (len(options[0]) == 2 or len(options[1]) == 2):
512 # a shortcut for '-h, --help' or '--abc', '-a'
513 action._formatted_action_invocation = orgstr # type: ignore
514 return orgstr
515 return_list = []
516 short_long: Dict[str, str] = {}
517 for option in options:
518 if len(option) == 2 or option[2] == " ":
519 continue
520 if not option.startswith("--"):
521 raise ArgumentError(
522 'long optional argument without "--": [%s]' % (option), option
523 )
524 xxoption = option[2:]
525 shortened = xxoption.replace("-", "")
526 if shortened not in short_long or len(short_long[shortened]) < len(
527 xxoption
528 ):
529 short_long[shortened] = xxoption
530 # now short_long has been filled out to the longest with dashes
531 # **and** we keep the right option ordering from add_argument
532 for option in options:
533 if len(option) == 2 or option[2] == " ":
534 return_list.append(option)
535 if option[2:] == short_long.get(option.replace("-", "")):
536 return_list.append(option.replace(" ", "=", 1))
537 formatted_action_invocation = ", ".join(return_list)
538 action._formatted_action_invocation = formatted_action_invocation # type: ignore
539 return formatted_action_invocation
541 def _split_lines(self, text, width):
542 """Wrap lines after splitting on original newlines.
544 This allows to have explicit line breaks in the help text.
545 """
546 import textwrap
548 lines = []
549 for line in text.splitlines():
550 lines.extend(textwrap.wrap(line.strip(), width))
551 return lines