argparsing.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. # mypy: allow-untyped-defs
  2. from __future__ import annotations
  3. import argparse
  4. from collections.abc import Callable
  5. from collections.abc import Mapping
  6. from collections.abc import Sequence
  7. import os
  8. import sys
  9. from typing import Any
  10. from typing import final
  11. from typing import Literal
  12. from typing import NoReturn
  13. from .exceptions import UsageError
  14. import _pytest._io
  15. from _pytest.deprecated import check_ispytest
  16. FILE_OR_DIR = "file_or_dir"
  17. class NotSet:
  18. def __repr__(self) -> str:
  19. return "<notset>"
  20. NOT_SET = NotSet()
  21. @final
  22. class Parser:
  23. """Parser for command line arguments and config-file values.
  24. :ivar extra_info: Dict of generic param -> value to display in case
  25. there's an error processing the command line arguments.
  26. """
  27. def __init__(
  28. self,
  29. usage: str | None = None,
  30. processopt: Callable[[Argument], None] | None = None,
  31. *,
  32. _ispytest: bool = False,
  33. ) -> None:
  34. check_ispytest(_ispytest)
  35. from _pytest._argcomplete import filescompleter
  36. self._processopt = processopt
  37. self.extra_info: dict[str, Any] = {}
  38. self.optparser = PytestArgumentParser(self, usage, self.extra_info)
  39. anonymous_arggroup = self.optparser.add_argument_group("Custom options")
  40. self._anonymous = OptionGroup(
  41. anonymous_arggroup, "_anonymous", self, _ispytest=True
  42. )
  43. self._groups = [self._anonymous]
  44. file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*")
  45. file_or_dir_arg.completer = filescompleter # type: ignore
  46. self._inidict: dict[str, tuple[str, str, Any]] = {}
  47. # Maps alias -> canonical name.
  48. self._ini_aliases: dict[str, str] = {}
  49. @property
  50. def prog(self) -> str:
  51. return self.optparser.prog
  52. @prog.setter
  53. def prog(self, value: str) -> None:
  54. self.optparser.prog = value
  55. def processoption(self, option: Argument) -> None:
  56. if self._processopt:
  57. if option.dest:
  58. self._processopt(option)
  59. def getgroup(
  60. self, name: str, description: str = "", after: str | None = None
  61. ) -> OptionGroup:
  62. """Get (or create) a named option Group.
  63. :param name: Name of the option group.
  64. :param description: Long description for --help output.
  65. :param after: Name of another group, used for ordering --help output.
  66. :returns: The option group.
  67. The returned group object has an ``addoption`` method with the same
  68. signature as :func:`parser.addoption <pytest.Parser.addoption>` but
  69. will be shown in the respective group in the output of
  70. ``pytest --help``.
  71. """
  72. for group in self._groups:
  73. if group.name == name:
  74. return group
  75. arggroup = self.optparser.add_argument_group(description or name)
  76. group = OptionGroup(arggroup, name, self, _ispytest=True)
  77. i = 0
  78. for i, grp in enumerate(self._groups):
  79. if grp.name == after:
  80. break
  81. self._groups.insert(i + 1, group)
  82. # argparse doesn't provide a way to control `--help` order, so must
  83. # access its internals ☹.
  84. self.optparser._action_groups.insert(i + 1, self.optparser._action_groups.pop())
  85. return group
  86. def addoption(self, *opts: str, **attrs: Any) -> None:
  87. """Register a command line option.
  88. :param opts:
  89. Option names, can be short or long options.
  90. :param attrs:
  91. Same attributes as the argparse library's :meth:`add_argument()
  92. <argparse.ArgumentParser.add_argument>` function accepts.
  93. After command line parsing, options are available on the pytest config
  94. object via ``config.option.NAME`` where ``NAME`` is usually set
  95. by passing a ``dest`` attribute, for example
  96. ``addoption("--long", dest="NAME", ...)``.
  97. """
  98. self._anonymous.addoption(*opts, **attrs)
  99. def parse(
  100. self,
  101. args: Sequence[str | os.PathLike[str]],
  102. namespace: argparse.Namespace | None = None,
  103. ) -> argparse.Namespace:
  104. """Parse the arguments.
  105. Unlike ``parse_known_args`` and ``parse_known_and_unknown_args``,
  106. raises PrintHelp on `--help` and UsageError on unknown flags
  107. :meta private:
  108. """
  109. from _pytest._argcomplete import try_argcomplete
  110. try_argcomplete(self.optparser)
  111. strargs = [os.fspath(x) for x in args]
  112. if namespace is None:
  113. namespace = argparse.Namespace()
  114. try:
  115. namespace._raise_print_help = True
  116. return self.optparser.parse_intermixed_args(strargs, namespace=namespace)
  117. finally:
  118. del namespace._raise_print_help
  119. def parse_known_args(
  120. self,
  121. args: Sequence[str | os.PathLike[str]],
  122. namespace: argparse.Namespace | None = None,
  123. ) -> argparse.Namespace:
  124. """Parse the known arguments at this point.
  125. :returns: An argparse namespace object.
  126. """
  127. return self.parse_known_and_unknown_args(args, namespace=namespace)[0]
  128. def parse_known_and_unknown_args(
  129. self,
  130. args: Sequence[str | os.PathLike[str]],
  131. namespace: argparse.Namespace | None = None,
  132. ) -> tuple[argparse.Namespace, list[str]]:
  133. """Parse the known arguments at this point, and also return the
  134. remaining unknown flag arguments.
  135. :returns:
  136. A tuple containing an argparse namespace object for the known
  137. arguments, and a list of unknown flag arguments.
  138. """
  139. strargs = [os.fspath(x) for x in args]
  140. if sys.version_info < (3, 12, 8) or (3, 13) <= sys.version_info < (3, 13, 1):
  141. # Older argparse have a bugged parse_known_intermixed_args.
  142. namespace, unknown = self.optparser.parse_known_args(strargs, namespace)
  143. assert namespace is not None
  144. file_or_dir = getattr(namespace, FILE_OR_DIR)
  145. unknown_flags: list[str] = []
  146. for arg in unknown:
  147. (unknown_flags if arg.startswith("-") else file_or_dir).append(arg)
  148. return namespace, unknown_flags
  149. else:
  150. return self.optparser.parse_known_intermixed_args(strargs, namespace)
  151. def addini(
  152. self,
  153. name: str,
  154. help: str,
  155. type: Literal[
  156. "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
  157. ]
  158. | None = None,
  159. default: Any = NOT_SET,
  160. *,
  161. aliases: Sequence[str] = (),
  162. ) -> None:
  163. """Register a configuration file option.
  164. :param name:
  165. Name of the configuration.
  166. :param type:
  167. Type of the configuration. Can be:
  168. * ``string``: a string
  169. * ``bool``: a boolean
  170. * ``args``: a list of strings, separated as in a shell
  171. * ``linelist``: a list of strings, separated by line breaks
  172. * ``paths``: a list of :class:`pathlib.Path`, separated as in a shell
  173. * ``pathlist``: a list of ``py.path``, separated as in a shell
  174. * ``int``: an integer
  175. * ``float``: a floating-point number
  176. .. versionadded:: 8.4
  177. The ``float`` and ``int`` types.
  178. For ``paths`` and ``pathlist`` types, they are considered relative to the config-file.
  179. In case the execution is happening without a config-file defined,
  180. they will be considered relative to the current working directory (for example with ``--override-ini``).
  181. .. versionadded:: 7.0
  182. The ``paths`` variable type.
  183. .. versionadded:: 8.1
  184. Use the current working directory to resolve ``paths`` and ``pathlist`` in the absence of a config-file.
  185. Defaults to ``string`` if ``None`` or not passed.
  186. :param default:
  187. Default value if no config-file option exists but is queried.
  188. :param aliases:
  189. Additional names by which this option can be referenced.
  190. Aliases resolve to the canonical name.
  191. .. versionadded:: 9.0
  192. The ``aliases`` parameter.
  193. The value of configuration keys can be retrieved via a call to
  194. :py:func:`config.getini(name) <pytest.Config.getini>`.
  195. """
  196. assert type in (
  197. None,
  198. "string",
  199. "paths",
  200. "pathlist",
  201. "args",
  202. "linelist",
  203. "bool",
  204. "int",
  205. "float",
  206. )
  207. if type is None:
  208. type = "string"
  209. if default is NOT_SET:
  210. default = get_ini_default_for_type(type)
  211. self._inidict[name] = (help, type, default)
  212. for alias in aliases:
  213. if alias in self._inidict:
  214. raise ValueError(
  215. f"alias {alias!r} conflicts with existing configuration option"
  216. )
  217. if (already := self._ini_aliases.get(alias)) is not None:
  218. raise ValueError(f"{alias!r} is already an alias of {already!r}")
  219. self._ini_aliases[alias] = name
  220. def get_ini_default_for_type(
  221. type: Literal[
  222. "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
  223. ],
  224. ) -> Any:
  225. """
  226. Used by addini to get the default value for a given config option type, when
  227. default is not supplied.
  228. """
  229. if type in ("paths", "pathlist", "args", "linelist"):
  230. return []
  231. elif type == "bool":
  232. return False
  233. elif type == "int":
  234. return 0
  235. elif type == "float":
  236. return 0.0
  237. else:
  238. return ""
  239. class ArgumentError(Exception):
  240. """Raised if an Argument instance is created with invalid or
  241. inconsistent arguments."""
  242. def __init__(self, msg: str, option: Argument | str) -> None:
  243. self.msg = msg
  244. self.option_id = str(option)
  245. def __str__(self) -> str:
  246. if self.option_id:
  247. return f"option {self.option_id}: {self.msg}"
  248. else:
  249. return self.msg
  250. class Argument:
  251. """Class that mimics the necessary behaviour of optparse.Option.
  252. It's currently a least effort implementation and ignoring choices
  253. and integer prefixes.
  254. https://docs.python.org/3/library/optparse.html#optparse-standard-option-types
  255. """
  256. def __init__(self, *names: str, **attrs: Any) -> None:
  257. """Store params in private vars for use in add_argument."""
  258. self._attrs = attrs
  259. self._short_opts: list[str] = []
  260. self._long_opts: list[str] = []
  261. try:
  262. self.type = attrs["type"]
  263. except KeyError:
  264. pass
  265. try:
  266. # Attribute existence is tested in Config._processopt.
  267. self.default = attrs["default"]
  268. except KeyError:
  269. pass
  270. self._set_opt_strings(names)
  271. dest: str | None = attrs.get("dest")
  272. if dest:
  273. self.dest = dest
  274. elif self._long_opts:
  275. self.dest = self._long_opts[0][2:].replace("-", "_")
  276. else:
  277. try:
  278. self.dest = self._short_opts[0][1:]
  279. except IndexError as e:
  280. self.dest = "???" # Needed for the error repr.
  281. raise ArgumentError("need a long or short option", self) from e
  282. def names(self) -> list[str]:
  283. return self._short_opts + self._long_opts
  284. def attrs(self) -> Mapping[str, Any]:
  285. # Update any attributes set by processopt.
  286. for attr in ("default", "dest", "help", self.dest):
  287. try:
  288. self._attrs[attr] = getattr(self, attr)
  289. except AttributeError:
  290. pass
  291. return self._attrs
  292. def _set_opt_strings(self, opts: Sequence[str]) -> None:
  293. """Directly from optparse.
  294. Might not be necessary as this is passed to argparse later on.
  295. """
  296. for opt in opts:
  297. if len(opt) < 2:
  298. raise ArgumentError(
  299. f"invalid option string {opt!r}: "
  300. "must be at least two characters long",
  301. self,
  302. )
  303. elif len(opt) == 2:
  304. if not (opt[0] == "-" and opt[1] != "-"):
  305. raise ArgumentError(
  306. f"invalid short option string {opt!r}: "
  307. "must be of the form -x, (x any non-dash char)",
  308. self,
  309. )
  310. self._short_opts.append(opt)
  311. else:
  312. if not (opt[0:2] == "--" and opt[2] != "-"):
  313. raise ArgumentError(
  314. f"invalid long option string {opt!r}: "
  315. "must start with --, followed by non-dash",
  316. self,
  317. )
  318. self._long_opts.append(opt)
  319. def __repr__(self) -> str:
  320. args: list[str] = []
  321. if self._short_opts:
  322. args += ["_short_opts: " + repr(self._short_opts)]
  323. if self._long_opts:
  324. args += ["_long_opts: " + repr(self._long_opts)]
  325. args += ["dest: " + repr(self.dest)]
  326. if hasattr(self, "type"):
  327. args += ["type: " + repr(self.type)]
  328. if hasattr(self, "default"):
  329. args += ["default: " + repr(self.default)]
  330. return "Argument({})".format(", ".join(args))
  331. class OptionGroup:
  332. """A group of options shown in its own section."""
  333. def __init__(
  334. self,
  335. arggroup: argparse._ArgumentGroup,
  336. name: str,
  337. parser: Parser | None,
  338. _ispytest: bool = False,
  339. ) -> None:
  340. check_ispytest(_ispytest)
  341. self._arggroup = arggroup
  342. self.name = name
  343. self.options: list[Argument] = []
  344. self.parser = parser
  345. def addoption(self, *opts: str, **attrs: Any) -> None:
  346. """Add an option to this group.
  347. If a shortened version of a long option is specified, it will
  348. be suppressed in the help. ``addoption('--twowords', '--two-words')``
  349. results in help showing ``--two-words`` only, but ``--twowords`` gets
  350. accepted **and** the automatic destination is in ``args.twowords``.
  351. :param opts:
  352. Option names, can be short or long options.
  353. :param attrs:
  354. Same attributes as the argparse library's :meth:`add_argument()
  355. <argparse.ArgumentParser.add_argument>` function accepts.
  356. """
  357. conflict = set(opts).intersection(
  358. name for opt in self.options for name in opt.names()
  359. )
  360. if conflict:
  361. raise ValueError(f"option names {conflict} already added")
  362. option = Argument(*opts, **attrs)
  363. self._addoption_instance(option, shortupper=False)
  364. def _addoption(self, *opts: str, **attrs: Any) -> None:
  365. option = Argument(*opts, **attrs)
  366. self._addoption_instance(option, shortupper=True)
  367. def _addoption_instance(self, option: Argument, shortupper: bool = False) -> None:
  368. if not shortupper:
  369. for opt in option._short_opts:
  370. if opt[0] == "-" and opt[1].islower():
  371. raise ValueError("lowercase shortoptions reserved")
  372. if self.parser:
  373. self.parser.processoption(option)
  374. self._arggroup.add_argument(*option.names(), **option.attrs())
  375. self.options.append(option)
  376. class PytestArgumentParser(argparse.ArgumentParser):
  377. def __init__(
  378. self,
  379. parser: Parser,
  380. usage: str | None,
  381. extra_info: dict[str, str],
  382. ) -> None:
  383. self._parser = parser
  384. super().__init__(
  385. usage=usage,
  386. add_help=False,
  387. formatter_class=DropShorterLongHelpFormatter,
  388. allow_abbrev=False,
  389. fromfile_prefix_chars="@",
  390. )
  391. # extra_info is a dict of (param -> value) to display if there's
  392. # an usage error to provide more contextual information to the user.
  393. self.extra_info = extra_info
  394. def error(self, message: str) -> NoReturn:
  395. """Transform argparse error message into UsageError."""
  396. msg = f"{self.prog}: error: {message}"
  397. if self.extra_info:
  398. msg += "\n" + "\n".join(
  399. f" {k}: {v}" for k, v in sorted(self.extra_info.items())
  400. )
  401. raise UsageError(self.format_usage() + msg)
  402. class DropShorterLongHelpFormatter(argparse.HelpFormatter):
  403. """Shorten help for long options that differ only in extra hyphens.
  404. - Collapse **long** options that are the same except for extra hyphens.
  405. - Shortcut if there are only two options and one of them is a short one.
  406. - Cache result on the action object as this is called at least 2 times.
  407. """
  408. def __init__(self, *args: Any, **kwargs: Any) -> None:
  409. # Use more accurate terminal width.
  410. if "width" not in kwargs:
  411. kwargs["width"] = _pytest._io.get_terminal_width()
  412. super().__init__(*args, **kwargs)
  413. def _format_action_invocation(self, action: argparse.Action) -> str:
  414. orgstr = super()._format_action_invocation(action)
  415. if orgstr and orgstr[0] != "-": # only optional arguments
  416. return orgstr
  417. res: str | None = getattr(action, "_formatted_action_invocation", None)
  418. if res:
  419. return res
  420. options = orgstr.split(", ")
  421. if len(options) == 2 and (len(options[0]) == 2 or len(options[1]) == 2):
  422. # a shortcut for '-h, --help' or '--abc', '-a'
  423. action._formatted_action_invocation = orgstr # type: ignore
  424. return orgstr
  425. return_list = []
  426. short_long: dict[str, str] = {}
  427. for option in options:
  428. if len(option) == 2 or option[2] == " ":
  429. continue
  430. if not option.startswith("--"):
  431. raise ArgumentError(
  432. f'long optional argument without "--": [{option}]', option
  433. )
  434. xxoption = option[2:]
  435. shortened = xxoption.replace("-", "")
  436. if shortened not in short_long or len(short_long[shortened]) < len(
  437. xxoption
  438. ):
  439. short_long[shortened] = xxoption
  440. # now short_long has been filled out to the longest with dashes
  441. # **and** we keep the right option ordering from add_argument
  442. for option in options:
  443. if len(option) == 2 or option[2] == " ":
  444. return_list.append(option)
  445. if option[2:] == short_long.get(option.replace("-", "")):
  446. return_list.append(option.replace(" ", "=", 1))
  447. formatted_action_invocation = ", ".join(return_list)
  448. action._formatted_action_invocation = formatted_action_invocation # type: ignore
  449. return formatted_action_invocation
  450. def _split_lines(self, text, width):
  451. """Wrap lines after splitting on original newlines.
  452. This allows to have explicit line breaks in the help text.
  453. """
  454. import textwrap
  455. lines = []
  456. for line in text.splitlines():
  457. lines.extend(textwrap.wrap(line.strip(), width))
  458. return lines
  459. class OverrideIniAction(argparse.Action):
  460. """Custom argparse action that makes a CLI flag equivalent to overriding an
  461. option, in addition to behaving like `store_true`.
  462. This can simplify things since code only needs to inspect the config option
  463. and not consider the CLI flag.
  464. """
  465. def __init__(
  466. self,
  467. option_strings: Sequence[str],
  468. dest: str,
  469. nargs: int | str | None = None,
  470. *args,
  471. ini_option: str,
  472. ini_value: str,
  473. **kwargs,
  474. ) -> None:
  475. super().__init__(option_strings, dest, 0, *args, **kwargs)
  476. self.ini_option = ini_option
  477. self.ini_value = ini_value
  478. def __call__(
  479. self,
  480. parser: argparse.ArgumentParser,
  481. namespace: argparse.Namespace,
  482. *args,
  483. **kwargs,
  484. ) -> None:
  485. setattr(namespace, self.dest, True)
  486. current_overrides = getattr(namespace, "override_ini", None)
  487. if current_overrides is None:
  488. current_overrides = []
  489. current_overrides.append(f"{self.ini_option}={self.ini_value}")
  490. setattr(namespace, "override_ini", current_overrides)