Coverage for yuio / app.py: 94%

365 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-03 15:42 +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, # [1]_ 

24 /, 

25 *, 

26 #: help message for `--flag` 

27 flag: int = 0 # [2]_ 

28 ): 

29 \"""this command does a thing\""" 

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

31 

32 if __name__ == "__main__": 

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

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

35 # python process with an appropriate exit code. 

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

37 

38.. code-annotations:: 

39 

40 1. Positional-only arguments become positional CLI options. 

41 2. Other arguments become CLI flags. 

42 

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

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

45 

46Help messages for the flags are parsed from line comments 

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

48They are all formatted using Markdown or RST depending on :attr:`App.doc_format`. 

49 

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

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

52 

53Arguments with bool parsers and parsers that support 

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

55are handled to provide better CLI experience: 

56 

57.. invisible-code-block: python 

58 

59 import pathlib 

60 

61.. code-block:: python 

62 

63 @app 

64 def main( 

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

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

67 # to reduce clutter. 

68 verbose: bool = False, 

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

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

71 ): ... 

72 

73.. autofunction:: app 

74 

75.. autoclass:: App 

76 

77 .. automethod:: run 

78 

79 .. method:: wrapped(...) 

80 

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

82 

83 

84Configuring CLI arguments 

85------------------------- 

86 

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

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

89 

90.. autofunction:: field 

91 

92.. autofunction:: inline 

93 

94 

95Using configs in CLI 

96-------------------- 

97 

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

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

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

101with :func:`field` or :func:`inline`: 

102 

103.. code-block:: python 

104 

105 class KillCmdConfig(yuio.config.Config): 

106 signal: int 

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

108 

109 

110 @app 

111 def main( 

112 kill_cmd: KillCmdConfig, # [1]_ 

113 kill_cmd_2: KillCmdConfig = field(flags="--kill"), # [2]_ 

114 kill_cmd_3: KillCmdConfig = field(flags=""), # [3]_ 

115 ): ... 

116 

117.. code-annotations:: 

118 

119 1. ``kill_cmd.signal`` will be loaded from :flag:`--kill-cmd-signal`. 

120 2. ``copy_cmd_2.signal`` will be loaded from :flag:`--kill-signal`. 

121 3. ``kill_cmd_3.signal`` will be loaded from :flag:`--signal`. 

122 

123.. note:: 

124 

125 Positional arguments are not allowed in configs, 

126 only in apps. 

127 

128 

129App settings 

130------------ 

131 

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

133help formatting using its arguments: 

134 

135.. class:: App 

136 :noindex: 

137 

138 .. autoattribute:: prog 

139 

140 .. autoattribute:: usage 

141 

142 .. autoattribute:: description 

143 

144 .. autoattribute:: help 

145 

146 .. autoattribute:: epilog 

147 

148 .. autoattribute:: allow_abbrev 

149 

150 .. autoattribute:: subcommand_required 

151 

152 .. autoattribute:: setup_logging 

153 

154 .. autoattribute:: theme 

155 

156 .. autoattribute:: version 

157 

158 .. autoattribute:: bug_report 

159 

160 .. autoattribute:: is_dev_mode 

161 

162 .. autoattribute:: doc_format 

163 

164 

165Creating sub-commands 

166--------------------- 

167 

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

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

170 

171.. code-block:: python 

172 

173 @app 

174 def main(): ... 

175 

176 

177 @main.subcommand 

178 def do_stuff(): ... 

179 

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

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

182 

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

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

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

186 

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

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

189 

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

191 

192.. class:: App 

193 :noindex: 

194 

195 .. automethod:: subcommand 

196 

197 

198.. _sub-commands-more: 

199 

200Controlling how sub-commands are invoked 

201---------------------------------------- 

202 

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

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

205to :data:`False`. 

206 

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

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

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

210including its name and subcommand: 

211 

212.. code-block:: python 

213 

214 @app 

215 def main(_command_info: CommandInfo): 

216 if _command_info.subcommand is not None: 

217 # A subcommand was invoked. 

218 ... 

219 

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

221as a callable: 

222 

223.. code-block:: python 

224 

225 @app 

226 def main(_command_info: CommandInfo): 

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

228 _command_info.subcommand() # manually invoking a subcommand 

229 

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

231from the main function: 

232 

233.. code-block:: python 

234 

235 @app 

236 def main(_command_info: CommandInfo): 

237 ... 

238 # Subcommand will not be invoked. 

239 return False 

240 

241.. autoclass:: CommandInfo 

242 :members: 

243 

244 

245.. _flags-with-multiple-values: 

246 

247Handling options with multiple values 

248------------------------------------- 

249 

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

251by specifying multiple arguments. For example: 

252 

253.. code-block:: python 

254 

255 @yuio.app.app 

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

257 print(list) 

258 

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

260 

261.. code-block:: console 

262 

263 $ app --list 1 2 3 

264 [1, 2, 3] 

265 

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

267a delimiter-separated list: 

268 

269.. code-block:: console 

270 

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

272 [1, 2, 3] 

273 

274This allows resolving ambiguities between flags and positional arguments: 

275 

276.. code-block:: console 

277 

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

279 

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

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

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

283 

284 

285.. _flags-with-optional-values: 

286 

287Handling flags with optional values 

288----------------------------------- 

289 

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

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

292 

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

294 

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

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

297 

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

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

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

301 

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

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

304 

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

306 

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

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

309 

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

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

312 

313 For example: 

314 

315 .. code-block:: console 

316 

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

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

319 

320Here's how Yuio handles this dilemma: 

321 

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

323 

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

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

326 correspond to the greedy approach. 

327 

328 .. note:: 

329 

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

331 create ambiguities. 

332 

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

334 

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

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

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

338 

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

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

341 

3424. On lower levels of API, Yuio allows precise control over this behavior 

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

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

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

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

347 

348 

349.. _custom-cli-options: 

350 

351Creating custom CLI options 

352--------------------------- 

353 

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

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

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

357is parsed, presented in CLI help, etc. 

358 

359.. autofunction:: bool_option 

360 

361.. autofunction:: parse_one_option 

362 

363.. autofunction:: parse_many_option 

364 

365.. autofunction:: store_const_option 

366 

367.. autofunction:: count_option 

368 

369.. autofunction:: store_true_option 

370 

371.. autofunction:: store_false_option 

372 

373.. type:: OptionCtor 

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

375 

376 CLI option constructor. Takes a single positional argument 

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

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

379 

380.. autoclass:: OptionSettings 

381 :members: 

382 

383 

384Re-imports 

385---------- 

386 

387.. type:: HelpGroup 

388 :no-index: 

389 

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

391 

392.. type:: MutuallyExclusiveGroup 

393 :no-index: 

394 

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

396 

397.. data:: MISC_GROUP 

398 :no-index: 

399 

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

401 

402.. data:: OPTS_GROUP 

403 :no-index: 

404 

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

406 

407.. data:: SUBCOMMANDS_GROUP 

408 :no-index: 

409 

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

411 

412""" 

