Coverage for yuio / app.py: 88%

438 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-29 19:55 +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:: subcommand_required 

149 

150 .. autoattribute:: allow_abbrev 

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 .. automethod:: lazy_subcommand 

198 

199 

200.. _sub-commands-more: 

201 

202Controlling how sub-commands are invoked 

203---------------------------------------- 

204 

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

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

207to :data:`False`. 

208 

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

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

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

212including its name and subcommand: 

213 

214.. code-block:: python 

215 

216 @app 

217 def main(_command_info: CommandInfo): 

218 if _command_info.subcommand is not None: 

219 # A subcommand was invoked. 

220 ... 

221 

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

223as a callable: 

224 

225.. code-block:: python 

226 

227 @app 

228 def main(_command_info: CommandInfo): 

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

230 _command_info.subcommand() # manually invoking a subcommand 

231 

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

233from the main function: 

234 

235.. code-block:: python 

236 

237 @app 

238 def main(_command_info: CommandInfo): 

239 ... 

240 # Subcommand will not be invoked. 

241 return False 

242 

243.. autoclass:: CommandInfo 

244 :members: 

245 

246 

247.. _flags-with-multiple-values: 

248 

249Handling options with multiple values 

250------------------------------------- 

251 

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

253by specifying multiple arguments. For example: 

254 

255.. code-block:: python 

256 

257 @yuio.app.app 

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

259 print(list) 

260 

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

262 

263.. code-block:: console 

264 

265 $ app --list 1 2 3 

266 [1, 2, 3] 

267 

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

269a delimiter-separated list: 

270 

271.. code-block:: console 

272 

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

274 [1, 2, 3] 

275 

276This allows resolving ambiguities between flags and positional arguments: 

277 

278.. code-block:: console 

279 

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

281 

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

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

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

285 

286 

287.. _flags-with-optional-values: 

288 

289Handling flags with optional values 

290----------------------------------- 

291 

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

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

294 

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

296 

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

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

299 

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

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

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

303 

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

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

306 

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

308 

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

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

311 

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

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

314 

315 For example: 

316 

317 .. code-block:: console 

318 

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

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

321 

322Here's how Yuio handles this dilemma: 

323 

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

325 

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

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

328 correspond to the greedy approach. 

329 

330 .. note:: 

331 

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

333 create ambiguities. 

334 

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

336 

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

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

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

340 

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

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

343 

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

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

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

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

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

349 

350 

351.. _custom-cli-options: 

352 

353Creating custom CLI options 

354--------------------------- 

355 

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

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

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

359is parsed, presented in CLI help, etc. 

360 

361.. autofunction:: bool_option 

362 

363.. autofunction:: parse_one_option 

364 

365.. autofunction:: parse_many_option 

366 

367.. autofunction:: store_const_option 

368 

369.. autofunction:: count_option 

370 

371.. autofunction:: store_true_option 

372 

373.. autofunction:: store_false_option 

374 

375.. type:: OptionCtor 

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

377 

378 CLI option constructor. Takes a single positional argument 

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

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

381 

382.. autoclass:: OptionSettings 

383 :members: 

384 

385 

386Re-imports 

387---------- 

388 

389.. type:: HelpGroup 

390 :no-index: 

391 

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

393 

394.. type:: MutuallyExclusiveGroup 

395 :no-index: 

396 

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

398 

399.. data:: MISC_GROUP 

400 :no-index: 

401 

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

403 

404.. data:: OPTS_GROUP 

405 :no-index: 

406 

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

408 

409.. data:: SUBCOMMANDS_GROUP 

410 :no-index: 

411 

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

413 

