Coverage for arguably/_context.py: 89%

352 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-10 01:01 +0000

1from __future__ import annotations 

2 

3import argparse 

4import enum 

5import inspect 

6from contextlib import contextmanager 

7from dataclasses import dataclass 

8from typing import Any, TextIO, Union, Optional, List, Dict, Type, Tuple, Callable, Iterator, cast 

9 

10from ._argparse_extensions import HelpFormatter, FlagAction, ArgumentParser 

11from ._commands import CommandDecoratorInfo, SubtypeDecoratorInfo, Command, CommandArg, InputMethod 

12from ._modifiers import TupleModifier, ListModifier 

13from ._util import ( 

14 logger, 

15 log_args, 

16 ArguablyException, 

17 normalize_name, 

18 NoDefault, 

19 get_type_hints, 

20 info_for_flags, 

21 get_ancestors, 

22 get_parser_name, 

23 warn, 

24 func_or_class_info, 

25) 

26 

27 

28@dataclass 

29class _ContextOptions: 

30 name: Optional[str] 

31 

32 # Behavior options 

33 always_subcommand: bool 

34 version_flag: Union[bool, Tuple[str], Tuple[str, str]] 

35 strict: bool 

36 

37 # Formatting options 

38 show_defaults: bool 

39 show_types: bool 

40 command_metavar: str 

41 max_description_offset: int 

42 max_width: int 

43 output: Optional[TextIO] 

44 

45 def __post_init__(self) -> None: 

46 # When running as a module, show the script name as the module path. 

47 # Otherwise, use default argparse behavior 

48 if self.name is None: 

49 try: 

50 import importlib.util 

51 

52 self.name = importlib.util.find_spec("__main__").name # type: ignore[union-attr] 

53 except (ValueError, AttributeError): 

54 self.name = None 

55 

56 def get_version_flags(self) -> Union[Tuple[()], Tuple[str], Tuple[str, str]]: 

57 if self.version_flag is False: 

58 return cast(Tuple[()], tuple()) 

59 elif self.version_flag is True: 

60 return ("--version",) 

61 else: 

62 return self.version_flag 

63 

64 

65class _Context: 

66 """Singleton, used for storing arguably state.""" 

67 

68 def __init__(self) -> None: 

69 # These are `None` right now, they're set during `run()`. No methods making use of them are called before then. 

70 self._options: _ContextOptions = None # type: ignore[assignment] 

71 self._extra_argparser_options: Dict[str, Any] = None # type: ignore[assignment] 

72 

73 # Info for all invocations of `@arguably.command` 

74 self._command_decorator_info: List[CommandDecoratorInfo] = list() 

75 

76 # Info for all invocations of `@arguably.subtype` 

77 self._subtype_init_info: List[SubtypeDecoratorInfo] = list() 

78 

79 # Stores mapping from normalized names for an enum type to an enum value 

80 self._enum_mapping: Dict[Type[enum.Enum], Dict[str, enum.Enum]] = dict() 

81 

82 # Stores which flag arguments have had their default value cleared 

83 self._enum_flag_default_cleared: set[Tuple[argparse.ArgumentParser, str]] = set() 

84 

85 # Are we currently calling the targeted command (or just an ancestor?) 

86 self._is_calling_target = True 

87 

88 # Used for handling `error()`, keeps a reference to the parser for the current command 

89 self._current_parser: Optional[argparse.ArgumentParser] = None 

90 

91 # These are really only set and used in the run() method 

92 self._commands: Dict[str, Command] = dict() 

93 self._command_aliases: Dict[str, str] = dict() 

94 self._parsers: Dict[str, argparse.ArgumentParser] = dict() 

95 self._subparsers: Dict[str, Any] = dict() 

96 

97 def reset(self) -> None: 

98 self.__dict__.clear() 

99 self.__init__() # type: ignore[misc] 

100 

101 def add_command(self, **kwargs: Any) -> None: 

102 """Invoked by `@arguably.command`, saves info about a command to include when the parser is set up.""" 

103 info = CommandDecoratorInfo(**kwargs) 

104 self._command_decorator_info.append(info) 

105 

106 def add_subtype(self, **kwargs: Any) -> None: 

107 """Invoked by `@arguably.subtype`, saves info about a how to construct a type.""" 

108 type_ = SubtypeDecoratorInfo(**kwargs) 

109 self._subtype_init_info.append(type_) 

110 

111 def find_subtype(self, func_arg_type: type) -> List[SubtypeDecoratorInfo]: 

112 return [bi for bi in self._subtype_init_info if issubclass(bi.type_, func_arg_type)] 

113 

114 def is_target(self) -> bool: 