413 

414from __future__ import annotations 

415 

416import dataclasses 

417import functools 

418import inspect 

419import json 

420import logging 

421import pathlib 

422import sys 

423import types 

424from dataclasses import dataclass 

425 

426import yuio 

427import yuio.cli 

428import yuio.complete 

429import yuio.config 

430import yuio.dbg 

431import yuio.doc 

432import yuio.io 

433import yuio.parse 

434import yuio.string 

435import yuio.term 

436import yuio.theme 

437import yuio.util 

438from yuio.cli import ( 

439 MISC_GROUP, 

440 OPTS_GROUP, 

441 SUBCOMMANDS_GROUP, 

442 HelpGroup, 

443 MutuallyExclusiveGroup, 

444) 

445from yuio.config import ( 

446 OptionCtor, 

447 OptionSettings, 

448 bool_option, 

449 collect_option, 

450 count_option, 

451 field, 

452 inline, 

453 parse_many_option, 

454 parse_one_option, 

455 positional, 

456 store_const_option, 

457 store_false_option, 

458 store_true_option, 

459) 

460from yuio.util import find_docs as _find_docs 

461from yuio.util import to_dash_case as _to_dash_case 

462 

463from typing import TYPE_CHECKING 

464 

465if TYPE_CHECKING: 

466 import typing_extensions as _t 