414""" 

415 

416from __future__ import annotations 

417 

418import dataclasses 

419import functools 

420import inspect 

421import json 

422import logging 

423import pathlib 

424import sys 

425import types 

426from dataclasses import dataclass 

427 

428import yuio 

429import yuio.cli 

430import yuio.complete 

431import yuio.config 

432import yuio.dbg 

433import yuio.doc 

434import yuio.io 

435import yuio.parse 

436import yuio.string 

437import yuio.term 

438import yuio.theme 

439from yuio.cli import ( 

440 MISC_GROUP, 

441 OPTS_GROUP, 

442 SUBCOMMANDS_GROUP, 

443 HelpGroup, 

444 MutuallyExclusiveGroup, 

445) 

446from yuio.config import ( 

447 OptionCtor, 

448 OptionSettings, 

449 bool_option, 

450 collect_option, 

451 count_option, 

452 field, 

453 inline, 

454 parse_many_option, 

455 parse_one_option, 

456 positional, 

457 store_const_option, 

458 store_false_option, 

459 store_true_option, 

460) 

461from yuio.util import dedent as _dedent 

462from yuio.util import find_docs as _find_docs 

463from yuio.util import to_dash_case as _to_dash_case 

464 

465from typing import TYPE_CHECKING 

466from typing import ClassVar as _ClassVar 

467 

468if TYPE_CHECKING: 

469 import typing_extensions as _t 

470else: 

471 from yuio import _typing as _t 

472 

473__all__ = [ 

474 "MISC_GROUP", 

475 "OPTS_GROUP", 

476 "SUBCOMMANDS_GROUP", 

477 "App", 

478 "AppError", 

479 "CommandInfo", 

480 "HelpGroup", 

481 "MutuallyExclusiveGroup", 

482 "OptionCtor", 

483 "OptionSettings", 

484 "SubcommandRegistrar", 

485 "app", 

486 "bool_option", 

487 "collect_option", 

488 "count_option", 

489 "field", 

490 "inline", 

491 "parse_many_option", 

492 "parse_one_option", 

493 "positional", 

494 "store_const_option", 

495 "store_false_option", 

496 "store_true_option", 

497] 

498 

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

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

501CB = _t.TypeVar("CB", bound="App[_t.Any]") 

502 

503 

504class AppError(yuio.PrettyException, Exception): 

505 """ 

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

507 a traceback. 

508 

509 """ 

510 

511 

512@_t.overload 

513def app( 

514 *, 

515 prog: str | None = None, 

516 usage: str | None = None, 

517 description: str | None = None, 

518 epilog: str | None = None, 

519 version: str | None = None, 

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

521 is_dev_mode: bool | None = None, 

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

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

524@_t.overload 

525def app( 

526 command: C, 

527 /, 

528 *, 

529 prog: str | None = None, 

530 usage: str | None = None, 

531 description: str | None = None, 

532 epilog: str | None = None, 

533 version: str | None = None, 

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

535 is_dev_mode: bool | None = None, 

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

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

538def app( 

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

540 /, 

541 *, 

542 prog: str | None = None, 

543 usage: str | None = None, 

544 description: str | None = None, 

545 epilog: str | None = None, 

546 allow_abbrev: bool = False, 

547 subcommand_required: bool = True, 

548 setup_logging: bool = True, 

549 theme: ( 

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

551 ) = None, 

552 version: str | None = None, 

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

554 is_dev_mode: bool | None = None, 

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

556) -> _t.Any: 

557 """ 

558 Create an application. 

559 

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

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

562 

563 :param command: 

564 the main function of the application. 

565 :param prog: 

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

567 :param usage: 

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

569 :param description: 

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

571 :param epilog: 

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

573 :param allow_abbrev: 

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

575 :param subcommand_required: 

576 whether this app requires a subcommand, 

577 see :attr:`App.subcommand_required`. 

578 :param setup_logging: 

579 whether to perform basic logging setup on startup, 

580 see :attr:`App.setup_logging`. 

581 :param theme: 

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

583 see :attr:`App.theme`. 

584 :param version: 

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

586 :param bug_report: 

587 settings for automated bug report generation. If present, 

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

589 :param is_dev_mode: 

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

591 :param doc_format: 

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

593 :returns: 

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

595 

596 """ 

597 

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

599 return App( 

600 command, 

601 prog=prog, 

602 usage=usage, 

603 description=description, 

604 epilog=epilog, 

605 subcommand_required=subcommand_required, 

606 allow_abbrev=allow_abbrev, 

607 setup_logging=setup_logging, 

608 theme=theme, 

609 version=version, 

610 bug_report=bug_report, 

611 is_dev_mode=is_dev_mode, 

612 doc_format=doc_format, 

613 ) 

614 

615 if command is None: 

616 return registrar 

617 else: 

618 return registrar(command) 

619 

620 

621@_t.final 

622class CommandInfo: 

623 """ 

624 Data about the invoked command. 

625 

626 """ 

627 

628 def __init__( 

629 self, 

630 name: str, 

631 command: App[_t.Any], 

632 namespace: yuio.cli.ConfigNamespace["_CommandConfig"], 

633 ): 

634 self.name = name 

635 """ 

636 Name of the current command. 

637 

638 If it was invoked by alias, 

639 this will contains the primary command name. 

640 

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

642 

