Coverage for yuio / app.py: 59%

620 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-04 10:05 +0000

1# Yuio project, MIT license. 

2# 

3# https://github.com/taminomara/yuio/ 

4# 

5# You're free to copy this file to your project and edit it for your needs, 

6# just keep this copyright line please :3 

7 

8""" 

9This module provides base functionality to build CLI interfaces. 

10 

11Creating and running an app 

12--------------------------- 

13 

14Yuio's CLI applications have functional interface. Decorate main function 

15with the :func:`app` decorator, and use :meth:`App.run` method to start it: 

16 

17.. code-block:: python 

18 

19 # Let's define an app with one flag and one positional argument. 

20 @app 

21 def main( 

22 #: help message for `arg` 

23 arg: str = positional(), 

24 #: help message for `--flag` 

25 flag: int = 0 

26 ): 

27 \"""this command does a thing\""" 

28 yuio.io.info("flag=%r, arg=%r", flag, arg) 

29 

30 if __name__ == "__main__": 

31 # We can now use `main.run` to parse arguments and invoke `main`. 

32 # Notice that `run` does not return anything. Instead, it terminates 

33 # python process with an appropriate exit code. 

34 main.run("--flag 10 foobar!".split()) 

35 

36Function's arguments will become program's flags and positionals, and function's 

37docstring will become app's :attr:`~App.description`. 

38 

39Help messages for the flags are parsed from line comments 

40right above the field definition (comments must start with ``#:``). 

41They are all formatted using Markdown (see :mod:`yuio.md`). 

42 

43Parsers for CLI argument values are derived from type hints. 

44Use the ``parser`` parameter of the :func:`field` function to override them. 

45 

46Arguments with bool parsers and parsers that support 

47:meth:`parsing collections <yuio.parse.Parser.supports_parse_many>` 

48are handled to provide better CLI experience: 

49 

50.. invisible-code-block: python 

51 

52 import pathlib 

53 

54.. code-block:: python 

55 

56 @app 

57 def main( 

58 # Will create flags `--verbose` and `--no-verbose`. 

59 # Since default is `False`, `--no-verbose` will be hidden from help 

60 # to reduce clutter. 

61 verbose: bool = False, 

62 # Will create a flag with `nargs=*`: `--inputs path1 path2 ...` 

63 inputs: list[pathlib.Path] = [], 

64 ): ... 

65 

66.. autofunction:: app 

67 

68.. autoclass:: App 

69 

70 .. automethod:: run 

71 

72 .. method:: wrapped(...) 

73 

74 The original callable what was wrapped by :func:`app`. 

75 

76 

77Configuring CLI arguments 

78------------------------- 

79 

80Names and types of arguments are determined by names and types of the app function's 

81arguments. You can use the :func:`field` function to override them: 

82 

83.. autofunction:: field 

84 

85.. autodata:: yuio.DISABLED 

86 

87.. autodata:: yuio.MISSING 

88 

89.. autodata:: yuio.POSITIONAL 

90 

91.. autodata:: yuio.GROUP 

92 

93.. autofunction:: inline 

94 

95.. autofunction:: positional 

96 

97.. autoclass:: MutuallyExclusiveGroup 

98 

99 

100Creating argument groups 

101------------------------ 

102 

103You can use :class:`~yuio.config.Config` as a type of an app function's parameter. 

104This will make all of config fields into flags as well. By default, Yuio will use 

105parameter name as a prefix for all fields in the config; you can override it 

106with :func:`field` or :func:`inline`: 

107 

108.. code-block:: python 

109 

110 class KillCmdConfig(yuio.config.Config): 

111 # Will be loaded from `--signal`. 

112 signal: int 

113 

114 # Will be loaded from `-p` or `--pid`. 

115 pid: int = field(flags=["-p", "--pid"]) 

116 

117 

118 @app 

119 def main( 

120 # `kill_cmd.signal` will be loaded from `--kill-cmd-signal`. 

121 kill_cmd: KillCmdConfig, 

122 # `copy_cmd_2.signal` will be loaded from `--kill-signal`. 

123 kill_cmd_2: KillCmdConfig = field(flags="--kill"), 

124 # `kill_cmd_3.signal` will be loaded from `--signal`. 

125 kill_cmd_3: KillCmdConfig = field(flags=""), 

126 ): ... 

127 

128.. note:: 

129 

130 Positional arguments are not allowed in configs, 

131 only in apps. 

132 

133 

134App settings 

135------------ 

136 

137You can override default usage and help messages as well as control some of the app's 

138help formatting using its arguments: 

139 

140.. class:: App 

141 :noindex: 

142 

143 .. autoattribute:: prog 

144 

145 .. autoattribute:: usage 

146 

147 .. autoattribute:: description 

148 

149 .. autoattribute:: help 

150 

151 .. autoattribute:: epilog 

152 

153 .. autoattribute:: allow_abbrev 

154 

155 .. autoattribute:: subcommand_required 

156 

157 .. autoattribute:: setup_logging 

158 

159 .. autoattribute:: theme 

160 

161 .. autoattribute:: version 

162 

163 .. autoattribute:: bug_report 

164 

165 .. autoattribute:: is_dev_mode 

166 

167 

168Creating sub-commands 

169--------------------- 

170 

171You can create multiple sub-commands for the main function 

172using the :meth:`App.subcommand` method: 

173 

174.. code-block:: python 

175 

176 @app 

177 def main(): ... 

178 

179 

180 @main.subcommand 

181 def do_stuff(): ... 

182 

183There is no limit to how deep you can nest subcommands, but for usability reasons 

184we suggest not exceeding level of sub-sub-commands (``git stash push``, anyone?) 

185 

186When user invokes a subcommand, the ``main()`` function is called first, 

187then subcommand. In the above example, invoking our app with subcommand ``push`` 

188will cause ``main()`` to be called first, then ``push()``. 

189 

190This behavior is useful when you have some global configuration flags 

191attached to the ``main()`` command. See the `example app`_ for details. 

192 

193.. _example app: https://github.com/taminomara/yuio/blob/main/examples/app 

194 

195.. class:: App 

196 :noindex: 

197 

198 .. automethod:: subcommand 

199 

200 

201Controlling how sub-commands are invoked 

202---------------------------------------- 

203 

204By default, if a command has sub-commands, the user is required to provide 

205a sub-command. This behavior can be disabled by setting :attr:`App.subcommand_required` 

206to :data:`False`. 

207 

208When this happens, we need to understand whether a subcommand was invoked or not. 

209To determine this, you can accept a special parameter called ``_command_info`` 

210of type :class:`CommandInfo`. It will contain info about the current function, 

211including its name and subcommand: 

212 

213.. code-block:: python 

214 

215 @app 

216 def main(_command_info: CommandInfo): 

217 if _command_info.subcommand is not None: 

218 # A subcommand was invoked. 

219 ... 

220 

221You can call the subcommand on your own by using ``_command_info.subcommand`` 

222as a callable: 

223 

224.. code-block:: python 

225 

226 @app 

227 def main(_command_info: CommandInfo): 

228 if _command_info.subcommand is not None and ...: 

229 _command_info.subcommand() # manually invoking a subcommand 

230 

231If you wish to disable calling the subcommand, you can return :data:`False` 

232from the main function: 

233 

234.. code-block:: python 

235 

236 @app 

237 def main(_command_info: CommandInfo): 

238 ... 

239 # Subcommand will not be invoked. 

240 return False 

241 

242.. autoclass:: CommandInfo 

243 :members: 

244 

245""" 