115 """ 

116 Only useful if `invoke_ancestors=True`. Returns `True` if the targeted command is being executed and `False` if 

117 not. This is safe to call even if `arguably` is not being used, since it returns `True` if `arguably.run()` is 

118 not being used. 

119 

120 Returns: 

121 `False` if `arguably.run()` was called and the currently running command is not the targeted command, `True` 

122 in every other case. 

123 

124 Examples: 

125 ```python 

126 import arguably 

127 

128 @arguably.command 

129 def __root__(*, config_file=None): 

130 print(f"Using config {config_file}") 

131 if not arguably.is_target(): 

132 return 

133 print("__root__ is the target!") 

134 

135 @arguably.command 

136 def hi(): 

137 print("hi is the target!") 

138 

139 @arguably.command 

140 def bye(): 

141 print("bye is the target!") 

142 

143 if __name__ == "__main__": 

144 arguably.run() 

145 ``` 

146 

147 ```console 

148 user@machine:~$ python3 is_target.py --config-file foo.yml 

149 Using config foo.yml 

150 __root__ is the target! 

151 ``` 

152 

153 ```console 

154 user@machine:~$ python3 is_target.py --config-file foo.yml hi 

155 Using config foo.yml 

156 hi is the target! 

157 ``` 

158 """ 

159 return self._is_calling_target 

160 

161 def check_and_set_enum_flag_default_status(self, parser: argparse.ArgumentParser, cli_arg_name: str) -> bool: 

162 key = (parser, cli_arg_name) 

163 present = key in self._enum_flag_default_cleared 

164 self._enum_flag_default_cleared.add(key) 

165 return present 

166 

167 def _formatter(self, prog: str) -> HelpFormatter: 

168 """HelpFormatter for argparse, hooks up our max_name_width and max_width options.""" 

169 return HelpFormatter( 

170 prog, max_help_position=self._options.max_description_offset, width=self._options.max_width 

171 ) 

172 

173 def set_up_enum( 

174 self, enum_type: Type[enum.Enum], members: Optional[List[enum.Enum]] = None 

175 ) -> Dict[str, enum.Enum]: 

176 if enum_type not in self._enum_mapping: 

177 enum_name_dict: Dict[str, enum.Enum] = dict() 

178 self._enum_mapping[enum_type] = enum_name_dict 

179 

180 for enum_item in enum_type: 

181 if members is not None and enum_item not in members: 

182 continue 

183 enum_name = normalize_name(enum_item.name, spaces=False) 

184 if enum_name in enum_name_dict: 

185 raise ArguablyException( 

186 f"Normalized name {enum_name} already taken for enum {enum_type.__name__} by " 

187 f"{enum_name_dict[enum_name]}" 

188 ) 

189 enum_name_dict[enum_name] = enum_item 

190 

191 return self._enum_mapping[enum_type] 

192 

193 def get_enum_mapping(self, enum_type: Type[enum.Enum]) -> Dict[str, enum.Enum]: 

194 assert enum_type in self._enum_mapping 

195 return self._enum_mapping[enum_type] 

196 

197 def _validate_args(self, cmd: Command, is_root_cmd: bool) -> None: 

198 """Validates all arguments that will be added to the parser for a given command""" 

199 

200 for arg_ in cmd.args: 

201 arg_aliases = arg_.get_options() 

202 

203 # Validate no conflict with `--version` flag (or whatever it was set to) 

204 if is_root_cmd: 

205 version_flags = self._options.get_version_flags() 

206 conflicts = [opt for opt in arg_aliases if opt in version_flags] 

207 if len(conflicts) > 0: 

208 raise ArguablyException( 

209 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` conflicts with version flag." 

210 f"Conflicting items: {', '.join(conflicts)}" 

211 ) 

212 

213 # Validate no conflicts with `-h/--help` 

214 if cmd.add_help: 

215 help_flags = ("-h", "--help") 

216 conflicts = [opt for opt in arg_aliases if opt in help_flags] 

217 if len(conflicts) > 0: 

218 raise ArguablyException( 

219 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` conflicts with help flag." 

220 f"Conflicting items: {', '.join(conflicts)}" 

221 ) 

222 

223 # Validate positional arg names 

224 if arg_.input_method.is_positional: 

225 if arg_.func_arg_name == self._options.command_metavar: 

226 raise ArguablyException( 

227 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is named the same as " 

228 f"`command_metavar`. Either change the parameter name or set the `command_metavar` option to " 

229 f"something other than `{arg_.func_arg_name}` when calling arguably.run()" 

230 ) 

231 

232 # Validate `enum.Flag` 

233 if issubclass(arg_.arg_value_type, enum.Flag): 

234 if arg_.input_method.is_positional: 

235 raise ArguablyException( 

236 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is both positional and an enum.Flag." 

237 f" Positional enum flags are unsupported, since they are turned into options." 

238 ) 

239 if arg_.default is NoDefault: 

240 raise ArguablyException( 

241 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is an enum.Flag. Due to " 

242 f"implementation limitations, all enum.Flag parameters must have a default value." 

243 ) 

244 

245 # Validate `bool` 

246 if issubclass(arg_.arg_value_type, bool): 

247 if arg_.input_method is not InputMethod.OPTION or arg_.default is NoDefault: 

