Coverage for yuio / app.py: 93%

308 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:41 +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.. autofunction:: inline 

86 

87.. autofunction:: positional 

88 

89 

90Creating argument groups 

91------------------------ 

92 

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

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

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

96with :func:`field` or :func:`inline`: 

97 

98.. code-block:: python 

99 

100 class KillCmdConfig(yuio.config.Config): 

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

102 signal: int 

103 

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

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

106 

107 

108 @app 

109 def main( 

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

111 kill_cmd: KillCmdConfig, 

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

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

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

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

116 ): ... 

117 

118.. note:: 

119 

120 Positional arguments are not allowed in configs, 

121 only in apps. 

122 

123 

124App settings 

125------------ 

126 

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

128help formatting using its arguments: 

129 

130.. class:: App 

131 :noindex: 

132 

133 .. autoattribute:: prog 

134 

135 .. autoattribute:: usage 

136 

137 .. autoattribute:: description 

138 

139 .. autoattribute:: help 

140 

141 .. autoattribute:: epilog 

142 

143 .. autoattribute:: allow_abbrev 

144 

145 .. autoattribute:: subcommand_required 

146 

147 .. autoattribute:: setup_logging 

148 

149 .. autoattribute:: theme 

150 

151 .. autoattribute:: version 

152 

153 .. autoattribute:: bug_report 

154 

155 .. autoattribute:: is_dev_mode 

156 

157 

158Creating sub-commands 

159--------------------- 

160 

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

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

163 

164.. code-block:: python 

165 

166 @app 

167 def main(): ... 

168 

169 

170 @main.subcommand 

171 def do_stuff(): ... 

172 

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

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

175 

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

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

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

179 

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

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

182 

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

184 

185.. class:: App 

186 :noindex: 

187 

188 .. automethod:: subcommand 

189 

190 

191.. _sub-commands-more: 

192 

193Controlling how sub-commands are invoked 

194---------------------------------------- 

195 

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

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

198to :data:`False`. 

199 

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

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

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

203including its name and subcommand: 

204 

205.. code-block:: python 

206 

207 @app 

208 def main(_command_info: CommandInfo): 

209 if _command_info.subcommand is not None: 

210 # A subcommand was invoked. 

211 ... 

212 

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

214as a callable: 

215 

216.. code-block:: python 

217 

218 @app 

219 def main(_command_info: CommandInfo): 

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

221 _command_info.subcommand() # manually invoking a subcommand 

222 

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

224from the main function: 

225 

226.. code-block:: python 

227 

228 @app 

229 def main(_command_info: CommandInfo): 

230 ... 

231 # Subcommand will not be invoked. 

232 return False 

233 

234.. autoclass:: CommandInfo 

235 :members: 

236 

237 

238.. _flags-with-multiple-values: 

239 

240Handling options with multiple values 

241------------------------------------- 

242 

243When you create an option with a container type, Yuio enables passing its values 

244by specifying multiple arguments. For example: 

245 

246.. code-block:: python 

247 

248 @yuio.app.app 

249 def main(list: list[int]): 

250 print(list) 

251 

252Here, you can pass values to :flag:`--list` as separate arguments: 

253 

254.. code-block:: console 

255 

256 $ app --list 1 2 3 

257 [1, 2, 3] 

258 

259If you specify value for :flag:`--list` inline, it will be handled as 

260a delimiter-separated list: 

261 

262.. code-block:: console 

263 

264 $ app --list='1 2 3' 

265 [1, 2, 3] 

266 

267This allows resolving ambiguities between flags and positional arguments: 

268 

269.. code-block:: console 

270 

271 $ app --list='1 2 3' subcommand 

272 

273Technically, :flag:`--list 1 2 3` causes Yuio to invoke 

274``list_parser.parse_many(["1", "2", "3"])``, while :flag:`--list='1 2 3'` causes Yuio 

275to invoke ``list_parser.parse("1 2 3")``. 

276 

277 

278.. _flags-with-optional-values: 

279 

280Handling flags with optional values 

281----------------------------------- 

282 

283When designing a CLI, one important question is how to handle flags with optional 

284values, if at all. There are several things to consider: 