246 

247from __future__ import annotations 

248 

249import argparse 

250import contextlib 

251import dataclasses 

252import functools 

253import inspect 

254import logging 

255import os 

256import re 

257import sys 

258import types 

259from dataclasses import dataclass 

260 

261import yuio 

262import yuio.color 

263import yuio.complete 

264import yuio.config 

265import yuio.dbg 

266import yuio.io 

267import yuio.md 

268import yuio.parse 

269import yuio.string 

270import yuio.term 

271import yuio.theme 

272from yuio import _typing as _t 

273from yuio.config import MutuallyExclusiveGroup, field, inline, positional 

274from yuio.util import _find_docs 

275from yuio.util import dedent as _dedent 

276from yuio.util import to_dash_case as _to_dash_case 

277 

278__all__ = [ 

279 "App", 

280 "AppError", 

281 "CommandInfo", 

282 "MutuallyExclusiveGroup", 

283 "app", 

284 "field", 

285 "inline", 

286 "positional", 

287] 

288 

289C = _t.TypeVar("C", bound=_t.Callable[..., None]) 

290C2 = _t.TypeVar("C2", bound=_t.Callable[..., None]) 

291 

292 

293class AppError(yuio.PrettyException, Exception): 

294 """ 

295 An error that you can throw from an app to finish its execution without printing 

296 a traceback. 

297 

298 """ 

299 

300 

301@_t.overload 

302def app( 

303 *, 

304 prog: str | None = None, 

305 usage: str | None = None, 

306 description: str | None = None, 

307 epilog: str | None = None, 

308 version: str | None = None, 

309 bug_report: yuio.dbg.ReportSettings | bool = False, 

310 is_dev_mode: bool | None = None, 

311) -> _t.Callable[[C], App[C]]: ... 

312@_t.overload 

313def app( 

314 command: C, 

315 /, 

316 *, 

317 prog: str | None = None, 

318 usage: str | None = None, 

319 description: str | None = None, 

320 epilog: str | None = None, 

321 version: str | None = None, 

322 bug_report: yuio.dbg.ReportSettings | bool = False, 

323 is_dev_mode: bool | None = None, 

324) -> App[C]: ... 

325def app( 

326 command: _t.Callable[..., None] | None = None, 

327 /, 

328 *, 

329 prog: str | None = None, 

330 usage: str | None = None, 

331 description: str | None = None, 

332 epilog: str | None = None, 

333 version: str | None = None, 

334 bug_report: yuio.dbg.ReportSettings | bool = False, 

335 is_dev_mode: bool | None = None, 

336) -> _t.Any: 

337 """ 

338 Create an application. 

339 

340 This is a decorator that's supposed to be used on the main method 

341 of the application. This decorator returns an :class:`App` object. 

342 

343 :param command: 

344 the main function of the application. 

345 :param prog: 

346 overrides program's name, see :attr:`App.prog`. 

347 :param usage: 

348 overrides program's usage description, see :attr:`App.usage`. 

349 :param description: 

350 overrides program's description, see :attr:`App.description`. 

351 :param epilog: 

352 overrides program's epilog, see :attr:`App.epilog`. 

353 :param version: 

354 program's version, will be displayed using the :flag:`--version` flag. 

355 :param bug_report: 

356 settings for automated bug report generation. If present, 

357 adds the :flag:`--bug-report` flag. 

358 :param is_dev_mode: 

359 enables additional logging, see :attr:`App.is_dev_mode`. 

360 :returns: 

361 an :class:`App` object that wraps the original function. 

362 

363 """ 

364 

365 def registrar(command: C, /) -> App[C]: 

366 return App( 

367 command, 

368 prog=prog, 

369 usage=usage, 

370 description=description, 

371 epilog=epilog, 

372 version=version, 

373 bug_report=bug_report, 

374 is_dev_mode=is_dev_mode, 

375 ) 

376 

377 if command is None: 

378 return registrar 

379 else: 

380 return registrar(command) 

381 

382 

383@_t.final 

384@dataclass(frozen=True, eq=False, match_args=False, slots=True) 

385class CommandInfo: 

386 """ 

387 Data about the invoked command. 

388 

389 """ 

390 

391 name: str 

392 """ 

393 Name of the current command. 

394 

395 If it was invoked by alias, 

396 this will contains the primary command name. 

397 

398 For the main function, the name will be set to ``"__main__"``. 

399 

400 """ 

401 

402 subcommand: CommandInfo | None 

403 """ 

404 Subcommand of this command, if one was given. 

405 

406 """ 

407 

408 # Internal, do not use. 

409 _config: _t.Any = dataclasses.field(repr=False) 

410 _executed: bool = dataclasses.field(default=False, repr=False) 

411 

412 def __call__(self) -> _t.Literal[False]: 

413 """ 

414 Execute this command. 

415 

416 """ 

417 

418 if self._executed: 

419 return False 

420 object.__setattr__(self, "_executed", True) 

421 

422 if self._config is not None: 

423 should_invoke_subcommand = self._config._run(self) 

424 if should_invoke_subcommand is None: 

425 should_invoke_subcommand = True 

426 else: 

427 should_invoke_subcommand = True 

428 

429 if should_invoke_subcommand and self.subcommand is not None: 

430 self.subcommand() 

431 

432 return False 

433 

434 

435class App(_t.Generic[C]): 

436 """ 

437 A class that encapsulates app settings and logic for running it. 

438 

439 It is better to create instances of this class using the :func:`app` decorator, 

440 as it provides means to decorate the main function and specify all of the app's 

441 parameters. 

442 

443 """ 

444 

445 @dataclass(frozen=True, eq=False, match_args=False, slots=True) 

446 class _SubApp: 

447 app: App[_t.Any] 

448 name: str 

449 aliases: list[str] | None = None 

450 is_primary: bool = False 

451 

452 def __init__( 

453 self, 

454 command: C, 

455 /, 

456 *, 

457 prog: str | None = None, 

458 usage: str | None = None, 

459 help: str | yuio.Disabled | None = None, 

460 description: str | None = None, 

461 epilog: str | None = None, 

462 version: str | None = None, 

463 bug_report: yuio.dbg.ReportSettings | bool = False, 

464 is_dev_mode: bool | None = None, 

465 ): 

466 self.prog: str | None = prog 

467 """ 

468 Program or subcommand display name. 

469 

470 By default, inferred from :data:`sys.argv` and subcommand names. 

471 

472 See `prog <https://docs.python.org/3/library/argparse.html#prog>`_ 

473 in :mod:`argparse`. 

474 

475 """ 