248 raise ArguablyException( 

249 f"Function argument `{arg_.func_arg_name}` in `{cmd.name}` is a `bool`. Boolean parameters " 

250 f"must have a default value and be an optional, not a positional, argument." 

251 ) 

252 

253 def _set_up_args(self, cmd: Command) -> None: 

254 """Adds all arguments to the parser for a given command""" 

255 

256 parser = self._parsers[cmd.name] 

257 

258 for arg_ in cmd.args: 

259 # Short-circuit, different path for enum.Flag. We add multiple options, one for each flag entry 

260 if issubclass(arg_.arg_value_type, enum.Flag): 

261 parser.set_defaults(**{arg_.cli_arg_name: arg_.default}) 

262 for entry in info_for_flags(arg_.cli_arg_name, arg_.arg_value_type): 

263 argspec = log_args( 

264 logger.debug, 

265 f"Parser({repr(get_parser_name(parser.prog))}).", 

266 parser.add_argument.__name__, 

267 # Args for the call are below: 

268 *entry.option, 

269 action=FlagAction, 

270 const=entry, 

271 nargs=0, 

272 help=entry.description, 

273 ) 

274 parser.add_argument(*argspec.args, **argspec.kwargs) 

275 continue 

276 

277 # Optional kwargs for parser.add_argument 

278 add_arg_kwargs: Dict[str, Any] = dict(type=arg_.arg_value_type, action="store") 

279 

280 arg_description = arg_.description 

281 description_extras = [] 

282 

283 # Show arg type? 

284 if self._options.show_types: 

285 list_modifiers = [m for m in arg_.modifiers if isinstance(m, ListModifier)] 

286 tuple_modifiers = [m for m in arg_.modifiers if isinstance(m, TupleModifier)] 

287 if len(tuple_modifiers) > 0: 

288 assert len(tuple_modifiers) == 1 

289 type_name = f"({','.join(t.__name__ for t in tuple_modifiers[0].tuple_arg)})" 

290 else: 

291 type_name = arg_.arg_value_type.__name__ 

292 if len(list_modifiers) > 0: 

293 assert len(list_modifiers) == 1 

294 type_name = f"list[{type_name}]" 

295 description_extras.append(f"type: {type_name}") 

296 

297 # `default` value? 

298 if arg_.input_method.is_optional and arg_.default is not NoDefault: 

299 add_arg_kwargs.update(default=arg_.default) 

300 if self._options.show_defaults: 

301 if isinstance(arg_.default, enum.Enum): 

302 description_extras.append(f"default: {normalize_name(arg_.default.name, spaces=False)}") 

303 elif isinstance(arg_.default, str): 

304 str_default = arg_.default 

305 # Use the string repr if it contains spaces, contains a newline, or is zero-length 

306 if (" " in str_default) or ("\n" in str_default) or (len(str_default) == 0): 

307 str_default = repr(str_default) 

308 description_extras.append(f"default: {str_default}") 

309 else: 

310 description_extras.append(f"default: {arg_.default}") 

311 

312 # Number of arguments `nargs`? 

313 if arg_.is_variadic: 

314 add_arg_kwargs.update(nargs="*", default=list()) 

315 elif arg_.input_method is InputMethod.OPTIONAL_POSITIONAL: 

316 add_arg_kwargs.update(nargs="?") 

317 

318 # Any specified `metavar`s? 

319 if arg_.metavars is not None: 

320 if len(arg_.metavars) == 1: 

321 add_arg_kwargs.update(metavar=arg_.metavars[0]) 

322 else: 

323 add_arg_kwargs.update(metavar=tuple(arg_.metavars)) 

324 

325 # Possible choices `choices`? 

326 if issubclass(arg_.arg_value_type, enum.Enum): 

327 mapping = self.set_up_enum(arg_.arg_value_type) 

328 add_arg_kwargs.update(choices=[n for n in mapping]) 

329 

330 name_spec: Tuple[str, ...] = (arg_.func_arg_name,) 

331 

332 # Special handling for optional arguments 

333 if arg_.input_method is InputMethod.OPTION: 

334 name_spec = arg_.get_options() 

335 add_arg_kwargs.update(dest=arg_.func_arg_name) 

336 

337 # `bool` should be flags 

338 if issubclass(arg_.arg_value_type, bool): 

339 # Use `store_true` or `store_false` for bools 

340 add_arg_kwargs.update(action="store_true" if arg_.default is False else "store_false") 

341 if "type" in add_arg_kwargs: 

342 del add_arg_kwargs["type"] 

343 

344 # Set the help description 

345 if len(description_extras) > 0: 

346 if len(arg_description) > 0: 

347 arg_description += " " 

348 arg_description += f"({', '.join(description_extras)})" 

349 add_arg_kwargs.update(help=arg_description) 

350 

351 # Run modifiers for this arg 

352 for modifier in arg_.modifiers: 

353 modifier.modify_arg_dict(cmd, arg_, add_arg_kwargs) 