285 

2861. Does a flag have clear and predictable behavior when its value is not specified? 

287 

288 For boolean flags the default behavior is obvious: :flag:`--use-gpu` will enable 

289 GPU, i.e. it is equivalent to :flag:`--use-gpu=true`. 

290 

291 For flags that accept non-boolean values, though, things get messier. What will 

292 a flag like :flag:`--n-threads` do? Will it calculate number of threads based on 

293 available CPU cores? Will it use some default value? 

294 

295 In these cases, it is usually better to require a sentinel value: 

296 :flag:`--n-threads=auto`. 

297 

2982. Where should flag's value go, it it's provided? 

299 

300 We can only allow passing value inline, i.e. :flag:`--use-gpu=true`. Or we can 

301 greedily take the following argument as flag's value, i.e. :flag:`--use-gpu true`. 

302 

303 The later approach has a significant downside: we don't know 

304 whether the next argument was intended for the flag or for a free-standing option. 

305 

306 For example: 

307 

308 .. code-block:: console 

309 

310 $ my-renderer --color true # is `true` meant for `--color`, 

311 $ # or is it a subcommand for `my-renderer`? 

312 

313Here's how Yuio handles this dilemma: 

314 

3151. High level API does not allow creating flags with optional values. 

316 

317 To create one, you have to make a custom implementation of :class:`yuio.cli.Option` 

318 and set its :attr:`~yuio.cli.Option.allow_no_args` to :data:`True`. This will 

319 correspond to the greedy approach. 

320 

321 .. note:: 

322 

323 Positionals with defaults are treated as optional because they don't 

324 create ambiguities. 

325 

3262. Boolean flags allow specifying value inline, but not as a separate argument. 

327 

3283. Yuio does not allow passing inline values to short boolean flags 

329 without adding an equals sign. For example, :flag:`-ftrue` will not work, 

330 while :flag:`-f=true` will. 

331 

332 This is done to enable grouping short flags: :flag:`ls -laH` should be parsed 

333 as :flag:`ls -l -a -H`, not as :flag:`ls -l=aH`. 

334 

3354. On lower levels of API, Yuio allows precise control over these behavior 

336 by setting :attr:`Option.nargs <yuio.cli.Option.nargs>`, 

337 :attr:`Option.allow_no_args <yuio.cli.Option.allow_no_args>`, 

338 :attr:`Option.allow_inline_arg <yuio.cli.Option.allow_inline_arg>`, 

339 and :attr:`Option.allow_implicit_inline_arg <yuio.cli.Option.allow_implicit_inline_arg>`. 

340 

341 

342.. _custom-cli-options: 

343 

344Creating custom CLI options 

345--------------------------- 

346 

347You can override default behavior and presentation of a CLI option by passing 

348custom `option_ctor` to :func:`field`. Furthermore, you can create your own 

349implementation of :class:`yuio.cli.Option` to further fine-tune how an option 

350is parsed, presented in CLI help, etc. 

351 

352.. autofunction:: bool_option 

353 

354.. autofunction:: parse_one_option 

355 

356.. autofunction:: parse_many_option 

357 

358.. autofunction:: store_const_option 

359 

360.. autofunction:: count_option 

361 

362.. autofunction:: store_true_option 

363 

364.. autofunction:: store_false_option 

365 

366.. type:: OptionCtor 

367 :canonical: typing.Callable[[OptionSettings], yuio.cli.Option[T]] 

368 

369 CLI option constructor. Takes a single positional argument 

370 of type :class:`OptionSettings`, and returns an instance 

371 of :class:`yuio.cli.Option`. 

372 

373.. autoclass:: OptionSettings 

374 :members: 

375 

376 

377Re-imports 

378---------- 

379 

380.. type:: HelpGroup 

381 :no-index: 

382 

383 Alias of :obj:`yuio.cli.HelpGroup`. 

384 

385.. type:: MutuallyExclusiveGroup 

386 :no-index: 

387 

388 Alias of :obj:`yuio.cli.MutuallyExclusiveGroup`. 

389 

390.. data:: MISC_GROUP 

391 :no-index: 

392 