476 

477 self.usage: str | None = usage 

478 """ 

479 Program or subcommand synapsis. 

480 

481 This string will be processed using the to ``bash`` syntax, 

482 and then it will be ``%``-formatted with a single keyword argument ``prog``. 

483 If command supports multiple signatures, each of them should be listed 

484 on a separate string. For example:: 

485 

486 @app 

487 def main(): ... 

488 

489 main.usage = \""" 

490 %(prog)s [-q] [-f] [-m] [<branch>] 

491 %(prog)s [-q] [-f] [-m] --detach [<branch>] 

492 %(prog)s [-q] [-f] [-m] [--detach] <commit> 

493 ... 

494 \""" 

495 

496 By default, usage is generated from CLI flags. 

497 

498 See `usage <https://docs.python.org/3/library/argparse.html#usage>`_ 

499 in :mod:`argparse`. 

500 

501 """ 

502 

503 if not description and command.__doc__: 

504 description = command.__doc__ 

505 

506 self.description: str | None = description 

507 """ 

508 Text that is shown before CLI flags help, usually contains 

509 short description of the program or subcommand. 

510 

511 The text should be formatted using markdown. For example: 

512 

513 .. code-block:: python 

514 

515 @app 

516 def main(): ... 

517 

518 main.description = \""" 

519 this command does a thing. 

520 

521 # different ways to do a thing 

522 

523 this command can apply multiple algorithms to achieve 

524 a necessary state in which a thing can be done. This includes: 

525 

526 - randomly turning the screen on and off; 

527 

528 - banging a head on a table; 

529 

530 - fiddling with your PCs power cord. 

531 

532 By default, the best algorithm is determined automatically. 

533 However, you can hint a preferred algorithm via the `--hint-algo` flag. 

534 

535 \""" 

536 

537 By default, inferred from command's docstring. 

538 

539 See `description <https://docs.python.org/3/library/argparse.html#description>`_ 

540 in :mod:`argparse`. 

541 

542 """ 

543 

544 if help is yuio.DISABLED: 

545 help = argparse.SUPPRESS 

546 elif help is None and description: 

547 lines = description.split("\n\n", 1) 

548 help = lines[0].rstrip(".") 

549 

550 self.help: str | None = help 

551 """ 

552 Short help message that is shown when listing subcommands. 

553 

554 By default, inferred from command's docstring. 

555 

556 See `help <https://docs.python.org/3/library/argparse.html#help>`_ 

557 in :mod:`argparse`. 

558 

559 """ 

560 

561 self.epilog: str | None = epilog 

562 """ 

563 Text that is shown after the main portion of the help message. 

564 

565 Text format is identical to the one for :attr:`~App.description`. 

566 

567 See `epilog <https://docs.python.org/3/library/argparse.html#epilog>`_ 

568 in :mod:`argparse`. 

569 

570 """ 

571 

572 self.allow_abbrev: bool = False 

573 """ 

574 Allow abbreviating CLI flags if that doesn't create ambiguity. 

575 

576 Disabled by default. 

577 

578 See `allow_abbrev <https://docs.python.org/3/library/argparse.html#allow-abbrev>`_ 

579 in :mod:`argparse`. 

580 

581 """ 

582 

583 self.subcommand_required: bool = True 

584 """ 

585 Require the user to provide a subcommand for this command. 

586 

587 If this command doesn't have any subcommands, this option is ignored. 

588 

589 Enabled by default. 

590 

591 """ 

592 

593 self.setup_logging: bool = True 

594 """ 

595 If :data:`True`, the app will call :func:`logging.basicConfig` during 

596 its initialization. Disable this if you want to customize 

597 logging initialization. 

598 

599 Disabling this option also removes the ``--verbose`` flag form the CLI. 

600 

601 """ 

602 

603 self.theme: ( 

604 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None 

605 ) = None 

606 """ 

607 A custom theme that will be passed to :func:`yuio.io.setup` 

608 on application startup. 

609 

610 """ 

611 

612 self.version: str | None = version 

613 """ 

614 If not :data:`None`, add :flag:`--version` flag to the CLI. 

615 

616 """ 

617 

618 self.bug_report: yuio.dbg.ReportSettings | bool = bug_report 

619 """ 

620 If not :data:`False`, add :flag:`--bug-report` flag to the CLI. 

621 

622 This flag automatically collects data about environment and prints it 

623 in a format suitable for adding to a bug report. 

624 

625 """ 

626 

627 self.is_dev_mode: bool | None = is_dev_mode 

628 """ 

629 If :data:`True`, this will enable :func:`logging.captureWarnings` 

630 and configure internal Yuio logging to show warnings. 

631 

632 By default, dev mode is detected by checking if :attr:`~App.version` 

633 contains substring ``"dev"``. 

634 

635 .. note:: 

636 

637 You can always enable full debug logging by setting environment 

638 variable ``YUIO_DEBUG``. 

639 

640 If enabled, full log will be saved to ``YUIO_DEBUG_FILE`` 

641 (default is ``./yuio.log``). 

642 

643 """ 

644 

645 self.__sub_apps: dict[str, App._SubApp] = {} 

646 

647 if callable(command): 

648 self.__config_type = _command_from_callable(command) 

649 else: 

650 raise TypeError(f"expected a function, got {command}") 

651 

652 functools.update_wrapper( 

653 self, # type: ignore 

654 command, 

655 assigned=("__module__", "__name__", "__qualname__", "__doc__"), 

656 updated=(), 

657 ) 

658 

659 self._command = command 

660 

661 @functools.wraps(command) 

662 def wrapped_command(*args, **kwargs): 

663 if args: 

664 names = self.__config_type.__annotations__ 

665 if len(args) > len(names): 

666 s = "" if len(names) == 1 else "s" 

667 raise TypeError( 

668 f"expected at most {len(names)} positional argument{s}, got {len(args)}" 

669 ) 

670 for arg, name in zip(args, names): 

671 if name in kwargs: 

672 raise TypeError(f"argument {name} was given twice") 

673 kwargs[name] = arg 

674 return CommandInfo("__raw__", None, self.__config_type(**kwargs), False)() 

675 

676 self.wrapped: C = wrapped_command # type: ignore 

677 """ 

678 The original callable what was wrapped by :func:`app`. 

679 

680 """ 

681 

682 @_t.overload 

683 def subcommand( 

684 self, 

685 /, 

686 *, 

687 name: str | None = None, 

688 aliases: list[str] | None = None, 

689 usage: str | None = None, 

690 help: str | yuio.Disabled | None = None, 

691 description: str | None = None, 

692 epilog: str | None = None, 

693 ) -> _t.Callable[[C2], App[C2]]: ... 

694 

695 @_t.overload 

696 def subcommand( 

697 self, 

698 cb: C2, 

699 /, 

700 *, 

701 name: str | None = None, 

702 aliases: list[str] | None = None, 

703 usage: str | None = None, 

704 help: str | yuio.Disabled | None = None, 

705 description: str | None = None, 

706 epilog: str | None = None, 

707 ) -> App[C2]: ... 