354 

355 if ( 

356 "choices" not in add_arg_kwargs 

357 and "metavar" not in add_arg_kwargs 

358 and add_arg_kwargs["action"] not in ["store_true", "store_false", "count", "help", "version"] 

359 ): 

360 if arg_.input_method is InputMethod.OPTION: 

361 add_arg_kwargs.update(metavar=arg_.cli_arg_name.upper()) 

362 else: 

363 add_arg_kwargs.update(metavar=arg_.cli_arg_name) 

364 

365 # Add the argument to the parser 

366 argspec = log_args( 

367 logger.debug, 

368 f"Parser({repr(get_parser_name(parser.prog))}).", 

369 parser.add_argument.__name__, 

370 # Args for the call are below: 

371 *name_spec, 

372 **add_arg_kwargs, 

373 ) 

374 parser.add_argument(*argspec.args, **argspec.kwargs) 

375 

376 def _build_subparser_tree(self, command_decorator_info: CommandDecoratorInfo) -> str: 

377 """Builds up the subparser tree for a given `_CommandDecoratorInfo`. Inserts dummy entries to `self._parsers` 

378 and `self._commands` if necessary. Returns the name of the parent for this command.""" 

379 

380 prev_ancestor = "__root__" 

381 

382 # Create tree of parsers and subparsers for ancestors 

383 ancestor_names = get_ancestors(command_decorator_info.name) 

384 for ancestor in ancestor_names: 

385 required_subparser = False 

386 if ancestor not in self._commands: 

387 # Dummy command - this ancestor doesn't have a function of its own, it's just a path. 

388 self._commands[ancestor] = Command(lambda *_, **__: None, ancestor, []) 

389 if ancestor not in self._parsers: 

390 # Dummy parser - since there's nothing to run, require the subparser. 

391 required_subparser = True 

392 argspec = log_args( 

393 logger.debug, 

394 f"Subparsers({repr(prev_ancestor)}).", 

395 self._subparsers[prev_ancestor].add_parser.__name__, 

396 # Args for the call are below: 

397 ancestor.split(" ")[-1], 

398 help="", 

399 formatter_class=self._formatter, 

400 **self._extra_argparser_options, 

401 ) 

402 self._parsers[ancestor] = self._subparsers[prev_ancestor].add_parser(*argspec.args, **argspec.kwargs) 

403 if ancestor not in self._subparsers: 

404 # Add subparser to the parent command's parser. 

405 ancestor_cmd = self._commands[ancestor] 

406 if any(arg.input_method.is_positional for arg in ancestor_cmd.args): 

407 raise ArguablyException( 

408 f"Command `{ancestor}` cannot have both subcommands and positional arguments." 

409 ) 

410 argspec = log_args( 

411 logger.debug, 

412 f"Parser({repr(ancestor)}).", 

413 self._parsers[ancestor].add_subparsers.__name__, 

414 # Args for the call are below: 

415 parser_class=ArgumentParser, 

416 dest=ancestor_cmd.get_subcommand_metavar(self._options.command_metavar), 

417 metavar=self._options.command_metavar, 

418 required=required_subparser, 

419 ) 

420 self._subparsers[ancestor] = self._parsers[ancestor].add_subparsers(*argspec.args, **argspec.kwargs) 

421 prev_ancestor = ancestor 

422 return prev_ancestor 

423 

424 @contextmanager 

425 def current_parser(self, parser: argparse.ArgumentParser) -> Iterator[None]: 

426 last_parser = self._current_parser 

427 self._current_parser = parser 

428 try: 

429 yield 

430 finally: 

431 self._current_parser = last_parser 

432 

433 def error(self, message: str) -> None: 

434 """ 

435 Prints an error message and exits. Should be used when a CLI input is not of the correct form. `arguably` 

436 handles converting values to the correct type, but if extra validation is performed and fails, you should call 

437 this. 

438 

439 Args: 

440 message: A message to be printed to the console indicating why the input is wrong. 

441 

442 Raises: 

443 SystemExit: The script will exit. 

444 

445 Examples: 

446 ```python 

447 #!/usr/bin/env python3 

448 import arguably 

449 

450 @arguably.command 

451 def high_five(*people): 

452 if len(people) > 5: 

453 arguably.error("Too many people to high-five!") 

454 for person in people: 

455 print(f"High five, {person}!") 

456 

457 if __name__ == "__main__": 

458 arguably.run() 

459 ``` 

460 

461 ```console 

462 user@machine:~$ python3 error.py Graham John Terry Eric Terry Michael 

463 usage: error.py [-h] [people ...] 

464 error.py: error: Too many people to high-five! 

465 ``` 

466 """ 

467 if self._current_parser is None: 

468 raise ArguablyException("Unknown current parser.") 

469 self._current_parser.error(message) # This will exit the script 

470 

471 def _soft_failure(self, msg: str, function: Optional[Callable] = None) -> None: 

472 if self._options.strict: 

473 if function is not None: 

