Coverage for yuio / cli.py: 69%

1534 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""" 

9Low-level interface to CLI parser. 

10 

11Yuio's primary interface for building CLIs is :mod:`yuio.app`; this is low-level module 

12that actually parses arguments. Because of this, :mod:`yuio.cli` doesn't expose 

13a convenient interface for building CLIs. Instead, it exposes a set of classes 

14that describe an interface; :mod:`yuio.app` and :mod:`yuio.config` compose these 

15classes and pass them to :class:`CliParser`. 

16 

17This module is inspired by :mod:`argparse`, but there are differences: 

18 

19- all flags should start with ``-``, other symbols are not supported (at least 

20 for now); 

21 

22- unlike :mod:`argparse`, this module doesn't rely on partial parsing and sub-parses. 

23 Instead, it operates like a regular state machine, and any unmatched flags 

24 or arguments are reported right away; 

25 

26- it uses nested namespaces, one namespace per subcommand. When a subcommand 

27 is encountered, a new namespace is created and assigned to the corresponding 

28 :attr:`~Option.dest` in the parent namespace; 

29 

30- namespaces are abstracted away by the :class:`Namespace` protocol, which has an 

31 interface similar to :class:`dict`; 

32 

33- options from base command can be specified after a subcommand argument, unless 

34 subcommand shadows them. This is possible because we don't do partial parsing. 

35 

36 For example, consider this program: 

37 

38 .. code-block:: python 

39 

40 import argparse 

41 

42 parser = argparse.ArgumentParser() 

43 parser.add_argument("-v", "--verbose", action="count") 

44 subparsers = parser.add_subparsers() 

45 subcommand = subparsers.add_parser("subcommand") 

46 

47 Argparse will not recognize :flag:`--verbose` if it's specified 

48 after :flag:`subcommand`, but :mod:`yuio.cli` handles this just fine: 

49 

50 .. code-block:: console 

51 

52 $ prog subcommand --verbose 

53 

54- there's no distinction between ``nargs=None`` and ``nargs=1``; however, there is 

55 a distinction between argument being specified inline or not. This allows us to 

56 supply arguments for options with ``nargs=0``. 

57 

58 See :ref:`flags-with-optional-values` for details; 

59 

60- the above point also allows us to disambiguate positional arguments 

61 and arguments with ``nargs="*"``: 

62 

63 .. code-block:: console 

64 

65 $ prog --array='a b c' subcommand 

66 

67 See :ref:`flags-with-multiple-values` for details; 

68 

69- this parser tracks information about argument positions and offsets, allowing 

70 it to display rich error messages; 

71 

72- we expose more knobs to tweak help formatting; see functions on :class:`Option` 

73 for details. 

74 

75 

76Commands and sub-commands 

77------------------------- 

78 

79.. autoclass:: Command 

80 :members: 

81 

82.. autoclass:: LazyCommand 

83 :members: 

84 

85 

86Flags and positionals 

87--------------------- 

88 

89.. autoclass:: Option 

90 :members: 

91 

92.. autoclass:: ValueOption 

93 :members: 

94 

95.. autoclass:: ParserOption 

96 :members: 

97 

98.. autoclass:: BoolOption 

99 :members: 

100 

101.. autoclass:: ParseOneOption 

102 :members: 

103 

104.. autoclass:: ParseManyOption 

105 :members: 

106 

107.. autoclass:: StoreConstOption 

108 :members: 

109 

110.. autoclass:: StoreFalseOption 

111 :members: 

112 

113.. autoclass:: StoreTrueOption 

114 :members: 

115 

116.. autoclass:: CountOption 

117 :members: 

118 

119.. autoclass:: VersionOption 

120 :members: 

121 

122.. autoclass:: HelpOption 

123 :members: 

124 

125 

126Namespace 

127--------- 

128 

129.. autoclass:: Namespace 

130 

131 .. automethod:: __getitem__ 

132 

133 .. automethod:: __setitem__ 

134 

135 .. automethod:: __contains__ 

136 

137.. autoclass:: ConfigNamespace 

138 :members: 

139 

140 

141CLI parser 

142---------- 

143 

144.. autoclass:: CliParser 

145 :members: 

146 

147.. autoclass:: Argument 

148 :members: 

149 

150.. autoclass:: Flag 

151 :members: 

152 

153.. autoclass:: ArgumentError 

154 :members: 

155 

156.. type:: NArgs 

157 :canonical: int | typing.Literal["+"] 

158 

159 Type alias for :attr:`~Option.nargs`. 

160 

161 .. note:: 

162 

163 ``"*"`` from argparse is equivalent to ``nargs="+"`` with ``allow_no_args=True``; 

164 ``"?"`` from argparse is equivalent to ``nargs=1`` with ``allow_no_args=True``. 

165 

166 

167Option grouping 

168--------------- 

169 

170.. autoclass:: MutuallyExclusiveGroup 

171 :members: 

172 

173.. autoclass:: HelpGroup 

174 :members: 

175 

176.. autodata:: ARGS_GROUP 

177 

178.. autodata:: SUBCOMMANDS_GROUP 

179 

180.. autodata:: OPTS_GROUP 

181 

182.. autodata:: MISC_GROUP 

183 

184""" 

185 

186from __future__ import annotations 

187 

188import abc 

189import contextlib 

190import dataclasses 

191import functools 

192import re 

193import sys 

194import warnings 

195from dataclasses import dataclass 

196 

197import yuio 

198import yuio.complete 

199import yuio.doc 

200import yuio.hl 

201import yuio.parse 

202import yuio.string 

203from yuio.string import ColorizedString as _ColorizedString 

204from yuio.util import _UNPRINTABLE_TRANS 

205from yuio.util import commonprefix as _commonprefix 

206 

207from typing import TYPE_CHECKING 

208 

209if TYPE_CHECKING: 

210 import typing_extensions as _t 

211else: 

212 from yuio import _typing as _t 

213 

214if TYPE_CHECKING: 

215 import yuio.app 

216 import yuio.config 

217 import yuio.dbg 

218 

219__all__ = [ 

220 "ARGS_GROUP", 

221 "MISC_GROUP", 

222 "OPTS_GROUP", 

223 "SUBCOMMANDS_GROUP", 

224 "Argument", 

225 "ArgumentError", 

226 "BoolOption", 

227 "BugReportOption", 

228 "CliParser", 

229 "CliWarning", 

230 "CollectOption", 

231 "Command", 

232 "CompletionOption", 

233 "ConfigNamespace", 

234 "CountOption", 

235 "Flag", 

236 "HelpGroup", 

237 "HelpOption", 

238 "LazyCommand", 

239 "MutuallyExclusiveGroup", 

240 "NArgs", 

241 "Namespace", 

242 "Option", 

243 "ParseManyOption", 

244 "ParseOneOption", 

245 "ParserOption", 

246 "StoreConstOption", 

247 "StoreFalseOption", 

248 "StoreTrueOption", 

249 "ValueOption", 

250 "VersionOption", 

251] 

252 

253T = _t.TypeVar("T") 

254T_cov = _t.TypeVar("T_cov", covariant=True) 

255 

256_SHORT_FLAG_RE = r"^-[a-zA-Z0-9]$" 

257_LONG_FLAG_RE = r"^--[a-zA-Z0-9_+/.-]+$" 

258 

259_NUM_RE = r"""(?x) 

260 ^ 

261 [+-]? 

262 (?: 

263 (?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)? 

264 |0[bB][01]+ 

265 |0[oO][0-7]+ 

266 |0[xX][0-9a-fA-F]+ 

267 ) 

268 $ 

269""" 

270 

271NArgs: _t.TypeAlias = int | _t.Literal["+"] 

272""" 

273Type alias for nargs. 

274 

275.. note:: 

276 

277 ``"*"`` from argparse is equivalent to ``nargs="+"`` with ``allow_no_args=True``; 

278 ``"?"`` from argparse is equivalent to ``nargs=1`` with ``allow_no_args=True``. 

279 

280""" 

281 

282NamespaceT = _t.TypeVar("NamespaceT", bound="Namespace") 

283ConfigT = _t.TypeVar("ConfigT", bound="yuio.config.Config") 

284 

285 

286class CliWarning(yuio.YuioWarning): 

287 pass 

288 

289 

290@dataclass(frozen=True, slots=True) 

291class Argument: 

292 """ 

293 Represents a CLI argument, or its part. 

294 

295 For positionals, this will contain the entire argument. For inline values, 

296 this will contain value substring and its position relative to the full 

297 value. 

298 

299 :example: 

300 Consider the following command arguments: 

301 

302 .. code-block:: text 

303 

304 --arg=value 

305 

306 Argument ``"value"`` will be represented as: 

307 

308 .. code-block:: python 

309 

310 Argument(value="value", index=0, pos=6, flag="--arg", metavar=...) 

311 

312 """ 

313 

314 value: str 

315 """ 

316 Contents of the argument. 

317 

318 """ 

319 

320 index: int 

321 """ 

322 Index of this argument in the array that was passed to :meth:`CliParser.parse`. 

323 

324 Note that this array does not include executable name, so indexes are shifted 

325 relative to :data:`sys.argv`. 

326 

327 """ 

328 

329 pos: int 

330 """ 

331 Position of the :attr:`~Argument.value` relative to the original argument string. 

332 

333 """ 

334 

335 metavar: str 

336 """ 

337 Meta variable for this argument. 

338 

339 """ 

340 

341 flag: Flag | None 

342 """ 

343 If this argument belongs to a flag, this attribute will contain flag's name. 

344 

345 """ 

346 

347 def __str__(self) -> str: 

348 return self.metavar 

349 

350 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString: 

351 return _ColorizedString( 

352 ctx.get_color("msg/text:code/sh-usage hl/flag:sh-usage"), 

353 self.metavar, 

354 ) 

355 

356 

357@dataclass(frozen=True, slots=True) 

358class Flag: 

359 value: str 

360 """ 

361 Name of the flag. 

362 

363 """ 

364 

365 index: int 

366 """ 

367 Index of this flag in the array that was passed to :meth:`CliParser.parse`. 

368 

369 Note that this array does not include executable name, so indexes are shifted 

370 relative to :data:`sys.argv`. 

371 

372 """ 

373 

374 def __str__(self) -> str: 

375 return self.value 

376 

377 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString: 

378 return _ColorizedString( 

379 ctx.get_color("msg/text:code/sh-usage hl/flag:sh-usage"), 

380 self.value, 

381 ) 

382 

383 

384class ArgumentError(yuio.PrettyException, ValueError): 

385 """ 

386 Error that happened during argument parsing. 

387 

388 """ 

389 

390 @_t.overload 

391 def __init__( 

392 self, 

393 msg: _t.LiteralString, 

394 /, 

395 *args, 

396 flag: Flag | None = None, 

397 arguments: Argument | list[Argument] | None = None, 

398 n_arg: int | None = None, 

399 pos: tuple[int, int] | None = None, 

400 path: list[tuple[_t.Any, str | None]] | None = None, 

401 option: Option[_t.Any] | None = None, 

402 ): ... 

403 @_t.overload 

404 def __init__( 

405 self, 

406 msg: yuio.string.Colorable | None = None, 

407 /, 

408 *, 

409 flag: Flag | None = None, 

410 arguments: Argument | list[Argument] | None = None, 

411 n_arg: int | None = None, 

412 pos: tuple[int, int] | None = None, 

413 path: list[tuple[_t.Any, str | None]] | None = None, 

414 option: Option[_t.Any] | None = None, 

415 ): ... 

416 def __init__( 

417 self, 

418 *args, 

419 flag: Flag | None = None, 

420 arguments: Argument | list[Argument] | None = None, 

421 n_arg: int | None = None, 

422 pos: tuple[int, int] | None = None, 

423 path: list[tuple[_t.Any, str | None]] | None = None, 

424 option: Option[_t.Any] | None = None, 

425 ): 

426 super().__init__(*args) 

427 

428 self.flag: Flag | None = flag 

429 """ 

430 Flag that caused this error. Can be :data:`None` if error is caused 

431 by a positional argument. 

432 

433 """ 

434 

435 self.arguments: Argument | list[Argument] | None = arguments 

436 """ 

437 Arguments that caused this error. 

438 

439 This can be a single argument, or multiple arguments. In the later case, 

440 :attr:`~yuio.parse.ParsingError.n_arg` should correspond to the argument 

441 that failed to parse. If :attr:`~yuio.parse.ParsingError.n_arg` 

442 is :data:`None`, then all arguments are treated as faulty. 

443 

444 .. note:: 

445 

446 Don't confuse :attr:`~ArgumentError.arguments` and :attr:`~BaseException.args`: 

447 the latter contains formatting arguments and is defined 

448 in the :class:`BaseException` class. 

449 

450 """ 

451 

452 self.pos: tuple[int, int] | None = pos 

453 """ 

454 Position in the original string in which this error has occurred (start 

455 and end indices). 

456 

457 If :attr:`~ArgumentError.n_arg` is set, and :attr:`~ArgumentError.arguments` 

458 is given as a list, then this position is relative to the argument 

459 at index :attr:`~ArgumentError.n_arg`. 

460 

461 If :attr:`~ArgumentError.arguments` is given as a single argument (not a list), 

462 then this position is relative to that argument. 

463 

464 Otherwise, position is ignored. 

465 

466 """ 

467 

468 self.n_arg: int | None = n_arg 

469 """ 

470 Index of the argument that caused the error. 

471 

472 """ 

473 

474 self.path: list[tuple[_t.Any, str | None]] | None = path 

475 """ 

476 Same as in :attr:`ParsingError.path <yuio.parse.ParsingError.path>`. 

477 Can be present if parser uses :meth:`~yuio.parse.Parser.parse_config` 

478 for validation. 

479 

480 """ 

481 

482 self.option: Option[_t.Any] | None = option 

483 """ 

484 Option which caused failure. 

485 

486 """ 

487 

488 self.commandline: list[str] | None = None 

489 self.prog: str | None = None 

490 self.subcommands: list[str] | None = None 

491 self.help_parser: yuio.doc.DocParser | None = None 

492 

493 @classmethod 

494 def from_parsing_error( 

495 cls, 

496 e: yuio.parse.ParsingError, 

497 /, 

498 *, 

499 flag: Flag | None = None, 

500 arguments: Argument | list[Argument] | None = None, 

501 option: Option[_t.Any] | None = None, 

502 ): 

503 """ 

504 Convert parsing error to argument error. 

505 

506 """ 

507 

508 return cls( 

509 *e.args, 

510 flag=flag, 

511 arguments=arguments, 

512 n_arg=e.n_arg, 

513 pos=e.pos, 

514 path=e.path, 

515 option=option, 

516 ) 

517 

518 def to_colorable(self) -> yuio.string.Colorable: 

519 colorable = yuio.string.WithBaseColor( 

520 super().to_colorable(), 

521 base_color="msg/text:error", 

522 ) 

523 

524 msg = [] 

525 args = [] 

526 sep = False 

527 

528 if self.flag and self.flag.value: 

529 msg.append("in flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>") 

530 args.append(self.flag.value) 

531 sep = True 

532 

533 argument = None 

534 if isinstance(self.arguments, list): 

535 if self.n_arg is not None and self.n_arg < len(self.arguments): 

536 argument = self.arguments[self.n_arg] 

537 else: 

538 argument = self.arguments 

539 

540 if argument and argument.metavar: 

541 if sep: 

542 msg.append(", ") 

543 msg.append("in <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>") 