708 

709 def subcommand( 

710 self, 

711 cb: _t.Callable[..., None] | None = None, 

712 /, 

713 *, 

714 name: str | None = None, 

715 aliases: list[str] | None = None, 

716 usage: str | None = None, 

717 help: str | yuio.Disabled | None = None, 

718 description: str | None = None, 

719 epilog: str | None = None, 

720 ) -> _t.Any: 

721 """ 

722 Register a subcommand for the given app. 

723 

724 This method can be used as a decorator, similar to the :func:`app` function. 

725 

726 :param name: 

727 allows overriding subcommand's name. 

728 :param aliases: 

729 allows adding alias names for subcommand. 

730 :param usage: 

731 overrides subcommand's usage description, see :attr:`App.usage`. 

732 :param help: 

733 overrides subcommand's short help, see :attr:`App.help`. 

734 pass :data:`~yuio.DISABLED` to hide this subcommand in CLI help message. 

735 :param description: 

736 overrides subcommand's description, see :attr:`App.description`. 

737 :param epilog: 

738 overrides subcommand's epilog, see :attr:`App.epilog`. 

739 :returns: 

740 a new :class:`App` object for a subcommand. 

741 

742 """ 

743 

744 def registrar(cb: C2, /) -> App[C2]: 

745 app = App( 

746 cb, 

747 usage=usage, 

748 help=help, 

749 description=description, 

750 epilog=epilog, 

751 ) 

752 

753 app.allow_abbrev = self.allow_abbrev 

754 

755 main_name = name or _to_dash_case(cb.__name__) 

756 self.__sub_apps[main_name] = App._SubApp( 

757 app, main_name, aliases, is_primary=True 

758 ) 

759 if aliases: 

760 alias_app = App._SubApp(app, main_name) 

761 self.__sub_apps.update({alias: alias_app for alias in aliases}) 

762 

763 return app 

764 

765 if cb is None: 

766 return registrar 

767 else: 

768 return registrar(cb) 

769 

770 def run(self, args: _t.Sequence[str] | None = None) -> _t.NoReturn: 

771 """ 

772 Parse arguments, set up :mod:`yuio.io` and :mod:`logging`, 

773 and run the application. 

774 

775 :param args: 

776 command line arguments. If none are given, 

777 use arguments from :data:`sys.argv`. 

778 :returns: 

779 this method does not return, it exits the program instead. 

780 

781 """ 

782 

783 if args is None: 

784 args = sys.argv[1:] 

785 

786 if "--yuio-custom-completer--" in args: 

787 index = args.index("--yuio-custom-completer--") 

788 yuio.complete._run_custom_completer( 

789 self.__get_completions(), args[index + 1], args[index + 2] 

790 ) 

791 sys.exit(0) 

792 

793 if "--yuio-bug-report--" in args: 

794 from yuio.dbg import print_report 

795 

796 print_report(settings=self.bug_report, app=self) 

797 sys.exit(0) 

798 

799 yuio.io.setup(theme=self.theme, wrap_stdio=True) 

800 

801 parser, subparsers_map = self.__setup_arg_parser() 

802 namespace = parser.parse_args(args) 

803 

804 if self.is_dev_mode is None: 

805 self.is_dev_mode = ( 

806 self.version is not None and "dev" in self.version.lower() 

807 ) 

808 if self.is_dev_mode: 

809 yuio.enable_internal_logging(propagate=True) 

810 

811 if self.setup_logging: 

812 logging_level = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}.get( 

813 namespace.verbosity_level, logging.DEBUG 

814 ) 

815 logging.basicConfig(handlers=[yuio.io.Handler()], level=logging_level) 

816 

817 try: 

818 command = self.__load_from_namespace(namespace) 

819 command() 

820 sys.exit(0) 

821 except AppError as e: 

822 yuio.io.failure(e) 

823 sys.exit(1) 

824 except (argparse.ArgumentTypeError, argparse.ArgumentError) as e: 

825 # Make sure we print subcommand's usage, not the main one. 

826 subcommand_path = self.__get_subcommand_path(namespace) 

827 subparser = subparsers_map[subcommand_path] 

828 subparser.error(str(e)) 

829 except KeyboardInterrupt: 

830 yuio.io.failure("Received Keyboard Interrupt, stopping now") 

831 sys.exit(130) 

832 except Exception as e: 

833 msg = str(e) 

834 if "Original traceback:" in msg: 

835 msg = re.sub( 

836 r"\n*?^Original traceback:.*", 

837 "", 

838 msg, 

839 flags=re.MULTILINE | re.DOTALL, 

840 ) 

841 yuio.io.failure_with_tb("Error: %s", msg) 

842 sys.exit(3) 

843 finally: 

844 yuio.io.restore_streams() 

845 

846 def __load_from_namespace(self, namespace: argparse.Namespace) -> CommandInfo: 

847 return self.__load_from_namespace_impl(namespace, "app") 

848 

849 def __load_from_namespace_impl( 

850 self, namespace: argparse.Namespace, ns_prefix: str 

851 ) -> CommandInfo: 

852 config = self.__config_type._load_from_namespace(namespace, ns_prefix=ns_prefix) 

853 subcommand = None 

854 

855 if name := getattr(namespace, ns_prefix + "@subcommand", None): 

856 sub_app = self.__sub_apps[name] 

857 subcommand = dataclasses.replace( 

858 sub_app.app.__load_from_namespace_impl( 

859 namespace, f"{ns_prefix}/{sub_app.name}" 

860 ), 

861 name=sub_app.name, 

862 ) 

863 

864 return CommandInfo("__main__", subcommand, _config=config) 

865 

866 def __get_subcommand_path(self, namespace: argparse.Namespace) -> str: 

867 return self.__get_subcommand_path_impl(namespace, "app") 

868 

869 def __get_subcommand_path_impl(self, namespace: argparse.Namespace, ns_prefix: str): 

870 if name := getattr(namespace, ns_prefix + "@subcommand", None): 

871 sub_app = self.__sub_apps[name] 

872 return sub_app.app.__get_subcommand_path_impl( 

873 namespace, f"{ns_prefix}/{sub_app.name}" 

874 ) 

875 else: 

876 return ns_prefix 

877 

878 def __setup_arg_parser( 

879 self, parser: argparse.ArgumentParser | None = None 

880 ) -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentParser]]: 

881 prog = self.prog 

882 if not prog: 

883 prog = os.path.basename(sys.argv[0]) 

884 

885 parser = parser or _ArgumentParser( 

886 prog=self.prog, 

887 usage=self.usage, 

888 description=self.description, 

889 epilog=self.epilog, 

890 allow_abbrev=self.allow_abbrev, 

891 add_help=False, 

892 formatter_class=_HelpFormatter, # type: ignore 

893 ) 

894 

895 subparsers_map = {} 

896 

897 self.__setup_arg_parser_impl(self, parser, "app", prog, subparsers_map) 

898 