643 """ 

644 

645 self.__namespace = namespace 

646 self.__command = command 

647 self.__subcommand: CommandInfo | None | yuio.Missing = yuio.MISSING 

648 self.__executed: bool = False 

649 

650 @property 

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

652 """ 

653 Subcommand of this command, if one was given. 

654 

655 """ 

656 

657 if self.__subcommand is yuio.MISSING: 

658 self.__subcommand = self.__command._get_subcommand(self.__namespace) 

659 return self.__subcommand 

660 

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

662 """ 

663 Execute this command. 

664 

665 """ 

666 

667 if self.__executed: 

668 return False 

669 self.__executed = True 

670 

671 should_invoke_subcommand = self.__command._invoke(self.__namespace, self) 

672 if should_invoke_subcommand is None: 

673 should_invoke_subcommand = True 

674 

675 if should_invoke_subcommand and self.subcommand is not None: 

676 self.subcommand() 

677 

678 return False 

679 

680 

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

682class _SubcommandData: 

683 names: list[str] 

684 help: str | yuio.Disabled | None 

685 command: App[_t.Any] | _Lazy 

686 

687 def load(self) -> App[_t.Any]: 

688 if isinstance(self.command, _Lazy): 

689 self.command = self.command.load() 

690 return self.command 

691 

692 def make_cli_command(self): 

693 return self.load()._make_cli_command(self.name, self.help) 

694 

695 @property 

696 def name(self): 

697 return self.names[0] 

698 

699 

700class SubcommandRegistrar(_t.Protocol): 

701 """ 

702 Type for a callback returned from :meth:`App.subcommand`. 

703 

704 """ 

705 

706 @_t.overload 

707 def __call__(self, cb: C, /) -> App[C]: ... 

708 @_t.overload 

709 def __call__(self, cb: CB, /) -> CB: ... 

710 def __call__(self, cb, /) -> _t.Any: ... 

711 

712 

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

714class _Lazy: 

715 path: str 

716 

717 def load(self) -> App[_t.Any]: 

718 import importlib 

719 

720 path = self.path 

721 if ":" in path: 

722 mod, _, path = path.partition(":") 

723 path_parts = path.split(".") 

724 

725 try: 

726 root = importlib.import_module(mod) 

727 except ImportError as e: 

728 raise ImportError(f"failed to import lazy subcommand {self.path}: {e}") 

729 else: 

730 path_parts = path.split(".") 

731 

732 i = len(path_parts) 

733 while i > 0: 

734 try: 

735 root = importlib.import_module(".".join(path_parts[:i])) 

736 path_parts = path_parts[i:] 

737 except ImportError: 

738 pass 

739 else: 

740 break 

741 i -= 1 

742 else: 

743 raise ImportError(f"failed to import lazy subcommand {self.path}") 

744 

745 for name in path_parts: 

746 try: 

747 root = getattr(root, name) 

748 except AttributeError as e: 

749 raise AttributeError( 

750 f"failed to import lazy subcommand {self.path}: {e}" 

751 ) 

752 

753 if not isinstance(root, App): 

754 root = App(root) # type: ignore 

755 

756 return root 

757 

758 

759@_t.final 

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

761 """ 

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

763 

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

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

766 parameters. 

767 

768 """ 

769 

770 def __init__( 

771 self, 

772 command: C, 

773 /, 

774 *, 

775 prog: str | None = None, 

776 usage: str | None = None, 

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

778 description: str | None = None, 

779 epilog: str | None = None, 

780 subcommand_required: bool = True, 

781 allow_abbrev: bool = False, 

782 setup_logging: bool = True, 

783 theme: ( 

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

785 ) = None, 

786 version: str | None = None, 

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

788 is_dev_mode: bool | None = None, 

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

790 ): 

791 self.prog: str | None = prog 

792 """ 

793 Program's primary name. 

794 

795 For main app, this attribute controls its display name and generation 

796 of shell completion scripts. 

797 

798 For subcommands, this attribute is ignored. 

799 

800 By default, inferred from :data:`sys.argv`. 

801 

802 """ 

803 

804 self.usage: str = usage or "" 

805 """ 

806 Program or subcommand synapsis. 

807 

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

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

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

811 on a separate string. For example:: 

812 

813 @app 

814 def main(): ... 

815 

816 main.usage = \""" 

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

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

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

820 ... 

821 \""" 

822 

823 By default, usage is generated from CLI flags. 

824 

825 """ 

826 

827 if description is None and command.__doc__: 

828 description = command.__doc__ 

829 if description is None: 