393 Alias of :obj:`yuio.cli.MISC_GROUP`. 

394 

395.. data:: OPTS_GROUP 

396 :no-index: 

397 

398 Alias of :obj:`yuio.cli.OPTS_GROUP`. 

399 

400.. data:: SUBCOMMANDS_GROUP 

401 :no-index: 

402 

403 Alias of :obj:`yuio.cli.SUBCOMMANDS_GROUP`. 

404 

405""" 

406 

407from __future__ import annotations 

408 

409import dataclasses 

410import functools 

411import inspect 

412import json 

413import logging 

414import pathlib 

415import sys 

416import types 

417from dataclasses import dataclass 

418 

419import yuio 

420import yuio.cli 

421import yuio.complete 

422import yuio.config 

423import yuio.dbg 

424import yuio.io 

425import yuio.parse 

426import yuio.string 

427import yuio.term 

428import yuio.theme 

429import yuio.util 

430from yuio.cli import ( 

431 MISC_GROUP, 

432 OPTS_GROUP, 

433 SUBCOMMANDS_GROUP, 

434 HelpGroup, 

435 MutuallyExclusiveGroup, 

436) 

437from yuio.config import ( 

438 OptionCtor, 

439 OptionSettings, 

440 bool_option, 

441 count_option, 

442 field, 

443 inline, 

444 parse_many_option, 

445 parse_one_option, 

446 positional, 

447 store_const_option, 

448 store_false_option, 

449 store_true_option, 

450) 

451from yuio.util import _find_docs 

452from yuio.util import to_dash_case as _to_dash_case 

453 

454from typing import TYPE_CHECKING 

455 

456if TYPE_CHECKING: 

457 import typing_extensions as _t 

458else: 

459 from yuio import _typing as _t 

460 

461__all__ = [ 

462 "MISC_GROUP", 

463 "OPTS_GROUP", 

464 "SUBCOMMANDS_GROUP", 

465 "App", 

466 "AppError", 

467 "CommandInfo", 

468 "HelpGroup", 

469 "MutuallyExclusiveGroup", 

470 "OptionCtor", 

471 "OptionSettings", 

472 "app", 

473 "bool_option", 

474 "count_option", 

475 "field", 

476 "inline", 

477 "parse_many_option", 

478 "parse_one_option", 

479 "positional", 

480 "store_const_option", 

481 "store_false_option", 

482 "store_true_option", 

483] 

484 

485C = _t.TypeVar("C", bound=_t.Callable[..., None | bool]) 

486C2 = _t.TypeVar("C2", bound=_t.Callable[..., None | bool]) 

487 

488 

489class AppError(yuio.PrettyException, Exception): 

490 """ 

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

492 a traceback. 

493 

494 """ 

495 

496 

497@_t.overload 

498def app( 

499 *, 

500 prog: str | None = None, 

501 usage: str | None = None, 

502 description: str | None = None, 

503 epilog: str | None = None, 

504 version: str | None = None, 

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

506 is_dev_mode: bool | None = None, 

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

508@_t.overload 

509def app( 

510 command: C, 

511 /, 

512 *, 

513 prog: str | None = None, 

514 usage: str | None = None, 

515 description: str | None = None, 

516 epilog: str | None = None, 

517 version: str | None = None, 

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

519 is_dev_mode: bool | None = None, 

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

521def app( 

522 command: _t.Callable[..., None | bool] | None = None, 

523 /, 

524 *, 

525 prog: str | None = None, 

526 usage: str | None = None, 

527 description: str | None = None, 

528 epilog: str | None = None, 

529 allow_abbrev: bool = False, 

530 subcommand_required: bool = True, 

531 setup_logging: bool = True, 

532 theme: ( 

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

534 ) = None, 

535 version: str | None = None, 

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

537 is_dev_mode: bool | None = None, 

538) -> _t.Any: 

539 """ 

540 Create an application. 

541 

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

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

544 

545 :param command: 

546 the main function of the application. 

547 :param prog: 

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

549 :param usage: 

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

551 :param description: 

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

553 :param epilog: 

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

555 :param allow_abbrev: 