474 info = func_or_class_info(function) 

475 if info is not None: 

476 source_file, source_file_line = info 

477 msg = f"({source_file}:{source_file_line}) {function.__name__}: {msg}" 

478 raise ArguablyException(msg) 

479 else: 

480 warn(msg, function) 

481 

482 def run( 

483 self, 

484 name: Optional[str] = None, 

485 always_subcommand: bool = False, 

486 version_flag: Union[bool, Tuple[str], Tuple[str, str]] = False, 

487 strict: bool = True, 

488 show_defaults: bool = True, 

489 show_types: bool = True, 

490 max_description_offset: int = 60, 

491 max_width: int = 120, 

492 command_metavar: str = "command", 

493 output: Optional[TextIO] = None, 

494 ) -> Any: 

495 """ 

496 Set up the argument parser, parse argv, and run the appropriate command(s) 

497 

498 Args: 

499 name: Name of the script/program. Defaults to the filename or module name, depending on how the script is 

500 run. `$ python3 my/script.py` yields `script.py`, and `python3 -m my.script` yeilds `script`. 

501 always_subcommand: If true, will force a subcommand interface to be used, even if there's only one command. 

502 version_flag: If true, adds an option to show the script version using the value of `__version__` in the 

503 invoked script. If a tuple of one or two strings is passed in, like `("-V", "--ver")`, those are used 

504 instead of the default `--version`. 

505 strict: Will prevent the script from running if there are any `ArguablyException`s raised during CLI 

506 initialization. 

507 show_defaults: Show the default value (if any) for each argument at the end of its help string. 

508 show_types: Show the type of each argument at the end of its help string. 

509 max_description_offset: The maximum number of columns before argument descriptions are printed. Equivalent 

510 to `max_help_position` in argparse. 

511 max_width: The total maximum width of text to be displayed in the terminal. Equivalent to `width` in 

512 argparse. 

513 command_metavar: The name shown in the usage string for taking in a subcommand. Change this if you have a 

514 conflicting argument name. 

515 output: Where argparse output should be written - can write to a file, stderr, or anything similar. 

516 

517 Returns: 

518 The return value from the called function. 

519 

520 Examples: 

521 ```python 

522 #!/usr/bin/env python3 

523 \"\"\"description for this script\"\"\" 

524 from io import StringIO 

525 

526 import arguably 

527 

528 __version__ = "1.2.3" 

529 

530 @arguably.command 

531 def example(): ... 

532 

533 if __name__ == "__main__": 

534 output = StringIO() 

535 try: 

536 arguably.run( 

537 name="myname", 

538 always_subcommand=True, 

539 version_flag=True, 

540 command_metavar="mycmd", 

541 output=output 

542 ) 

543 finally: 

544 print(f"Captured output length: {len(output.getvalue())=}") 

545 print() 

546 print(output.getvalue(), end="") 

547 ``` 

548 

549 ```console 

550 user@machine:~$ python3 run.py -h 

551 Captured output length: len(output.getvalue())=222 

552 

553 usage: myname [-h] [--version] mycmd ... 

554 

555 description for this script 

556 

557 positional arguments: 

558 mycmd 

559 example 

560 

561 options: 

562 -h, --help show this help message and exit 

563 --version show program's version number and exit 

564 ``` 

565 

566 ```console 

567 user@machine:~$ python3 run.py --version 

568 Captured output length: len(output.getvalue())=13 

569 

570 myname 1.2.3 

571 ``` 

572 """ 

573 

574 # Set options 

575 self._options = _ContextOptions(**{k: v for k, v in locals().items() if k != "self"}) 

576 self._extra_argparser_options = dict(output=self._options.output) 

577 

578 self._is_calling_target = False 

579 

580 only_one_cmd = (len(self._command_decorator_info) == 1) and not self._options.always_subcommand 

581 

582 # Grab the description 

583 import __main__ 

584 

585 description = "" if __main__.__doc__ is None else __main__.__doc__.partition("\n\n\n")[0] 

586 

587 # TODO: Rewrite this code to remove the need for this line 

588 add_root_help = next( 

589 iter(info.help for info in self._command_decorator_info if info.name == "__root__" or only_one_cmd), True 

590 ) 

591 

592 # Set up the root parser 

593 argspec = log_args( 

594 logger.debug, 

595 f"Initializing {repr('__root__')} parser: ", 

596 ArgumentParser.__name__, 

597 # Args for the call are below: 

598 prog=self._options.name, 

599 description=description, 

600 formatter_class=self._formatter, 

601 add_help=add_root_help, 

602 **self._extra_argparser_options, 

603 ) 

604 root_parser = ArgumentParser(*argspec.args, **argspec.kwargs) 

605 self._parsers["__root__"] = root_parser 

606 

607 # Add version flags if necessary 

608 argparse_version_flags: Union[tuple, Tuple[str], Tuple[str, str]] = tuple() 

609 if self._options.version_flag: 