467else: 

468 from yuio import _typing as _t 

469 

470__all__ = [ 

471 "MISC_GROUP", 

472 "OPTS_GROUP", 

473 "SUBCOMMANDS_GROUP", 

474 "App", 

475 "AppError", 

476 "CommandInfo", 

477 "HelpGroup", 

478 "MutuallyExclusiveGroup", 

479 "OptionCtor", 

480 "OptionSettings", 

481 "app", 

482 "bool_option", 

483 "collect_option", 

484 "count_option", 

485 "field", 

486 "inline", 

487 "parse_many_option", 

488 "parse_one_option", 

489 "positional", 

490 "store_const_option", 

491 "store_false_option", 

492 "store_true_option", 

493] 

494 

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

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

497 

498 

499class AppError(yuio.PrettyException, Exception): 

500 """ 

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

502 a traceback. 

503 

504 """ 

505 

506 

507@_t.overload 

508def app( 

509 *, 

510 prog: str | None = None, 

511 usage: str | None = None, 

512 description: str | None = None, 

513 epilog: str | None = None, 

514 version: str | None = None, 

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

516 is_dev_mode: bool | None = None, 

517 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None, 

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

519@_t.overload 

520def app( 

521 command: C, 

522 /, 

523 *, 

524 prog: str | None = None, 

525 usage: str | None = None, 

526 description: str | None = None, 

527 epilog: str | None = None, 

528 version: str | None = None, 

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

530 is_dev_mode: bool | None = None, 

531 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None, 

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

533def app( 

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

535 /, 

536 *, 

537 prog: str | None = None, 

538 usage: str | None = None, 

539 description: str | None = None, 

540 epilog: str | None = None, 

541 allow_abbrev: bool = False, 

542 subcommand_required: bool = True, 

543 setup_logging: bool = True, 

544 theme: ( 

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

546 ) = None, 

547 version: str | None = None, 

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

549 is_dev_mode: bool | None = None, 

550 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None, 

551) -> _t.Any: 

552 """ 

553 Create an application. 

554 

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

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

557 

558 :param command: 

559 the main function of the application. 

560 :param prog: 

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

562 :param usage: 

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

564 :param description: 

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

566 :param epilog: 

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

568 :param allow_abbrev: 

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

570 :param subcommand_required: 

571 whether this app requires a subcommand, 

572 see :attr:`App.subcommand_required`. 

573 :param setup_logging: 

574 whether to perform basic logging setup on startup, 

575 see :attr:`App.setup_logging`. 

576 :param theme: 

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

578 see :attr:`App.theme`. 

579 :param version: 

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

581 :param bug_report: 

582 settings for automated bug report generation. If present, 

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

584 :param is_dev_mode: 

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

586 :param doc_format: 

587 overrides program's documentation format, see :attr:`App.doc_format`. 

588 :returns: 

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

590 

591 """ 

592 

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

594 return App( 

595 command, 

596 prog=prog, 

597 usage=usage, 

598 description=description, 

599 epilog=epilog, 

600 allow_abbrev=allow_abbrev, 

601 subcommand_required=subcommand_required, 

602 setup_logging=setup_logging, 

603 theme=theme, 

604 version=version, 

605 bug_report=bug_report, 

606 is_dev_mode=is_dev_mode, 

607 doc_format=doc_format, 

608 ) 

609 

610 if command is None: 

611 return registrar 

612 else: 

613 return registrar(command) 

614 

615 

616@_t.final 

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

618class CommandInfo: 

619 """ 

620 Data about the invoked command. 

621 

622 """ 

623 

624 name: str 

625 """ 

626 Name of the current command. 

627 

628 If it was invoked by alias, 

629 this will contains the primary command name. 

630 

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

632 

633 """ 

634 

635 # Internal, do not use. 

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

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

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

639 default=yuio.MISSING, repr=False 

640 ) 

641 

642 @property 

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

644 """ 

645 Subcommand of this command, if one was given. 

646 

647 """ 

648 

649 if self._subcommand is yuio.MISSING: 

650 if self._config._subcommand is None: 

651 subcommand = None 

652 else: 

653 subcommand = CommandInfo( 

654 self._config._subcommand, self._config._subcommand_ns.config 

655 ) 

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

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

658 

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

660 """ 

661 Execute this command. 

662 

663 """ 

664 

665 if self._executed: 

666 return False 

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

668 

669 should_invoke_subcommand = self._config._run(self) 

670 if should_invoke_subcommand is None: 

671 should_invoke_subcommand = True 

672 

673 if should_invoke_subcommand and self.subcommand is not None: 

674 self.subcommand() 

675 

676 return False 

677 

678 

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

680 """ 

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

682 

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

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

685 parameters. 

686 

687 """ 

688 

689 def __init__( 

690 self, 

691 command: C, 

692 /, 

693 *, 

694 prog: str | None = None, 

695 usage: str | None = None, 

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

697 description: str | None = None, 

698 epilog: str | None = None, 

699 allow_abbrev: bool = False, 

700 subcommand_required: bool = True, 

701 setup_logging: bool = True, 

702 theme: ( 

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

704 ) = None, 

705 version: str | None = None, 

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

707 is_dev_mode: bool | None = None, 

708 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None, 

709 ): 

710 self.prog: str | None = prog 

711 """ 

712 Program or subcommand's primary name. 

713 

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

715 scripts. 

716 

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

718 

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

720 

721 """ 

722 

723 self.usage: str | None = usage 

724 """ 

725 Program or subcommand synapsis. 

726 

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

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

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

730 on a separate string. For example:: 

731 

732 @app 

733 def main(): ... 

734 

735 main.usage = \""" 

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

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

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

739 ... 

740 \""" 

741 

742 By default, usage is generated from CLI flags. 

743 

744 """ 

745 

746 if description is None and command.__doc__: 

747 description = yuio.util.dedent(command.__doc__).removesuffix("\n") 

748 if description is None: 

749 description = "" 

750 

751 self.description: str = description 

752 """ 

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

754 short description of the program or subcommand. 

755 

756 The text should be formatted using Markdown or RST, 

757 depending on :attr:`~App.doc_format`. For example: 

758 

759 .. code-block:: python 

760 

761 @yuio.app.app(doc_format="md") 

762 def main(): ... 

763 

764 main.description = \""" 

765 This command does a thing. 

766 

767 # Different ways to do a thing 

768 

769 This command can apply multiple algorithms to achieve 

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

771 

772 - randomly turning the screen on and off; 

773 

774 - banging a head on a table; 

775 

776 - fiddling with your PCs power cord. 

777 

778 By default, the best algorithm is determined automatically. 

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

780 

781 \""" 

782 

783 By default, inferred from command's docstring. 

784 

785 """ 

786 

787 if help is None and description: 

788 help = description 

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

790 help = help[:index] 

791 elif help is None: 

792 help = "" 

793 

794 self.help: str | yuio.Disabled = help 

795 """ 

796 Short help message that is shown when listing subcommands. 

797 

798 By default, uses first paragraph of description. 

799 

800 """ 

801 

802 self.epilog: str | None = epilog 

803 """ 

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

805 

806 The text should be formatted using Markdown or RST, 

807 depending on :attr:`~App.doc_format`. 

808 

809 """ 

810 

811 self.allow_abbrev: bool = allow_abbrev 

812 """ 

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

814 

815 Disabled by default. 

816 

817 """ 

818 

819 self.subcommand_required: bool = subcommand_required 

820 """ 

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

822 

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

824 

825 Enabled by default. 

826 

827 """ 

828 

829 self.setup_logging: bool = setup_logging 

830 """ 

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

832 its initialization. Disable this if you want to customize 

833 logging initialization. 

834 

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

836 

837 """ 

838 

839 self.theme: ( 

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

841 ) = theme 

842 """ 

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

844 on application startup. 

845 

846 """ 

847 

848 self.version: str | None = version 

849 """ 

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

851 

852 """ 

853 

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

855 """ 

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

857 

858 This flag automatically collects data about environment and prints it 

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

860 

861 """ 

862 

863 self.is_dev_mode: bool | None = is_dev_mode 

864 """ 

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

866 and configure internal Yuio logging to show warnings. 

867 

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

869 contains substring ``"dev"``. 

870 

871 .. note:: 

872 

873 You can always enable full debug logging by setting environment 

874 variable ``YUIO_DEBUG``. 

875 

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

877 

878 """ 

879 

880 self.doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser = ( 

881 doc_format or "rst" 

882 ) 

883 """ 

884 Format or parser that will be used to interpret documentation. 

885 

886 """ 

887 

888 self._ordered_subcommands: list[App[_t.Any]] = [] 

889 self._subcommands: dict[str, App[_t.Any]] = {} 

890 self._parent: App[_t.Any] | None = None 

891 self._aliases: list[str] | None = None 

892 

893 if callable(command): 

894 self._config_type = _command_from_callable(command) 

895 else: 

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

897 

898 functools.update_wrapper( 

899 self, # type: ignore 

900 command, 

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

902 updated=(), 

903 ) 

904 

905 self._command = command 

906 

907 @functools.wraps(command) 

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

909 a_params: list[str] = getattr(self._config_type, "_a_params") 

910 a_kw_params: list[str] = getattr(self._config_type, "_a_kw_params") 

911 var_a_param: str | None = getattr(self._config_type, "_var_a_param") 

912 kw_params: list[str] = getattr(self._config_type, "_kw_params") 

913 

914 i = 0 

915 

916 for name in a_params: 

917 if name in kwargs: 

918 raise TypeError( 

919 f"positional-only argument {name} was given as keyword argument" 

920 ) 

921 if i < len(args): 

922 kwargs[name] = args[i] 

923 i += 1 

924 

925 for name in a_kw_params: 

926 if i >= len(args): 

927 break 

928 if name in kwargs: 

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

930 kwargs[name] = args[i] 

931 i += 1 

932 

933 if var_a_param: 

934 if var_a_param in kwargs: 

935 raise TypeError(f"unexpected argument {var_a_param}") 

936 kwargs[var_a_param] = args[i:] 

937 i = len(args) 

938 elif i < len(args): 

939 s = "" if i == 1 else "s" 

940 raise TypeError( 

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

942 ) 

943 

944 kwargs.pop("_command_info", None) 

945 

946 config = self._config_type(**kwargs) 

947 

948 for name in a_params + a_kw_params + kw_params: 

949 if not hasattr(config, name) and name != "_command_info": 

950 raise TypeError(f"missing required argument {name}") 

951 

952 return CommandInfo("__raw__", config, False)() 

953 

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

955 """ 

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

957 

958 """ 

959 

960 @_t.overload 

961 def subcommand( 

962 self, 

963 /, 

964 *, 

965 name: str | None = None, 

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

967 usage: str | None = None, 

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

969 description: str | None = None, 

970 epilog: str | None = None, 

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

972 

973 @_t.overload 

974 def subcommand( 

975 self, 

976 cb: C2, 

977 /, 

978 *, 

979 name: str | None = None, 

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

981 usage: str | None = None, 

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

983 description: str | None = None, 

984 epilog: str | None = None, 

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

986 

987 def subcommand( 

988 self, 

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

990 /, 

991 *, 

992 name: str | None = None, 

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

994 usage: str | None = None, 

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

996 description: str | None = None, 

997 epilog: str | None = None, 

998 subcommand_required: bool = True, 

999 ) -> _t.Any: 

1000 """ 

1001 Register a subcommand for the given app. 

1002 

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

1004 

1005 :param name: 

1006 allows overriding subcommand's name. 

1007 :param aliases: 

1008 allows adding alias names for subcommand. 

1009 :param usage: 

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

1011 :param help: 

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

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

1014 :param description: 

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

1016 :param epilog: 

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

1018 :param subcommand_required: 

1019 whether this subcommand requires another subcommand, 

1020 see :attr:`App.subcommand_required`. 

1021 :returns: 

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

1023 

1024 """ 

1025 

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

1027 main_name = name or _to_dash_case(cb.__name__) 

1028 app = App( 

1029 cb, 

1030 prog=main_name, 

1031 usage=usage, 

1032 help=help, 

1033 description=description, 

1034 epilog=epilog, 

1035 subcommand_required=subcommand_required, 

1036 ) 

1037 app._parent = self 

1038 app._aliases = aliases 

1039 

1040 self._ordered_subcommands.append(app) 

1041 self._subcommands[main_name] = app 

1042 if aliases: 

1043 self._subcommands.update({alias: app for alias in aliases}) 

1044 

1045 return app 

1046 

1047 if cb is None: 

1048 return registrar 

1049 else: 

1050 return registrar(cb) 

1051 

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

1053 """ 

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

1055 and run the application. 

1056 

1057 :param args: 

1058 command line arguments. If none are given, 

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

1060 :returns: 

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

1062 

1063 """ 

1064 

1065 if args is None: 

1066 args = sys.argv[1:] 

1067 

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

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

1070 _run_custom_completer( 

1071 self._make_cli_command(root=True), args[index + 1], args[index + 2] 

1072 ) 

1073 sys.exit(0) 

1074 

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

1076 from yuio.dbg import print_report 

1077 

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

1079 sys.exit(0) 

1080 

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

1082 

1083 try: 

1084 if self.is_dev_mode is None: 

1085 self.is_dev_mode = ( 

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

1087 ) 

1088 if self.is_dev_mode: 

1089 yuio.enable_internal_logging(add_handler=True) 

1090 

1091 help_parser = self._make_help_parser() 

1092 

1093 cli_command = self._make_cli_command(root=True) 

1094 namespace = yuio.cli.CliParser( 

1095 cli_command, help_parser=help_parser, allow_abbrev=self.allow_abbrev 

1096 ).parse(args) 

1097 

1098 if self.setup_logging: 

1099 logging_level = { 

1100 0: logging.WARNING, 

1101 1: logging.INFO, 

1102 2: logging.DEBUG, 

1103 }.get(namespace["_verbose"], logging.DEBUG) 

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

1105 

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

1107 command() 

1108 sys.exit(0) 

1109 except yuio.cli.ArgumentError as e: 

1110 yuio.io.raw(e, add_newline=True, wrap=True) 

1111 sys.exit(1) 

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

1113 yuio.io.failure(e) 

1114 sys.exit(1) 

1115 except KeyboardInterrupt: 

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

1117 sys.exit(130) 

1118 except Exception as e: 

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

1120 sys.exit(3) 

1121 finally: 

1122 yuio.io.restore_streams() 

1123 

1124 def _make_help_parser(self): 

1125 if self.doc_format == "md": 

1126 from yuio.md import MdParser 

1127 

1128 return MdParser() 

1129 elif self.doc_format == "rst": 

1130 from yuio.rst import RstParser 

1131 

1132 return RstParser() 

1133 else: 

1134 return self.doc_format 

1135 

1136 def _make_cli_command(self, root: bool = False): 

1137 options = self._config_type._build_options() 

1138 

1139 if root: 

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

1141 if self.version: 

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

1143 if self.setup_logging: 

1144 options.append( 

1145 yuio.cli.CountOption( 

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

1147 usage=yuio.COLLAPSE, 

1148 help="Increase output verbosity.", 

1149 help_group=yuio.cli.MISC_GROUP, 

1150 show_if_inherited=False, 

1151 dest="_verbose", 

1152 ) 

1153 ) 

1154 if self.bug_report: 

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

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

1157 options.append(_ColorOption()) 

1158 

1159 subcommands = {} 

1160 subcommand_for_app = {} 

1161 for name, sub_app in self._subcommands.items(): 

1162 if sub_app not in subcommand_for_app: 

1163 subcommand_for_app[sub_app] = sub_app._make_cli_command() 

1164 subcommands[name] = subcommand_for_app[sub_app] 

1165 

1166 return yuio.cli.Command( 

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

1168 desc=self.description or "", 

1169 help=self.help, 

1170 epilog=self.epilog or "", 

1171 usage=yuio.util.dedent(self.usage or ""), 

1172 options=options, 

1173 subcommands=subcommands, 

1174 subcommand_required=self.subcommand_required, 

1175 ns_ctor=lambda: yuio.cli.ConfigNamespace(self._config_type()), 

1176 dest="_subcommand", 

1177 ns_dest="_subcommand_ns", 

1178 ) 

1179 

1180 

1181def _command_from_callable( 

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

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

1184 sig = inspect.signature(cb) 

1185 

1186 dct = {} 

1187 annotations = {} 

1188 

1189 try: 

1190 docs = _find_docs(cb) 

1191 except Exception: 

1192 yuio._logger.warning( 

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

1194 cb.__module__, 

1195 cb.__qualname__, 

1196 ) 

1197 docs = {} 

1198 

1199 dct["_a_params"] = a_params = [] 

1200 dct["_var_a_param"] = var_a_param = None 

1201 dct["_a_kw_params"] = a_kw_params = [] 

1202 dct["_kw_params"] = kw_params = [] 

1203 

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

1205 if param.kind is param.VAR_KEYWORD: 

1206 raise TypeError("variadic keyword parameters are not supported") 

1207 

1208 is_special = False 

1209 if name.startswith("_"): 

1210 is_special = True 

1211 if name != "_command_info": 

1212 raise TypeError(f"unknown special parameter {name}") 

1213 if param.kind is param.VAR_POSITIONAL: 

1214 raise TypeError(f"special parameter {name} can't be variadic") 

1215 

1216 if param.default is not param.empty: 

1217 field = param.default 

1218 else: 

1219 field = yuio.MISSING 

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

1221 field = _t.cast( 

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

1223 ) 

1224 

1225 annotation = param.annotation 

1226 if annotation is param.empty and not is_special: 

1227 raise TypeError(f"parameter {name} requires type annotation") 

1228 

1229 match param.kind: 

1230 case param.POSITIONAL_ONLY: 

1231 if field.flags is None: 

1232 field = dataclasses.replace(field, flags=yuio.POSITIONAL) 

1233 a_params.append(name) 

1234 case param.VAR_POSITIONAL: 

1235 if field.flags is None: 

1236 field = dataclasses.replace(field, flags=yuio.POSITIONAL) 

1237 annotation = list[annotation] 

1238 dct["_var_a_param"] = var_a_param = name 

1239 case param.POSITIONAL_OR_KEYWORD: 

1240 a_kw_params.append(name) 

1241 case param.KEYWORD_ONLY: 

1242 kw_params.append(name) 

1243 

1244 if not is_special: 

1245 dct[name] = field 

1246 annotations[name] = annotation 

1247 

1248 dct["_run"] = _command_from_callable_run_impl( 

1249 cb, a_params + a_kw_params, var_a_param, kw_params 

1250 ) 

1251 dct["_color"] = None 

1252 dct["_verbose"] = 0 

1253 dct["_subcommand"] = None 

1254 dct["_subcommand_ns"] = None 

1255 dct["__annotations__"] = annotations 

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

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

1258 dct["__yuio_pre_parsed_docs__"] = docs 

1259 

1260 return types.new_class( 

1261 cb.__name__, 

1262 (yuio.config.Config,), 

1263 {"_allow_positionals": True}, 

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

1265 ) 

1266 

1267 

1268def _command_from_callable_run_impl( 

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

1270 a_params: list[str], 

1271 var_a_param: str | None, 

1272 kw_params: list[str], 

1273): 

1274 def run(self, command_info): 

1275 get = lambda name: ( 

1276 command_info if name == "_command_info" else getattr(self, name) 

1277 ) 

1278 args = [get(name) for name in a_params] 

1279 if var_a_param is not None: 

1280 args.extend(get(var_a_param)) 

1281 kwargs = {name: get(name) for name in kw_params} 

1282 return cb(*args, **kwargs) 

1283 

1284 return run 

1285 

1286 

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

1288 data = json.loads(raw_data) 

1289 path: str = data["path"] 

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

1291 index: int = data["index"] 

1292 

1293 root = command 

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

1295 if not name: 

1296 continue 

1297 if name not in command.subcommands: 

1298 return 

1299 root = command.subcommands[name] 

1300 

1301 positional_index = 0 

1302 for option in root.options: 

1303 option_flags = option.flags 

1304 if option_flags is yuio.POSITIONAL: 

1305 option_flags = [str(positional_index)] 

1306 positional_index += 1 

1307 if flags.intersection(option_flags): 

1308 completer, is_many = option.get_completer() 

1309 break 

1310 else: 

1311 completer, is_many = None, False 

1312 

1313 if completer: 

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

1315 

1316 

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

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

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

1320 # and adds help entry. 

1321 

1322 _ALLOWED_VALUES = ( 

1323 "y", 

1324 "yes", 

1325 "true", 

1326 "1", 

1327 "n", 

1328 "no", 

1329 "false", 

1330 "0", 

1331 "ansi", 

1332 "ansi-256", 

1333 "ansi-true", 

1334 ) 

1335 

1336 _PUBLIC_VALUES = ( 

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

1338 ("false", "disable colors"), 

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

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

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

1342 ) 

1343 

1344 def __init__(self): 

1345 super().__init__( 

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

1347 allow_inline_arg=True, 

1348 allow_implicit_inline_arg=True, 

1349 nargs=0, 

1350 allow_no_args=True, 

1351 required=False, 

1352 metavar=(), 

1353 mutex_group=None, 

1354 usage=yuio.COLLAPSE, 

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

1356 help_group=yuio.cli.MISC_GROUP, 

1357 show_if_inherited=False, 

1358 allow_abbrev=False, 

1359 dest="_color", 

1360 default_desc=None, 

1361 ) 

1362 

1363 def process( 

1364 self, 

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

1366 flag: yuio.cli.Flag | None, 

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

1368 ns: yuio.cli.Namespace, 

1369 ): 

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

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

1372 raise yuio.cli.ArgumentError( 

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

1374 ) 

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

1376 raise yuio.cli.ArgumentError( 

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

1378 arguments.value, 

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

1380 flag=flag, 

1381 arguments=arguments, 

1382 ) 

1383 

1384 @functools.cached_property 

1385 def primary_short_flag(self): 

1386 return None 

1387 

1388 @functools.cached_property 

1389 def primary_long_flags(self): 

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

1391 

1392 def format_alias_flags( 

1393 self, 

1394 ctx: yuio.string.ReprContext, 

1395 /, 

1396 *, 

1397 all: bool = False, 

1398 ) -> ( 

1399 list[yuio.string.ColorizedString | tuple[yuio.string.ColorizedString, str]] 

1400 | None 

1401 ): 

1402 if self.flags is yuio.POSITIONAL: 

1403 return None 

1404 

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

1406 if self.primary_short_flag: 

1407 primary_flags.add(self.primary_short_flag) 

1408 

1409 aliases: list[ 

1410 yuio.string.ColorizedString | tuple[yuio.string.ColorizedString, str] 

1411 ] = [] 

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

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

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

1415 res = yuio.string.ColorizedString() 

1416 res.start_no_wrap() 

1417 res.append_color(flag_color) 

1418 res.append_str("--color") 

1419 res.end_no_wrap() 

1420 res.append_color(punct_color) 

1421 res.append_str("={") 

1422 sep = False 

1423 for value, _ in self._PUBLIC_VALUES: 

1424 if sep: 

1425 res.append_color(punct_color) 

1426 res.append_str("|") 

1427 res.append_color(metavar_color) 

1428 res.append_str(value) 

1429 sep = True 

1430 res.append_color(punct_color) 

1431 res.append_str("}") 

1432 aliases.append(res) 

1433 return aliases 

1434 

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

1436 return yuio.complete.Choice( 

1437 [ 

1438 yuio.complete.Option(value, comment) 

1439 for value, comment in self._PUBLIC_VALUES 

1440 ] 

1441 ), False