899 return parser, subparsers_map 

900 

901 def __setup_arg_parser_impl( 

902 self, 

903 main_app: App[_t.Any], 

904 parser: argparse.ArgumentParser, 

905 ns_prefix: str, 

906 prog: str, 

907 subparsers_map: dict[str, argparse.ArgumentParser], 

908 ): 

909 subparsers_map[ns_prefix] = parser 

910 

911 self.__config_type._setup_arg_parser(parser, ns_prefix=ns_prefix) 

912 

913 if self.__sub_apps: 

914 subparsers = parser.add_subparsers( 

915 required=self.subcommand_required, 

916 dest=ns_prefix + "@subcommand", 

917 metavar="<subcommand>", 

918 parser_class=_ArgumentParser, 

919 ) 

920 

921 for name, sub_app in self.__sub_apps.items(): 

922 if not sub_app.is_primary: 

923 continue 

924 

925 sub_prog = f"{prog} {name}" 

926 

927 subparser = subparsers.add_parser( 

928 name, 

929 aliases=sub_app.aliases or [], 

930 prog=sub_prog, 

931 help=sub_app.app.help, 

932 usage=sub_app.app.usage, 

933 description=sub_app.app.description, 

934 epilog=sub_app.app.epilog, 

935 allow_abbrev=self.allow_abbrev, 

936 add_help=False, 

937 formatter_class=_HelpFormatter, # type: ignore 

938 ) 

939 

940 sub_app.app.__setup_arg_parser_impl( 

941 main_app, 

942 subparser, 

943 ns_prefix=f"{ns_prefix}/{name}", 

944 prog=sub_prog, 

945 subparsers_map=subparsers_map, 

946 ) 

947 

948 if main_app.__config_type is not self.__config_type: 

949 main_app.__config_type._setup_arg_parser( 

950 parser, 

951 group=parser.add_argument_group("global options"), # pyright: ignore[reportArgumentType] 

952 ns_prefix="app", 

953 ) 

954 

955 aux = parser.add_argument_group("auxiliary options") 

956 color = aux.add_mutually_exclusive_group() 

957 color.add_argument( 

958 "--force-color", 

959 help="force-enable colored output", 

960 action=_NoOpAction, # Note: `yuio.term` inspects `sys.argv` on its own 

961 nargs=0, 

962 ) 

963 color.add_argument( 

964 "--force-no-color", 

965 help="force-disable colored output", 

966 action=_NoOpAction, # Note: `yuio.term` inspects `sys.argv` on its own 

967 nargs=0, 

968 ) 

969 

970 aux.add_argument( 

971 "-h", 

972 "--help", 

973 help="show this help message and exit", 

974 action=_HelpAction, 

975 nargs=0, 

976 ) 

977 

978 if main_app.setup_logging: 

979 aux.add_argument( 

980 "-v", 

981 "--verbose", 

982 help="increase output verbosity", 

983 # note the merge function in `_Namespace` for this dest. 

984 action=_StoreConstAction, 

985 const=1, 

986 default=0, 

987 nargs=0, 

988 dest="verbosity_level", 

989 ) 

990 

991 if main_app.version is not None: 

992 aux.add_argument( 

993 "-V", 

994 "--version", 

995 action=_VersionAction, 

996 nargs=0, 

997 version=main_app.version, 

998 help="show program's version number and exit", 

999 ) 

1000 

1001 if main_app.bug_report: 

1002 aux.add_argument( 

1003 "--bug-report", 

1004 action=_BugReportAction, 

1005 nargs=0, 

1006 app=main_app, 

1007 help="show environment data for bug report and exit", 

1008 ) 

1009 

1010 aux.add_argument( 

1011 "--completions", 

1012 help="generate autocompletion scripts and exit", 

1013 nargs="?", 

1014 action=_make_completions_action(main_app), 

1015 ) 

1016 

1017 def __get_completions(self) -> yuio.complete._CompleterSerializer: 

1018 serializer = yuio.complete._CompleterSerializer( 

1019 add_help=True, 

1020 add_version=self.version is not None, 

1021 add_bug_report=bool(self.bug_report), 

1022 ) 

1023 self.__setup_arg_parser(serializer.as_parser()) 

1024 return serializer 

1025 

1026 def __write_completions(self, shell: str): 

1027 yuio.complete._write_completions(self.__get_completions(), self.prog, shell) 

1028 

1029 

1030class _NoReprConfig(yuio.config.Config): 

1031 def __repr__(self): 

1032 return "<move along, nothing to see here>" 

1033 

1034 

1035def _command_from_callable(cb: _t.Callable[..., None]) -> type[yuio.config.Config]: 

1036 sig = inspect.signature(cb) 

1037 

1038 dct = {} 

1039 annotations = {} 

1040 

1041 accepts_command_info = False 

1042 

1043 try: 

1044 docs = _find_docs(cb) 

1045 except Exception: 

1046 yuio._logger.warning( 

1047 "unable to get documentation for %s.%s", 

1048 cb.__module__, 

1049 cb.__qualname__, 

1050 ) 

1051 docs = {} 

1052 

1053 for name, param in sig.parameters.items(): 

1054 if param.kind not in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY): 

1055 raise TypeError("positional-only and variadic arguments are not supported") 

1056 

1057 if name.startswith("_"): 

1058 if name == "_command_info": 

1059 accepts_command_info = True 

1060 continue 

1061 else: 

1062 raise TypeError(f"unknown special param {name}") 

1063 

1064 if param.default is not param.empty: 

1065 field = param.default 

1066 else: 

1067 field = yuio.MISSING 

1068 if not isinstance(field, yuio.config._FieldSettings): 

1069 field = _t.cast( 

1070 yuio.config._FieldSettings, yuio.config.field(default=field) 

1071 ) 

1072 if field.default is yuio.MISSING: 

1073 field = dataclasses.replace(field, required=True) 

1074 if name in docs: 

1075 field = dataclasses.replace(field, help=docs[name]) 

1076 

1077 if param.annotation is param.empty: 

1078 raise TypeError(f"param {name} requires type annotation") 

1079 

1080 dct[name] = field 

1081 annotations[name] = param.annotation 

1082 

1083 dct["_run"] = _command_from_callable_run_impl( 

1084 cb, list(annotations.keys()), accepts_command_info 

1085 ) 

1086 dct["__annotations__"] = annotations 

1087 dct["__module__"] = getattr(cb, "__module__", None) 

1088 dct["__doc__"] = getattr(cb, "__doc__", None) 

1089 

1090 return types.new_class( 

1091 cb.__name__, 

1092 (_NoReprConfig,), 

1093 {"_allow_positionals": True}, 

1094 exec_body=lambda ns: ns.update(dct), 

1095 ) 

1096 

1097 

1098def _command_from_callable_run_impl( 

1099 cb: _t.Callable[..., None], params: list[str], accepts_command_info 

1100): 

1101 def run(self, command_info): 

1102 kw = {name: getattr(self, name) for name in params} 

1103 if accepts_command_info: 