610 if not hasattr(__main__, "__version__"): 

611 self._soft_failure("__version__ must be defined if version_flag is set") 

612 else: 

613 argparse_version_flags = self._options.get_version_flags() 

614 version_string = f"%(prog)s {__main__.__version__}" 

615 argspec = log_args( 

616 logger.debug, 

617 f"Parser({repr('__root__')}).", 

618 root_parser.add_argument.__name__, 

619 # Args for the call are below: 

620 *argparse_version_flags, 

621 action="version", 

622 version=version_string, 

623 ) 

624 root_parser.add_argument(*argspec.args, **argspec.kwargs) 

625 

626 # Check the number of commands we have 

627 if len(self._command_decorator_info) == 0: 

628 raise ArguablyException("At least one command is required") 

629 

630 for command_decorator_info in sorted( 

631 self._command_decorator_info, key=lambda v: (v.name != "__root__", v.name.count(" ")) 

632 ): 

633 if only_one_cmd: 

634 parent_name = "__root__" 

635 self._parsers[command_decorator_info.name] = self._parsers["__root__"] 

636 else: 

637 parent_name = self._build_subparser_tree(command_decorator_info) 

638 

639 is_root = only_one_cmd or command_decorator_info.name == "__root__" 

640 cmd = command_decorator_info.command 

641 

642 try: 

643 self._validate_args(cmd, is_root) 

644 except ArguablyException as e: 

645 self._soft_failure(str(e), cmd.function) 

646 continue 

647 

648 # Save command and its alias to the dicts 

649 if cmd.name in self._commands: 

650 self._soft_failure(f"Name `{cmd.name}` is already taken", cmd.function) 

651 continue 

652 if cmd.alias is not None: 

653 if cmd.alias in self._command_aliases: 

654 self._soft_failure( 

655 f"Alias `{cmd.alias}` for `{cmd.name}` is already taken by " 

656 f"`{self._command_aliases[cmd.alias]}`", 

657 cmd.function, 

658 ) 

659 continue 

660 self._command_aliases[cmd.alias] = cmd.name 

661 self._commands[cmd.name] = cmd 

662 

663 # Add the parser for the command 

664 if not only_one_cmd and cmd.name != "__root__": 

665 argspec = log_args( 

666 logger.debug, 

667 f"Subparsers({repr(parent_name)}).", 

668 self._subparsers[parent_name].add_parser.__name__, 

669 # Args for the call are below: 

670 cmd.name.split(" ")[-1], 

671 aliases=[cmd.alias] if cmd.alias is not None else [], 

672 help=cmd.description, 

673 description=cmd.description, 

674 formatter_class=self._formatter, 

675 add_help=cmd.add_help, 

676 **self._extra_argparser_options, 

677 ) 

678 self._parsers[cmd.name] = self._subparsers[parent_name].add_parser(*argspec.args, **argspec.kwargs) 

679 

680 # Add the arguments to the command's parser 

681 self._set_up_args(cmd) 

682 

683 # Use the function description, not the __main__ docstring, if only one command 

684 if only_one_cmd: 

685 self._parsers["__root__"].description = next(iter(self._commands.values())).description 

686 

687 # Make the magic happen 

688 parsed_args = vars(root_parser.parse_args()) 

689 

690 # Resolve the command that needs to be called 

691 if only_one_cmd: 

692 cmd = next(iter(self._commands.values())) 

693 self._current_parser = self._parsers["__root__"] 

694 else: 

695 # Find the actual command we need to execute by traversing the subparser tree. Call each stop along the way. 

696 path = "__root__" 

697 while path in self._subparsers: 

698 # Find the variable name for this subparser's command metavar and read the value. If it's none, run the 

699 # current stop of our path in the tree. 

700 subcmd_metavar = self._commands[path].get_subcommand_metavar(self._options.command_metavar) 

701 subcmd_name = parsed_args[subcmd_metavar] 

702 if subcmd_name is None: 

703 break 

704 

705 # Resolve any command aliases 

706 if subcmd_name in self._command_aliases: 

707 subcmd_name = self._command_aliases[subcmd_name] 

708 

709 self._is_calling_target = False 

710 self._current_parser = self._parsers[path] 

711 self._commands[path].call(parsed_args) 

712 

713 # Update the path and continue 

714 if path == "__root__": 

715 path = subcmd_name 

716 else: 

717 path += f" {subcmd_name}" 

718 

719 # If the command is unknown, print the help for the most recent parent 

720 if path not in self._commands: 

721 self._parsers[path.rsplit(" ", 1)[0]].print_help(file=self._options.output) 

722 return None 

723 if path == "__root__" and self._commands["__root__"].function.__name__ == "<lambda>": 

724 root_parser.print_help(file=self._options.output) 

725 return None 

726 

727 # Found command 

728 self._current_parser = self._parsers[path] 

729 cmd = self._commands[path] 

730 

731 self._is_calling_target = True 

732 result = cmd.call(parsed_args) 

733 self._current_parser = None 