544 args.append(argument.metavar) 

545 

546 if self.path: 

547 if sep: 

548 msg.append(", ") 

549 msg.append("in <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>") 

550 args.append(yuio.parse._PathRenderer(self.path)) 

551 

552 if sep: 

553 msg.insert(0, "Error ") 

554 msg.append(":") 

555 

556 colorable = yuio.string.Stack( 

557 yuio.string.WithBaseColor( 

558 yuio.string.Format("".join(msg), *args), 

559 base_color="msg/text:failure", 

560 ), 

561 yuio.string.Indent(colorable), 

562 ) 

563 else: 

564 colorable = yuio.string.WithBaseColor( 

565 colorable, 

566 base_color="msg/text:failure", 

567 ) 

568 

569 if commandline := self._make_commandline(): 

570 colorable = yuio.string.Stack( 

571 commandline, 

572 colorable, 

573 ) 

574 

575 if usage := self._make_usage(): 

576 colorable = yuio.string.Stack( 

577 colorable, 

578 usage, 

579 ) 

580 

581 return colorable 

582 

583 def _make_commandline(self): 

584 if not self.prog or not self.commandline: 

585 return None 

586 

587 argument = None 

588 if isinstance(self.arguments, list): 

589 if self.n_arg is not None and self.n_arg < len(self.arguments): 

590 argument = self.arguments[self.n_arg] 

591 else: 

592 argument = self.arguments 

593 

594 if argument: 

595 arg_index = argument.index 

596 arg_pos = (argument.pos, argument.pos + len(argument.value)) 

597 if self.pos: 

598 arg_pos = ( 

599 arg_pos[0] + self.pos[0], 

600 min(arg_pos[1], arg_pos[0] + self.pos[1]), 

601 ) 

602 elif self.flag: 

603 arg_index = self.flag.index 

604 arg_pos = (0, len(self.commandline[arg_index])) 

605 else: 

606 return None 

607 

608 text = self.prog 

609 text += " " 

610 text += " ".join(_quote(arg) for arg in self.commandline[:arg_index]) 

611 if arg_index > 0: 

612 text += " " 

613 

614 center, pos = _quote_and_adjust_pos(self.commandline[arg_index], arg_pos) 

615 pos = (pos[0] + len(text), pos[1] + len(text)) 

616 

617 text += center 

618 text += " " 

619 text += " ".join(_quote(arg) for arg in self.commandline[arg_index + 1 :]) 

620 

621 if text: 

622 return yuio.parse._CodeRenderer(text, pos, as_cli=True) 

623 else: 

624 return None 

625 

626 def _make_usage(self): 

627 if not self.option or not self.option.help or not self.help_parser: 

628 return None 

629 else: 

630 return _ShortUsageFormatter(self.help_parser, self.subcommands, self.option) 

631 

632 

633class Namespace(_t.Protocol): 

634 """ 

635 Protocol for namespace implementations. 

636 

637 """ 

638 

639 @abc.abstractmethod 

640 def __getitem__(self, key: str, /) -> _t.Any: ... 

641 

642 @abc.abstractmethod 

643 def __setitem__(self, key: str, value: _t.Any, /): ... 

644 

645 @abc.abstractmethod 

646 def __contains__(self, key: str, /) -> bool: ... 

647 

648 

649@yuio.string.repr_from_rich 

650class ConfigNamespace(Namespace, _t.Generic[ConfigT]): 

651 """ 

652 Wrapper that makes :class:`~yuio.config.Config` instances behave like namespaces. 

653 

654 """ 

655 

656 def __init__(self, config: ConfigT) -> None: 

657 self.__config = config 

658 

659 @property 

660 def config(self) -> ConfigT: 

661 """ 

662 Wrapped config instance. 

663 

664 """ 

665 

666 return self.__config 

667 

668 def __getitem__(self, key: str) -> _t.Any: 

669 root, key = self.__split_key(key) 

670 try: 

671 return getattr(root, key) 

672 except AttributeError as e: 

673 raise KeyError(str(e)) from None 

674 

675 def __setitem__(self, key: str, value: _t.Any): 

676 root, key = self.__split_key(key) 

677 try: 

678 return setattr(root, key, value) 

679 except AttributeError as e: 

680 raise KeyError(str(e)) from None 

681 

682 def __contains__(self, key: str): 

683 root, key = self.__split_key(key) 

684 return key in root.__dict__ 

685 

686 def __split_key(self, key: str) -> tuple[yuio.config.Config, str]: 

687 root = self.__config 

688 *parents, key = key.split(".") 

689 for parent in parents: 

690 root = getattr(root, parent) 

691 return root, key 

692 

693 def __rich_repr__(self): 

694 yield None, self.__config 

695 

696 

697@dataclass(eq=False) 

698class HelpGroup: 

699 """ 

700 Group of flags in CLI help. 

701 

702 """ 

703 

704 title: str 

705 """ 

706 Title for this group. 

707 

708 """ 

709 

710 help: str | yuio.Disabled = dataclasses.field(default="", kw_only=True) 

711 """ 

712 Help message for an option. 

713 

714 """ 

715 

716 collapse: bool = dataclasses.field(default=False, kw_only=True) 

717 """ 

718 Hide options from this group in CLI help, but show group's title and help. 

719 

720 """ 

721 

722 _slug: str | None = dataclasses.field(default=None, kw_only=True) 

723 

724 

725ARGS_GROUP = HelpGroup("Arguments") 

726""" 

727Help group for positional arguments. 

728 

729""" 

730 

731SUBCOMMANDS_GROUP = HelpGroup("Subcommands") 

732""" 

733Help group for subcommands. 

734 

735""" 

736 

737OPTS_GROUP = HelpGroup("Options") 

738""" 

739Help group for flags. 

740 

741""" 

742 

743MISC_GROUP = HelpGroup("Misc options") 

744""" 

745Help group for misc flags such as :flag:`--help` or :flag:`--version`. 

746 

747""" 

748 

749 

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

751class MutuallyExclusiveGroup: 

752 """ 

753 A sentinel for creating mutually exclusive groups. 

754 

755 Pass an instance of this class all :func:`~yuio.app.field`\\ s that should 

756 be mutually exclusive. 

757 

758 """ 

759 

760 required: bool = False 

761 """ 

762 Require that one of the mutually exclusive options is always given. 

763 

764 """ 

765 

766 

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

768class Option(abc.ABC, _t.Generic[T_cov]): 

769 """ 

770 Base class for a CLI option. 

771 

772 """ 

773 

774 flags: list[str] | yuio.Positional 

775 """ 

776 Flags corresponding to this option. Positional options have flags set to 

777 :data:`yuio.POSITIONAL`. 

778 

779 """ 

780 

781 allow_inline_arg: bool 

782 """ 

783 Whether to allow specifying argument inline (i.e. :flag:`--foo=bar`). 

784 

785 Inline arguments are handled separately from normal arguments, 

786 and :attr:`~Option.nargs` setting does not affect them. 

787 

788 Positional options can't take inline arguments, so this attribute has 

789 no effect on them. 

790 

791 """ 

792 

793 allow_implicit_inline_arg: bool 

794 """ 

795 Whether to allow specifying argument inline with short flags without equals sign 

796 (i.e. :flag:`-fValue`). 

797 

798 Inline arguments are handled separately from normal arguments, 

799 and :attr:`~Option.nargs` setting does not affect them. 

800 

801 Positional options can't take inline arguments, so this attribute has 

802 no effect on them. 

803 

804 """ 

805 

806 nargs: NArgs 

807 """ 

808 How many arguments this option takes. 

809 

810 """ 

811 

812 allow_no_args: bool 

813 """ 

814 Whether to allow passing no arguments even if :attr:`~Option.nargs` requires some. 

815 

816 """ 

817 

818 required: bool 

819 """ 

820 Makes this option required. The parsing will fail if this option is not 

821 encountered among CLI arguments. 

822 

823 Note that positional arguments are always parsed; if no positionals are given, 

824 all positional options are processed with zero arguments, at which point they'll 

825 fail :attr:`~Option.nargs` check. Thus, :attr:`~Option.required` has no effect 

826 on positionals. 

827 

828 """ 

829 

830 metavar: str | tuple[str, ...] 

831 """ 

832 Option's meta variable, used for displaying help messages. 

833 

834 If :attr:`~Option.nargs` is an integer, this can be a tuple of strings, 

835 one for each argument. If :attr:`~Option.nargs` is zero, this can be an empty 

836 tuple. 

837 

838 """ 

839 

840 mutex_group: None | MutuallyExclusiveGroup 

841 """ 

842 Mutually exclusive group for this option. Positional options can't have 

843 mutex groups. 

844 

845 """ 

846 

847 usage: yuio.Collapse | bool 

848 """ 

849 Specifies whether this option should be displayed in CLI usage. Positional options 

850 are always displayed, regardless of this setting. 

851 

852 """ 

853 

854 help: str | yuio.Disabled 

855 """ 

856 Help message for an option. 

857 

858 """ 

859 

860 help_group: HelpGroup | None 

861 """ 

862 Group for this flag, default is :data:`OPTS_GROUP` for flags and :data:`ARGS_GROUP` 

863 for positionals. Positionals are flags are never mixed together; if they appear 

864 in the same group, the group title will be repeated twice. 

865 

866 """ 

867 

868 default_desc: str | None 

869 """ 

870 Overrides description of default value. 

871 

872 """ 

873 

874 show_if_inherited: bool 

875 """ 

876 Force-show this flag if it's inherited from parent command. Positionals can't be 

877 inherited because subcommand argument always goes last. 

878 

879 """ 

880 

881 allow_abbrev: bool 

882 """ 

883 Allow abbreviation for this option. 

884 

885 """ 

886 

887 dest: str 

888 """ 

889 Key where to store parsed argument. 

890 

891 """ 

892 

893 @abc.abstractmethod 

894 def process( 

895 self, 

896 cli_parser: CliParser[Namespace], 

897 flag: Flag | None, 

898 arguments: Argument | list[Argument], 

899 ns: Namespace, 

900 ): 

901 """ 

902 Process this argument. 

903 

904 This method is called every time an option is encountered 

905 on the command line. It should parse option's args and merge them 

906 with previous values, if there are any. 

907 

908 When option's arguments are passed separately (i.e. :flag:`--opt arg1 arg2 ...`), 

909 `args` is given as a list. List's length is checked against 

910 :attr:`~Option.nargs` before this method is called. 

911 

912 When option's arguments are passed as an inline value (i.e. :flag:`--long=arg` 

913 or :flag:`-Sarg`), the `args` is given as a string. :attr:`~Option.nargs` 

914 are not checked in this case, giving you an opportunity to handle inline option 

915 however you like. 

916 

917 :param cli_parser: 

918 CLI parser instance that's doing the parsing. Not to be confused with 

919 :class:`yuio.parse.Parser`. 

920 :param flag: 

921 flag that set this option. This will be set to :data:`None` 

922 for positional arguments. 

923 :param arguments: 

924 option arguments, see above. 

925 :param ns: 

926 namespace where parsed arguments should be stored. 

927 :raises: 

928 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`. 

929 

930 """ 

931 

932 def post_process( 

933 self, 

934 cli_parser: CliParser[Namespace], 

935 arguments: list[Argument], 

936 ns: Namespace, 

937 ): 

938 """ 

939 Called once at the end of parsing to post-process all arguments. 

940 

941 :param cli_parser: 

942 CLI parser instance that's doing the parsing. Not to be confused with 

943 :class:`yuio.parse.Parser`. 

944 :param arguments: 

945 option arguments that were ever passed to this option. 

946 :param ns: 

947 namespace where parsed arguments should be stored. 

948 :raises: 

949 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`. 

950 

951 """ 

952 

953 @functools.cached_property 

954 def short_flags(self) -> list[str] | None: 

955 if self.flags is yuio.POSITIONAL: 

956 return None 

957 else: 

958 return [flag for flag in self.flags if _is_short(flag)] 

959 

960 @functools.cached_property 

961 def long_flags(self) -> list[str] | None: 

962 if self.flags is yuio.POSITIONAL: 

963 return None 

964 else: 

965 return [flag for flag in self.flags if not _is_short(flag)] 

966 

967 @functools.cached_property 

968 def primary_short_flag(self) -> str | None: 

969 """ 

970 Short flag that will be displayed in CLI help. 

971 

972 """ 

973 

974 if short_flags := self.short_flags: 

975 return short_flags[0] 

976 else: 

977 return None 

978 

979 @functools.cached_property 

980 def primary_long_flags(self) -> list[str] | None: 

981 """ 

982 Long flags that will be displayed in CLI help. 

983 

984 """ 

985 

986 if long_flags := self.long_flags: 

987 return [long_flags[0]] 

988 else: 

989 return None 

990 

991 def format_usage( 

992 self, 

993 ctx: yuio.string.ReprContext, 

994 /, 

995 ) -> tuple[_ColorizedString, bool]: 

996 """ 

997 Allows customizing how this option looks in CLI usage. 

998 

999 :param ctx: 

1000 repr context for formatting help. 

1001 :returns: 

1002 a string that will be used to represent this option in program's 

1003 usage section. 

1004 

1005 """ 

1006 

1007 can_group = False 

1008 res = _ColorizedString() 

1009 if self.flags is not yuio.POSITIONAL and self.flags: 

1010 flag = self.primary_short_flag 

1011 if flag: 

1012 can_group = True 

1013 elif self.primary_long_flags: 

1014 flag = self.primary_long_flags[0] 

1015 else: 

1016 flag = self.flags[0] 

1017 res.append_color(ctx.get_color("hl/flag:sh-usage")) 

1018 res.append_str(flag) 

1019 if metavar := self.format_metavar(ctx): 

1020 res.append_colorized_str(metavar) 

1021 can_group = False 

1022 return res, can_group 

1023 

1024 def format_metavar( 

1025 self, 

1026 ctx: yuio.string.ReprContext, 

1027 /, 

1028 ) -> _ColorizedString: 

1029 """ 

1030 Allows customizing how this option looks in CLI help. 

1031 

1032 :param ctx: 

1033 repr context for formatting help. 

1034 :returns: 

1035 a string that will be appended to the list of option's flags 

1036 to format an entry for this option in CLI help message. 

1037 

1038 """ 

1039 

1040 res = _ColorizedString() 

1041 

1042 if not self.nargs: 

1043 return res 

1044 

1045 res.append_color(ctx.get_color("hl/punct:sh-usage")) 

1046 if self.flags: 

1047 res.append_str(" ") 

1048 

1049 if self.nargs == "+": 

1050 if self.allow_no_args: 

1051 res.append_str("[") 

1052 res.append_colorized_str(_format_metavar(self.nth_metavar(0), ctx)) 

1053 if self.allow_no_args: 

1054 res.append_str(" ...]") 

1055 else: 

1056 res.append_str(" [") 

1057 res.append_colorized_str(_format_metavar(self.nth_metavar(0), ctx)) 

1058 res.append_str(" ...]") 

1059 elif isinstance(self.nargs, int) and self.nargs: 

1060 if self.allow_no_args: 

1061 res.append_str("[") 

1062 sep = False 

1063 for i in range(self.nargs): 

1064 if sep: 

1065 res.append_str(" ") 

1066 res.append_colorized_str(_format_metavar(self.nth_metavar(i), ctx)) 

1067 sep = True 

1068 if self.allow_no_args: 

1069 res.append_str("]") 

1070 

1071 return res 

1072 