1104 kw["_command_info"] = command_info 

1105 return cb(**kw) 

1106 

1107 return run 

1108 

1109 

1110class _ArgumentParser(argparse.ArgumentParser): 

1111 def parse_known_args(self, args=None, namespace=None): # type: ignore 

1112 self._merge_by_dest: dict[str, _t.Callable[[_t.Any, _t.Any], _t.Any]] = { 

1113 action.dest: merge 

1114 for action in self._actions 

1115 if (get_merge := getattr(action, "get_merge", None)) 

1116 and (merge := get_merge()) 

1117 } 

1118 self._merge_by_dest["verbosity_level"] = lambda l, r: l + r 

1119 if namespace is None: 

1120 namespace = _Namespace(self) 

1121 return super().parse_known_args(args=args, namespace=namespace) 

1122 

1123 def error(self, message: str) -> _t.NoReturn: 

1124 self.print_usage() 

1125 yuio.io.failure("Error: %s", message) 

1126 sys.exit(2) 

1127 

1128 

1129class _Namespace(argparse.Namespace): 

1130 # Since we add flags from main function to all of the subparsers, 

1131 # we need to merge them properly. Otherwise, values from subcommands 

1132 # will override values from the main command: `app --foo=x subcommand --foo=y` 

1133 # will result in `--foo` being just `y`, not `merge(x, y)`. In fact, argparse 

1134 # will override every absent flag with its default: `app --foo x subcommand` 

1135 # will result in `--foo` having a default value. 

1136 def __init__(self, parser: _ArgumentParser): 

1137 self.__parser = parser 

1138 

1139 def __setattr__(self, name: str, value: _t.Any): 

1140 if value is yuio.MISSING and hasattr(self, name): 

1141 # Flag was not specified in a subcommand, don't override it. 

1142 return 

1143 if (prev := getattr(self, name, yuio.MISSING)) is not yuio.MISSING and ( 

1144 merge := self.__parser._merge_by_dest.get(name) 

1145 ) is not None: 

1146 # Flag was specified in main command and in a subcommand, merge the values. 

1147 value = merge(prev, value) 

1148 super().__setattr__(name, value) 

1149 

1150 

1151def _make_completions_action(app: App[_t.Any]): 

1152 class _CompletionsAction(argparse.Action): 

1153 @staticmethod 

1154 def get_usage(): 

1155 return False 

1156 

1157 @staticmethod 

1158 def get_parser(): 

1159 return yuio.parse.OneOf( 

1160 yuio.parse.Lower(yuio.parse.Str()), 

1161 ["all", "bash", "zsh", "fish", "pwsh", "uninstall"], 

1162 ) 

1163 

1164 def __init__(self, **kwargs): 

1165 kwargs["metavar"] = self.get_parser().describe_or_def() 

1166 super().__init__(**kwargs) 

1167 

1168 def __call__(self, parser, namespace, value, *args): 

1169 try: 

1170 app._App__write_completions(self.get_parser().parse(value or "all")) # type: ignore 

1171 except argparse.ArgumentTypeError as e: 

1172 raise argparse.ArgumentError(self, str(e)) 

1173 parser.exit() 

1174 

1175 return _CompletionsAction 

1176 

1177 

1178class _NoOpAction(argparse.Action): 

1179 @staticmethod 

1180 def get_usage(): 

1181 return False 

1182 

1183 def __call__(self, parser, namespace, value, *args): 

1184 pass 

1185 

1186 

1187class _StoreConstAction(argparse.Action): 

1188 @staticmethod 

1189 def get_usage(): 

1190 return False 

1191 

1192 def __call__(self, parser, namespace, values, option_string=None): 

1193 setattr(namespace, self.dest, self.const) 

1194 

1195 

1196class _HelpAction(argparse.Action): 

1197 @staticmethod 

1198 def get_usage(): 

1199 return False 

1200 

1201 def __call__(self, parser, namespace, values, option_string=None): 

1202 parser.print_help() 

1203 parser.exit() 

1204 

1205 

1206class _VersionAction(argparse.Action): 

1207 @staticmethod 

1208 def get_usage(): 

1209 return False 

1210 

1211 def __init__(self, version=None, **kwargs): 

1212 super().__init__(**kwargs) 

1213 self.version = version 

1214 

1215 def __call__(self, parser, namespace, values, option_string=None): 

1216 print(self.version) # noqa: T201 

1217 parser.exit() 

1218 

1219 

1220class _BugReportAction(argparse.Action): 

1221 @staticmethod 

1222 def get_usage(): 

1223 return False 

1224 

1225 def __init__(self, app: App[_t.Any], **kwargs): 

1226 super().__init__(**kwargs) 

1227 self.app = app 

1228 

1229 def __call__(self, parser, namespace, values, option_string=None): 

1230 yuio.dbg.print_report(settings=self.app.bug_report, app=self.app) 

1231 parser.exit() 

1232 

1233 

1234_MAX_ARGS_COLUMN_WIDTH = 24 

1235 

1236 

1237class _CliMdFormatter(yuio.md.MdFormatter): # type: ignore 

1238 def __init__( 

1239 self, 

1240 theme: yuio.theme.Theme, 

1241 *, 

1242 width: int | None = None, 

1243 ): 

1244 self._heading_indent = contextlib.ExitStack() 

1245 self._args_column_width = _MAX_ARGS_COLUMN_WIDTH 

1246 

1247 super().__init__( 

1248 theme, 

1249 width=width, 

1250 allow_headings=True, 

1251 ) 

1252 

1253 self.width = min(self.width, 80) 

1254 

1255 def colorize( 

1256 self, 

1257 s: str, 

1258 /, 

1259 *, 

1260 default_color: yuio.color.Color | str = yuio.color.Color.NONE, 

1261 ): 

1262 return yuio.string.colorize( 

1263 s, 

1264 default_color=default_color, 

1265 parse_cli_flags_in_backticks=True, 

1266 ctx=self.theme, 

1267 ) 

1268 

1269 def _format_Heading(self, node: yuio.md.Heading): 

1270 if node.level == 1: 

1271 self._heading_indent.close() 

1272 

1273 decoration = self.theme.msg_decorations.get("heading/section", "") 

1274 with self._with_indent("msg/decoration:heading/section", decoration): 

1275 self._format_Text( 

1276 node, 

1277 default_color=self.theme.get_color("msg/text:heading/section"), 

1278 ) 

1279 

1280 if node.level == 1: 

1281 self._heading_indent.enter_context(self._with_indent(None, " ")) 

1282 else: 

1283 self._line(self._indent) 

1284 

1285 self._is_first_line = True 

1286 

1287 def _format_Usage(self, node: "_Usage"): 

1288 with self._with_indent(None, node.prefix): 

1289 self._line( 

1290 node.usage.indent( 

1291 indent=self._indent, 

1292 continuation_indent=self._continuation_indent, 

1293 ) 

1294 ) 

1295 

1296 def _format_HelpArg(self, node: _HelpArg): 

1297 if node.help is None: 