830 description = "" 

831 

832 self.description: str = description 

833 """ 

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

835 short description of the program or subcommand. 

836 

837 The text should be formatted using Markdown or RST, 

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

839 

840 .. code-block:: python 

841 

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

843 def main(): ... 

844 

845 main.description = \""" 

846 This command does a thing. 

847 

848 # Different ways to do a thing 

849 

850 This command can apply multiple algorithms to achieve 

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

852 

853 - randomly turning the screen on and off; 

854 

855 - banging a head on a table; 

856 

857 - fiddling with your PCs power cord. 

858 

859 By default, the best algorithm is determined automatically. 

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

861 

862 \""" 

863 

864 By default, inferred from command's docstring. 

865 

866 """ 

867 

868 if help is None and description: 

869 help = description 

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

871 help = help[:index] 

872 elif help is None: 

873 help = "" 

874 

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

876 """ 

877 Short help message that is shown when listing subcommands. 

878 

879 By default, uses first paragraph of description. 

880 

881 """ 

882 

883 self.epilog: str = epilog or "" 

884 """ 

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

886 

887 The text should be formatted using Markdown or RST, 

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

889 

890 """ 

891 

892 self.subcommand_required: bool = subcommand_required 

893 """ 

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

895 

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

897 

898 Enabled by default. 

899 

900 """ 

901 

902 self.allow_abbrev: bool = allow_abbrev 

903 """ 

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

905 

906 Disabled by default. 

907 

908 .. note:: 

909 

910 This attribute should be set in the root app; it is ignored in subcommands. 

911 

912 """ 

913 

914 self.setup_logging: bool = setup_logging 

915 """ 

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

917 its initialization. Disable this if you want to customize 

918 logging initialization. 

919 

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

921 

922 .. note:: 

923 

924 This attribute should be set in the root app; it is ignored in subcommands. 

925 

926 """ 

927 

928 self.theme: ( 

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

930 ) = theme 

931 """ 

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

933 on application startup. 

934 

935 .. note:: 

936 

937 This attribute should be set in the root app; it is ignored in subcommands. 

938 

939 """ 

940 

941 self.version: str | None = version 

942 """ 

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

944 

945 .. note:: 

946 

947 This attribute should be set in the root app; it is ignored in subcommands. 

948 

949 """ 

950 

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

952 """ 

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

954 

955 This flag automatically collects data about environment and prints it 

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

957 

958 .. note:: 

959 

960 This attribute should be set in the root app; it is ignored in subcommands. 

961 

962 """ 

963 

964 self.is_dev_mode: bool | None = is_dev_mode 

965 """ 

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

967 and configure internal Yuio logging to show warnings. 

968 

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

970 contains substring ``"dev"``. 

971 

972 .. tip:: 

973 

974 You can always enable full debug logging by setting environment 

975 variable ``YUIO_DEBUG``. 

976 

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

978 

979 .. note:: 

980 

981 This attribute should be set in the root app; it is ignored in subcommands. 

982 

983 """ 

984 

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

986 doc_format or "rst" 

987 ) 

988 """ 

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

990 

991 .. note:: 

992 

993 This attribute should be set in the root app; it is ignored in subcommands. 

994 

995 """ 

996 

997 self._subcommands: dict[str, _SubcommandData] = {} 

998 

999 if callable(command): 

1000 self._config_type, self._callback = _command_from_callable(command) 

1001 else: 

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

1003 

1004 functools.update_wrapper( 

1005 self, # type: ignore 

1006 command, 

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

1008 updated=(), 

1009 ) 

1010 

1011 @functools.wraps(command) 

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

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

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

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

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

1017 

1018 i = 0 

1019 

1020 for name in a_params: 

1021 if name in kwargs: 

1022 raise TypeError( 

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

1024 ) 

1025 if i < len(args): 

1026 kwargs[name] = args[i] 

1027 i += 1 

1028 

1029 for name in a_kw_params: 

1030 if i >= len(args): 

1031 break 

1032 if name in kwargs: 

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

1034 kwargs[name] = args[i] 

1035 i += 1 

1036 

1037 if var_a_param: 

1038 if var_a_param in kwargs: 

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

1040 kwargs[var_a_param] = args[i:] 

1041 i = len(args) 

1042 elif i < len(args): 

1043 s = "" if i == 1 else "s" 

1044 raise TypeError( 

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

1046 ) 

1047 

1048 kwargs.pop("_command_info", None) 

1049 

1050 config = self._config_type(**kwargs) 