1073 def format_help_tail( 

1074 self, ctx: yuio.string.ReprContext, /, *, all: bool = False 

1075 ) -> _ColorizedString: 

1076 """ 

1077 Format additional content that will be added to the end of the help message, 

1078 such as aliases, default value, etc. 

1079 

1080 :param ctx: 

1081 repr context for formatting help. 

1082 :param all: 

1083 whether :flag:`--help=all` was specified. 

1084 :returns: 

1085 a string that will be appended to the main help message. 

1086 

1087 """ 

1088 

1089 base_color = ctx.get_color("msg/text:help/tail msg/text:code/sh-usage") 

1090 

1091 res = _ColorizedString(base_color) 

1092 

1093 if alias_flags := self.format_alias_flags(ctx, all=all): 

1094 es = "" if len(alias_flags) == 1 else "es" 

1095 res.append_str(f"Alias{es}: ") 

1096 sep = False 

1097 for alias_flag in alias_flags: 

1098 if isinstance(alias_flag, tuple): 

1099 alias_flag = alias_flag[0] 

1100 if sep: 

1101 res.append_str(", ") 

1102 res.append_colorized_str(alias_flag.with_base_color(base_color)) 

1103 sep = True 

1104 

1105 if default := self.format_default(ctx, all=all): 

1106 if res: 

1107 res.append_str("; ") 

1108 res.append_str("Default: ") 

1109 res.append_colorized_str(default.with_base_color(base_color)) 

1110 

1111 if res: 

1112 res.append_str(".") 

1113 

1114 return res 

1115 