1298 self._line(self._indent + node.args) 

1299 return 

1300 

1301 if node.args.width + 2 > self._args_column_width: 

1302 self._line(self._indent + node.indent + node.args) 

1303 indent_ctx = self._with_indent(None, " " * self._args_column_width) 

1304 else: 

1305 indent_ctx = self._with_indent( 

1306 None, 

1307 node.indent 

1308 + node.args 

1309 + " " * (self._args_column_width - len(node.indent) - node.args.width), 

1310 ) 

1311 

1312 with indent_ctx: 

1313 if node.help: 

1314 self._format(node.help) 

1315 

1316 def _format_HelpArgGroup(self, node: _HelpArgGroup): 

1317 for item in node.items: 

1318 self._format(item) 

1319 

1320 

1321@dataclass(eq=False, match_args=False, slots=True) 

1322class _Usage(yuio.md.AstBase): 

1323 prefix: yuio.string.ColorizedString 

1324 usage: yuio.string.ColorizedString 

1325 

1326 

1327@dataclass(eq=False, match_args=False, slots=True) 

1328class _HelpArg(yuio.md.AstBase): 

1329 indent: str 

1330 args: yuio.string.ColorizedString 

1331 help: yuio.md.AstBase | None 

1332 

1333 

1334@dataclass(eq=False, match_args=False, slots=True) 

1335class _HelpArgGroup(yuio.md.Container[_HelpArg]): 

1336 pass 

1337 

1338 

1339class _HelpFormatter: 

1340 def __init__(self, prog: str): 

1341 self._prog = prog 

1342 self._term = yuio.io.get_term() 

1343 self._theme = yuio.io.get_theme() 

1344 self._usage_main_color = self._theme.get_color("msg/text:code/sh-usage") 

1345 self._usage_prog_color = self._usage_main_color | self._theme.get_color( 

1346 "hl/prog:sh-usage" 

1347 ) 

1348 self._usage_punct_color = self._usage_main_color | self._theme.get_color( 

1349 "hl/punct:sh-usage" 

1350 ) 

1351 self._usage_metavar_color = self._usage_main_color | self._theme.get_color( 

1352 "hl/metavar:sh-usage" 

1353 ) 

1354 self._usage_flag_color = self._usage_main_color | self._theme.get_color( 

1355 "hl/flag:sh-usage" 

1356 ) 

1357 self._formatter = _CliMdFormatter(self._theme) 

1358 self._nodes: list[yuio.md.AstBase] = [] 

1359 self._args_column_width = 0 

1360 

1361 def start_section(self, heading: str | None): 

1362 if heading: 

1363 if not heading.endswith(":"): 

1364 heading += ":" 

1365 self._nodes.append(yuio.md.Heading(lines=[heading], level=1)) 

1366 

1367 def end_section(self): 

1368 if self._nodes and isinstance(self._nodes[-1], yuio.md.Heading): 

1369 self._nodes.pop() 

1370 

1371 def add_text(self, text): 

1372 if text != argparse.SUPPRESS and text: 

1373 self._nodes.append(self._formatter.parse(text)) 

1374 

1375 def add_usage( 

1376 self, usage, actions: _t.Iterable[argparse.Action], groups, prefix=None 

1377 ): 

1378 if usage == argparse.SUPPRESS: 

1379 return 

1380 

1381 if prefix is not None: 

1382 c_prefix = self._formatter.colorize( 

1383 prefix, 

1384 default_color="msg/text:heading/section", 

1385 ) 

1386 else: 

1387 c_prefix = yuio.string.ColorizedString( 

1388 [self._theme.get_color("msg/text:heading/section"), "usage: "] 

1389 ) 

1390 

1391 if usage is not None: 

1392 usage = _dedent(usage.strip()) 

1393 sh_usage_highlighter = yuio.md.SyntaxHighlighter.get_highlighter("sh-usage") 

1394 

1395 c_usage = sh_usage_highlighter.highlight( 

1396 self._theme, 

1397 usage, 

1398 ).percent_format({"prog": self._prog}, self._theme) 

1399 else: 

1400 c_usage = yuio.string.ColorizedString( 

1401 [self._usage_prog_color, str(self._prog)] 

1402 ) 

1403 c_usage_elems = yuio.string.ColorizedString() 

1404 

1405 optionals: list[argparse.Action | argparse._MutuallyExclusiveGroup] = [] 

1406 positionals: list[argparse.Action | argparse._MutuallyExclusiveGroup] = [] 

1407 for action in actions: 

1408 if action.option_strings: 

1409 optionals.append(action) 

1410 else: 

1411 positionals.append(action) 

1412 for group in groups: 

1413 if len(group._group_actions) <= 1: 

1414 continue 

1415 for arr in [optionals, positionals]: 

1416 try: 

1417 start = arr.index(group._group_actions[0]) 

1418 except (ValueError, IndexError): 

1419 continue 

1420 else: 

1421 end = start + len(group._group_actions) 

1422 if arr[start:end] == group._group_actions: 

1423 arr[start:end] = [group] 

1424 

1425 has_omitted_usages = False 

1426 sep = False 

1427 for arr in optionals, positionals: 

1428 for elem in arr: 

1429 if isinstance(elem, argparse.Action): 

1430 usage_settings = getattr(elem, "get_usage", lambda: True)() 

1431 if usage_settings is yuio.GROUP: 

1432 has_omitted_usages = True 

1433 continue 

1434 if ( 

1435 not usage_settings 

1436 or elem.help == argparse.SUPPRESS 

1437 or elem.metavar == argparse.SUPPRESS 

1438 ): 

1439 continue 

1440 if sep: 

1441 c_usage_elems += self._usage_main_color 

1442 c_usage_elems += " " 

1443 self._format_action_short(elem, c_usage_elems) 

1444 sep = True 

1445 elif elem._group_actions: 

1446 group_actions = [] 

1447 for action in elem._group_actions: 

1448 usage_settings = getattr( 

1449 action, "get_usage", lambda: True 

1450 )() 

1451 if usage_settings is yuio.GROUP: 

1452 has_omitted_usages = True 

1453 elif ( 

1454 usage_settings 

1455 and action.help != argparse.SUPPRESS 

1456 and action.metavar != argparse.SUPPRESS 

1457 ): 

1458 group_actions.append(action) 

1459 if not group_actions: 

1460 continue 

1461 if sep: 

1462 c_usage_elems += self._usage_main_color 

1463 c_usage_elems += " " 

1464 if len(group_actions) == 1: 

1465 self._format_action_short(group_actions[0], c_usage_elems) 

1466 sep = True 

1467 else: 

1468 for i, action in enumerate(group_actions): 

1469 if i == 0: 

1470 c_usage_elems += self._usage_punct_color 

1471 c_usage_elems += "(" if elem.required else "[" 

1472 self._format_action_short( 

1473 action, c_usage_elems, in_group=True 

1474 ) 

1475 if i + 1 < len(group_actions): 

1476 c_usage_elems += self._usage_punct_color 

1477 c_usage_elems += "|" 