734 return result 

735 

736 def _build_subtype( 

737 self, parent_func_arg_name: str, subtype_info: SubtypeDecoratorInfo, build_kwargs: Dict[str, Any] 

738 ) -> Any: 

739 type_ = subtype_info.type_ 

740 factory = subtype_info.factory or type_.__call__ 

741 template = subtype_info.factory or type_.__init__ # type: ignore[misc] 

742 hints = get_type_hints(template, include_extras=True) 

743 normalized_kwargs: Dict[str, Any] = dict() 

744 

745 params: Dict[str, inspect.Parameter] = { 

746 k: v 

747 for k, v in inspect.signature(template).parameters.items() 

748 if (v.kind != v.VAR_POSITIONAL) and (v.kind != v.VAR_KEYWORD) 

749 } 

750 

751 missing_required_keys = [ 

752 normalize_name(n) 

753 for i, (n, p) in enumerate(params.items()) 

754 if (n not in build_kwargs) and (i != 0 or p.name != "self") 

755 ] 

756 if len(missing_required_keys) > 0: 

757 missing_specs = list() 

758 for key in missing_required_keys: 

759 arg_value_type, modifiers = CommandArg.normalize_type(type_.__name__, params[key], hints) 

760 missing_specs.append(f"{key} ({arg_value_type.__name__})") 

761 self.error(f"the following keys are required for {parent_func_arg_name}: {', '.join(missing_specs)}") 

762 

763 # Iterate over all parameters 

764 for func_arg_name, param in params.items(): 

765 try: 

766 func_arg_name = normalize_name(func_arg_name) 

767 if func_arg_name == "self": 

768 continue 

769 param_value = build_kwargs[func_arg_name] 

770 del build_kwargs[func_arg_name] 

771 arg_value_type, modifiers = CommandArg.normalize_type(type_.__name__, param, hints) 

772 except ArguablyException: 

773 raise ArguablyException(f"Error processing parameter {func_arg_name} of subtype {type_.__name__}") 

774 if len(modifiers) > 0: 

775 raise ArguablyException( 

776 f"Error processing parameter {func_arg_name} of subtype {type_.__name__}: Cannot use modifiers " 

777 f"on subtypes" 

778 ) 

779 normalized_kwargs[func_arg_name] = arg_value_type(param_value) 

780 

781 # The calls to .error() cause an exit 

782 if len(build_kwargs) > 1: 

783 self.error(f"unexpected keys for {parent_func_arg_name}: {', '.join(build_kwargs)}") 

784 elif len(build_kwargs) > 0: 

785 self.error(f"unexpected key for {parent_func_arg_name}: {next(iter(build_kwargs))}") 

786 

787 return factory(**normalized_kwargs) 

788 

789 def resolve_subtype( 

790 self, func_arg_name: str, arg_value_type: type, subtype_: Optional[str], build_kwargs: Dict[str, Any] 

791 ) -> Any: 

792 options = self.find_subtype(arg_value_type) 

793 if len(options) == 0: 

794 options = [SubtypeDecoratorInfo(arg_value_type)] 

795 if len(options) == 1: 

796 return self._build_subtype(func_arg_name, options[0], build_kwargs) 

797 matches = [op for op in options if op.alias == subtype_] 

798 if len(matches) == 0: 

799 self.error(f"unknown subtype `{subtype_}` for {func_arg_name}") 

800 if len(matches) > 1: 

801 raise ArguablyException(f"More than one match for subtype `{subtype_}` of type {arg_value_type}") 

802 return self._build_subtype(func_arg_name, matches[0], build_kwargs) 

803 

804 

805context = _Context() 

806 

807 

808######################################################################################################################## 

809# Exposed for API 

810 

811 

812run = context.run 

813is_target = context.is_target 

814error = context.error 

815 

816 

817def command( 

818 func: Optional[Callable] = None, 

819 /, 

820 *, 

821 # Arguments below are passed through to `CommandDecoratorInfo` 

822 alias: Optional[str] = None, 

823 help: bool = True, 

824) -> Callable: 