1051 

1052 for name in a_params + a_kw_params + kw_params: 

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

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

1055 

1056 namespace = yuio.cli.ConfigNamespace(config) 

1057 

1058 return CommandInfo("__main__", self, namespace)() 

1059 

1060 self.__wrapped__ = _t.cast(C, wrapped_command) 

1061 

1062 @property 

1063 def wrapped(self) -> C: 

1064 """ 

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

1066 

1067 """ 

1068 

1069 return self.__wrapped__ 

1070 

1071 @_t.overload 

1072 def subcommand( 

1073 self, 

1074 /, 

1075 *, 

1076 name: str | None = None, 

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

1078 usage: str | None = None, 

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

1080 description: str | None = None, 

1081 epilog: str | None = None, 

1082 ) -> SubcommandRegistrar: ... 

1083 @_t.overload 

1084 def subcommand( 

1085 self, 

1086 cb: C2, 

1087 /, 

1088 *, 

1089 name: str | None = None, 

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

1091 usage: str | None = None, 

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

1093 description: str | None = None, 

1094 epilog: str | None = None, 

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

1096 @_t.overload 

1097 def subcommand( 

1098 self, 

1099 cb: CB, 

1100 /, 

1101 *, 

1102 name: str | None = None, 

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

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

1105 ) -> CB: ... 

1106 def subcommand( 

1107 self, 

1108 cb: _t.Callable[..., None | bool] | App[_t.Any] | None = None, 

1109 /, 

1110 *, 

1111 name: str | None = None, 

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

1113 usage: str | None = None, 

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

1115 description: str | None = None, 

1116 epilog: str | None = None, 

1117 subcommand_required: bool = True, 

1118 ) -> _t.Any: 

1119 """ 

1120 Register a subcommand for the given app. 

1121 

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

1123 

1124 :param name: 

1125 allows overriding subcommand's name. 

1126 :param aliases: 

1127 allows adding alias names for subcommand. 

1128 :param usage: 

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

1130 :param help: 

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

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

1133 :param description: 

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

1135 :param epilog: 

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

1137 :param subcommand_required: 

1138 whether this subcommand requires another subcommand, 

1139 see :attr:`App.subcommand_required`. 

1140 :returns: 

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

1142 

1143 """ 

1144 

1145 def registrar(cb, /) -> App[_t.Any]: 

1146 if not isinstance(cb, App): 

1147 cb = App( 

1148 cb, 

1149 usage=usage, 

1150 help=help, 

1151 description=description, 

1152 epilog=epilog, 

1153 subcommand_required=subcommand_required, 

1154 ) 

1155 

1156 names = [name or _to_dash_case(cb.wrapped.__name__), *(aliases or [])] 

1157 subcommand_data = _SubcommandData(names, help, cb) 

1158 self._add_subcommand(subcommand_data) 

1159 

1160 return cb 

1161 

1162 if cb is None: 

1163 return registrar 

1164 else: 

1165 return registrar(cb) 

1166 

1167 def lazy_subcommand( 

1168 self, 

1169 path: str, 

1170 name: str, 

1171 /, 

1172 *, 

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

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

1175 ): 

1176 """ 

1177 Add a subcommand for this app that will be imported and loaded on demand. 

1178 

1179 :param path: 

1180 dot-separated path to a command or command's main function. 

1181 

1182 As a hint, module can be separated from the rest of the path with 

1183 a semicolon, i.e. ``"module.submodule:class.method"``. 

1184 :param name: 

1185 subcommand's primary name. 

1186 :param aliases: 

1187 allows adding alias names for subcommand. 

1188 :param help: 

1189 allows specifying subcommand's help. If given, generating CLI help for 

1190 base command will not require importing subcommand. 

1191 :example: 

1192 In module ``my_app.commands.run``: 

1193 

1194 .. code-block:: python 

1195 

1196 import yuio.app 

1197 

1198 

1199 @yuio.app.app 

1200 def command(): ... 

1201 

1202 In module ``my_app.main``: 

1203 

1204 .. code-block:: python 

1205 

1206 import yuio.app 

1207 

1208 

1209 @yuio.app.app 

1210 def main(): ... 

1211 

1212 

1213 main.lazy_subcommand("my_app.commands.run:command", "run") 

1214 

1215 """ 

1216 

1217 subcommand_data = _SubcommandData([name, *(aliases or [])], help, _Lazy(path)) 

1218 self._add_subcommand(subcommand_data) 

1219 

1220 def _add_subcommand(self, subcommand_data: _SubcommandData): 

1221 for nam in subcommand_data.names: 

1222 if nam in self._subcommands: 

1223 subcommand = self._subcommands[nam].load() 

1224 raise ValueError( 

1225 f"{self.__class__.__module__}.{self.__class__.__name__}: " 

1226 f"subcommand {nam!r} already registered in " 

1227 f"{subcommand.__class__.__module__}.{subcommand.__class__.__name__}" 

1228 ) 

1229 self._subcommands.update(dict.fromkeys(subcommand_data.names, subcommand_data)) 

1230 

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

1232 """ 

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

1234 and run the application. 

1235 

1236 :param args: 

1237 command line arguments. If none are given, 

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

1239 :returns: 

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

1241 

1242 """ 

1243 

1244 if args is None: 

1245 args = sys.argv[1:] 

1246 

1247 prog = self.prog or pathlib.Path(sys.argv[0]).stem 

1248 

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

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

1251 _run_custom_completer( 

1252 self._make_cli_command(prog, is_root=True), 

1253 args[index + 1], 

1254 args[index + 2], 

1255 ) 

1256 sys.exit(0) 

1257 

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

1259 from yuio.dbg import print_report 

1260 

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

1262 sys.exit(0) 

1263 

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

1265 

1266 try: 

1267 if self.is_dev_mode is None: 

1268 self.is_dev_mode = ( 

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

1270 ) 

1271 if self.is_dev_mode: 

1272 yuio.enable_internal_logging(add_handler=True) 

1273 

1274 help_parser = self._make_help_parser() 

1275 

1276 cli_command = self._make_cli_command(prog, is_root=True) 

1277 namespace = yuio.cli.CliParser( 

1278 cli_command, help_parser=help_parser, allow_abbrev=self.allow_abbrev 

1279 ).parse(args) 

1280 

1281 if self.setup_logging: 

1282 logging_level = { 

1283 0: logging.WARNING, 

1284 1: logging.INFO, 

1285 2: logging.DEBUG, 

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

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

1288 

1289 CommandInfo("__main__", self, namespace)() 

1290 sys.exit(0) 

1291 except yuio.cli.ArgumentError as e: 

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

1293 sys.exit(1) 

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

1295 yuio.io.failure(e) 

1296 sys.exit(1) 

1297 except KeyboardInterrupt: 

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

1299 sys.exit(130) 

1300 except Exception as e: 

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

1302 sys.exit(3) 

1303 finally: 

1304 yuio.io.restore_streams() 

1305 

1306 def _make_help_parser(self): 

1307 if self.doc_format == "md": 

1308 from yuio.md import MdParser 

1309 

1310 return MdParser() 

1311 elif self.doc_format == "rst": 

1312 from yuio.rst import RstParser 

1313 

1314 return RstParser() 

1315 else: 

1316 return self.doc_format 

1317 

1318 def _make_cli_command( 

1319 self, name: str, help: str | yuio.Disabled | None = None, is_root: bool = False 

1320 ): 

1321 options: list[yuio.cli.Option[_t.Any]] = self._config_type._build_options() 

1322 

1323 if is_root: 

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

1325 if self.version: 

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

1327 if self.setup_logging: 

1328 options.append( 

1329 yuio.cli.CountOption( 

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

1331 usage=yuio.COLLAPSE, 

1332 help="Increase output verbosity.", 

1333 help_group=yuio.cli.MISC_GROUP, 

1334 show_if_inherited=False, 

1335 dest="_verbose", 

1336 ) 

1337 ) 

1338 if self.bug_report: 

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

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

1341 options.append(_ColorOption()) 

1342 

1343 subcommands: dict[ 

1344 str, yuio.cli.Command[_t.Any] | yuio.cli.LazyCommand[_t.Any] 

1345 ] = {} 

1346 for subcommand_name, subcommand_data in self._subcommands.items(): 

1347 if subcommand_data.name not in subcommands: 

1348 subcommands[subcommand_data.name] = yuio.cli.LazyCommand( 

1349 help=subcommand_data.help, 

1350 loader=subcommand_data.make_cli_command, 

1351 ) 

1352 subcommands[subcommand_name] = subcommands[subcommand_data.name] 

1353 

1354 if help is None: 

1355 help = self.help 

1356 

1357 return yuio.cli.Command( 

1358 name=name, 

1359 desc=_dedent(self.description), 

1360 help=_dedent(help) if help is not yuio.DISABLED else help, 

1361 epilog=_dedent(self.epilog), 

1362 usage=_dedent(self.usage).strip(), 

1363 options=options, 

1364 subcommands=subcommands, 

1365 subcommand_required=self.subcommand_required, 

1366 ns_ctor=self._create_ns, 

1367 dest="_subcommand", 

1368 ns_dest="_subcommand_ns", 

1369 ) 

1370 

1371 def _create_ns(self): 

1372 return yuio.cli.ConfigNamespace(self._config_type()) 

1373 

1374 def _invoke( 

1375 self, 

1376 namespace: yuio.cli.ConfigNamespace["_CommandConfig"], 

1377 command_info: CommandInfo, 

1378 ) -> bool | None: 

1379 return self._callback(namespace.config, command_info) 

1380 

1381 def _get_subcommand( 

1382 self, namespace: yuio.cli.ConfigNamespace["_CommandConfig"] 

1383 ) -> CommandInfo | None: 

1384 config = namespace.config 

1385 if config._subcommand is None: 

1386 return None 

1387 else: 

1388 subcommand_ns = config._subcommand_ns 

1389 subcommand_data = self._subcommands[config._subcommand] 

1390 

1391 assert subcommand_ns is not None 

1392 

1393 return CommandInfo( 

1394 subcommand_data.name, subcommand_data.load(), subcommand_ns 

1395 ) 

1396 

1397 

1398class _CommandConfig(yuio.config.Config): 

1399 _a_params: _ClassVar[list[str]] 

1400 _var_a_param: _ClassVar[str | None] 

1401 _a_kw_params: _ClassVar[list[str]] 

1402 _kw_params: _ClassVar[list[str]] 

1403 _subcommand: str | None = None 

1404 _subcommand_ns: yuio.cli.ConfigNamespace[_CommandConfig] | None = None 

1405 

1406 

1407def _command_from_callable( 

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

1409) -> tuple[ 

1410 type[_CommandConfig], 

1411 _t.Callable[[_CommandConfig, CommandInfo], bool | None], 

1412]: 

1413 sig = inspect.signature(cb) 

1414 

1415 dct = {} 

1416 annotations = {} 

1417 

1418 try: 

1419 docs = _find_docs(cb) 

1420 except Exception: 

1421 yuio._logger.warning( 

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

1423 cb.__module__, 

1424 cb.__qualname__, 

1425 ) 

1426 docs = {} 

1427 

1428 dct["_a_params"] = a_params = [] 

1429 dct["_var_a_param"] = var_a_param = None 

1430 dct["_a_kw_params"] = a_kw_params = [] 

1431 dct["_kw_params"] = kw_params = [] 

1432 

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

1434 if param.kind is param.VAR_KEYWORD: 

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

1436 

1437 is_special = False 

1438 if name.startswith("_"): 

1439 is_special = True 

1440 if name != "_command_info": 

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

1442 if param.kind is param.VAR_POSITIONAL: 

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

1444 

1445 if param.default is not param.empty: 

1446 field = param.default 

1447 else: 

1448 field = yuio.MISSING 

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

1450 field = _t.cast( 

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

1452 ) 

1453 

1454 annotation = param.annotation 

1455 if annotation is param.empty and not is_special: 

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

1457 

1458 match param.kind: 

1459 case param.POSITIONAL_ONLY: 

1460 if field.flags is None: 

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

1462 a_params.append(name) 

1463 case param.VAR_POSITIONAL: 

1464 if field.flags is None: 

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

1466 annotation = list[annotation] 

1467 dct["_var_a_param"] = var_a_param = name 

1468 case param.POSITIONAL_OR_KEYWORD: 

1469 a_kw_params.append(name) 

1470 case param.KEYWORD_ONLY: 

1471 kw_params.append(name) 

1472 

1473 if not is_special: 

1474 dct[name] = field 

1475 annotations[name] = annotation 

1476 

1477 dct["_color"] = None 

1478 dct["_verbose"] = 0 

1479 dct["_subcommand"] = None 

1480 dct["_subcommand_ns"] = None 

1481 dct["__annotations__"] = annotations 

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

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

1484 dct["__yuio_pre_parsed_docs__"] = docs 

1485 

1486 config = types.new_class( 

1487 cb.__name__, 

1488 (_CommandConfig,), 

1489 {"_allow_positionals": True}, 

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

1491 ) 

1492 callback = _command_from_callable_run_impl( 

1493 cb, a_params + a_kw_params, var_a_param, kw_params 

1494 ) 

1495 

1496 return config, callback 

1497 

1498 

1499def _command_from_callable_run_impl( 

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

1501 a_params: list[str], 

1502 var_a_param: str | None, 

1503 kw_params: list[str], 

1504): 

1505 def run(config: _CommandConfig, command_info: CommandInfo): 

1506 def get(name: str) -> _t.Any: 

1507 return command_info if name == "_command_info" else getattr(config, name) 

1508 

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

1510 if var_a_param is not None: 

1511 args.extend(get(var_a_param)) 

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

1513 return cb(*args, **kwargs) 

1514 

1515 return run 

1516 

1517 

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

1519 data = json.loads(raw_data) 

1520 path: str = data["path"] 

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

1522 index: int = data["index"] 

1523 

1524 root = command 

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

1526 if not name: 

1527 continue 

1528 if name not in command.subcommands: 

1529 return 

1530 root = command.subcommands[name].load() 

1531 

1532 positional_index = 0 

1533 for option in root.options: 

1534 option_flags = option.flags 

1535 if option_flags is yuio.POSITIONAL: 

1536 option_flags = [str(positional_index)] 

1537 positional_index += 1 

1538 if flags.intersection(option_flags): 

1539 completer, is_many = option.get_completer() 

1540 break 

1541 else: 

1542 completer, is_many = None, False 

1543 

1544 if completer: 

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

1546 

1547 

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

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

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

1551 # and adds help entry. 

1552 

1553 _ALLOWED_VALUES = ( 

1554 "y", 

1555 "yes", 

1556 "true", 

1557 "1", 

1558 "n", 

1559 "no", 

1560 "false", 

1561 "0", 

1562 "ansi", 

1563 "ansi-256", 

1564 "ansi-true", 

1565 ) 

1566 

1567 _PUBLIC_VALUES = ( 

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

1569 ("false", "disable colors"), 

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

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

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

1573 ) 

1574 

1575 def __init__(self): 

1576 super().__init__( 

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

1578 allow_inline_arg=True, 

1579 allow_implicit_inline_arg=True, 

1580 nargs=0, 

1581 allow_no_args=True, 

1582 required=False, 

1583 metavar=(), 

1584 mutex_group=None, 

1585 usage=yuio.COLLAPSE, 

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

1587 help_group=yuio.cli.MISC_GROUP, 

1588 show_if_inherited=False, 

1589 allow_abbrev=False, 

1590 dest="_color", 

1591 default_desc=None, 

1592 ) 

1593 

1594 def process( 

1595 self, 

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

1597 flag: yuio.cli.Flag | None, 

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

1599 ns: yuio.cli.Namespace, 

1600 ): 

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

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

1603 raise yuio.cli.ArgumentError( 

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

1605 ) 

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

1607 raise yuio.cli.ArgumentError( 

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

1609 arguments.value, 

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

1611 flag=flag, 

1612 arguments=arguments, 

1613 ) 

1614 

1615 @functools.cached_property 

1616 def primary_short_flag(self): 

1617 return None 

1618 

1619 @functools.cached_property 

1620 def primary_long_flags(self): 

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

1622 

1623 def format_alias_flags( 

1624 self, 

1625 ctx: yuio.string.ReprContext, 

1626 /, 

1627 *, 

1628 all: bool = False, 

1629 ) -> ( 

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

1631 | None 

1632 ): 

1633 if self.flags is yuio.POSITIONAL: 

1634 return None 

1635 

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

1637 if self.primary_short_flag: 

1638 primary_flags.add(self.primary_short_flag) 

1639 

1640 aliases: list[ 

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

1642 ] = [] 

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

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

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

1646 res = yuio.string.ColorizedString() 

1647 res.start_no_wrap() 

1648 res.append_color(flag_color) 

1649 res.append_str("--color") 

1650 res.end_no_wrap() 

1651 res.append_color(punct_color) 

1652 res.append_str("={") 

1653 sep = False 

1654 for value, _ in self._PUBLIC_VALUES: 

1655 if sep: 

1656 res.append_color(punct_color) 

1657 res.append_str("|") 

1658 res.append_color(metavar_color) 

1659 res.append_str(value) 

1660 sep = True 

1661 res.append_color(punct_color) 

1662 res.append_str("}") 

1663 aliases.append(res) 

1664 return aliases 

1665 

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

1667 return yuio.complete.Choice( 

1668 [ 

1669 yuio.complete.Option(value, comment) 

1670 for value, comment in self._PUBLIC_VALUES 

1671 ] 

1672 ), False