556 whether to allow abbreviating unambiguous flags, see :attr:`App.allow_abbrev`. 

557 :param subcommand_required: 

558 whether this app requires a subcommand, 

559 see :attr:`App.subcommand_required`. 

560 :param setup_logging: 

561 whether to perform basic logging setup on startup, 

562 see :attr:`App.setup_logging`. 

563 :param theme: 

564 overrides theme that will be used when setting up :mod:`yuio.io`, 

565 see :attr:`App.theme`. 

566 :param version: 

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

568 :param bug_report: 

569 settings for automated bug report generation. If present, 

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

571 :param is_dev_mode: 

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

573 :returns: 

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

575 

576 """ 

577 

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

579 return App( 

580 command, 

581 prog=prog, 

582 usage=usage, 

583 description=description, 

584 epilog=epilog, 

585 allow_abbrev=allow_abbrev, 

586 subcommand_required=subcommand_required, 

587 setup_logging=setup_logging, 

588 theme=theme, 

589 version=version, 

590 bug_report=bug_report, 

591 is_dev_mode=is_dev_mode, 

592 ) 

593 

594 if command is None: 

595 return registrar 

596 else: 

597 return registrar(command) 

598 

599 

600@_t.final 

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

602class CommandInfo: 

603 """ 

604 Data about the invoked command. 

605 

606 """ 

607 

608 name: str 

609 """ 

610 Name of the current command. 

611 

612 If it was invoked by alias, 

613 this will contains the primary command name. 

614 

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

616 

617 """ 

618 

619 # Internal, do not use. 

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

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

622 _subcommand: CommandInfo | None | yuio.Missing = dataclasses.field( 

623 default=yuio.MISSING, repr=False 

624 ) 

625 

626 @property 

627 def subcommand(self) -> CommandInfo | None: 

628 """ 

629 Subcommand of this command, if one was given. 

630 

631 """ 

632 

633 if self._subcommand is yuio.MISSING: 

634 if self._config._subcommand is None: 

635 subcommand = None 

636 else: 

637 subcommand = CommandInfo( 

638 self._config._subcommand, self._config._subcommand_ns.config 

639 ) 

640 object.__setattr__(self, "_subcommand", subcommand) 

641 return self._subcommand # pyright: ignore[reportReturnType] 

642 

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

644 """ 

645 Execute this command. 

646 

647 """ 

648 

649 if self._executed: 

650 return False 

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

652 

653 should_invoke_subcommand = self._config._run(self) 

654 if should_invoke_subcommand is None: 

655 should_invoke_subcommand = True 

656 

657 if should_invoke_subcommand and self.subcommand is not None: 

658 self.subcommand() 

659 

660 return False 

661 

662 

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

664 """ 

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

666 

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

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

669 parameters. 

670 

671 """ 

672 

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

674 class _SubApp: 

675 app: App[_t.Any] 

676 name: str 

677 aliases: list[str] | None = None 

678 is_primary: bool = False 

679 

680 def __init__( 

681 self, 

682 command: C, 

683 /, 

684 *, 

685 prog: str | None = None, 

686 usage: str | None = None, 

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

688 description: str | None = None, 

689 epilog: str | None = None, 

690 allow_abbrev: bool = False, 

691 subcommand_required: bool = True, 

692 setup_logging: bool = True, 

693 theme: ( 

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

695 ) = None, 

696 version: str | None = None, 

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

698 is_dev_mode: bool | None = None, 

699 ): 

700 self.prog: str | None = prog 

701 """ 

702 Program or subcommand's primary name. 

703 

704 For main app, this controls its display name and generation of shell completion 

705 scripts. 

706 

707 For subcommands, this is always equal to subcommand's main name. 

708 

709 By default, inferred from :data:`sys.argv` and subcommand name. 

710 

711 """ 

712 

713 self.usage: str | None = usage 

714 """ 

715 Program or subcommand synapsis. 

716 

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

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

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

720 on a separate string. For example:: 

721 

722 @app 

723 def main(): ... 

724 

725 main.usage = \""" 

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

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

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

729 ... 

730 \""" 

731 

732 By default, usage is generated from CLI flags. 

733 

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

735 in :mod:`argparse`. 

736 

737 """ 

