1 import argparse

2 import os

3 import sys

4 import warnings

5 from gettext import gettext

6 from typing import Any

7 from typing import Callable

8 from typing import cast

9 from typing import Dict

10 from typing import List

11 from typing import Mapping

12 from typing import NoReturn

13 from typing import Optional

14 from typing import Sequence

15 from typing import Tuple

16 from typing import TYPE_CHECKING

17 from typing import Union

18

19 import _pytest._io

20 from _pytest.compat import final

21 from _pytest.config.exceptions import UsageError

22 from _pytest.deprecated import ARGUMENT_PERCENT_DEFAULT

23 from _pytest.deprecated import ARGUMENT_TYPE_STR

24 from _pytest.deprecated import ARGUMENT_TYPE_STR_CHOICE

25 from _pytest.deprecated import check_ispytest

26

27 if TYPE_CHECKING:

28 from typing_extensions import Literal

29

30 FILE_OR_DIR = "file_or_dir"

31

32

33@final

34 class Parser:

35 """Parser for command line arguments and ini-file values.

36

37 :ivar extra_info: Dict of generic param -> value to display in case

38 there's an error processing the command line arguments.

39 """

40

41 prog: Optional[str] = None

42

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] = {}

58

59 def processoption(self, option: "Argument") -> None:

60 if self._processopt:

61 if option.dest:

62 self._processopt(option)

63

64 def getgroup(

65 self, name: str, description: str = "", after: Optional[str] = None

66 ) -> "OptionGroup":

67 """Get (or create) a named option Group.

68

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.

73

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

89

90 def addoption(self, *opts: str, **attrs: Any) -> None:

91 """Register a command line option.

92

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.

98

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)

105

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

112

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)

117

118 def _getparser(self) -> "MyOptionParser":

119 from _pytest._argcomplete import filescompleter

120

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

136

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))

147

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.

154

155 :returns: An argparse namespace object.

156 """

157 return self.parse_known_and_unknown_args(args, namespace=namespace)[0]

158

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.

166

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)

174

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.

185

186 :param name:

187 Name of the ini-variable.

188 :param type:

189 Type of the variable. Can be:

190

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

197

198 .. versionadded:: 7.0

199 The ``paths`` variable type.

200

201 Defaults to ``string`` if ``None`` or not passed.

202 :param default:

203 Default value if no ini-file option exists but is queried.

204

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)

211

212

213 class ArgumentError(Exception):

214 """Raised if an Argument instance is created with invalid or

215 inconsistent arguments."""

216

217 def __init__(self, msg: str, option: Union["Argument", str]) -> None:

218 self.msg = msg

219 self.option_id = str(option)

220

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

226

227

228 class Argument:

229 """Class that mimics the necessary behaviour of optparse.Option.

230

231 It's currently a least effort implementation and ignoring choices

232 and integer prefixes.

233

234 https://docs.python.org/3/library/optparse.html#optparse-standard-option-types

235 """

236

237 _typ_map = {"int": int, "string": str, "float": float, "complex": complex}

238

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

287

288 def names(self) -> List[str]:

289 return self._short_opts + self._long_opts

290

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

306

307 def _set_opt_strings(self, opts: Sequence[str]) -> None:

308 """Directly from optparse.

309

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)

335

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))

348

349

350 class OptionGroup:

351 """A group of options shown in its own section."""

352

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

366

367 def addoption(self, *opts: str, **attrs: Any) -> None:

368 """Add an option to this group.

369

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``.

374

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)

388

389 def _addoption(self, *opts: str, **attrs: Any) -> None:

390 option = Argument(*opts, **attrs)

391 self._addoption_instance(option, shortupper=True)

392

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)

401

402

403 class 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 {}

421

422 def error(self, message: str) -> NoReturn:

423 """Transform argparse error message into UsageError."""

424 msg = f"{self.prog}: error: {message}"

425

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

429

430 raise UsageError(self.format_usage() + msg)

431

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

449

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

487

488

489 class DropShorterLongHelpFormatter(argparse.HelpFormatter):

490 """Shorten help for long options that differ only in extra hyphens.

491

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 """

496

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)

502

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

540

541 def _split_lines(self, text, width):

542 """Wrap lines after splitting on original newlines.

543

544 This allows to have explicit line breaks in the help text.

545 """

546 import textwrap

547

548 lines = []

549 for line in text.splitlines():

550 lines.extend(textwrap.wrap(line.strip(), width))

551 return lines