1116 def format_alias_flags( 

1117 self, ctx: yuio.string.ReprContext, /, *, all: bool = False 

1118 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None: 

1119 """ 

1120 Format alias flags that weren't included in :attr:`~Option.primary_short_flag` 

1121 and :attr:`~Option.primary_long_flags`. 

1122 

1123 :param ctx: 

1124 repr context for formatting help. 

1125 :param all: 

1126 whether :flag:`--help=all` was specified. 

1127 :returns: 

1128 a list of strings, one per each alias. 

1129 

1130 """ 

1131 

1132 if self.flags is yuio.POSITIONAL: 

1133 return None 

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

1135 if self.primary_short_flag: 

1136 primary_flags.add(self.primary_short_flag) 

1137 aliases: list[_ColorizedString | tuple[_ColorizedString, str]] = [] 

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

1139 for flag in self.flags: 

1140 if flag not in primary_flags: 

1141 res = _ColorizedString() 

1142 res.start_no_wrap() 

1143 res.append_color(flag_color) 

1144 res.append_str(flag) 

1145 res.end_no_wrap() 

1146 aliases.append(res) 

1147 return aliases 

1148 

1149 def format_default( 

1150 self, ctx: yuio.string.ReprContext, /, *, all: bool = False 

1151 ) -> _ColorizedString | None: 

1152 """ 

1153 Format default value that will be included in the CLI help. 

1154 

1155 :param ctx: 

1156 repr context for formatting help. 

1157 :param all: 

1158 whether :flag:`--help=all` was specified. 

1159 :returns: 

1160 a string that will be appended to the main help message. 

1161 

1162 """ 

1163 

1164 if self.default_desc is not None: 

1165 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code")) 

1166 

1167 return None 

1168 

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

1170 return None, False 

1171 

1172 def nth_metavar(self, n: int) -> str: 

1173 """ 

1174 Get metavar for n-th argument for this option. 

1175 

1176 """ 

1177 

1178 if not self.metavar: 

1179 return "<argument>" 

1180 if isinstance(self.metavar, tuple): 

1181 if n >= len(self.metavar): 

1182 return self.metavar[-1] 

1183 else: 

1184 return self.metavar[n] 

1185 else: 

1186 return self.metavar 

1187 

1188 

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

1190class ValueOption(Option[T], _t.Generic[T]): 

1191 """ 

1192 Base class for options that parse arguments and assign them to namespace. 

1193 

1194 This base handles assigning parsed value to the target destination and merging 

1195 values if option is invoked multiple times. Call ``self.set(ns, value)`` from 

1196 :meth:`Option.process` to set result of option processing. 

1197 

1198 """ 

1199 

1200 merge: _t.Callable[[T, T], T] | None 

1201 """ 

1202 Function to merge previous and new value. 

1203 

1204 """ 

1205 

1206 default: object 

1207 """ 

1208 Default value that will be used if this flag is not given. 

1209 

1210 Used for formatting help, does not affect actual parsing. 

1211 

1212 """ 

1213 

1214 def set(self, ns: Namespace, value: T): 

1215 """ 

1216 Save new value. If :attr:`~ValueOption.merge` is given, automatically 

1217 merge old and new value. 

1218 

1219 """ 

1220 

1221 if self.merge and self.dest in ns: 

1222 ns[self.dest] = self.merge(ns[self.dest], value) 

1223 else: 

1224 ns[self.dest] = value 

1225 

1226 

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

1228class ParserOption(ValueOption[T], _t.Generic[T]): 

1229 """ 

1230 Base class for options that use :mod:`yuio.parse` to process arguments. 

1231 

1232 """ 

1233 

1234 parser: yuio.parse.Parser[T] 

1235 """ 

1236 A parser used to parse option's arguments. 

1237 

1238 """ 

1239 

1240 def format_default( 

1241 self, ctx: yuio.string.ReprContext, /, *, all: bool = False 

1242 ) -> _ColorizedString | None: 

1243 if self.default_desc is not None: 

1244 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code")) 

1245 

1246 if self.default is yuio.MISSING or self.default is None: 

1247 return None 

1248 

1249 try: 

1250 return ctx.hl(self.parser.describe_value(self.default)).with_base_color( 

1251 ctx.get_color("code") 

1252 ) 

1253 except TypeError: 

1254 return ctx.repr(self.default) 

1255 

1256 

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

1258class BoolOption(ParserOption[bool]): 

1259 """ 

1260 An option that combines :class:`StoreTrueOption`, :class:`StoreFalseOption`, 

1261 and :class:`ParseOneOption`. 

1262 

1263 If any of the :attr:`~BoolOption.pos_flags` are given without arguments, it works like 

1264 :class:`StoreTrueOption`. 

1265 

1266 If any of the :attr:`~BoolOption.neg_flags` are given, it works like 

1267 :class:`StoreFalseOption`. 

1268 

1269 If any of the :attr:`~BoolOption.pos_flags` are given with an inline argument, 

1270 the argument is parsed as a :class:`bool`. 

1271 

1272 .. note:: 

1273 

1274 Bool option has :attr:`~Option.nargs` set to ``0``, so non-inline arguments 

1275 (i.e. :flag:`--json false`) are not recognized. You should always use inline 

1276 argument to set boolean flag's value (i.e. :flag:`--json=false`). This avoids 

1277 ambiguity in cases like the following: 

1278 

1279 .. code-block:: console 

1280 

1281 $ prog --json subcommand # Ok 

1282 $ prog --json=true subcommand # Ok 

1283 $ prog --json true subcommand # Not allowed 

1284 

1285 :example: 

1286 .. code-block:: python 

1287 

1288 option = yuio.cli.BoolOption( 

1289 pos_flags=["--json"], 

1290 neg_flags=["--no-json"], 

1291 dest=..., 

1292 ) 

1293 

1294 .. code-block:: console 

1295 

1296 $ prog --json # Set `dest` to `True` 

1297 $ prog --no-json # Set `dest` to `False` 

1298 $ prog --json=$value # Set `dest` to parsed `$value` 

1299 

1300 """ 

1301 

1302 pos_flags: list[str] 

1303 """ 

1304 List of flag names that enable this boolean option. Should be non-empty. 

1305 

1306 """ 

1307 

1308 neg_flags: list[str] 

1309 """ 

1310 List of flag names that disable this boolean option. 

1311 

1312 """ 

1313 

1314 def __init__( 

1315 self, 

1316 *, 

1317 pos_flags: list[str], 

1318 neg_flags: list[str], 

1319 required: bool = False, 

1320 mutex_group: None | MutuallyExclusiveGroup = None, 

1321 usage: yuio.Collapse | bool = True, 

1322 help: str | yuio.Disabled = "", 

1323 help_group: HelpGroup | None = None, 

1324 show_if_inherited: bool = False, 

1325 dest: str, 

1326 parser: yuio.parse.Parser[bool] | None = None, 

1327 merge: _t.Callable[[bool, bool], bool] | None = None, 

1328 default: bool | yuio.Missing = yuio.MISSING, 

1329 allow_abbrev: bool = True, 

1330 default_desc: str | None = None, 

1331 ): 

1332 self.pos_flags = pos_flags 

1333 self.neg_flags = neg_flags 

1334 

1335 super().__init__( 

1336 flags=pos_flags + neg_flags, 

1337 allow_inline_arg=True, 

1338 allow_implicit_inline_arg=False, 

1339 nargs=0, 

1340 allow_no_args=True, 

1341 required=required, 

1342 metavar=(), 

1343 mutex_group=mutex_group, 

1344 usage=usage, 

1345 help=help, 

1346 help_group=help_group, 

1347 show_if_inherited=show_if_inherited, 

1348 dest=dest, 

1349 merge=merge, 

1350 default=default, 

1351 parser=parser or yuio.parse.Bool(), 

1352 allow_abbrev=allow_abbrev, 

1353 default_desc=default_desc, 

1354 ) 

1355 

1356 def process( 

1357 self, 

1358 cli_parser: CliParser[Namespace], 

1359 flag: Flag | None, 

1360 arguments: Argument | list[Argument], 

1361 ns: Namespace, 

1362 ): 

1363 if flag and flag.value in self.neg_flags: 

1364 if arguments: 

1365 raise ArgumentError( 

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

1367 ) 

1368 value = False 

1369 elif isinstance(arguments, Argument): 

1370 value = self.parser.parse(arguments.value) 

1371 else: 

1372 value = True 

1373 self.set(ns, value) 

1374 

1375 @functools.cached_property 

1376 def primary_short_flag(self): 

1377 if self.flags is yuio.POSITIONAL: 

1378 return None 

1379 if self.default is True: 

1380 flags = self.neg_flags 

1381 else: 

1382 flags = self.pos_flags 

1383 for flag in flags: 

1384 if _is_short(flag): 

1385 return flag 

1386 return None 

1387 

1388 @functools.cached_property 

1389 def primary_long_flags(self): 

1390 flags = [] 

1391 if self.default is not True: 

1392 for flag in self.pos_flags: 

1393 if not _is_short(flag): 

1394 flags.append(flag) 

1395 break 

1396 if self.default is not False: 

1397 for flag in self.neg_flags: 

1398 if not _is_short(flag): 

1399 flags.append(flag) 

1400 break 

1401 return flags 

1402 

1403 def format_alias_flags( 

1404 self, ctx: yuio.string.ReprContext, *, all: bool = False 

1405 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None: 

1406 if self.flags is yuio.POSITIONAL: 

1407 return None 

1408 

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

1410 if self.primary_short_flag: 

1411 primary_flags.add(self.primary_short_flag) 

1412 

1413 aliases: list[_ColorizedString | tuple[_ColorizedString, str]] = [] 

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

1415 if all: 

1416 alias_candidates = self.pos_flags + self.neg_flags 

1417 else: 

1418 alias_candidates = [] 

1419 if self.default is not True: 

1420 alias_candidates += self.pos_flags 

1421 if self.default is not False: 

1422 alias_candidates += self.neg_flags 

1423 for flag in alias_candidates: 

1424 if flag not in primary_flags: 

1425 res = _ColorizedString() 

1426 res.start_no_wrap() 

1427 res.append_color(flag_color) 

1428 res.append_str(flag) 

1429 res.end_no_wrap() 

1430 aliases.append(res) 

1431 if self.pos_flags and all: 

1432 primary_pos_flag = self.pos_flags[0] 

1433 for pos_flag in self.pos_flags: 

1434 if not _is_short(pos_flag): 

1435 primary_pos_flag = pos_flag 

1436 break 

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

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

1439 res = _ColorizedString() 

1440 res.start_no_wrap() 

1441 res.append_color(flag_color) 

1442 res.append_str(primary_pos_flag) 

1443 res.end_no_wrap() 

1444 res.append_color(punct_color) 

1445 res.append_str("={") 

1446 res.append_color(metavar_color) 

1447 res.append_str("true") 

1448 res.append_color(punct_color) 

1449 res.append_str("|") 

1450 res.append_color(metavar_color) 

1451 res.append_str("false") 

1452 res.append_color(punct_color) 

1453 res.append_str("}") 

1454 aliases.append(res) 

1455 return aliases 

1456 

1457 def format_default( 

1458 self, ctx: yuio.string.ReprContext, /, *, all: bool = False 

1459 ) -> _ColorizedString | None: 

1460 if self.default_desc is not None: 

1461 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code")) 

1462 

1463 return None 

1464 

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

1466 return ( 

1467 yuio.complete.Choice( 

1468 [ 

1469 yuio.complete.Option("true"), 

1470 yuio.complete.Option("false"), 

1471 ] 

1472 ), 

1473 False, 

1474 ) 

1475 

1476 

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

1478class ParseOneOption(ParserOption[T], _t.Generic[T]): 

1479 """ 

1480 An option with a single argument that uses Yuio parser. 

1481 

1482 """ 

1483 

1484 def __init__( 

1485 self, 

1486 *, 

1487 flags: list[str] | yuio.Positional, 

1488 required: bool = False, 

1489 mutex_group: None | MutuallyExclusiveGroup = None, 

1490 usage: yuio.Collapse | bool = True, 

1491 help: str | yuio.Disabled = "", 

1492 help_group: HelpGroup | None = None, 

1493 show_if_inherited: bool = False, 

1494 dest: str, 

1495 parser: yuio.parse.Parser[T], 

1496 merge: _t.Callable[[T, T], T] | None = None, 

1497 default: T | yuio.Missing = yuio.MISSING, 

1498 allow_abbrev: bool = True, 

1499 default_desc: str | None = None, 

1500 ): 

1501 super().__init__( 

1502 flags=flags, 

1503 allow_inline_arg=True, 

1504 allow_implicit_inline_arg=True, 

1505 nargs=1, 

1506 allow_no_args=default is not yuio.MISSING and flags is yuio.POSITIONAL, 

1507 required=required, 

1508 metavar=parser.describe_or_def(), 

1509 mutex_group=mutex_group, 

1510 usage=usage, 

1511 help=help, 

1512 help_group=help_group, 

1513 show_if_inherited=show_if_inherited, 

1514 dest=dest, 

1515 merge=merge, 

1516 default=default, 

1517 parser=parser, 

1518 allow_abbrev=allow_abbrev, 

1519 default_desc=default_desc, 

1520 ) 

1521 

1522 def process( 

1523 self, 

1524 cli_parser: CliParser[Namespace], 

1525 flag: Flag | None, 

1526 arguments: Argument | list[Argument], 

1527 ns: Namespace, 

1528 ): 

1529 if isinstance(arguments, list): 

1530 if not arguments and self.allow_no_args: 

1531 return # Don't set value so that app falls back to default. 

1532 arguments = arguments[0] 

1533 try: 

1534 self.set(ns, self.parser.parse(arguments.value)) 

1535 except yuio.parse.ParsingError as e: 

1536 e.n_arg = 0 

1537 raise 

1538 

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

1540 return (self.parser.completer(), False) 

1541 

1542 

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

1544class ParseManyOption(ParserOption[T], _t.Generic[T]): 

1545 """ 

1546 An option with multiple arguments that uses Yuio parser. 

1547 

1548 """ 

1549 

1550 def __init__( 

1551 self, 

1552 *, 

1553 flags: list[str] | yuio.Positional, 

1554 required: bool = False, 

1555 mutex_group: None | MutuallyExclusiveGroup = None, 

1556 usage: yuio.Collapse | bool = True, 

1557 help: str | yuio.Disabled = "", 

1558 help_group: HelpGroup | None = None, 

1559 show_if_inherited: bool = False, 

1560 dest: str, 

1561 parser: yuio.parse.Parser[T], 

1562 merge: _t.Callable[[T, T], T] | None = None, 

1563 default: T | yuio.Missing = yuio.MISSING, 

1564 allow_abbrev: bool = True, 

1565 default_desc: str | None = None, 

1566 ): 

1567 assert parser.supports_parse_many() 

1568 

1569 nargs = parser.get_nargs() 

1570 allow_no_args = default is not yuio.MISSING and flags is yuio.POSITIONAL 

1571 if nargs == "*": 

1572 nargs = "+" 

1573 allow_no_args = True 

1574 

1575 super().__init__( 

1576 flags=flags, 

1577 allow_inline_arg=True, 

1578 allow_implicit_inline_arg=True, 

1579 nargs=nargs, 

1580 allow_no_args=allow_no_args, 

1581 required=required, 

1582 metavar=parser.describe_many(), 

1583 mutex_group=mutex_group, 

1584 usage=usage, 

1585 help=help, 

1586 help_group=help_group, 

1587 show_if_inherited=show_if_inherited, 

1588 dest=dest, 

1589 merge=merge, 

1590 default=default, 

1591 parser=parser, 

1592 allow_abbrev=allow_abbrev, 

1593 default_desc=default_desc, 

1594 ) 

1595 

1596 def process( 

1597 self, 

1598 cli_parser: CliParser[Namespace], 

1599 flag: Flag | None, 

1600 arguments: Argument | list[Argument], 

1601 ns: Namespace, 

1602 ): 

1603 if ( 

1604 not arguments 

1605 and self.allow_no_args 

1606 and self.default is not yuio.MISSING 

1607 and self.flags is yuio.POSITIONAL 

1608 ): 

1609 return # Don't set value so that app falls back to default. 

1610 

1611 if isinstance(arguments, list): 

1612 self.set(ns, self.parser.parse_many([arg.value for arg in arguments])) 

1613 else: 

1614 self.set(ns, self.parser.parse(arguments.value)) 

1615 

1616 def format_alias_flags( 

1617 self, ctx: yuio.string.ReprContext, /, *, all: bool = False 

1618 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None: 

1619 aliases = super().format_alias_flags(ctx, all=all) or [] 

1620 if all: 

1621 flag = self.primary_short_flag 

1622 if not flag and self.primary_long_flags: 

1623 flag = self.primary_long_flags[0] 

1624 if not flag and self.flags: 

1625 flag = self.flags[0] 

1626 if flag: 

1627 res = _ColorizedString() 

1628 res.start_no_wrap() 

1629 res.append_color(ctx.get_color("hl/flag:sh-usage")) 

1630 res.append_str(flag) 

1631 res.end_no_wrap() 

1632 res.append_color(ctx.get_color("hl/punct:sh-usage")) 

1633 res.append_str("=") 

1634 res.append_color(ctx.get_color("hl/str:sh-usage")) 

1635 res.append_str("'") 

1636 res.append_str(self.parser.describe_or_def()) 

1637 res.append_str("'") 

1638 comment = ( 

1639 "can be given as a single argument with delimiter-separated list." 

1640 ) 

1641 aliases.append((res, comment)) 

1642 return aliases 

1643 

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

1645 return (self.parser.completer(), True) 

1646 

1647 

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

1649class CollectOption(ParserOption[T], _t.Generic[T]): 

1650 """ 

1651 An option with single argument that collects all of its instances and passes them 

1652 to :meth:`Parser.parse_many <yuio.parse.Parser.parse_many>`. 

1653 

1654 """ 

1655 

1656 def __init__( 

1657 self, 

1658 *, 

1659 flags: list[str] | yuio.Positional, 

1660 required: bool = False, 

1661 mutex_group: None | MutuallyExclusiveGroup = None, 

1662 usage: yuio.Collapse | bool = True, 

1663 help: str | yuio.Disabled = "", 

1664 help_group: HelpGroup | None = None, 

1665 show_if_inherited: bool = False, 

1666 dest: str, 

1667 parser: yuio.parse.Parser[T], 

1668 merge: _t.Callable[[T, T], T] | None = None, 

1669 default: T | yuio.Missing = yuio.MISSING, 

1670 allow_abbrev: bool = True, 

1671 default_desc: str | None = None, 

1672 ): 

1673 assert parser.supports_parse_many() 

1674 

1675 if flags is yuio.POSITIONAL: 

1676 raise TypeError( 

1677 "ParseManyOneByOneOption can't be used with positional arguments" 

1678 ) 

1679 

1680 nargs = parser.get_nargs() 

1681 if nargs not in ["*", "+"]: 

1682 raise TypeError( 

1683 "ParseManyOneByOneOption can't be used with parser " 

1684 "that limits length of its collection" 

1685 ) 

1686 

1687 super().__init__( 

1688 flags=flags, 

1689 allow_inline_arg=True, 

1690 allow_implicit_inline_arg=True, 

1691 nargs=1, 

1692 allow_no_args=False, 

1693 required=required, 

1694 metavar=parser.describe_many(), 

1695 mutex_group=mutex_group, 

1696 usage=usage, 

1697 help=help, 

1698 help_group=help_group, 

1699 show_if_inherited=show_if_inherited, 

1700 dest=dest, 

1701 merge=merge, 

1702 default=default, 

1703 parser=parser, 

1704 allow_abbrev=allow_abbrev, 

1705 default_desc=default_desc, 

1706 ) 

1707 

1708 def process( 

1709 self, 

1710 cli_parser: CliParser[Namespace], 

1711 flag: Flag | None, 

1712 arguments: Argument | list[Argument], 

1713 ns: Namespace, 

1714 ): 

1715 pass 

1716 

1717 def post_process( 

1718 self, 

1719 cli_parser: CliParser[Namespace], 

1720 arguments: list[Argument], 

1721 ns: Namespace, 

1722 ): 

1723 self.set(ns, self.parser.parse_many([arg.value for arg in arguments])) 

1724 

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

1726 return (self.parser.completer(), True) 

1727 

1728 

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

1730class StoreConstOption(ValueOption[T], _t.Generic[T]): 

1731 """ 

1732 An option with no arguments that stores a constant to namespace. 

1733 

1734 """ 

1735 

1736 const: T 

1737 """ 

1738 Constant that will be stored. 

1739 

1740 """ 

1741 

1742 def __init__( 

1743 self, 

1744 *, 

1745 flags: list[str], 

1746 required: bool = False, 

1747 mutex_group: None | MutuallyExclusiveGroup = None, 

1748 usage: yuio.Collapse | bool = True, 

1749 help: str | yuio.Disabled = "", 

1750 help_group: HelpGroup | None = None, 

1751 show_if_inherited: bool = False, 

1752 dest: str, 

1753 merge: _t.Callable[[T, T], T] | None = None, 

1754 default: T | yuio.Missing = yuio.MISSING, 

1755 const: T, 

1756 allow_abbrev: bool = True, 

1757 default_desc: str | None = None, 

1758 ): 

1759 self.const = const 

1760 

1761 super().__init__( 

1762 flags=flags, 

1763 allow_inline_arg=False, 

1764 allow_implicit_inline_arg=False, 

1765 nargs=0, 

1766 allow_no_args=True, 

1767 required=required, 

1768 metavar=(), 

1769 mutex_group=mutex_group, 

1770 usage=usage, 

1771 help=help, 

1772 help_group=help_group, 

1773 show_if_inherited=show_if_inherited, 

1774 dest=dest, 

1775 merge=merge, 

1776 default=default, 

1777 allow_abbrev=allow_abbrev, 

1778 default_desc=default_desc, 

1779 ) 

1780 

1781 def process( 

1782 self, 

1783 cli_parser: CliParser[Namespace], 

1784 flag: Flag | None, 

1785 arguments: Argument | list[Argument], 

1786 ns: Namespace, 

1787 ): 

1788 if self.merge and self.dest in ns: 

1789 ns[self.dest] = self.merge(ns[self.dest], self.const) 

1790 else: 

1791 ns[self.dest] = self.const 

1792 

1793 

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

1795class CountOption(StoreConstOption[int]): 

1796 """ 

1797 An option that counts number of its appearances on the command line. 

1798 

1799 """ 

1800 

1801 def __init__( 

1802 self, 

1803 *, 

1804 flags: list[str], 

1805 required: bool = False, 

1806 mutex_group: None | MutuallyExclusiveGroup = None, 

1807 usage: yuio.Collapse | bool = True, 

1808 help: str | yuio.Disabled = "", 

1809 help_group: HelpGroup | None = None, 

1810 show_if_inherited: bool = False, 

1811 dest: str, 

1812 default: int | yuio.Missing = yuio.MISSING, 

1813 allow_abbrev: bool = True, 

1814 default_desc: str | None = None, 

1815 ): 

1816 super().__init__( 

1817 flags=flags, 

1818 required=required, 

1819 mutex_group=mutex_group, 

1820 usage=usage, 

1821 help=help, 

1822 help_group=help_group, 

1823 show_if_inherited=show_if_inherited, 

1824 dest=dest, 

1825 merge=lambda x, y: x + y, 

1826 default=default, 

1827 const=1, 

1828 allow_abbrev=allow_abbrev, 

1829 default_desc=default_desc, 

1830 ) 

1831 

1832 def format_metavar(self, ctx: yuio.string.ReprContext) -> _ColorizedString: 

1833 return _ColorizedString((ctx.get_color("hl/flag:sh-usage"), "...")) 

1834 

1835 

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

1837class StoreTrueOption(StoreConstOption[bool]): 

1838 """ 

1839 An option that stores :data:`True` to namespace. 

1840 

1841 """ 

1842 

1843 def __init__( 

1844 self, 

1845 *, 

1846 flags: list[str], 

1847 required: bool = False, 

1848 mutex_group: None | MutuallyExclusiveGroup = None, 

1849 usage: yuio.Collapse | bool = True, 

1850 help: str | yuio.Disabled = "", 

1851 help_group: HelpGroup | None = None, 

1852 show_if_inherited: bool = False, 

1853 dest: str, 

1854 default: bool | yuio.Missing = yuio.MISSING, 

1855 allow_abbrev: bool = True, 

1856 default_desc: str | None = None, 

1857 ): 

1858 super().__init__( 

1859 flags=flags, 

1860 required=required, 

1861 mutex_group=mutex_group, 

1862 usage=usage, 

1863 help=help, 

1864 help_group=help_group, 

1865 show_if_inherited=show_if_inherited, 

1866 dest=dest, 

1867 merge=None, 

1868 default=default, 

1869 const=True, 

1870 allow_abbrev=allow_abbrev, 

1871 default_desc=default_desc, 

1872 ) 

1873 

1874 

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

1876class StoreFalseOption(StoreConstOption[bool]): 

1877 """ 

1878 An option that stores :data:`False` to namespace. 

1879 

1880 """ 

1881 

1882 def __init__( 

1883 self, 

1884 *, 

1885 flags: list[str], 

1886 required: bool = False, 

1887 mutex_group: None | MutuallyExclusiveGroup = None, 

1888 usage: yuio.Collapse | bool = True, 

1889 help: str | yuio.Disabled = "", 

1890 help_group: HelpGroup | None = None, 

1891 show_if_inherited: bool = False, 

1892 dest: str, 

1893 default: bool | yuio.Missing = yuio.MISSING, 

1894 allow_abbrev: bool = True, 

1895 default_desc: str | None = None, 

1896 ): 

1897 super().__init__( 

1898 flags=flags, 

1899 required=required, 

1900 mutex_group=mutex_group, 

1901 usage=usage, 

1902 help=help, 

1903 help_group=help_group, 

1904 show_if_inherited=show_if_inherited, 

1905 dest=dest, 

1906 merge=None, 

1907 default=default, 

1908 const=False, 

1909 allow_abbrev=allow_abbrev, 

1910 default_desc=default_desc, 

1911 ) 

1912 

1913 

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

1915class VersionOption(Option[_t.Never]): 

1916 """ 

1917 An option that prints app's version and stops the program. 

1918 

1919 """ 

1920 

1921 version: str 

1922 """ 

1923 Version to print. 

1924 

1925 """ 

1926 

1927 def __init__( 

1928 self, 

1929 *, 

1930 version: str, 

1931 flags: list[str] = ["-V", "--version"], 

1932 usage: yuio.Collapse | bool = yuio.COLLAPSE, 

1933 help: str | yuio.Disabled = "Print program version and exit.", 

1934 help_group: HelpGroup | None = MISC_GROUP, 

1935 allow_abbrev: bool = True, 

1936 ): 

1937 super().__init__( 

1938 flags=flags, 

1939 allow_inline_arg=False, 

1940 allow_implicit_inline_arg=False, 

1941 nargs=0, 

1942 allow_no_args=True, 

1943 required=False, 

1944 metavar=(), 

1945 mutex_group=None, 

1946 usage=usage, 

1947 help=help, 

1948 help_group=help_group, 

1949 show_if_inherited=False, 

1950 allow_abbrev=allow_abbrev, 

1951 dest="_version", 

1952 default_desc=None, 

1953 ) 

1954 

1955 self.version = version 

1956 

1957 def process( 

1958 self, 

1959 cli_parser: CliParser[Namespace], 

1960 flag: Flag | None, 

1961 arguments: Argument | list[Argument], 

1962 ns: Namespace, 

1963 ): 

1964 import yuio.io 

1965 

1966 if self.version: 

1967 yuio.io.raw(self.version, add_newline=True, to_stdout=True) 

1968 else: 

1969 yuio.io.raw("<unknown version>", add_newline=True, to_stdout=True) 

1970 sys.exit(0) 

1971 

1972 

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

1974class BugReportOption(Option[_t.Never]): 

1975 """ 

1976 An option that prints bug report. 

1977 

1978 """ 

1979 

1980 settings: yuio.dbg.ReportSettings | bool | None 

1981 """ 

1982 Settings for bug report generation. 

1983 

1984 """ 

1985 

1986 app: yuio.app.App[_t.Any] | None 

1987 """ 

1988 Main app of the project, used to extract project's version and dependencies. 

1989 

1990 """ 

1991 

1992 def __init__( 

1993 self, 

1994 *, 

1995 settings: yuio.dbg.ReportSettings | bool | None = None, 

1996 app: yuio.app.App[_t.Any] | None = None, 

1997 flags: list[str] = ["--bug-report"], 

1998 usage: yuio.Collapse | bool = yuio.COLLAPSE, 

1999 help: str | yuio.Disabled = "Print environment data for bug report and exit.", 

2000 help_group: HelpGroup | None = MISC_GROUP, 

2001 allow_abbrev: bool = True, 

2002 ): 

2003 super().__init__( 

2004 flags=flags, 

2005 allow_inline_arg=False, 

2006 allow_implicit_inline_arg=False, 

2007 nargs=0, 

2008 allow_no_args=True, 

2009 required=False, 

2010 metavar=(), 

2011 mutex_group=None, 

2012 usage=usage, 

2013 help=help, 

2014 help_group=help_group, 

2015 show_if_inherited=False, 

2016 allow_abbrev=allow_abbrev, 

2017 dest="_bug_report", 

2018 default_desc=None, 

2019 ) 

2020 

2021 self.settings = settings 

2022 self.app = app 

2023 

2024 def process( 

2025 self, 

2026 cli_parser: CliParser[Namespace], 

2027 flag: Flag | None, 

2028 arguments: Argument | list[Argument], 

2029 ns: Namespace, 

2030 ): 

2031 import yuio.dbg 

2032 

2033 yuio.dbg.print_report(settings=self.settings, app=self.app) 

2034 sys.exit(0) 

2035 

2036 

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

2038class CompletionOption(Option[_t.Never]): 

2039 """ 

2040 An option that installs autocompletion. 

2041 

2042 """ 

2043 

2044 _SHELLS = [ 

2045 "all", 

2046 "uninstall", 

2047 "bash", 

2048 "zsh", 

2049 "fish", 

2050 "pwsh", 

2051 ] 

2052 

2053 def __init__( 

2054 self, 

2055 *, 

2056 flags: list[str] = ["--completions"], 

2057 usage: yuio.Collapse | bool = yuio.COLLAPSE, 

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

2059 help_group: HelpGroup | None = MISC_GROUP, 

2060 allow_abbrev: bool = True, 

2061 ): 

2062 if help is None: 

2063 shells = yuio.string.Or(f"``{shell}``" for shell in self._SHELLS) 

2064 help = ( 

2065 "Install or update autocompletion scripts and exit.\n\n" 

2066 f"Supported shells: {shells}." 

2067 ) 

2068 super().__init__( 

2069 flags=flags, 

2070 allow_inline_arg=True, 

2071 allow_implicit_inline_arg=True, 

2072 nargs=1, 

2073 allow_no_args=True, 

2074 required=False, 

2075 metavar="<shell>", 

2076 mutex_group=None, 

2077 usage=usage, 

2078 help=help, 

2079 help_group=help_group, 

2080 show_if_inherited=False, 

2081 allow_abbrev=allow_abbrev, 

2082 dest="_completions", 

2083 default_desc=None, 

2084 ) 

2085 

2086 def process( 

2087 self, 

2088 cli_parser: CliParser[Namespace], 

2089 flag: Flag | None, 

2090 arguments: Argument | list[Argument], 

2091 ns: Namespace, 

2092 ): 

2093 if isinstance(arguments, list): 

2094 argument = arguments[0].value if arguments else "all" 

2095 else: 

2096 argument = arguments.value 

2097 

2098 if argument not in self._SHELLS: 

2099 raise ArgumentError( 

2100 "Unknown shell `%r`, should be %s", 

2101 argument, 

2102 yuio.string.Or(self._SHELLS), 

2103 flag=flag, 

2104 arguments=arguments, 

2105 n_arg=0, 

2106 ) 

2107 

2108 root = cli_parser._root_command 

2109 help_parser = cli_parser._help_parser 

2110 

2111 if argument == "uninstall": 

2112 compdata = "" 

2113 else: 

2114 serializer = yuio.complete._ProgramSerializer() 

2115 self._dump(root, serializer, [], help_parser) 

2116 compdata = serializer.dump() 

2117 

2118 yuio.complete._write_completions(compdata, root.name, argument) 

2119 

2120 sys.exit(0) 

2121 

2122 def _dump( 

2123 self, 

2124 command: Command[_t.Any], 

2125 serializer: yuio.complete._ProgramSerializer, 

2126 parent_options: list[Option[_t.Any]], 

2127 help_parser: yuio.doc.DocParser, 

2128 ): 

2129 seen_flags: set[str] = set() 

2130 seen_options: list[Option[_t.Any]] = [] 

2131 

2132 # Add command's options, keep track of flags from the current command. 

2133 for option in command.options: 

2134 completer, is_many = option.get_completer() 

2135 help = option.help 

2136 if help is not yuio.DISABLED: 

2137 ctx = yuio.string.ReprContext.make_dummy(is_unicode=False) 

2138 ctx.width = 60 

2139 parsed_help = _parse_option_help(option, help_parser, ctx) 

2140 if parsed_help: 

2141 lines = _CliFormatter(help_parser, ctx).format(parsed_help) 

2142 if not lines: 

2143 help = "" 

2144 elif len(lines) == 1: 

2145 help = str(lines[0]) 

2146 else: 

2147 help = str(lines[0]) + ("..." if lines[1] else "") 

2148 else: 

2149 help = "" 

2150 serializer.add_option( 

2151 flags=option.flags, 

2152 nargs=option.nargs, 

2153 metavar=option.metavar, 

2154 help=help, 

2155 completer=completer, 

2156 is_many=is_many, 

2157 ) 

2158 if option.flags is not yuio.POSITIONAL: 

2159 seen_flags |= seen_flags 

2160 seen_options.append(option) 

2161 

2162 # Add parent options if their flags were not shadowed. 

2163 for option in parent_options: 

2164 assert option.flags is not yuio.POSITIONAL 

2165 

2166 flags = [flag for flag in option.flags if flag not in seen_flags] 

2167 if not flags: 

2168 continue 

2169 

2170 completer, is_many = option.get_completer() 

2171 help = option.help 

2172 if help is not yuio.DISABLED and not option.show_if_inherited: 

2173 # TODO: not sure if disabling help for inherited options is 

2174 # the best approach here. 

2175 help = yuio.DISABLED 

2176 nargs = option.nargs 

2177 if option.allow_no_args: 

2178 if nargs == 1: 

2179 nargs = "?" 

2180 elif nargs == "+": 

2181 nargs = "*" 

2182 serializer.add_option( 

2183 flags=flags, 

2184 nargs=nargs, 

2185 metavar=option.metavar, 

2186 help=help, 

2187 completer=completer, 

2188 is_many=is_many, 

2189 ) 

2190 

2191 seen_flags |= seen_flags 

2192 seen_options.append(option) 

2193 

2194 for name, subcommand in command.subcommands.items(): 

2195 subcommand = subcommand.load() 

2196 subcommand_serializer = serializer.add_subcommand( 

2197 name=name, is_alias=name != subcommand.name, help=subcommand.help 

2198 ) 

2199 self._dump(subcommand, subcommand_serializer, seen_options, help_parser) 

2200 

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

2202 return ( 

2203 yuio.complete.Choice( 

2204 [yuio.complete.Option(shell) for shell in self._SHELLS] 

2205 ), 

2206 False, 

2207 ) 

2208 

2209 

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

2211class HelpOption(Option[_t.Never]): 

2212 """ 

2213 An option that prints help message and stops the program. 

2214 

2215 """ 

2216 

2217 def __init__( 

2218 self, 

2219 *, 

2220 flags: list[str] = ["-h", "--help"], 

2221 usage: yuio.Collapse | bool = yuio.COLLAPSE, 

2222 help: str | yuio.Disabled = "Print this message and exit.", 

2223 help_group: HelpGroup | None = MISC_GROUP, 

2224 allow_abbrev: bool = True, 

2225 ): 

2226 super().__init__( 

2227 flags=flags, 

2228 allow_inline_arg=True, 

2229 allow_implicit_inline_arg=True, 

2230 nargs=0, 

2231 allow_no_args=True, 

2232 required=False, 

2233 metavar=(), 

2234 mutex_group=None, 

2235 usage=usage, 

2236 help=help, 

2237 help_group=help_group, 

2238 show_if_inherited=True, 

2239 allow_abbrev=allow_abbrev, 

2240 dest="_help", 

2241 default_desc=None, 

2242 ) 

2243 

2244 def process( 

2245 self, 

2246 cli_parser: CliParser[Namespace], 

2247 flag: Flag | None, 

2248 arguments: Argument | list[Argument], 

2249 ns: Namespace, 

2250 ): 

2251 import yuio.io 

2252 import yuio.string 

2253 

2254 if isinstance(arguments, list): 

2255 argument = arguments[0].value if arguments else "" 

2256 else: 

2257 argument = arguments.value 

2258 

2259 if argument not in ("all", ""): 

2260 raise ArgumentError( 

2261 "Unknown help scope <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, should be %s", 

2262 argument, 

2263 yuio.string.Or( 

2264 ["all"], color="msg/text:code/sh-usage hl/flag:sh-usage" 

2265 ), 

2266 flag=flag, 

2267 arguments=arguments, 

2268 n_arg=0, 

2269 ) 

2270 

2271 formatter = _HelpFormatter(cli_parser._help_parser, all=argument == "all") 

2272 inherited_options = [] 

2273 seen_inherited_options = set() 

2274 for opt in cli_parser._inherited_options.values(): 

2275 if opt not in seen_inherited_options: 

2276 seen_inherited_options.add(opt) 

2277 inherited_options.append(opt) 

2278 formatter.add_command( 

2279 " ".join(cli_parser._current_path), 

2280 cli_parser._current_command, 

2281 list(inherited_options), 

2282 ) 

2283 

2284 yuio.io.raw(formatter, add_newline=True, to_stdout=True) 

2285 sys.exit(0) 

2286 

2287 

2288@dataclass(kw_only=True, eq=False, match_args=False) 

2289class LazyCommand(_t.Generic[NamespaceT]): 

2290 """ 

2291 Lazy loader for data about CLI interface of a single command or subcommand. 

2292 

2293 """ 

2294 

2295 help: str | yuio.Disabled | None 

2296 """ 

2297 Help message for this command, displayed when listing subcommands. 

2298 

2299 """ 

2300 

2301 loader: _t.Callable[[], Command[NamespaceT]] 

2302 """ 

2303 Callback that loads the rest of the command data. 

2304 

2305 """ 

2306 

2307 __loaded = None 

2308 

2309 def load(self): 

2310 """ 

2311 Load full command data. 

2312 

2313 """ 

2314 

2315 if self.__loaded is None: 

2316 self.__loaded = self.loader() 

2317 return self.__loaded 

2318 

2319 def get_help(self) -> str | yuio.Disabled: 

2320 """ 

2321 Get or load command help. 

2322 

2323 """ 

2324 

2325 if self.help is None: 

2326 self.help = self.load().help 

2327 return self.help 

2328 

2329 

2330@dataclass(kw_only=True, eq=False, match_args=False) 

2331class Command(_t.Generic[NamespaceT]): 

2332 """ 

2333 Data about CLI interface of a single command or subcommand. 

2334 

2335 """ 

2336 

2337 name: str 

2338 """ 

2339 Canonical name of this command. 

2340 

2341 """ 

2342 

2343 desc: str 

2344 """ 

2345 Long description for a command. 

2346 

2347 """ 

2348 

2349 help: str | yuio.Disabled 

2350 """ 

2351 Help message for this command, displayed when listing subcommands. 

2352 

2353 """ 

2354 

2355 epilog: str 

2356 """ 

2357 Long description printed after command help. 

2358 

2359 """ 

2360 

2361 usage: str 

2362 """ 

2363 Override for usage section of CLI help. 

2364 

2365 """ 

2366 

2367 options: list[Option[_t.Any]] 

2368 """ 

2369 Options for this command. 

2370 

2371 """ 

2372 

2373 subcommands: dict[str, Command[Namespace] | LazyCommand[Namespace]] 

2374 """ 

2375 Last positional option can be a sub-command. 

2376 

2377 This is a map from subcommand's name or alias to subcommand's implementation. 

2378 

2379 """ 

2380 

2381 subcommand_required: bool 

2382 """ 

2383 Whether subcommand is required or optional. If no :attr:`~Command.subcommands` 

2384 are given, this attribute is ignored. 

2385 

2386 """ 

2387 

2388 ns_ctor: _t.Callable[[], NamespaceT] 

2389 """ 

2390 A constructor that will be called to create namespace for command's arguments. 

2391 

2392 """ 

2393 

2394 dest: str 

2395 """ 

2396 Where to save subcommand's name. 

2397 

2398 """ 

2399 

2400 ns_dest: str 

2401 """ 

2402 Where to save subcommand's namespace. 

2403 

2404 """ 

2405 

2406 metavar: str = "<subcommand>" 

2407 """ 

2408 Meta variable used for subcommand option. 

2409 

2410 """ 

2411 

2412 def load(self): 

2413 return self 

2414 

2415 def get_help(self): 

2416 return self.help 

2417 

2418 

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

2420class _SubCommandOption(ValueOption[str]): 

2421 command: Command[Namespace] 

2422 """ 

2423 All subcommands. 

2424 

2425 """ 

2426 

2427 def __init__( 

2428 self, 

2429 *, 

2430 command: Command[Namespace], 

2431 metavar: str = "<subcommand>", 

2432 help_group: HelpGroup | None = SUBCOMMANDS_GROUP, 

2433 show_if_inherited: bool = False, 

2434 ): 

2435 # subcommand_names = [ 

2436 # f"``{name}``" 

2437 # for name, subcommand in subcommands.items() 

2438 # if name == subcommand.name and subcommand.help is not yuio.DISABLED 

2439 # ] 

2440 # help = f"Available subcommands: {yuio.string.Or(subcommand_names)}" 

2441 

2442 self.command = command 

2443 

2444 super().__init__( 

2445 flags=yuio.POSITIONAL, 

2446 allow_inline_arg=False, 

2447 allow_implicit_inline_arg=False, 

2448 nargs=1, 

2449 allow_no_args=not command.subcommand_required, 

2450 required=False, 

2451 metavar=metavar, 

2452 mutex_group=None, 

2453 usage=True, 

2454 help="", 

2455 help_group=help_group, 

2456 show_if_inherited=show_if_inherited, 

2457 dest=self.command.dest, 

2458 merge=None, 

2459 default=yuio.MISSING, 

2460 allow_abbrev=False, 

2461 default_desc=None, 

2462 ) 

2463 

2464 def process( 

2465 self, 

2466 cli_parser: CliParser[Namespace], 

2467 flag: Flag | None, 

2468 arguments: Argument | list[Argument], 

2469 ns: Namespace, 

2470 ): 

2471 assert isinstance(arguments, list) 

2472 if not arguments: 

2473 return 

2474 subcommand = self.command.subcommands.get(arguments[0].value) 

2475 if subcommand is None: 

2476 raise ArgumentError( 

2477 "Unknown subcommand <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, can be %s", 

2478 arguments[0].value, 

2479 yuio.string.Or( 

2480 ( 

2481 name 

2482 for name, subcommand in self.command.subcommands.items() 

2483 if subcommand.help != yuio.DISABLED 

2484 ), 

2485 color="msg/text:code/sh-usage hl/flag:sh-usage", 

2486 ), 

2487 arguments=arguments, 

2488 ) 

2489 subcommand = subcommand.load() 

2490 ns[self.dest] = subcommand.name 

2491 ns[self.command.ns_dest] = new_ns = subcommand.ns_ctor() 

2492 cli_parser._load_command(subcommand, new_ns) 

2493 

2494 

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

2496class _BoundOption: 

2497 wrapped: Option[_t.Any] 

2498 ns: Namespace 

2499 seen: bool = False 

2500 

2501 @property 

2502 def usage(self): 

2503 return self.wrapped.usage 

2504 

2505 @property 

2506 def flags(self): 

2507 return self.wrapped.flags 

2508 

2509 @property 

2510 def nargs(self): 

2511 return self.wrapped.nargs 

2512 

2513 @property 

2514 def allow_no_args(self): 

2515 return self.wrapped.allow_no_args 

2516 

2517 @property 

2518 def allow_inline_arg(self): 

2519 return self.wrapped.allow_inline_arg 

2520 

2521 @property 

2522 def allow_implicit_inline_arg(self): 

2523 return self.wrapped.allow_implicit_inline_arg 

2524 

2525 @property 

2526 def mutex_group(self): 

2527 return self.wrapped.mutex_group 

2528 

2529 @property 

2530 def required(self): 

2531 return self.wrapped.required 

2532 

2533 @property 

2534 def allow_abbrev(self): 

2535 return self.wrapped.allow_abbrev 

2536 

2537 def nth_metavar(self, n: int) -> str: 

2538 return self.wrapped.nth_metavar(n) 

2539 

2540 

2541class CliParser(_t.Generic[NamespaceT]): 

2542 """ 

2543 CLI arguments parser. 

2544 

2545 :param command: 

2546 root command. 

2547 :param allow_abbrev: 

2548 allow abbreviating CLI flags if that doesn't create ambiguity. 

2549 :param help_parser: 

2550 help parser that will be used to parse and display help for options 

2551 that've failed to parse. 

2552 

2553 """ 

2554 

2555 def __init__( 

2556 self, 

2557 command: Command[NamespaceT], 

2558 /, 

2559 *, 

2560 help_parser: yuio.doc.DocParser, 

2561 allow_abbrev: bool, 

2562 ): 

2563 self._root_command = command 

2564 self._allow_abbrev = allow_abbrev 

2565 self._help_parser = help_parser 

2566 

2567 def _load_command(self, command: Command[_t.Any], ns: Namespace): 

2568 # All pending flags and positionals should've been flushed by now. 

2569 assert self._current_flag is None 

2570 assert self._current_positional == len(self._positionals) 

2571 

2572 self._inherited_options.update( 

2573 {flag: opt.wrapped for flag, opt in self._known_long_flags.items()} 

2574 ) 

2575 self._inherited_options.update( 

2576 {flag: opt.wrapped for flag, opt in self._known_short_flags.items()} 

2577 ) 

2578 self._current_path.append(command.name) 

2579 

2580 # Update known flags and positionals. 

2581 self._positionals = [] 

2582 seen_flags: set[str] = set() 

2583 for option in command.options: 

2584 bound_option = _BoundOption(option, ns) 

2585 if option.flags is yuio.POSITIONAL: 

2586 if option.mutex_group is not None: 

2587 raise TypeError( 

2588 f"{option}: positional arguments can't appear " 

2589 "in mutually exclusive groups" 

2590 ) 

2591 if option.nargs == 0: 

2592 raise TypeError( 

2593 f"{option}: positional arguments can't nave nargs=0" 

2594 ) 

2595 self._positionals.append(bound_option) 

2596 else: 

2597 if option.mutex_group is not None: 

2598 self._mutex_groups.setdefault(option.mutex_group, []).append(option) 

2599 if not option.flags: 

2600 raise TypeError(f"{option}: option has no flags") 

2601 for flag in option.flags: 

2602 if flag in seen_flags: 

2603 raise TypeError( 

2604 f"got multiple options with the same flag {flag}" 

2605 ) 

2606 seen_flags.add(flag) 

2607 self._inherited_options.pop(flag, None) 

2608 _check_flag(flag) 

2609 if _is_short(flag): 

2610 dest = self._known_short_flags 

2611 else: 

2612 dest = self._known_long_flags 

2613 if flag in dest: 

2614 warnings.warn( 

2615 f"flag {flag} from subcommand {command.name} shadows " 

2616 f"the same flag from command {self._current_command.name}", 

2617 CliWarning, 

2618 ) 

2619 self._finalize_unused_flag(flag, dest[flag]) 

2620 dest[flag] = bound_option 

2621 if command.subcommands: 

2622 self._positionals.append( 

2623 _BoundOption(_SubCommandOption(command=command), ns) 

2624 ) 

2625 self._current_command = command 

2626 self._current_positional = 0 

2627 

2628 def parse(self, args: list[str] | None = None) -> NamespaceT: 

2629 """ 

2630 Parse arguments and invoke their actions. 

2631 

2632 :param args: 

2633 CLI arguments, not including the program name (i.e. the first argument). 

2634 If :data:`None`, use :data:`sys.argv` instead. 

2635 :returns: 

2636 namespace with parsed arguments. 

2637 :raises: 

2638 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`. 

2639 

2640 """ 

2641 

2642 if args is None: 

2643 args = sys.argv[1:] 

2644 

2645 try: 

2646 return self._parse(args) 

2647 except ArgumentError as e: 

2648 e.commandline = args 

2649 e.prog = self._root_command.name 

2650 e.subcommands = self._current_path 

2651 e.help_parser = self._help_parser 

2652 raise 

2653 

2654 def _parse(self, args: list[str]) -> NamespaceT: 

2655 self._current_command = self._root_command 

2656 self._current_path: list[str] = [] 

2657 self._inherited_options: dict[str, Option[_t.Any]] = {} 

2658 

2659 self._seen_mutex_groups: dict[ 

2660 MutuallyExclusiveGroup, tuple[_BoundOption, Flag] 

2661 ] = {} 

2662 self._mutex_groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {} 

2663 

2664 self._current_index = 0 

2665 

2666 self._known_long_flags: dict[str, _BoundOption] = {} 

2667 self._known_short_flags: dict[str, _BoundOption] = {} 

2668 self._positionals: list[_BoundOption] = [] 

2669 self._current_positional: int = 0 

2670 

2671 self._current_flag: tuple[_BoundOption, Flag] | None = None 

2672 self._current_flag_args: list[Argument] = [] 

2673 self._current_positional_args: list[Argument] = [] 

2674 

2675 self._post_process: dict[ 

2676 _BoundOption, tuple[list[Argument], list[Flag | None]] 

2677 ] = {} 

2678 

2679 root_ns = self._root_command.ns_ctor() 

2680 self._load_command(self._root_command, root_ns) 

2681 

2682 allow_flags = True 

2683 

2684 for i, arg in enumerate(args): 

2685 self._current_index = i 

2686 

2687 # Handle `--`. 

2688 if arg == "--" and allow_flags: 

2689 self._flush_flag() 

2690 allow_flags = False 

2691 continue 

2692 

2693 # Check what we have here. 

2694 if allow_flags: 

2695 result = self._detect_flag(arg) 

2696 else: 

2697 result = None 

2698 

2699 if result is None: 

2700 # This not a flag. Can be an argument to a positional/flag option. 

2701 self._handle_positional(arg) 

2702 else: 

2703 # This is a flag. 

2704 options, inline_arg = result 

2705 self._handle_flags(options, inline_arg) 

2706 

2707 self._finalize() 

2708 

2709 return root_ns 

2710 

2711 def _finalize(self): 

2712 self._flush_flag() 

2713 

2714 for flag, option in self._known_long_flags.items(): 

2715 self._finalize_unused_flag(flag, option) 

2716 for flag, option in self._known_short_flags.items(): 

2717 self._finalize_unused_flag(flag, option) 

2718 while self._current_positional < len(self._positionals): 

2719 self._flush_positional() 

2720 for group, options in self._mutex_groups.items(): 

2721 if group.required and group not in self._seen_mutex_groups: 

2722 raise ArgumentError( 

2723 "%s %s must be provided", 

2724 "Either" if len(options) > 1 else "Flag", 

2725 yuio.string.Or( 

2726 (option.flags[0] for option in options if option.flags), 

2727 color="msg/text:code/sh-usage hl/flag:sh-usage", 

2728 ), 

2729 ) 

2730 for option, (arguments, flags) in self._post_process.items(): 

2731 try: 

2732 option.wrapped.post_process( 

2733 _t.cast(CliParser[Namespace], self), arguments, option.ns 

2734 ) 

2735 except ArgumentError as e: 

2736 if e.arguments is None: 

2737 e.arguments = arguments 

2738 if e.flag is None and e.n_arg is not None and 0 <= e.n_arg < len(flags): 

2739 e.flag = flags[e.n_arg] 

2740 if e.option is None: 

2741 e.option = option.wrapped 

2742 raise 

2743 except yuio.parse.ParsingError as e: 

2744 flag = None 

2745 if e.n_arg is not None and 0 <= e.n_arg < len(flags): 

2746 flag = flags[e.n_arg] 

2747 raise ArgumentError.from_parsing_error( 

2748 e, flag=flag, arguments=arguments, option=option.wrapped 

2749 ) 

2750 

2751 def _finalize_unused_flag(self, flag: str, option: _BoundOption): 

2752 if option.required and not option.seen: 

2753 raise ArgumentError( 

2754 "Missing required flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>", 

2755 flag, 

2756 ) 

2757 

2758 def _detect_flag( 

2759 self, arg: str 

2760 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None: 

2761 if not arg.startswith("-") or len(arg) <= 1: 

2762 # This is a positional. 

2763 return None 

2764 

2765 if arg.startswith("--"): 

2766 # This is a long flag. 

2767 return self._parse_long_flag(arg) 

2768 else: 

2769 return self._detect_short_flag(arg) 

2770 

2771 def _parse_long_flag( 

2772 self, arg: str 

2773 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None: 

2774 if "=" in arg: 

2775 flag, inline_arg = arg.split("=", maxsplit=1) 

2776 else: 

2777 flag, inline_arg = arg, None 

2778 flag = self._make_flag(flag) 

2779 if long_opt := self._known_long_flags.get(flag.value): 

2780 if inline_arg is not None: 

2781 inline_arg = self._make_arg( 

2782 long_opt, inline_arg, len(flag.value) + 1, flag 

2783 ) 

2784 return [(long_opt, flag)], inline_arg 

2785 

2786 # Try as abbreviated long flags. 

2787 candidates: list[str] = [] 

2788 if self._allow_abbrev: 

2789 for candidate in self._known_long_flags: 

2790 if candidate.startswith(flag.value): 

2791 candidates.append(candidate) 

2792 if len(candidates) == 1: 

2793 candidate = candidates[0] 

2794 opt = self._known_long_flags[candidate] 

2795 if not opt.allow_abbrev: 

2796 raise ArgumentError( 

2797 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, did you mean %s?", 

2798 flag, 

2799 candidate, 

2800 flag=self._make_flag(""), 

2801 ) 

2802 flag = self._make_flag(candidate) 

2803 if inline_arg is not None: 

2804 inline_arg = self._make_arg( 

2805 opt, inline_arg, len(flag.value) + 1, flag 

2806 ) 

2807 return [(opt, flag)], inline_arg 

2808 

2809 if candidates: 

2810 raise ArgumentError( 

2811 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, can be %s", 

2812 flag, 

2813 yuio.string.Or( 

2814 candidates, color="msg/text:code/sh-usage hl/flag:sh-usage" 

2815 ), 

2816 flag=self._make_flag(""), 

2817 ) 

2818 else: 

2819 raise ArgumentError( 

2820 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>", 

2821 flag, 

2822 flag=self._make_flag(""), 

2823 ) 

2824 

2825 def _detect_short_flag( 

2826 self, arg: str 

2827 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None: 

2828 # Try detecting short flags first. 

2829 short_opts: list[tuple[_BoundOption, Flag]] = [] 

2830 inline_arg = None 

2831 inline_arg_pos = 0 

2832 unknown_ch = None 

2833 for i, ch in enumerate(arg[1:]): 

2834 if ch == "=": 

2835 # Short flag with explicit argument. 

2836 inline_arg_pos = i + 2 

2837 inline_arg = arg[inline_arg_pos:] 

2838 break 

2839 elif short_opts and ( 

2840 short_opts[-1][0].allow_implicit_inline_arg 

2841 or short_opts[-1][0].nargs != 0 

2842 ): 

2843 # Short flag with implicit argument. 

2844 inline_arg_pos = i + 1 

2845 inline_arg = arg[inline_arg_pos:] 

2846 break 

2847 elif short_opt := self._known_short_flags.get("-" + ch): 

2848 # Short flag, arguments may follow. 

2849 short_opts.append((short_opt, self._make_flag("-" + ch))) 

2850 else: 

2851 # Unknown short flag. Will try parsing as abbreviated long flag next. 

2852 unknown_ch = ch 

2853 break 

2854 if short_opts and not unknown_ch: 

2855 if inline_arg is not None: 

2856 inline_arg = self._make_arg( 

2857 short_opts[-1][0], inline_arg, inline_arg_pos, short_opts[-1][1] 

2858 ) 

2859 return short_opts, inline_arg 

2860 

2861 # Try as signed int. 

2862 if re.match(_NUM_RE, arg): 

2863 # This is a positional. 

2864 return None 

2865 

2866 if unknown_ch and len(arg) > 2: 

2867 raise ArgumentError( 

2868 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>-%s</c> in argument <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>", 

2869 unknown_ch, 

2870 arg, 

2871 flag=self._make_flag(""), 

2872 ) 

2873 else: 

2874 raise ArgumentError( 

2875 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>", 

2876 arg, 

2877 flag=self._make_flag(""), 

2878 ) 

2879 

2880 def _make_arg( 

2881 self, opt: _BoundOption, arg: str, pos: int, flag: Flag | None = None 

2882 ): 

2883 return Argument( 

2884 arg, 

2885 index=self._current_index, 

2886 pos=pos, 

2887 metavar=opt.nth_metavar(0), 

2888 flag=flag, 

2889 ) 

2890 

2891 def _make_flag(self, arg: str): 

2892 return Flag(arg, self._current_index) 

2893 

2894 def _handle_positional(self, arg: str): 

2895 if self._current_flag is not None: 

2896 opt, flag = self._current_flag 

2897 # This is an argument for a flag option. 

2898 self._current_flag_args.append( 

2899 Argument( 

2900 arg, 

2901 index=self._current_index, 

2902 pos=0, 

2903 metavar=opt.nth_metavar(len(self._current_flag_args)), 

2904 flag=flag, 

2905 ) 

2906 ) 

2907 nargs = opt.nargs 

2908 if isinstance(nargs, int) and len(self._current_flag_args) == nargs: 

2909 self._flush_flag() # This flag is full. 

2910 else: 

2911 # This is an argument for a positional option. 

2912 if self._current_positional >= len(self._positionals): 

2913 raise ArgumentError( 

2914 "Unexpected positional argument <c msg/text:code/sh-usage hl/flag:sh-usage>%r</c>", 

2915 arg, 

2916 arguments=Argument( 

2917 arg, index=self._current_index, pos=0, metavar="", flag=None 

2918 ), 

2919 ) 

2920 current_positional = self._positionals[self._current_positional] 

2921 self._current_positional_args.append( 

2922 Argument( 

2923 arg, 

2924 index=self._current_index, 

2925 pos=0, 

2926 metavar=current_positional.nth_metavar( 

2927 len(self._current_positional_args) 

2928 ), 

2929 flag=None, 

2930 ) 

2931 ) 

2932 nargs = current_positional.nargs 

2933 if isinstance(nargs, int) and len(self._current_positional_args) == nargs: 

2934 self._flush_positional() # This positional is full. 

2935 

2936 def _handle_flags( 

2937 self, options: list[tuple[_BoundOption, Flag]], inline_arg: Argument | None 

2938 ): 

2939 # If we've seen another flag before this one, and we were waiting 

2940 # for that flag's arguments, flush them now. 

2941 self._flush_flag() 

2942 

2943 # Handle short flags in multi-arg sequence, i.e. `-li` -> `-l -i` 

2944 for opt, name in options[:-1]: 

2945 self._eval_option(opt, name, []) 

2946 

2947 # Handle the last short flag in multi-arg sequence. 

2948 opt, name = options[-1] 

2949 if inline_arg is not None: 

2950 # Flag with an inline argument, i.e. `-Xfoo`/`-X=foo` -> `-X foo` 

2951 self._eval_option(opt, name, inline_arg) 

2952 else: 

2953 self._push_flag(opt, name) 

2954 

2955 def _flush_positional(self): 

2956 if self._current_positional >= len(self._positionals): 

2957 return 

2958 opt, args = ( 

2959 self._positionals[self._current_positional], 

2960 self._current_positional_args, 

2961 ) 

2962 

2963 self._current_positional += 1 

2964 self._current_positional_args = [] 

2965 

2966 self._eval_option(opt, None, args) 

2967 

2968 def _flush_flag(self): 

2969 if self._current_flag is None: 

2970 return 

2971 

2972 (opt, name), args = (self._current_flag, self._current_flag_args) 

2973 

2974 self._current_flag = None 

2975 self._current_flag_args = [] 

2976 

2977 self._eval_option(opt, name, args) 

2978 

2979 def _push_flag(self, opt: _BoundOption, flag: Flag): 

2980 assert self._current_flag is None 

2981 

2982 if opt.nargs == 0: 

2983 # Flag without arguments, handle it right now. 

2984 self._eval_option(opt, flag, []) 

2985 else: 

2986 # Flag with possible arguments, save it. If we see a non-flag later, 

2987 # it will be added to this flag's arguments. 

2988 self._current_flag = (opt, flag) 

2989 self._current_flag_args = [] 

2990 

2991 def _eval_option( 

2992 self, opt: _BoundOption, flag: Flag | None, arguments: Argument | list[Argument] 

2993 ): 

2994 if opt.mutex_group is not None: 

2995 if seen := self._seen_mutex_groups.get(opt.mutex_group): 

2996 raise ArgumentError( 

2997 "Flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c> can't be given together with flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>", 

2998 flag or self._make_flag(opt.nth_metavar(0)), 

2999 seen[1], 

3000 ) 

3001 self._seen_mutex_groups[opt.mutex_group] = ( 

3002 opt, 

3003 flag or self._make_flag(opt.nth_metavar(0)), 

3004 ) 

3005 

3006 if isinstance(arguments, list): 

3007 _check_nargs(opt, flag, arguments) 

3008 elif not opt.allow_inline_arg: 

3009 raise ArgumentError( 

3010 "This flag can't have arguments", 

3011 flag=flag, 

3012 arguments=arguments, 

3013 option=opt.wrapped, 

3014 ) 

3015 

3016 opt.seen = True 

3017 try: 

3018 opt.wrapped.process( 

3019 _t.cast(CliParser[Namespace], self), flag, arguments, opt.ns 

3020 ) 

3021 except ArgumentError as e: 

3022 if e.flag is None: 

3023 e.flag = flag 

3024 if e.arguments is None: 

3025 e.arguments = arguments 

3026 if e.option is None: 

3027 e.option = opt.wrapped 

3028 raise 

3029 except yuio.parse.ParsingError as e: 

3030 raise ArgumentError.from_parsing_error( 

3031 e, flag=flag, arguments=arguments, option=opt.wrapped 

3032 ) 

3033 

3034 if not isinstance(arguments, list): 

3035 arguments = [arguments] 

3036 if opt not in self._post_process: 

3037 self._post_process[opt] = ([], []) 

3038 self._post_process[opt][0].extend(arguments) 

3039 self._post_process[opt][1].extend([flag] * len(arguments)) 

3040 

3041 

3042def _check_flag(flag: str): 

3043 if not flag.startswith("-"): 

3044 raise TypeError(f"flag {flag!r} should start with `-`") 

3045 if len(flag) == 2: 

3046 if not re.match(_SHORT_FLAG_RE, flag): 

3047 raise TypeError(f"invalid short flag {flag!r}") 

3048 elif len(flag) == 1: 

3049 raise TypeError(f"flag {flag!r} is too short") 

3050 else: 

3051 if not re.match(_LONG_FLAG_RE, flag): 

3052 raise TypeError(f"invalid long flag {flag!r}") 

3053 

3054 

3055def _is_short(flag: str): 

3056 return flag.startswith("-") and len(flag) == 2 and flag != "--" 

3057 

3058 

3059def _check_nargs(opt: _BoundOption, flag: Flag | None, args: list[Argument]): 

3060 if not args and opt.allow_no_args: 

3061 return 

3062 match opt.nargs: 

3063 case "+": 

3064 if not args: 

3065 if opt.flags is yuio.POSITIONAL: 

3066 raise ArgumentError( 

3067 "Missing required positional <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>", 

3068 opt.nth_metavar(0), 

3069 flag=flag, 

3070 option=opt.wrapped, 

3071 ) 

3072 else: 

3073 raise ArgumentError( 

3074 "Expected at least `1` argument, got `0`", 

3075 flag=flag, 

3076 option=opt.wrapped, 

3077 ) 

3078 case n: 

3079 if len(args) < n and (opt.flags is yuio.POSITIONAL): 

3080 s = "" if n - len(args) == 1 else "s" 

3081 raise ArgumentError( 

3082 "Missing required positional%s %s", 

3083 s, 

3084 yuio.string.JoinStr( 

3085 [opt.nth_metavar(i) for i in range(len(args), n)], 

3086 color="msg/text:code/sh-usage hl/flag:sh-usage", 

3087 ), 

3088 flag=flag, 

3089 option=opt.wrapped, 

3090 ) 

3091 elif len(args) != n: 

3092 s = "" if n == 1 else "s" 

3093 raise ArgumentError( 

3094 "Expected `%s` argument%s, got `%s`", 

3095 n, 

3096 s, 

3097 len(args), 

3098 flag=flag, 

3099 option=opt.wrapped, 

3100 ) 

3101 

3102 

3103def _quote_and_adjust_pos(s: str, pos: tuple[int, int]): 

3104 s = s.translate(_UNPRINTABLE_TRANS) 

3105 

3106 if not s: 

3107 return "''", (1, 1) 

3108 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII): 

3109 return s, pos 

3110 

3111 start, end = pos 

3112 

3113 start_shift = 1 + s[:start].count("'") * 4 

3114 end_shift = start_shift + s[start:end].count("'") * 4 

3115 

3116 return "'" + s.replace("'", "'\"'\"'") + "'", (start + start_shift, end + end_shift) 

3117 

3118 

3119def _quote(s: str): 

3120 s = s.translate(_UNPRINTABLE_TRANS) 

3121 

3122 if not s: 

3123 return "''" 

3124 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII): 

3125 return s 

3126 else: 

3127 return "'" + s.replace("'", "'\"'\"'") + "'" 

3128 

3129 

3130class _HelpFormatter: 

3131 def __init__(self, parser: yuio.doc.DocParser, all: bool = False) -> None: 

3132 self.nodes: list[yuio.doc.AstBase] = [] 

3133 self.parser = parser 

3134 self.all = all 

3135 

3136 def add_command( 

3137 self, prog: str, cmd: Command[Namespace], inherited: list[Option[_t.Any]], / 

3138 ): 

3139 self._add_usage(prog, cmd, inherited) 

3140 if cmd.desc: 

3141 self.nodes.extend(self.parser.parse(cmd.desc).items) 

3142 self._add_options(cmd) 

3143 self._add_subcommands(cmd) 

3144 self._add_flags(cmd, inherited) 

3145 if cmd.epilog: 

3146 self.nodes.append(_SetIndentation()) 

3147 self.nodes.extend(self.parser.parse(cmd.epilog).items) 

3148 

3149 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString: 

3150 return self.format(ctx) 

3151 

3152 def format(self, ctx: yuio.string.ReprContext): 

3153 res = _ColorizedString() 

3154 lines = _CliFormatter(self.parser, ctx, all=self.all).format( 

3155 yuio.doc.Document(items=self.nodes) 

3156 ) 

3157 sep = False 

3158 for line in lines: 

3159 if sep: 

3160 res.append_str("\n") 

3161 res.append_colorized_str(line) 

3162 sep = True 

3163 return res 

3164 

3165 def _add_usage( 

3166 self, prog: str, cmd: Command[Namespace], inherited: list[Option[_t.Any]], / 

3167 ): 

3168 self.nodes.append(_Usage(prog=prog, cmd=cmd, inherited=inherited)) 

3169 

3170 def _add_options(self, cmd: Command[Namespace], /): 

3171 groups: dict[HelpGroup, list[Option[_t.Any]]] = {} 

3172 for opt in cmd.options: 

3173 if opt.flags is not yuio.POSITIONAL: 

3174 continue 

3175 if opt.help is yuio.DISABLED: 

3176 continue 

3177 group = opt.help_group or ARGS_GROUP 

3178 if group.help is yuio.DISABLED: 

3179 continue 

3180 if group not in groups: 

3181 groups[group] = [] 

3182 groups[group].append(opt) 

3183 for group, options in groups.items(): 

3184 assert group.help is not yuio.DISABLED 

3185 self.nodes.append( 

3186 yuio.doc.Heading( 

3187 items=self.parser.parse_paragraph(group.title), level=1 

3188 ) 

3189 ) 

3190 if group.help: 

3191 self.nodes.append( 

3192 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items) 

3193 ) 

3194 arg_group = _HelpArgGroup(items=[]) 

3195 for opt in options: 

3196 assert opt.help is not yuio.DISABLED 

3197 arg_group.items.append(_HelpArg(opt)) 

3198 self.nodes.append(arg_group) 

3199 

3200 def _add_subcommands(self, cmd: Command[Namespace], /): 

3201 subcommands: dict[Command[Namespace] | LazyCommand[Namespace], list[str]] = {} 

3202 for name, subcommand in cmd.subcommands.items(): 

3203 if subcommand.get_help() is yuio.DISABLED: 

3204 continue 

3205 if subcommand not in subcommands: 

3206 subcommands[subcommand] = [name] 

3207 else: 

3208 subcommands[subcommand].append(name) 

3209 if not subcommands: 

3210 return 

3211 group = SUBCOMMANDS_GROUP 

3212 self.nodes.append( 

3213 yuio.doc.Heading(items=self.parser.parse_paragraph(group.title), level=1) 

3214 ) 

3215 if group.help: 

3216 self.nodes.append( 

3217 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items) 

3218 ) 

3219 arg_group = _HelpArgGroup(items=[]) 

3220 for subcommand, names in subcommands.items(): 

3221 help = subcommand.get_help() 

3222 assert help is not yuio.DISABLED 

3223 arg_group.items.append(_HelpSubCommand(names, help)) 

3224 self.nodes.append(arg_group) 

3225 

3226 def _add_flags(self, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /): 

3227 groups: dict[ 

3228 HelpGroup, tuple[list[Option[_t.Any]], list[Option[_t.Any]], int] 

3229 ] = {} 

3230 for i, opt in enumerate(cmd.options + inherited): 

3231 if not opt.flags: 

3232 continue 

3233 if opt.help is yuio.DISABLED: 

3234 continue 

3235 group = opt.help_group or OPTS_GROUP 

3236 if group.help is yuio.DISABLED: 

3237 continue 

3238 is_inherited = i >= len(cmd.options) 

3239 if group not in groups: 

3240 groups[group] = ([], [], 0) 

3241 if opt.required or (opt.mutex_group and opt.mutex_group.required): 

3242 groups[group][0].append(opt) 

3243 elif is_inherited and not opt.show_if_inherited and not self.all: 

3244 required, optional, n_inherited = groups[group] 

3245 groups[group] = required, optional, n_inherited + 1 

3246 else: 

3247 groups[group][1].append(opt) 

3248 for group, (required, optional, n_inherited) in groups.items(): 

3249 assert group.help is not yuio.DISABLED 

3250 

3251 if group.collapse and not self.all and not (required or optional): 

3252 continue 

3253 

3254 self.nodes.append( 

3255 yuio.doc.Heading( 

3256 items=self.parser.parse_paragraph(group.title), level=1 

3257 ) 

3258 ) 

3259 

3260 if group.collapse and not self.all: 

3261 all_flags: set[str] = set() 

3262 for opt in required or optional: 

3263 all_flags.update(opt.primary_long_flags or []) 

3264 if len(all_flags) == 1: 

3265 prefix = all_flags.pop() 

3266 else: 

3267 prefix = _commonprefix(all_flags) 

3268 if not prefix: 

3269 prefix = "--*" 

3270 elif prefix.endswith("-"): 

3271 prefix += "*" 

3272 else: 

3273 prefix += "-*" 

3274 help = yuio.doc.NoHeadings(items=self.parser.parse(group.help).items) 

3275 self.nodes.append( 

3276 _CollapsedOpt( 

3277 flags=[prefix], 

3278 items=[help], 

3279 ) 

3280 ) 

3281 continue 

3282 

3283 if group.help and (required or optional): 

3284 self.nodes.append( 

3285 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items) 

3286 ) 

3287 arg_group = _HelpArgGroup(items=[]) 

3288 for opt in required: 

3289 assert opt.help is not yuio.DISABLED 

3290 arg_group.items.append(_HelpOpt(opt)) 

3291 for opt in optional: 

3292 assert opt.help is not yuio.DISABLED 

3293 arg_group.items.append(_HelpOpt(opt)) 

3294 if n_inherited > 0: 

3295 arg_group.items.append(_InheritedOpts(n_inherited=n_inherited)) 

3296 self.nodes.append(arg_group) 

3297 

3298 

3299def _format_metavar(metavar: str, ctx: yuio.string.ReprContext): 

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

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

3302 

3303 res = _ColorizedString() 

3304 is_punctuation = False 

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

3306 if is_punctuation: 

3307 res.append_color(punct_color) 

3308 else: 

3309 res.append_color(metavar_color) 

3310 res.append_str(part) 

3311 is_punctuation = not is_punctuation 

3312 

3313 return res 

3314 

3315 

3316_ARGS_COLUMN_WIDTH = 26 

3317_ARGS_COLUMN_WIDTH_NARROW = 8 

3318 

3319 

3320class _CliFormatter(yuio.doc.Formatter): # type: ignore 

3321 def __init__( 

3322 self, 

3323 parser: yuio.doc.DocParser, 

3324 ctx: yuio.string.ReprContext, 

3325 /, 

3326 *, 

3327 all: bool = False, 

3328 ): 

3329 self.parser = parser 

3330 self.all = all 

3331 

3332 self._heading_indent = contextlib.ExitStack() 

3333 self._args_column_width = ( 

3334 _ARGS_COLUMN_WIDTH if ctx.width >= 50 else _ARGS_COLUMN_WIDTH_NARROW 

3335 ) 

3336 ctx.width = min(ctx.width, 80) 

3337 

3338 super().__init__(ctx, allow_headings=True) 

3339 

3340 self.base_color = self.ctx.get_color("msg/text:code/sh-usage") 

3341 self.prog_color = self.base_color | self.ctx.get_color("hl/prog:sh-usage") 

3342 self.punct_color = self.base_color | self.ctx.get_color("hl/punct:sh-usage") 

3343 self.metavar_color = self.base_color | self.ctx.get_color("hl/metavar:sh-usage") 

3344 self.flag_color = self.base_color | self.ctx.get_color("hl/flag:sh-usage") 

3345 

3346 def _format_Heading(self, node: yuio.doc.Heading): 

3347 if not self._allow_headings: 

3348 with self._with_color("msg/text:paragraph"): 

3349 self._format_Text(node) 

3350 return 

3351 

3352 if node.level == 1: 

3353 self._heading_indent.close() 

3354 

3355 raw_heading = "".join(map(str, node.items)) 

3356 if raw_heading and raw_heading[-1].isalnum(): 

3357 node.items.append(":") 

3358 

3359 decoration = self.ctx.get_msg_decoration("heading/section") 

3360 with ( 

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

3362 self._with_color("msg/text:heading/section"), 

3363 ): 

3364 self._format_Text(node) 

3365 

3366 if node.level == 1: 

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

3368 elif self._separate_paragraphs: 

3369 self._line(self._indent) 

3370 

3371 self._is_first_line = True 

3372 

3373 def _format_SetIndentation(self, node: _SetIndentation): 

3374 self._heading_indent.close() 

3375 self._is_first_line = True 

3376 if node.indent: 

3377 self._heading_indent.enter_context(self._with_indent(None, node.indent)) 

3378 

3379 def _format_Usage(self, node: _Usage): 

3380 if node.prefix: 

3381 prefix = _ColorizedString( 

3382 self.ctx.get_color("msg/text:heading/section"), 

3383 node.prefix, 

3384 self.base_color, 

3385 " ", 

3386 ) 

3387 else: 

3388 prefix = _ColorizedString() 

3389 

3390 usage = _ColorizedString() 

3391 if node.cmd.usage: 

3392 sh_usage_highlighter, sh_usage_syntax_name = yuio.hl.get_highlighter( 

3393 "sh-usage" 

3394 ) 

3395 

3396 usage = sh_usage_highlighter.highlight( 

3397 node.cmd.usage.rstrip(), 

3398 theme=self.ctx.theme, 

3399 syntax=sh_usage_syntax_name, 

3400 ).percent_format({"prog": node.prog}, self.ctx) 

3401 else: 

3402 usage = self._build_usage(node) 

3403 

3404 with self._with_indent(None, prefix): 

3405 self._line( 

3406 usage.indent( 

3407 indent=self._indent, 

3408 continuation_indent=self._continuation_indent, 

3409 ) 

3410 ) 

3411 

3412 def _build_usage(self, node: _Usage): 

3413 flags_and_groups: list[ 

3414 Option[_t.Any] | tuple[MutuallyExclusiveGroup, list[Option[_t.Any]]] 

3415 ] = [] 

3416 positionals: list[Option[_t.Any]] = [] 

3417 groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {} 

3418 has_grouped_flags = False 

3419 

3420 for i, opt in enumerate(node.cmd.options + node.inherited): 

3421 is_inherited = i >= len(node.cmd.options) 

3422 if is_inherited and ( 

3423 not opt.show_if_inherited or opt.flags is yuio.POSITIONAL 

3424 ): 

3425 continue 

3426 if opt.help is yuio.DISABLED: 

3427 continue 

3428 if opt.help_group is not None and opt.help_group.help is yuio.DISABLED: 

3429 continue 

3430 if opt.flags is yuio.POSITIONAL: 

3431 positionals.append(opt) 

3432 elif opt.usage is yuio.COLLAPSE: 

3433 has_grouped_flags = True 

3434 elif not opt.usage: 

3435 pass 

3436 elif opt.mutex_group: 

3437 if opt.mutex_group not in groups: 

3438 group_items = [] 

3439 groups[opt.mutex_group] = group_items 

3440 flags_and_groups.append((opt.mutex_group, group_items)) 

3441 else: 

3442 group_items = groups[opt.mutex_group] 

3443 group_items.append(opt) 

3444 else: 

3445 flags_and_groups.append(opt) 

3446 

3447 res = _ColorizedString() 

3448 res.append_color(self.prog_color) 

3449 res.append_str(node.prog) 

3450 

3451 if has_grouped_flags: 

3452 res.append_color(self.base_color) 

3453 res.append_str(" ") 

3454 res.append_color(self.flag_color) 

3455 res.append_str("<options>") 

3456 

3457 res.append_color(self.base_color) 

3458 

3459 in_opt_short_group = False 

3460 for flag_or_group in flags_and_groups: 

3461 match flag_or_group: 

3462 case (group, flags): 

3463 res.append_color(self.base_color) 

3464 res.append_str(" ") 

3465 res.append_color(self.punct_color) 

3466 res.append_str("(" if group.required else "[") 

3467 sep = False 

3468 for flag in flags: 

3469 if sep: 

3470 res.append_str("|") 

3471 usage, _ = flag.format_usage(self.ctx) 

3472 res.append_colorized_str(usage.with_base_color(self.base_color)) 

3473 sep = True 

3474 res.append_str(")" if group.required else "]") 

3475 case flag: 

3476 usage, can_group = flag.format_usage(self.ctx) 

3477 if not flag.primary_short_flag or flag.nargs != 0 or flag.required: 

3478 can_group = False 

3479 

3480 if can_group: 

3481 if not in_opt_short_group: 

3482 res.append_color(self.base_color) 

3483 res.append_str(" ") 

3484 res.append_color(self.punct_color) 

3485 res.append_str("[") 

3486 res.append_color(self.flag_color) 

3487 res.append_str("-") 

3488 in_opt_short_group = True 

3489 letter = (flag.primary_short_flag or "")[1:] 

3490 res.append_str(letter) 

3491 continue 

3492 

3493 if in_opt_short_group: 

3494 res.append_color(self.punct_color) 

3495 res.append_str("]") 

3496 in_opt_short_group = False 

3497 

3498 res.append_color(self.base_color) 

3499 res.append_str(" ") 

3500 

3501 if not flag.required: 

3502 res.append_color(self.punct_color) 

3503 res.append_str("[") 

3504 res.append_colorized_str(usage.with_base_color(self.base_color)) 

3505 if not flag.required: 

3506 res.append_color(self.punct_color) 

3507 res.append_str("]") 

3508 

3509 if in_opt_short_group: 

3510 res.append_color(self.punct_color) 

3511 res.append_str("]") 

3512 in_opt_short_group = False 

3513 

3514 for positional in positionals: 

3515 res.append_color(self.base_color) 

3516 res.append_str(" ") 

3517 res.append_colorized_str( 

3518 positional.format_usage(self.ctx)[0].with_base_color(self.base_color) 

3519 ) 

3520 

3521 if node.cmd.subcommands: 

3522 res.append_str(" ") 

3523 if not node.cmd.subcommand_required: 

3524 res.append_color(self.punct_color) 

3525 res.append_str("[") 

3526 res.append_colorized_str( 

3527 _format_metavar(node.cmd.metavar, self.ctx).with_base_color( 

3528 self.base_color 

3529 ) 

3530 ) 

3531 res.append_color(self.base_color) 

3532 res.append_str(" ") 

3533 res.append_color(self.metavar_color) 

3534 res.append_str("...") 

3535 if not node.cmd.subcommand_required: 

3536 res.append_color(self.punct_color) 

3537 res.append_str("]") 

3538 

3539 return res 

3540 

3541 def _format_HelpOpt(self, node: _HelpOpt): 

3542 lead = _ColorizedString() 

3543 if node.arg.primary_short_flag: 

3544 lead.append_color(self.flag_color) 

3545 lead.append_str(node.arg.primary_short_flag) 

3546 sep = True 

3547 else: 

3548 lead.append_color(self.base_color) 

3549 lead.append_str(" ") 

3550 sep = False 

3551 for flag in node.arg.primary_long_flags or []: 

3552 if sep: 

3553 lead.append_color(self.punct_color) 

3554 lead.append_str(", ") 

3555 lead.append_color(self.flag_color) 

3556 lead.append_str(flag) 

3557 sep = True 

3558 

3559 lead.append_colorized_str( 

3560 node.arg.format_metavar(self.ctx).with_base_color(self.base_color) 

3561 ) 

3562 

3563 help = _parse_option_help(node.arg, self.parser, self.ctx, all=self.all) 

3564 

3565 if help is None: 

3566 self._line(self._indent + lead) 

3567 return 

3568 

3569 if lead.width + 2 > self._args_column_width: 

3570 self._line(self._indent + lead) 

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

3572 else: 

3573 indent_ctx = self._with_indent(None, self._make_lead_padding(lead)) 

3574 

3575 with indent_ctx: 

3576 self._format(help) 

3577 

3578 def _format_HelpArg(self, node: _HelpArg): 

3579 lead = _format_metavar(node.arg.nth_metavar(0), self.ctx).with_base_color( 

3580 self.base_color 

3581 ) 

3582 

3583 help = _parse_option_help(node.arg, self.parser, self.ctx, all=self.all) 

3584 

3585 if help is None: 

3586 self._line(self._indent + lead) 

3587 return 

3588 

3589 if lead.width + 2 > self._args_column_width: 

3590 self._line(self._indent + lead) 

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

3592 else: 

3593 indent_ctx = self._with_indent(None, self._make_lead_padding(lead)) 

3594 

3595 with indent_ctx: 

3596 self._format(help) 

3597 

3598 def _format_HelpSubCommand(self, node: _HelpSubCommand): 

3599 lead = _ColorizedString() 

3600 sep = False 

3601 for name in node.names: 

3602 if sep: 

3603 lead.append_color(self.punct_color) 

3604 lead.append_str(", ") 

3605 lead.append_color(self.flag_color) 

3606 lead.append_str(name) 

3607 sep = True 

3608 

3609 help = node.help 

3610 

3611 if not help: 

3612 self._line(self._indent + lead) 

3613 return 

3614 

3615 if lead.width + 2 > self._args_column_width: 

3616 self._line(self._indent + lead) 

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

3618 else: 

3619 indent_ctx = self._with_indent(None, self._make_lead_padding(lead)) 

3620 

3621 with indent_ctx: 

3622 self._format(self.parser.parse(help)) 

3623 

3624 def _format_CollapsedOpt(self, node: _CollapsedOpt): 

3625 if not node.flags: 

3626 self._format_Container(node) 

3627 return 

3628 

3629 lead = _ColorizedString() 

3630 sep = False 

3631 for flag in node.flags: 

3632 if sep: 

3633 lead.append_color(self.punct_color) 

3634 lead.append_str(", ") 

3635 lead.append_color(self.flag_color) 

3636 lead.append_str(flag) 

3637 sep = True 

3638 

3639 if lead.width + 2 > self._args_column_width: 

3640 self._line(self._indent + lead) 

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

3642 else: 

3643 indent_ctx = self._with_indent(None, self._make_lead_padding(lead)) 

3644 

3645 with indent_ctx: 

3646 self._separate_paragraphs = False 

3647 self._allow_headings = False 

3648 self._format_Container(node) 

3649 self._separate_paragraphs = True 

3650 self._allow_headings = True 

3651 

3652 def _format_InheritedOpts(self, node: _InheritedOpts): 

3653 raw = _ColorizedString() 

3654 s = "" if node.n_inherited == 1 else "s" 

3655 raw.append_color(self.ctx.get_color("secondary_color")) 

3656 raw.append_str(f" +{node.n_inherited} global option{s}, --help=all to show") 

3657 self._line(raw) 

3658 

3659 def _format_HelpArgGroup(self, node: _HelpArgGroup): 

3660 self._separate_paragraphs = False 

3661 self._allow_headings = False 

3662 self._format_Container(node) 

3663 self._separate_paragraphs = True 

3664 self._allow_headings = True 

3665 

3666 def _make_lead_padding(self, lead: _ColorizedString): 

3667 color = self.base_color 

3668 return lead + color + " " * (self._args_column_width - lead.width) 

3669 

3670 

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

3672class _SetIndentation(yuio.doc.AstBase): 

3673 indent: str = "" 

3674 

3675 

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

3677class _Usage(yuio.doc.AstBase): 

3678 prog: str 

3679 cmd: Command[Namespace] 

3680 inherited: list[Option[_t.Any]] 

3681 prefix: str = "Usage:" 

3682 

3683 

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

3685class _HelpOpt(yuio.doc.AstBase): 

3686 arg: Option[_t.Any] 

3687 

3688 

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

3690class _CollapsedOpt(yuio.doc.Container[yuio.doc.AstBase]): 

3691 flags: list[str] 

3692 

3693 

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

3695class _HelpArg(yuio.doc.AstBase): 

3696 arg: Option[_t.Any] 

3697 

3698 

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

3700class _InheritedOpts(yuio.doc.AstBase): 

3701 n_inherited: int 

3702 

3703 

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

3705class _HelpSubCommand(yuio.doc.AstBase): 

3706 names: list[str] 

3707 help: str | None 

3708 

3709 

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

3711class _HelpArgGroup(yuio.doc.Container[yuio.doc.AstBase]): 

3712 pass 

3713 

3714 

3715class _ShortUsageFormatter: 

3716 def __init__( 

3717 self, 

3718 parser: yuio.doc.DocParser, 

3719 subcommands: list[str] | None, 

3720 option: Option[_t.Any], 

3721 ): 

3722 self.parser = parser 

3723 self.subcommands = subcommands 

3724 self.option = option 

3725 

3726 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString: 

3727 note_color = ctx.get_color("msg/text:error/note") 

3728 heading_color = ctx.get_color("msg/text:heading/note") 

3729 code_color = ctx.get_color("msg/text:code/sh-usage") 

3730 punct_color = code_color | ctx.get_color("hl/punct:sh-usage") 

3731 flag_color = code_color | ctx.get_color("hl/flag:sh-usage") 

3732 

3733 res = _ColorizedString() 

3734 res.append_color(heading_color) 

3735 res.append_str("Help: ") 

3736 

3737 if self.option.flags is not yuio.POSITIONAL: 

3738 sep = False 

3739 if self.option.primary_short_flag: 

3740 res.append_color(flag_color) 

3741 res.append_str(self.option.primary_short_flag) 

3742 sep = True 

3743 for flag in self.option.primary_long_flags or []: 

3744 if sep: 

3745 res.append_color(punct_color) 

3746 res.append_str(", ") 

3747 res.append_color(flag_color) 

3748 res.append_str(flag) 

3749 sep = True 

3750 

3751 res.append_colorized_str( 

3752 self.option.format_metavar(ctx).with_base_color(code_color) 

3753 ) 

3754 

3755 res.append_color(heading_color) 

3756 res.append_str("\n") 

3757 res.append_color(note_color) 

3758 

3759 if help := _parse_option_help(self.option, self.parser, ctx): 

3760 with ctx.with_settings(width=ctx.width - 2): 

3761 formatter = _CliFormatter(self.parser, ctx) 

3762 sep = False 

3763 for line in formatter.format( 

3764 _HelpArgGroup(items=[_SetIndentation(" "), help]) 

3765 ): 

3766 if sep: 

3767 res.append_str("\n") 

3768 res.append_colorized_str(line.with_base_color(note_color)) 

3769 sep = True 

3770 

3771 return res 

3772 

3773 

3774def _parse_option_help( 

3775 option: Option[_t.Any], 

3776 parser: yuio.doc.DocParser, 

3777 ctx: yuio.string.ReprContext, 

3778 /, 

3779 *, 

3780 all: bool = False, 

3781) -> yuio.doc.AstBase | None: 

3782 help = parser.parse(option.help or "") 

3783 if help_tail := option.format_help_tail(ctx, all=all): 

3784 help.items.append(yuio.doc.Raw(raw=help_tail)) 

3785 

3786 return help if help.items else None