738 

739 if not description and command.__doc__: 

740 description = yuio.util._process_docstring( 

741 command.__doc__, only_first_paragraph=False 

742 ) 

743 

744 self.description: str | None = description 

745 """ 

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

747 short description of the program or subcommand. 

748 

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

750 

751 .. code-block:: python 

752 

753 @app 

754 def main(): ... 

755 

756 main.description = \""" 

757 This command does a thing. 

758 

759 # Different ways to do a thing: 

760 

761 This command can apply multiple algorithms to achieve 

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

763 

764 - randomly turning the screen on and off; 

765 

766 - banging a head on a table; 

767 

768 - fiddling with your PCs power cord. 

769 

770 By default, the best algorithm is determined automatically. 

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

772 

773 \""" 

774 

775 By default, inferred from command's docstring. 

776 

777 """ 

778 

779 if help is None and description: 

780 help = description 

781 if (index := help.find("\n\n")) != -1: 

782 help = help[:index] 

783 

784 self.help: str | yuio.Disabled | None = help 

785 """ 

786 Short help message that is shown when listing subcommands. 

787 

788 By default, uses first paragraph of description. 

789 

790 """ 

791 

792 self.epilog: str | None = epilog 

793 """ 

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

795 

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

797 

798 """ 

799 

800 self.allow_abbrev: bool = allow_abbrev 

801 """ 

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

803 

804 Disabled by default. 

805 

806 """ 

807 

808 self.subcommand_required: bool = subcommand_required 

809 """ 

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

811 

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

813 

814 Enabled by default. 

815 

816 """ 

817 

818 self.setup_logging: bool = setup_logging 

819 """ 

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

821 its initialization. Disable this if you want to customize 

822 logging initialization. 

823 

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

825 

826 """ 

827 

828 self.theme: ( 

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

830 ) = theme 

831 """ 

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

833 on application startup. 

834 

835 """ 

836 

837 self.version: str | None = version 

838 """ 

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

840 

841 """ 

842 

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

844 """ 

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

846 

847 This flag automatically collects data about environment and prints it 

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

849 

850 """ 

851 

852 self.is_dev_mode: bool | None = is_dev_mode 

853 """ 

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

855 and configure internal Yuio logging to show warnings. 

856 

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

858 contains substring ``"dev"``. 

859 

860 .. note:: 

861 

862 You can always enable full debug logging by setting environment 

863 variable ``YUIO_DEBUG``. 

864 

865 If enabled, full log will be saved to ``YUIO_DEBUG_FILE``. 

866 

867 """ 

868 

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

870 

871 if callable(command): 

872 self.__config_type = _command_from_callable(command) 

873 else: 

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

875 

876 functools.update_wrapper( 

877 self, # type: ignore 

878 command, 

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

880 updated=(), 

881 ) 

882 

883 self._command = command 

884 

885 @functools.wraps(command) 

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

887 if args: 

888 names = self.__config_type.__annotations__ 

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

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

891 raise TypeError( 

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

893 ) 

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

895 if name in kwargs: 

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

897 kwargs[name] = arg 

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

899 

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

901 """ 

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

903 

904 """ 

905 

906 @_t.overload 