825 """ 

826 Mark a function as a command that should appear on the CLI. If multiple functions are decorated with this, they will 

827 all be available as subcommands. If only one function is decorated, it is automatically selected - no need to 

828 specify it on the CLI. 

829 

830 Args: 

831 func: The target function. 

832 alias: An alias for this function. For example, `@arguably.command(alias="h")` would alias `h` to the function 

833 that follows. 

834 help: If `False`, the help flag `-h/--help` will not automatically be added to this function. 

835 

836 Returns: 

837 If called with parens `@arguably.command(...)`, returns the decorated function. If called without parens 

838 `@arguably.command`, returns the function `wrap(func_)`, which returns `func_`. 

839 

840 Examples: 

841 ```python 

842 #!/usr/bin/env python3 

843 import arguably 

844 

845 @arguably.command 

846 def some_function(required, not_required=2, *others: int, option: float = 3.14): 

847 \"\"\" 

848 this function is on the command line! 

849 

850 Args: 

851 required: a required argument 

852 not_required: this one isn't required, since it has a default value 

853 *others: all the other positional arguments go here 

854 option: [-x] keyword-only args are options, short name is in brackets 

855 \"\"\" 

856 

857 if __name__ == "__main__": 

858 arguably.run() 

859 ``` 

860 

861 ```console 

862 user@machine:~$ python3 intro.py -h 

863 usage: intro.py [-h] [-x OPTION] required [not-required] [others ...] 

864 

865 this function is on the command line! 

866 

867 positional arguments: 

868 required a required argument (type: str) 

869 not-required this one isn't required, since it has a default value (type: int, default: 2) 

870 others all the other positional arguments go here (type: int) 

871 

872 options: 

873 -h, --help show this help message and exit 

874 -x, --option OPTION keyword-only args are options, short name is in brackets (type: float, default: 3.14) 

875 ``` 

876 

877 Or, with multiple commands: 

878 

879 ```python 

880 #!/usr/bin/env python3 

881 import arguably 

882 

883 @arguably.command(alias="f") 

884 def first(): ... 

885 

886 @arguably.command(alias="s") 

887 def second(): ... 

888 

889 @arguably.command 

890 def second__subcmd1(): ... 

891 

892 def second__subcmd2(): ... 

893 arguably.command(second__subcmd2) # Can also be invoked this way 

894 

895 if __name__ == "__main__": 

896 arguably.run() 

897 ``` 

898 

899 ```console 

900 user@machine:~$ python3 command.py -h 

901 usage: command-example-2.py [-h] command ... 

902 

903 positional arguments: 

904 command 

905 first (f) 

906 second (s) 

907 

908 options: 

909 -h, --help show this help message and exit 

910 ``` 

911 

912 ```console 

913 user@machine:~$ python3 command.py s -h 

914 usage: command-example-2.py second [-h] command ... 

915 

916 positional arguments: 

917 command 

918 subcmd1 

919 subcmd2 

920 

921 options: 

922 -h, --help show this help message and exit 

923 ``` 

924 """ 

925 

926 def wrap(func_: Callable) -> Callable: 

927 context.add_command(function=func_, alias=alias, help=help) 

928 return func_ 

929 

930 # Handle being called as either @arguably.command or @arguably.command() 

931 # We have type: ignore due to https://github.com/python/mypy/issues/10740 

932 return wrap if func is None else wrap(func) # type: ignore[return-value] 

933 

934 

935def subtype( 

936 cls: Optional[type] = None, 

937 /, 

938 *, 

939 # Arguments below are passed through to `SubtypeDecoratorInfo` 

940 alias: str, 

941 factory: Callable | None = None, 

942) -> Union[Callable[[type], type], type]: 

943 """ 

944 Mark a decorated class as a subtype that should be buildable for a parameter using arg.builder(). The alias 

945 parameter is required. 

946 

947 Args: 

948 cls: The target class. 

949 alias: An alias for this class. For example, `@arguably.subtype(alias="foo")` would cause this class to be built 

950 any time an applicable arg is given a string starting with `foo,...` 

951 factory: What should be called to actually build the subtype. This should only be needed if the default behavior 

952 doesn't work. 

953 

954 Returns: 

955 If called with parens `@arguably.subtype(...)`, returns the decorated class. If called without parens 

956 `@arguably.subtype`, returns the function `wrap(cls_)`, which returns `cls_`. 

957 

958 Examples: 

959 ```python 

960 import arguably 

961 from dataclasses import dataclass 

962 from typing import Annotated 

963 

964 class Nic: ... 

965 

966 @arguably.subtype(alias="tap") 

967 @dataclass 

968 class TapNic(Nic): 

969 model: str 

970 

971 @dataclass 

972 class UserNic(Nic): 

973 hostfwd: str 

974 

975 arguably.subtype(UserNic, alias="user") # Can also be called like this 

976 

977 @arguably.command 

978 def qemu_style(*, nic: Annotated[list[Nic], arguably.arg.builder()]): 

979 print(f"{nic=}") 

980 

981 if __name__ == "__main__": 

982 arguably.run() 

983 ``` 

984 

985 ```console 

986 user@machine:~$ python3 subtype.py --nic tap,model=e1000 --nic user,hostfwd=tcp::10022-:22 

987 nic=[TapNic(model='e1000'), UserNic(hostfwd='tcp::10022-:22')] 

988 ``` 

989 """ 

990 

991 def wrap(cls_: type) -> type: 

992 if not isinstance(cls_, type): 

993 raise ArguablyException( 

994 f"Decorated value {cls_} is not a type, which is required for `@arguably.subtype()`" 

995 ) 

996 context.add_subtype(type_=cls_, alias=alias, factory=factory) 

997 return cls_ 

998 

999 # Handle being called as either @arguably.subtype or @arguably.subtype() 

1000 return wrap if cls is None else wrap(cls)