1478 else: 

1479 c_usage_elems += self._usage_punct_color 

1480 c_usage_elems += ")" if elem.required else "]" 

1481 sep = True 

1482 

1483 if has_omitted_usages: 

1484 c_usage_elems_prev = c_usage_elems 

1485 c_usage_elems = yuio.string.ColorizedString( 

1486 [ 

1487 self._usage_punct_color, 

1488 "[", 

1489 self._usage_flag_color, 

1490 "<options>", 

1491 self._usage_punct_color, 

1492 "]", 

1493 ] 

1494 ) 

1495 if c_usage_elems_prev: 

1496 c_usage_elems += self._usage_main_color 

1497 c_usage_elems += " " 

1498 c_usage_elems += c_usage_elems_prev 

1499 

1500 if c_usage_elems: 

1501 c_usage += self._usage_main_color 

1502 c_usage += " " 

1503 c_usage += c_usage_elems 

1504 

1505 self._nodes.append( 

1506 _Usage( 

1507 prefix=c_prefix, 

1508 usage=c_usage, 

1509 ) 

1510 ) 

1511 

1512 def add_argument(self, action: argparse.Action, indent: str = ""): 

1513 if action.help != argparse.SUPPRESS: 

1514 c_usage = yuio.string.ColorizedString() 

1515 sep = False 

1516 if not action.option_strings: 

1517 self._format_action_metavar(action, 0, c_usage) 

1518 for option_string in action.option_strings: 

1519 if sep: 

1520 c_usage += self._usage_punct_color 

1521 c_usage += " " 

1522 c_usage += self._usage_flag_color 

1523 c_usage += option_string 

1524 if action.nargs != 0: 

1525 c_usage += self._usage_punct_color 

1526 c_usage += " " 

1527 self._format_action_metavar_expl(action, c_usage) 

1528 sep = True 

1529 

1530 if self._nodes and isinstance(self._nodes[-1], _HelpArgGroup): 

1531 group = self._nodes[-1] 

1532 else: 

1533 group = _HelpArgGroup(items=[]) 

1534 self._nodes.append(group) 

1535 group.items.append( 

1536 _HelpArg( 

1537 indent=indent, 

1538 args=c_usage, 

1539 help=self._formatter.parse(action.help) if action.help else None, 

1540 ) 

1541 ) 

1542 

1543 arg_width = len(indent) + c_usage.width + 2 

1544 if arg_width <= _MAX_ARGS_COLUMN_WIDTH: 

1545 self._args_column_width = max(self._args_column_width, arg_width) 

1546 

1547 try: 

1548 get_subactions = action._get_subactions # type: ignore 

1549 except AttributeError: 

1550 pass 

1551 else: 

1552 self.add_arguments(get_subactions(), indent + " ") 

1553 

1554 def add_arguments(self, actions, indent: str = ""): 

1555 for action in actions: 

1556 self.add_argument(action, indent) 

1557 

1558 def format_help(self) -> str: 

1559 self._formatter._args_column_width = self._args_column_width 

1560 res = yuio.string.ColorizedString() 

1561 for line in self._formatter.format_node(yuio.md.Document(items=self._nodes)): 

1562 res += line 

1563 res += "\n" 

1564 res += yuio.color.Color() 

1565 return "".join(res.process_colors(self._term.color_support)) 

1566 

1567 def _format_action_short( 

1568 self, 

1569 action: argparse.Action, 

1570 out: yuio.string.ColorizedString, 

1571 in_group: bool = False, 

1572 ): 

1573 if not in_group and not action.required: 

1574 out += self._usage_punct_color 

1575 out += "[" 

1576 

1577 if action.option_strings: 

1578 out += self._usage_flag_color 

1579 out += action.option_strings[0] 

1580 if action.nargs != 0: 

1581 out += self._usage_punct_color 

1582 out += " " 

1583 

1584 self._format_action_metavar_expl(action, out) 

1585 

1586 if not in_group and not action.required: 

1587 out += self._usage_punct_color 

1588 out += "]" 

1589 

1590 def _format_action_metavar_expl( 

1591 self, action: argparse.Action, out: yuio.string.ColorizedString 

1592 ): 

1593 nargs = action.nargs if action.nargs is not None else 1 

1594 

1595 if nargs == argparse.OPTIONAL: 

1596 out += self._usage_punct_color 

1597 out += "[" 

1598 self._format_action_metavar(action, 0, out) 

1599 out += self._usage_punct_color 

1600 out += "]" 

1601 elif nargs == argparse.ZERO_OR_MORE: 

1602 out += self._usage_punct_color 

1603 out += "[" 

1604 self._format_action_metavar(action, 0, out) 

1605 out += self._usage_punct_color 

1606 out += " ...]" 

1607 elif nargs == argparse.ONE_OR_MORE: 

1608 self._format_action_metavar(action, 0, out) 

1609 out += self._usage_punct_color 

1610 out += " [" 

1611 self._format_action_metavar(action, 1, out) 

1612 out += self._usage_punct_color 

1613 out += " ...]" 

1614 elif nargs == argparse.REMAINDER: 

1615 out += self._usage_main_color 

1616 out += "..." 

1617 elif nargs == argparse.PARSER: 

1618 self._format_action_metavar(action, 1, out) 

1619 out += self._usage_main_color 

1620 out += " ..." 

1621 elif isinstance(nargs, int): 

1622 sep = False 

1623 for i in range(nargs): 

1624 if sep: 

1625 out += self._usage_punct_color 

1626 out += " " 

1627 self._format_action_metavar(action, i, out) 

1628 sep = True 

1629 

1630 def _format_action_metavar( 

1631 self, action: argparse.Action, n: int, out: yuio.string.ColorizedString 

1632 ): 

1633 metavar_t = action.metavar 

1634 if not metavar_t and action.option_strings: 

1635 metavar_t = f"<{action.option_strings[0]}>" 

1636 if not metavar_t: 

1637 metavar_t = "<value>" 

1638 if isinstance(metavar_t, tuple): 

1639 metavar = metavar_t[n] if n < len(metavar_t) else metavar_t[-1] 

1640 else: 

1641 metavar = metavar_t 

1642 

1643 cur_color = None 

1644 is_punctuation = False 

1645 for part in re.split(r"((?:[{}()[\]\\;!&]|\s)+)", metavar): 

1646 if is_punctuation and cur_color is not self._usage_punct_color: 

1647 cur_color = self._usage_punct_color 

1648 out += self._usage_punct_color 

1649 elif not is_punctuation and cur_color is not self._usage_metavar_color: 

1650 cur_color = self._usage_metavar_color 

1651 out += self._usage_metavar_color 

1652 out += part 

1653 is_punctuation = not is_punctuation 

1654 

1655 def _format_args(self, *_): 

1656 # argparse calls this method sometimes 

1657 # to check if given metavar is valid or not (TODO!) 

1658 pass 

1659 

1660 def _set_color(self, *_): 

1661 pass 

1662 

1663 def _expand_help(self, *_): 

1664 pass