907 def subcommand( 

908 self, 

909 /, 

910 *, 

911 name: str | None = None, 

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

913 usage: str | None = None, 

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

915 description: str | None = None, 

916 epilog: str | None = None, 

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

918 

919 @_t.overload 

920 def subcommand( 

921 self, 

922 cb: C2, 

923 /, 

924 *, 

925 name: str | None = None, 

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

927 usage: str | None = None, 

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

929 description: str | None = None, 

930 epilog: str | None = None, 

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

932 

933 def subcommand( 

934 self, 

935 cb: _t.Callable[..., None | bool] | None = None, 

936 /, 

937 *, 

938 name: str | None = None, 

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

940 usage: str | None = None, 

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

942 description: str | None = None, 

943 epilog: str | None = None, 

944 subcommand_required: bool = True, 

945 ) -> _t.Any: 

946 """ 

947 Register a subcommand for the given app. 

948 

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

950 

951 :param name: 

952 allows overriding subcommand's name. 

953 :param aliases: 

954 allows adding alias names for subcommand. 

955 :param usage: 

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

957 :param help: 

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

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

960 :param description: 

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

962 :param epilog: 

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

964 :param subcommand_required: 

965 whether this subcommand requires another subcommand, 

966 see :attr:`App.subcommand_required`. 

967 :returns: 

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

969 

970 """ 

971 

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

973 main_name = name or _to_dash_case(cb.__name__) 

974 app = App( 

975 cb, 

976 prog=main_name, 

977 usage=usage, 

978 help=help, 

979 description=description, 

980 epilog=epilog, 

981 subcommand_required=subcommand_required, 

982 ) 

983 

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

985 app, main_name, aliases, is_primary=True 

986 ) 

987 if aliases: 

988 alias_app = App._SubApp(app, main_name) 

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

990 

991 return app 

992 

993 if cb is None: 

994 return registrar 

995 else: 

996 return registrar(cb) 

997 

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

999 """ 

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

1001 and run the application. 

1002 

1003 :param args: 

1004 command line arguments. If none are given, 

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

1006 :returns: 

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

1008 

1009 """ 

1010 

1011 if args is None: 

1012 args = sys.argv[1:] 

1013 

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

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

1016 _run_custom_completer( 

1017 self.__make_cli_command(root=True), args[index + 1], args[index + 2] 

1018 ) 

1019 sys.exit(0) 

1020 

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

1022 from yuio.dbg import print_report 

1023 

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

1025 sys.exit(0) 

1026 

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

1028 

1029 try: 

1030 if self.is_dev_mode is None: 

1031 self.is_dev_mode = ( 

1032 self.version is not None and "dev" in self.version.casefold() 

1033 ) 

1034 if self.is_dev_mode: 

1035 yuio.enable_internal_logging(add_handler=True) 

1036 

1037 cli_command = self.__make_cli_command(root=True) 

1038 namespace = yuio.cli.CliParser(cli_command).parse(args) 

1039 

1040 if self.setup_logging: 

1041 logging_level = { 

1042 0: logging.WARNING, 

1043 1: logging.INFO, 

1044 2: logging.DEBUG, 

1045 }.get(namespace["_verbosity"], logging.DEBUG) 

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

1047 

1048 command = CommandInfo("__main__", _config=namespace.config) 

1049 command() 

1050 sys.exit(0) 

1051 except yuio.cli.ArgumentError as e: 

1052 yuio.io.raw(e, add_newline=True) 

1053 sys.exit(1) 

1054 except (AppError, yuio.cli.ArgumentError, yuio.parse.ParsingError) as e: 

1055 yuio.io.failure(e) 

1056 sys.exit(1) 

1057 except KeyboardInterrupt: 

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

1059 sys.exit(130) 

1060 except Exception as e: 

1061 yuio.io.failure_with_tb("Error: %s", e) 

1062 sys.exit(3) 

1063 finally: 

1064 yuio.io.restore_streams() 

1065 

1066 def __make_cli_command(self, root: bool = False): 

1067 options = self.__config_type._build_options() 

1068 

1069 if root: 

1070 options.append(yuio.cli.HelpOption()) 

1071 if self.version: 

1072 options.append(yuio.cli.VersionOption(version=self.version)) 

1073 if self.setup_logging: 

1074 options.append( 

1075 yuio.cli.CountOption( 

1076 flags=["-v", "--verbose"], 

1077 usage=yuio.GROUP, 

1078 help="Increase output verbosity.", 

1079 help_group=yuio.cli.MISC_GROUP, 

1080 show_if_inherited=False, 

1081 dest="_verbosity", 

1082 ) 

1083 ) 

1084 if self.bug_report: 

1085 options.append(yuio.cli.BugReportOption(app=self)) 

1086 options.append(yuio.cli.CompletionOption()) 

1087 options.append(_ColorOption()) 

1088 

1089 subcommands = {} 

1090 for sub_app in self.__sub_apps.values(): 

1091 if not sub_app.is_primary: 

1092 continue 

1093 aubcommand = sub_app.app.__make_cli_command() 

1094 subcommands[sub_app.name] = aubcommand 

1095 for alias in sub_app.aliases or []: 

1096 subcommands[alias] = aubcommand 

1097 

1098 return yuio.cli.Command( 

1099 name=self.prog or pathlib.Path(sys.argv[0]).stem, 

1100 desc=self.description or "", 

1101 help=self.help if self.help is not None else "", 

1102 epilog=self.epilog or "", 

1103 usage=self.usage, 

1104 options=options, 

1105 subcommands=subcommands, 

1106 subcommand_required=self.subcommand_required, 

1107 ns_ctor=lambda: yuio.cli.ConfigNamespace(self.__config_type()), 

1108 dest="_subcommand", 

1109 ns_dest="_subcommand_ns", 

1110 ) 

1111 

1112 

1113def _command_from_callable( 

1114 cb: _t.Callable[..., None | bool], 

1115) -> type[yuio.config.Config]: 

1116 sig = inspect.signature(cb) 

1117 

1118 dct = {} 

1119 annotations = {} 

1120 

1121 accepts_command_info = False 

1122 

1123 try: 

1124 docs = _find_docs(cb) 

1125 except Exception: 

1126 yuio._logger.warning( 

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

1128 cb.__module__, 

1129 cb.__qualname__, 

1130 ) 

1131 docs = {} 

1132 

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

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

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

1136 

1137 if name.startswith("_"): 

1138 if name == "_command_info": 

1139 accepts_command_info = True 

1140 continue 

1141 else: 

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

1143 

1144 if param.default is not param.empty: 

1145 field = param.default 

1146 else: 

1147 field = yuio.MISSING 

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

1149 field = _t.cast( 

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

1151 ) 

1152 if name in docs: 

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

1154 

1155 if param.annotation is param.empty: 

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

1157 

1158 dct[name] = field 

1159 annotations[name] = param.annotation 

1160 

1161 dct["_run"] = _command_from_callable_run_impl( 

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

1163 ) 

1164 dct["_color"] = None 

1165 dct["_verbosity"] = 0 

1166 dct["_subcommand"] = None 

1167 dct["_subcommand_ns"] = None 

1168 dct["__annotations__"] = annotations 

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

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

1171 

1172 return types.new_class( 

1173 cb.__name__, 

1174 (yuio.config.Config,), 

1175 {"_allow_positionals": True}, 

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

1177 ) 

1178 

1179 

1180def _command_from_callable_run_impl( 

1181 cb: _t.Callable[..., None | bool], params: list[str], accepts_command_info 

1182): 

1183 def run(self, command_info): 

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

1185 if accepts_command_info: 

1186 kw["_command_info"] = command_info 

1187 return cb(**kw) 

1188 

1189 return run 

1190 

1191 

1192def _run_custom_completer(command: yuio.cli.Command[_t.Any], raw_data: str, word: str): 

1193 data = json.loads(raw_data) 

1194 path: str = data["path"] 

1195 flags: set[str] = set(data["flags"]) 

1196 index: int = data["index"] 

1197 

1198 root = command 

1199 for name in path.split("/"): 

1200 if not name: 

1201 continue 

1202 if name not in command.subcommands: 

1203 return 

1204 root = command.subcommands[name] 

1205 

1206 positional_index = 0 

1207 for option in root.options: 

1208 option_flags = option.flags 

1209 if option_flags is yuio.POSITIONAL: 

1210 option_flags = [str(positional_index)] 

1211 positional_index += 1 

1212 if flags.intersection(option_flags): 

1213 completer, is_many = option.get_completer() 

1214 break 

1215 else: 

1216 completer, is_many = None, False 

1217 

1218 if completer: 

1219 yuio.complete._run_completer_at_index(completer, is_many, index, word) 

1220 

1221 

1222@dataclass(eq=False, kw_only=True) 

1223class _ColorOption(yuio.cli.Option[_t.Never]): 

1224 # `yuio.term` will scan `sys.argv` on its own, this option just checks format 

1225 # and adds help entry. 

1226 

1227 _ALLOWED_VALUES = ( 

1228 "y", 

1229 "yes", 

1230 "true", 

1231 "1", 

1232 "n", 

1233 "no", 

1234 "false", 

1235 "0", 

1236 "ansi", 

1237 "ansi-256", 

1238 "ansi-true", 

1239 ) 

1240 

1241 _PUBLIC_VALUES = ( 

1242 ("true", "3-bit colors or higher"), 

1243 ("false", "disable colors"), 

1244 ("ansi", "force 3-bit colors"), 

1245 ("ansi-256", "force 8-bit colors"), 

1246 ("ansi-true", "force 24-bit colors"), 

1247 ) 

1248 

1249 def __init__(self): 

1250 super().__init__( 

1251 flags=["--color", "--no-color"], 

1252 allow_inline_arg=True, 

1253 allow_implicit_inline_arg=True, 

1254 nargs=0, 

1255 allow_no_args=True, 

1256 required=False, 

1257 metavar=(), 

1258 mutex_group=None, 

1259 usage=yuio.GROUP, 

1260 help="Enable or disable ANSI colors.", 

1261 help_group=yuio.cli.MISC_GROUP, 

1262 show_if_inherited=False, 

1263 allow_abbrev=False, 

1264 ) 

1265 

1266 def process( 

1267 self, 

1268 cli_parser: yuio.cli.CliParser[yuio.cli.Namespace], 

1269 flag: yuio.cli.Flag | None, 

1270 arguments: yuio.cli.Argument | list[yuio.cli.Argument], 

1271 ns: yuio.cli.Namespace, 

1272 ): 

1273 if isinstance(arguments, yuio.cli.Argument): 

1274 if flag and flag.value == "--no-color": 

1275 raise yuio.cli.ArgumentError( 

1276 "This flag can't have arguments", flag=flag, arguments=arguments 

1277 ) 

1278 if arguments.value.casefold() not in self._ALLOWED_VALUES: 

1279 raise yuio.cli.ArgumentError( 

1280 "Can't parse `%r` as color, should be %s", 

1281 arguments.value, 

1282 yuio.string.Or(value for value, _ in self._PUBLIC_VALUES), 

1283 flag=flag, 

1284 arguments=arguments, 

1285 ) 

1286 

1287 @functools.cached_property 

1288 def primary_short_flag(self): 

1289 return None 

1290 

1291 @functools.cached_property 

1292 def primary_long_flags(self): 

1293 return ["--color", "--no-color"] 

1294 

1295 def format_alias_flags( 

1296 self, 

1297 ctx: yuio.string.ReprContext, 

1298 /, 

1299 *, 

1300 all: bool = False, 

1301 ) -> list[yuio.string.ColorizedString] | None: 

1302 if self.flags is yuio.POSITIONAL: 

1303 return None 

1304 

1305 primary_flags = set(self.primary_long_flags or []) 

1306 if self.primary_short_flag: 

1307 primary_flags.add(self.primary_short_flag) 

1308 

1309 aliases: list[yuio.string.ColorizedString] = [] 

1310 flag_color = ctx.get_color("hl/flag:sh-usage") 

1311 punct_color = ctx.get_color("hl/punct:sh-usage") 

1312 metavar_color = ctx.get_color("hl/metavar:sh-usage") 

1313 res = yuio.string.ColorizedString() 

1314 res.start_no_wrap() 

1315 res.append_color(flag_color) 

1316 res.append_str("--color") 

1317 res.end_no_wrap() 

1318 res.append_color(punct_color) 

1319 res.append_str("={") 

1320 sep = False 

1321 for value, _ in self._PUBLIC_VALUES: 

1322 if sep: 

1323 res.append_color(punct_color) 

1324 res.append_str("|") 

1325 res.append_color(metavar_color) 

1326 res.append_str(value) 

1327 sep = True 

1328 res.append_color(punct_color) 

1329 res.append_str("}") 

1330 aliases.append(res) 

1331 return aliases 

1332 

1333 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]: 

1334 return yuio.complete.Choice( 

1335 [ 

1336 yuio.complete.Option(value, comment) 

1337 for value, comment in self._PUBLIC_VALUES 

1338 ] 

1339 ), False