Coverage for yuio / cli.py: 75%

1524 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-03 15:42 +0000

1# Yuio project, MIT license. 

2# 

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

4# 

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

6# just keep this copyright line please :3 

7 

8""" 

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 

83Flags and positionals 

84--------------------- 

85 

86.. autoclass:: Option 

87 :members: 

88 

89.. autoclass:: ValueOption 

90 :members: 

91 

92.. autoclass:: ParserOption 

93 :members: 

94 

95.. autoclass:: BoolOption 

96 :members: 

97 

98.. autoclass:: ParseOneOption 

99 :members: 

100 

101.. autoclass:: ParseManyOption 

102 :members: 

103 

104.. autoclass:: StoreConstOption 

105 :members: 

106 

107.. autoclass:: StoreFalseOption 

108 :members: 

109 

110.. autoclass:: StoreTrueOption 

111 :members: 

112 

113.. autoclass:: CountOption 

114 :members: 

115 

116.. autoclass:: VersionOption 

117 :members: 

118 

119.. autoclass:: HelpOption 

120 :members: 

121 

122 

123Namespace 

124--------- 

125 

126.. autoclass:: Namespace 

127 

128 .. automethod:: __getitem__ 

129 

130 .. automethod:: __setitem__ 

131 

132 .. automethod:: __contains__ 

133 

134.. autoclass:: ConfigNamespace 

135 :members: 

136 

137 

138CLI parser 

139---------- 

140 

141.. autoclass:: CliParser 

142 :members: 

143 

144.. autoclass:: Argument 

145 :members: 

146 

147.. autoclass:: Flag 

148 :members: 

149 

150.. autoclass:: ArgumentError 

151 :members: 

152 

153.. type:: NArgs 

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

155 

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

157 

158 .. note:: 

159 

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

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

162 

163 

164Option grouping 

165--------------- 

166 

167.. autoclass:: MutuallyExclusiveGroup 

168 :members: 

169 

170.. autoclass:: HelpGroup 

171 :members: 

172 

173.. autodata:: ARGS_GROUP 

174 

175.. autodata:: SUBCOMMANDS_GROUP 

176 

177.. autodata:: OPTS_GROUP 

178 

179.. autodata:: MISC_GROUP 

180 

181""" 

182 

183from __future__ import annotations 

184 

185import abc 

186import contextlib 

187import dataclasses 

188import functools 

189import re 

190import sys 

191import warnings 

192from dataclasses import dataclass 

193 

194import yuio 

195import yuio.complete 

196import yuio.doc 

197import yuio.hl 

198import yuio.parse 

199import yuio.string 

200from yuio.string import ColorizedString as _ColorizedString 

201from yuio.util import _UNPRINTABLE_TRANS 

202from yuio.util import commonprefix as _commonprefix 

203 

204from typing import TYPE_CHECKING 

205 

206if TYPE_CHECKING: 

207 import typing_extensions as _t 

208else: 

209 from yuio import _typing as _t 

210 

211if TYPE_CHECKING: 

212 import yuio.app 

213 import yuio.config 

214 import yuio.dbg 

215 

216__all__ = [ 

217 "ARGS_GROUP", 

218 "MISC_GROUP", 

219 "OPTS_GROUP", 

220 "SUBCOMMANDS_GROUP", 

221 "Argument", 

222 "ArgumentError", 

223 "BoolOption", 

224 "BugReportOption", 

225 "CliParser", 

226 "CliWarning", 

227 "CollectOption", 

228 "Command", 

229 "CompletionOption", 

230 "ConfigNamespace", 

231 "CountOption", 

232 "Flag", 

233 "HelpGroup", 

234 "HelpOption", 

235 "MutuallyExclusiveGroup", 

236 "NArgs", 

237 "Namespace", 

238 "Option", 

239 "ParseManyOption", 

240 "ParseOneOption", 

241 "ParserOption", 

242 "StoreConstOption", 

243 "StoreFalseOption", 

244 "StoreTrueOption", 

245 "ValueOption", 

246 "VersionOption", 

247] 

248 

249T = _t.TypeVar("T") 

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

251 

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

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

254 

255_NUM_RE = r"""(?x) 

256 ^ 

257 [+-]? 

258 (?: 

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

260 |0[bB][01]+ 

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

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

263 ) 

264 $ 

265""" 

266 

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

268""" 

269Type alias for nargs. 

270 

271.. note:: 

272 

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

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

275 

276""" 

277 

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

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

280 

281 

282class CliWarning(yuio.YuioWarning): 

283 pass 

284 

285 

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

287class Argument: 

288 """ 

289 Represents a CLI argument, or its part. 

290 

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

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

293 value. 

294 

295 :example: 

296 Consider the following command arguments: 

297 

298 .. code-block:: text 

299 

300 --arg=value 

301 

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

303 

304 .. code-block:: python 

305 

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

307 

308 """ 

309 

310 value: str 

311 """ 

312 Contents of the argument. 

313 

314 """ 

315 

316 index: int 

317 """ 

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

319 

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

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

322 

323 """ 

324 

325 pos: int 

326 """ 

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

328 

329 """ 

330 

331 metavar: str 

332 """ 

333 Meta variable for this argument. 

334 

335 """ 

336 

337 flag: Flag | None 

338 """ 

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

340 

341 """ 

342 

343 def __str__(self) -> str: 

344 return self.metavar 

345 

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

347 return _ColorizedString( 

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

349 self.metavar, 

350 ) 

351 

352 

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

354class Flag: 

355 value: str 

356 """ 

357 Name of the flag. 

358 

359 """ 

360 

361 index: int 

362 """ 

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

364 

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

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

367 

368 """ 

369 

370 def __str__(self) -> str: 

371 return self.value 

372 

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

374 return _ColorizedString( 

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

376 self.value, 

377 ) 

378 

379 

380class ArgumentError(yuio.PrettyException, ValueError): 

381 """ 

382 Error that happened during argument parsing. 

383 

384 """ 

385 

386 @_t.overload 

387 def __init__( 

388 self, 

389 msg: _t.LiteralString, 

390 /, 

391 *args, 

392 flag: Flag | None = None, 

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

394 n_arg: int | None = None, 

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

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

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

398 ): ... 

399 @_t.overload 

400 def __init__( 

401 self, 

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

403 /, 

404 *, 

405 flag: Flag | None = None, 

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

407 n_arg: int | None = None, 

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

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

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

411 ): ... 

412 def __init__( 

413 self, 

414 *args, 

415 flag: Flag | None = None, 

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

417 n_arg: int | None = None, 

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

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

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

421 ): 

422 super().__init__(*args) 

423 

424 self.flag: Flag | None = flag 

425 """ 

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

427 by a positional argument. 

428 

429 """ 

430 

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

432 """ 

433 Arguments that caused this error. 

434 

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

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

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

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

439 

440 .. note:: 

441 

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

443 the latter contains formatting arguments and is defined 

444 in the :class:`BaseException` class. 

445 

446 """ 

447 

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

449 """ 

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

451 and end indices). 

452 

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

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

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

456 

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

458 then this position is relative to that argument. 

459 

460 Otherwise, position is ignored. 

461 

462 """ 

463 

464 self.n_arg: int | None = n_arg 

465 """ 

466 Index of the argument that caused the error. 

467 

468 """ 

469 

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

471 """ 

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

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

474 for validation. 

475 

476 """ 

477 

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

479 """ 

480 Option which caused failure. 

481 

482 """ 

483 

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

485 self.prog: str | None = None 

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

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

488 

489 @classmethod 

490 def from_parsing_error( 

491 cls, 

492 e: yuio.parse.ParsingError, 

493 /, 

494 *, 

495 flag: Flag | None = None, 

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

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

498 ): 

499 """ 

500 Convert parsing error to argument error. 

501 

502 """ 

503 

504 return cls( 

505 *e.args, 

506 flag=flag, 

507 arguments=arguments, 

508 n_arg=e.n_arg, 

509 pos=e.pos, 

510 path=e.path, 

511 option=option, 

512 ) 

513 

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

515 colorable = yuio.string.WithBaseColor( 

516 super().to_colorable(), 

517 base_color="msg/text:error", 

518 ) 

519 

520 msg = [] 

521 args = [] 

522 sep = False 

523 

524 if self.flag and self.flag.value: 

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

526 args.append(self.flag.value) 

527 sep = True 

528 

529 argument = None 

530 if isinstance(self.arguments, list): 

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

532 argument = self.arguments[self.n_arg] 

533 else: 

534 argument = self.arguments 

535 

536 if argument and argument.metavar: 

537 if sep: 

538 msg.append(", ") 

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

540 args.append(argument.metavar) 

541 

542 if self.path: 

543 if sep: 

544 msg.append(", ") 

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

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

547 

548 if sep: 

549 msg.insert(0, "Error ") 

550 msg.append(":") 

551 

552 colorable = yuio.string.Stack( 

553 yuio.string.WithBaseColor( 

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

555 base_color="msg/text:failure", 

556 ), 

557 yuio.string.Indent(colorable), 

558 ) 

559 else: 

560 colorable = yuio.string.WithBaseColor( 

561 colorable, 

562 base_color="msg/text:failure", 

563 ) 

564 

565 if commandline := self._make_commandline(): 

566 colorable = yuio.string.Stack( 

567 commandline, 

568 colorable, 

569 ) 

570 

571 if usage := self._make_usage(): 

572 colorable = yuio.string.Stack( 

573 colorable, 

574 usage, 

575 ) 

576 

577 return colorable 

578 

579 def _make_commandline(self): 

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

581 return None 

582 

583 argument = None 

584 if isinstance(self.arguments, list): 

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

586 argument = self.arguments[self.n_arg] 

587 else: 

588 argument = self.arguments 

589 

590 if argument: 

591 arg_index = argument.index 

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

593 if self.pos: 

594 arg_pos = ( 

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

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

597 ) 

598 elif self.flag: 

599 arg_index = self.flag.index 

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

601 else: 

602 return None 

603 

604 text = self.prog 

605 text += " " 

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

607 if arg_index > 0: 

608 text += " " 

609 

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

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

612 

613 text += center 

614 text += " " 

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

616 

617 if text: 

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

619 else: 

620 return None 

621 

622 def _make_usage(self): 

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

624 return None 

625 else: 

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

627 

628 

629class Namespace(_t.Protocol): 

630 """ 

631 Protocol for namespace implementations. 

632 

633 """ 

634 

635 @abc.abstractmethod 

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

637 

638 @abc.abstractmethod 

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

640 

641 @abc.abstractmethod 

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

643 

644 

645@yuio.string.repr_from_rich 

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

647 """ 

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

649 

650 """ 

651 

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

653 self.__config = config 

654 

655 @property 

656 def config(self) -> ConfigT: 

657 """ 

658 Wrapped config instance. 

659 

660 """ 

661 

662 return self.__config 

663 

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

665 root, key = self.__split_key(key) 

666 try: 

667 return getattr(root, key) 

668 except AttributeError as e: 

669 raise KeyError(str(e)) from None 

670 

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

672 root, key = self.__split_key(key) 

673 try: 

674 return setattr(root, key, value) 

675 except AttributeError as e: 

676 raise KeyError(str(e)) from None 

677 

678 def __contains__(self, key: str): 

679 root, key = self.__split_key(key) 

680 return key in root.__dict__ 

681 

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

683 root = self.__config 

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

685 for parent in parents: 

686 root = getattr(root, parent) 

687 return root, key 

688 

689 def __rich_repr__(self): 

690 yield None, self.__config 

691 

692 

693@dataclass(eq=False) 

694class HelpGroup: 

695 """ 

696 Group of flags in CLI help. 

697 

698 """ 

699 

700 title: str 

701 """ 

702 Title for this group. 

703 

704 """ 

705 

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

707 """ 

708 Help message for an option. 

709 

710 """ 

711 

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

713 """ 

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

715 

716 """ 

717 

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

719 

720 

721ARGS_GROUP = HelpGroup("Arguments") 

722""" 

723Help group for positional arguments. 

724 

725""" 

726 

727SUBCOMMANDS_GROUP = HelpGroup("Subcommands") 

728""" 

729Help group for subcommands. 

730 

731""" 

732 

733OPTS_GROUP = HelpGroup("Options") 

734""" 

735Help group for flags. 

736 

737""" 

738 

739MISC_GROUP = HelpGroup("Misc options") 

740""" 

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

742 

743""" 

744 

745 

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

747class MutuallyExclusiveGroup: 

748 """ 

749 A sentinel for creating mutually exclusive groups. 

750 

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

752 be mutually exclusive. 

753 

754 """ 

755 

756 required: bool = False 

757 """ 

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

759 

760 """ 

761 

762 

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

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

765 """ 

766 Base class for a CLI option. 

767 

768 """ 

769 

770 flags: list[str] | yuio.Positional 

771 """ 

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

773 :data:`yuio.POSITIONAL`. 

774 

775 """ 

776 

777 allow_inline_arg: bool 

778 """ 

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

780 

781 Inline arguments are handled separately from normal arguments, 

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

783 

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

785 no effect on them. 

786 

787 """ 

788 

789 allow_implicit_inline_arg: bool 

790 """ 

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

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

793 

794 Inline arguments are handled separately from normal arguments, 

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

796 

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

798 no effect on them. 

799 

800 """ 

801 

802 nargs: NArgs 

803 """ 

804 How many arguments this option takes. 

805 

806 """ 

807 

808 allow_no_args: bool 

809 """ 

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

811 

812 """ 

813 

814 required: bool 

815 """ 

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

817 encountered among CLI arguments. 

818 

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

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

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

822 on positionals. 

823 

824 """ 

825 

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

827 """ 

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

829 

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

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

832 tuple. 

833 

834 """ 

835 

836 mutex_group: None | MutuallyExclusiveGroup 

837 """ 

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

839 mutex groups. 

840 

841 """ 

842 

843 usage: yuio.Collapse | bool 

844 """ 

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

846 are always displayed, regardless of this setting. 

847 

848 """ 

849 

850 help: str | yuio.Disabled 

851 """ 

852 Help message for an option. 

853 

854 """ 

855 

856 help_group: HelpGroup | None 

857 """ 

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

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

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

861 

862 """ 

863 

864 default_desc: str | None 

865 """ 

866 Overrides description of default value. 

867 

868 """ 

869 

870 show_if_inherited: bool 

871 """ 

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

873 inherited because subcommand argument always goes last. 

874 

875 """ 

876 

877 allow_abbrev: bool 

878 """ 

879 Allow abbreviation for this option. 

880 

881 """ 

882 

883 dest: str 

884 """ 

885 Key where to store parsed argument. 

886 

887 """ 

888 

889 @abc.abstractmethod 

890 def process( 

891 self, 

892 cli_parser: CliParser[Namespace], 

893 flag: Flag | None, 

894 arguments: Argument | list[Argument], 

895 ns: Namespace, 

896 ): 

897 """ 

898 Process this argument. 

899 

900 This method is called every time an option is encountered 

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

902 with previous values, if there are any. 

903 

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

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

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

907 

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

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

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

911 however you like. 

912 

913 :param cli_parser: 

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

915 :class:`yuio.parse.Parser`. 

916 :param flag: 

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

918 for positional arguments. 

919 :param arguments: 

920 option arguments, see above. 

921 :param ns: 

922 namespace where parsed arguments should be stored. 

923 :raises: 

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

925 

926 """ 

927 

928 def post_process( 

929 self, 

930 cli_parser: CliParser[Namespace], 

931 arguments: list[Argument], 

932 ns: Namespace, 

933 ): 

934 """ 

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

936 

937 :param cli_parser: 

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

939 :class:`yuio.parse.Parser`. 

940 :param arguments: 

941 option arguments that were ever passed to this option. 

942 :param ns: 

943 namespace where parsed arguments should be stored. 

944 :raises: 

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

946 

947 """ 

948 

949 @functools.cached_property 

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

951 if self.flags is yuio.POSITIONAL: 

952 return None 

953 else: 

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

955 

956 @functools.cached_property 

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

958 if self.flags is yuio.POSITIONAL: 

959 return None 

960 else: 

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

962 

963 @functools.cached_property 

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

965 """ 

966 Short flag that will be displayed in CLI help. 

967 

968 """ 

969 

970 if short_flags := self.short_flags: 

971 return short_flags[0] 

972 else: 

973 return None 

974 

975 @functools.cached_property 

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

977 """ 

978 Long flags that will be displayed in CLI help. 

979 

980 """ 

981 

982 if long_flags := self.long_flags: 

983 return [long_flags[0]] 

984 else: 

985 return None 

986 

987 def format_usage( 

988 self, 

989 ctx: yuio.string.ReprContext, 

990 /, 

991 ) -> tuple[_ColorizedString, bool]: 

992 """ 

993 Allows customizing how this option looks in CLI usage. 

994 

995 :param ctx: 

996 repr context for formatting help. 

997 :returns: 

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

999 usage section. 

1000 

1001 """ 

1002 

1003 can_group = False 

1004 res = _ColorizedString() 

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

1006 flag = self.primary_short_flag 

1007 if flag: 

1008 can_group = True 

1009 elif self.primary_long_flags: 

1010 flag = self.primary_long_flags[0] 

1011 else: 

1012 flag = self.flags[0] 

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

1014 res.append_str(flag) 

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

1016 res.append_colorized_str(metavar) 

1017 can_group = False 

1018 return res, can_group 

1019 

1020 def format_metavar( 

1021 self, 

1022 ctx: yuio.string.ReprContext, 

1023 /, 

1024 ) -> _ColorizedString: 

1025 """ 

1026 Allows customizing how this option looks in CLI help. 

1027 

1028 :param ctx: 

1029 repr context for formatting help. 

1030 :returns: 

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

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

1033 

1034 """ 

1035 

1036 res = _ColorizedString() 

1037 

1038 if not self.nargs: 

1039 return res 

1040 

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

1042 if self.flags: 

1043 res.append_str(" ") 

1044 

1045 if self.nargs == "+": 

1046 if self.allow_no_args: 

1047 res.append_str("[") 

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

1049 if self.allow_no_args: 

1050 res.append_str(" ...]") 

1051 else: 

1052 res.append_str(" [") 

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

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

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

1056 if self.allow_no_args: 

1057 res.append_str("[") 

1058 sep = False 

1059 for i in range(self.nargs): 

1060 if sep: 

1061 res.append_str(" ") 

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

1063 sep = True 

1064 if self.allow_no_args: 

1065 res.append_str("]") 

1066 

1067 return res 

1068 

1069 def format_help_tail( 

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

1071 ) -> _ColorizedString: 

1072 """ 

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

1074 such as aliases, default value, etc. 

1075 

1076 :param ctx: 

1077 repr context for formatting help. 

1078 :param all: 

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

1080 :returns: 

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

1082 

1083 """ 

1084 

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

1086 

1087 res = _ColorizedString(base_color) 

1088 

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

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

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

1092 sep = False 

1093 for alias_flag in alias_flags: 

1094 if isinstance(alias_flag, tuple): 

1095 alias_flag = alias_flag[0] 

1096 if sep: 

1097 res.append_str(", ") 

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

1099 sep = True 

1100 

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

1102 if res: 

1103 res.append_str("; ") 

1104 res.append_str("Default: ") 

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

1106 

1107 if res: 

1108 res.append_str(".") 

1109 

1110 return res 

1111 

1112 def format_alias_flags( 

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

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

1115 """ 

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

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

1118 

1119 :param ctx: 

1120 repr context for formatting help. 

1121 :param all: 

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

1123 :returns: 

1124 a list of strings, one per each alias. 

1125 

1126 """ 

1127 

1128 if self.flags is yuio.POSITIONAL: 

1129 return None 

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

1131 if self.primary_short_flag: 

1132 primary_flags.add(self.primary_short_flag) 

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

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

1135 for flag in self.flags: 

1136 if flag not in primary_flags: 

1137 res = _ColorizedString() 

1138 res.start_no_wrap() 

1139 res.append_color(flag_color) 

1140 res.append_str(flag) 

1141 res.end_no_wrap() 

1142 aliases.append(res) 

1143 return aliases 

1144 

1145 def format_default( 

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

1147 ) -> _ColorizedString | None: 

1148 """ 

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

1150 

1151 :param ctx: 

1152 repr context for formatting help. 

1153 :param all: 

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

1155 :returns: 

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

1157 

1158 """ 

1159 

1160 if self.default_desc is not None: 

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

1162 

1163 return None 

1164 

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

1166 return None, False 

1167 

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

1169 """ 

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

1171 

1172 """ 

1173 

1174 if not self.metavar: 

1175 return "<argument>" 

1176 if isinstance(self.metavar, tuple): 

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

1178 return self.metavar[-1] 

1179 else: 

1180 return self.metavar[n] 

1181 else: 

1182 return self.metavar 

1183 

1184 

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

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

1187 """ 

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

1189 

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

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

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

1193 

1194 """ 

1195 

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

1197 """ 

1198 Function to merge previous and new value. 

1199 

1200 """ 

1201 

1202 default: object 

1203 """ 

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

1205 

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

1207 

1208 """ 

1209 

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

1211 """ 

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

1213 merge old and new value. 

1214 

1215 """ 

1216 

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

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

1219 else: 

1220 ns[self.dest] = value 

1221 

1222 

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

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

1225 """ 

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

1227 

1228 """ 

1229 

1230 parser: yuio.parse.Parser[T] 

1231 """ 

1232 A parser used to parse option's arguments. 

1233 

1234 """ 

1235 

1236 def format_default( 

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

1238 ) -> _ColorizedString | None: 

1239 if self.default_desc is not None: 

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

1241 

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

1243 return None 

1244 

1245 try: 

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

1247 ctx.get_color("code") 

1248 ) 

1249 except TypeError: 

1250 return ctx.repr(self.default) 

1251 

1252 

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

1254class BoolOption(ParserOption[bool]): 

1255 """ 

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

1257 and :class:`ParseOneOption`. 

1258 

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

1260 :class:`StoreTrueOption`. 

1261 

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

1263 :class:`StoreFalseOption`. 

1264 

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

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

1267 

1268 .. note:: 

1269 

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

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

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

1273 ambiguity in cases like the following: 

1274 

1275 .. code-block:: console 

1276 

1277 $ prog --json subcommand # Ok 

1278 $ prog --json=true subcommand # Ok 

1279 $ prog --json true subcommand # Not allowed 

1280 

1281 :example: 

1282 .. code-block:: python 

1283 

1284 option = yuio.cli.BoolOption( 

1285 pos_flags=["--json"], 

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

1287 dest=..., 

1288 ) 

1289 

1290 .. code-block:: console 

1291 

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

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

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

1295 

1296 """ 

1297 

1298 pos_flags: list[str] 

1299 """ 

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

1301 

1302 """ 

1303 

1304 neg_flags: list[str] 

1305 """ 

1306 List of flag names that disable this boolean option. 

1307 

1308 """ 

1309 

1310 def __init__( 

1311 self, 

1312 *, 

1313 pos_flags: list[str], 

1314 neg_flags: list[str], 

1315 required: bool = False, 

1316 mutex_group: None | MutuallyExclusiveGroup = None, 

1317 usage: yuio.Collapse | bool = True, 

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

1319 help_group: HelpGroup | None = None, 

1320 show_if_inherited: bool = False, 

1321 dest: str, 

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

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

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

1325 allow_abbrev: bool = True, 

1326 default_desc: str | None = None, 

1327 ): 

1328 self.pos_flags = pos_flags 

1329 self.neg_flags = neg_flags 

1330 

1331 super().__init__( 

1332 flags=pos_flags + neg_flags, 

1333 allow_inline_arg=True, 

1334 allow_implicit_inline_arg=False, 

1335 nargs=0, 

1336 allow_no_args=True, 

1337 required=required, 

1338 metavar=(), 

1339 mutex_group=mutex_group, 

1340 usage=usage, 

1341 help=help, 

1342 help_group=help_group, 

1343 show_if_inherited=show_if_inherited, 

1344 dest=dest, 

1345 merge=merge, 

1346 default=default, 

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

1348 allow_abbrev=allow_abbrev, 

1349 default_desc=default_desc, 

1350 ) 

1351 

1352 def process( 

1353 self, 

1354 cli_parser: CliParser[Namespace], 

1355 flag: Flag | None, 

1356 arguments: Argument | list[Argument], 

1357 ns: Namespace, 

1358 ): 

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

1360 if arguments: 

1361 raise ArgumentError( 

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

1363 ) 

1364 value = False 

1365 elif isinstance(arguments, Argument): 

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

1367 else: 

1368 value = True 

1369 self.set(ns, value) 

1370 

1371 @functools.cached_property 

1372 def primary_short_flag(self): 

1373 if self.flags is yuio.POSITIONAL: 

1374 return None 

1375 if self.default is True: 

1376 flags = self.neg_flags 

1377 else: 

1378 flags = self.pos_flags 

1379 for flag in flags: 

1380 if _is_short(flag): 

1381 return flag 

1382 return None 

1383 

1384 @functools.cached_property 

1385 def primary_long_flags(self): 

1386 flags = [] 

1387 if self.default is not True: 

1388 for flag in self.pos_flags: 

1389 if not _is_short(flag): 

1390 flags.append(flag) 

1391 break 

1392 if self.default is not False: 

1393 for flag in self.neg_flags: 

1394 if not _is_short(flag): 

1395 flags.append(flag) 

1396 break 

1397 return flags 

1398 

1399 def format_alias_flags( 

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

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

1402 if self.flags is yuio.POSITIONAL: 

1403 return None 

1404 

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

1406 if self.primary_short_flag: 

1407 primary_flags.add(self.primary_short_flag) 

1408 

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

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

1411 if all: 

1412 alias_candidates = self.pos_flags + self.neg_flags 

1413 else: 

1414 alias_candidates = [] 

1415 if self.default is not True: 

1416 alias_candidates += self.pos_flags 

1417 if self.default is not False: 

1418 alias_candidates += self.neg_flags 

1419 for flag in alias_candidates: 

1420 if flag not in primary_flags: 

1421 res = _ColorizedString() 

1422 res.start_no_wrap() 

1423 res.append_color(flag_color) 

1424 res.append_str(flag) 

1425 res.end_no_wrap() 

1426 aliases.append(res) 

1427 if self.pos_flags and all: 

1428 primary_pos_flag = self.pos_flags[0] 

1429 for pos_flag in self.pos_flags: 

1430 if not _is_short(pos_flag): 

1431 primary_pos_flag = pos_flag 

1432 break 

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

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

1435 res = _ColorizedString() 

1436 res.start_no_wrap() 

1437 res.append_color(flag_color) 

1438 res.append_str(primary_pos_flag) 

1439 res.end_no_wrap() 

1440 res.append_color(punct_color) 

1441 res.append_str("={") 

1442 res.append_color(metavar_color) 

1443 res.append_str("true") 

1444 res.append_color(punct_color) 

1445 res.append_str("|") 

1446 res.append_color(metavar_color) 

1447 res.append_str("false") 

1448 res.append_color(punct_color) 

1449 res.append_str("}") 

1450 aliases.append(res) 

1451 return aliases 

1452 

1453 def format_default( 

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

1455 ) -> _ColorizedString | None: 

1456 if self.default_desc is not None: 

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

1458 

1459 return None 

1460 

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

1462 return ( 

1463 yuio.complete.Choice( 

1464 [ 

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

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

1467 ] 

1468 ), 

1469 False, 

1470 ) 

1471 

1472 

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

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

1475 """ 

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

1477 

1478 """ 

1479 

1480 def __init__( 

1481 self, 

1482 *, 

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

1484 required: bool = False, 

1485 mutex_group: None | MutuallyExclusiveGroup = None, 

1486 usage: yuio.Collapse | bool = True, 

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

1488 help_group: HelpGroup | None = None, 

1489 show_if_inherited: bool = False, 

1490 dest: str, 

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

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

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

1494 allow_abbrev: bool = True, 

1495 default_desc: str | None = None, 

1496 ): 

1497 super().__init__( 

1498 flags=flags, 

1499 allow_inline_arg=True, 

1500 allow_implicit_inline_arg=True, 

1501 nargs=1, 

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

1503 required=required, 

1504 metavar=parser.describe_or_def(), 

1505 mutex_group=mutex_group, 

1506 usage=usage, 

1507 help=help, 

1508 help_group=help_group, 

1509 show_if_inherited=show_if_inherited, 

1510 dest=dest, 

1511 merge=merge, 

1512 default=default, 

1513 parser=parser, 

1514 allow_abbrev=allow_abbrev, 

1515 default_desc=default_desc, 

1516 ) 

1517 

1518 def process( 

1519 self, 

1520 cli_parser: CliParser[Namespace], 

1521 flag: Flag | None, 

1522 arguments: Argument | list[Argument], 

1523 ns: Namespace, 

1524 ): 

1525 if isinstance(arguments, list): 

1526 if not arguments and self.allow_no_args: 

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

1528 arguments = arguments[0] 

1529 try: 

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

1531 except yuio.parse.ParsingError as e: 

1532 e.n_arg = 0 

1533 raise 

1534 

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

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

1537 

1538 

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

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

1541 """ 

1542 An option with multiple arguments that uses Yuio parser. 

1543 

1544 """ 

1545 

1546 def __init__( 

1547 self, 

1548 *, 

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

1550 required: bool = False, 

1551 mutex_group: None | MutuallyExclusiveGroup = None, 

1552 usage: yuio.Collapse | bool = True, 

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

1554 help_group: HelpGroup | None = None, 

1555 show_if_inherited: bool = False, 

1556 dest: str, 

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

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

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

1560 allow_abbrev: bool = True, 

1561 default_desc: str | None = None, 

1562 ): 

1563 assert parser.supports_parse_many() 

1564 

1565 nargs = parser.get_nargs() 

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

1567 if nargs == "*": 

1568 nargs = "+" 

1569 allow_no_args = True 

1570 

1571 super().__init__( 

1572 flags=flags, 

1573 allow_inline_arg=True, 

1574 allow_implicit_inline_arg=True, 

1575 nargs=nargs, 

1576 allow_no_args=allow_no_args, 

1577 required=required, 

1578 metavar=parser.describe_many(), 

1579 mutex_group=mutex_group, 

1580 usage=usage, 

1581 help=help, 

1582 help_group=help_group, 

1583 show_if_inherited=show_if_inherited, 

1584 dest=dest, 

1585 merge=merge, 

1586 default=default, 

1587 parser=parser, 

1588 allow_abbrev=allow_abbrev, 

1589 default_desc=default_desc, 

1590 ) 

1591 

1592 def process( 

1593 self, 

1594 cli_parser: CliParser[Namespace], 

1595 flag: Flag | None, 

1596 arguments: Argument | list[Argument], 

1597 ns: Namespace, 

1598 ): 

1599 if ( 

1600 not arguments 

1601 and self.allow_no_args 

1602 and self.default is not yuio.MISSING 

1603 and self.flags is yuio.POSITIONAL 

1604 ): 

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

1606 

1607 if isinstance(arguments, list): 

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

1609 else: 

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

1611 

1612 def format_alias_flags( 

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

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

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

1616 if all: 

1617 flag = self.primary_short_flag 

1618 if not flag and self.primary_long_flags: 

1619 flag = self.primary_long_flags[0] 

1620 if not flag and self.flags: 

1621 flag = self.flags[0] 

1622 if flag: 

1623 res = _ColorizedString() 

1624 res.start_no_wrap() 

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

1626 res.append_str(flag) 

1627 res.end_no_wrap() 

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

1629 res.append_str("=") 

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

1631 res.append_str("'") 

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

1633 res.append_str("'") 

1634 comment = ( 

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

1636 ) 

1637 aliases.append((res, comment)) 

1638 return aliases 

1639 

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

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

1642 

1643 

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

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

1646 """ 

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

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

1649 

1650 """ 

1651 

1652 def __init__( 

1653 self, 

1654 *, 

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

1656 required: bool = False, 

1657 mutex_group: None | MutuallyExclusiveGroup = None, 

1658 usage: yuio.Collapse | bool = True, 

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

1660 help_group: HelpGroup | None = None, 

1661 show_if_inherited: bool = False, 

1662 dest: str, 

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

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

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

1666 allow_abbrev: bool = True, 

1667 default_desc: str | None = None, 

1668 ): 

1669 assert parser.supports_parse_many() 

1670 

1671 if flags is yuio.POSITIONAL: 

1672 raise TypeError( 

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

1674 ) 

1675 

1676 nargs = parser.get_nargs() 

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

1678 raise TypeError( 

1679 "ParseManyOneByOneOption can't be used with parser " 

1680 "that limits length of its collection" 

1681 ) 

1682 

1683 super().__init__( 

1684 flags=flags, 

1685 allow_inline_arg=True, 

1686 allow_implicit_inline_arg=True, 

1687 nargs=1, 

1688 allow_no_args=False, 

1689 required=required, 

1690 metavar=parser.describe_many(), 

1691 mutex_group=mutex_group, 

1692 usage=usage, 

1693 help=help, 

1694 help_group=help_group, 

1695 show_if_inherited=show_if_inherited, 

1696 dest=dest, 

1697 merge=merge, 

1698 default=default, 

1699 parser=parser, 

1700 allow_abbrev=allow_abbrev, 

1701 default_desc=default_desc, 

1702 ) 

1703 

1704 def process( 

1705 self, 

1706 cli_parser: CliParser[Namespace], 

1707 flag: Flag | None, 

1708 arguments: Argument | list[Argument], 

1709 ns: Namespace, 

1710 ): 

1711 pass 

1712 

1713 def post_process( 

1714 self, 

1715 cli_parser: CliParser[Namespace], 

1716 arguments: list[Argument], 

1717 ns: Namespace, 

1718 ): 

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

1720 

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

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

1723 

1724 

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

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

1727 """ 

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

1729 

1730 """ 

1731 

1732 const: T 

1733 """ 

1734 Constant that will be stored. 

1735 

1736 """ 

1737 

1738 def __init__( 

1739 self, 

1740 *, 

1741 flags: list[str], 

1742 required: bool = False, 

1743 mutex_group: None | MutuallyExclusiveGroup = None, 

1744 usage: yuio.Collapse | bool = True, 

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

1746 help_group: HelpGroup | None = None, 

1747 show_if_inherited: bool = False, 

1748 dest: str, 

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

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

1751 const: T, 

1752 allow_abbrev: bool = True, 

1753 default_desc: str | None = None, 

1754 ): 

1755 self.const = const 

1756 

1757 super().__init__( 

1758 flags=flags, 

1759 allow_inline_arg=False, 

1760 allow_implicit_inline_arg=False, 

1761 nargs=0, 

1762 allow_no_args=True, 

1763 required=required, 

1764 metavar=(), 

1765 mutex_group=mutex_group, 

1766 usage=usage, 

1767 help=help, 

1768 help_group=help_group, 

1769 show_if_inherited=show_if_inherited, 

1770 dest=dest, 

1771 merge=merge, 

1772 default=default, 

1773 allow_abbrev=allow_abbrev, 

1774 default_desc=default_desc, 

1775 ) 

1776 

1777 def process( 

1778 self, 

1779 cli_parser: CliParser[Namespace], 

1780 flag: Flag | None, 

1781 arguments: Argument | list[Argument], 

1782 ns: Namespace, 

1783 ): 

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

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

1786 else: 

1787 ns[self.dest] = self.const 

1788 

1789 

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

1791class CountOption(StoreConstOption[int]): 

1792 """ 

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

1794 

1795 """ 

1796 

1797 def __init__( 

1798 self, 

1799 *, 

1800 flags: list[str], 

1801 required: bool = False, 

1802 mutex_group: None | MutuallyExclusiveGroup = None, 

1803 usage: yuio.Collapse | bool = True, 

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

1805 help_group: HelpGroup | None = None, 

1806 show_if_inherited: bool = False, 

1807 dest: str, 

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

1809 allow_abbrev: bool = True, 

1810 default_desc: str | None = None, 

1811 ): 

1812 super().__init__( 

1813 flags=flags, 

1814 required=required, 

1815 mutex_group=mutex_group, 

1816 usage=usage, 

1817 help=help, 

1818 help_group=help_group, 

1819 show_if_inherited=show_if_inherited, 

1820 dest=dest, 

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

1822 default=default, 

1823 const=1, 

1824 allow_abbrev=allow_abbrev, 

1825 default_desc=default_desc, 

1826 ) 

1827 

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

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

1830 

1831 

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

1833class StoreTrueOption(StoreConstOption[bool]): 

1834 """ 

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

1836 

1837 """ 

1838 

1839 def __init__( 

1840 self, 

1841 *, 

1842 flags: list[str], 

1843 required: bool = False, 

1844 mutex_group: None | MutuallyExclusiveGroup = None, 

1845 usage: yuio.Collapse | bool = True, 

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

1847 help_group: HelpGroup | None = None, 

1848 show_if_inherited: bool = False, 

1849 dest: str, 

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

1851 allow_abbrev: bool = True, 

1852 default_desc: str | None = None, 

1853 ): 

1854 super().__init__( 

1855 flags=flags, 

1856 required=required, 

1857 mutex_group=mutex_group, 

1858 usage=usage, 

1859 help=help, 

1860 help_group=help_group, 

1861 show_if_inherited=show_if_inherited, 

1862 dest=dest, 

1863 merge=None, 

1864 default=default, 

1865 const=True, 

1866 allow_abbrev=allow_abbrev, 

1867 default_desc=default_desc, 

1868 ) 

1869 

1870 

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

1872class StoreFalseOption(StoreConstOption[bool]): 

1873 """ 

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

1875 

1876 """ 

1877 

1878 def __init__( 

1879 self, 

1880 *, 

1881 flags: list[str], 

1882 required: bool = False, 

1883 mutex_group: None | MutuallyExclusiveGroup = None, 

1884 usage: yuio.Collapse | bool = True, 

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

1886 help_group: HelpGroup | None = None, 

1887 show_if_inherited: bool = False, 

1888 dest: str, 

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

1890 allow_abbrev: bool = True, 

1891 default_desc: str | None = None, 

1892 ): 

1893 super().__init__( 

1894 flags=flags, 

1895 required=required, 

1896 mutex_group=mutex_group, 

1897 usage=usage, 

1898 help=help, 

1899 help_group=help_group, 

1900 show_if_inherited=show_if_inherited, 

1901 dest=dest, 

1902 merge=None, 

1903 default=default, 

1904 const=False, 

1905 allow_abbrev=allow_abbrev, 

1906 default_desc=default_desc, 

1907 ) 

1908 

1909 

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

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

1912 """ 

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

1914 

1915 """ 

1916 

1917 version: str 

1918 """ 

1919 Version to print. 

1920 

1921 """ 

1922 

1923 def __init__( 

1924 self, 

1925 *, 

1926 version: str, 

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

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

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

1930 help_group: HelpGroup | None = MISC_GROUP, 

1931 allow_abbrev: bool = True, 

1932 ): 

1933 super().__init__( 

1934 flags=flags, 

1935 allow_inline_arg=False, 

1936 allow_implicit_inline_arg=False, 

1937 nargs=0, 

1938 allow_no_args=True, 

1939 required=False, 

1940 metavar=(), 

1941 mutex_group=None, 

1942 usage=usage, 

1943 help=help, 

1944 help_group=help_group, 

1945 show_if_inherited=False, 

1946 allow_abbrev=allow_abbrev, 

1947 dest="_version", 

1948 default_desc=None, 

1949 ) 

1950 

1951 self.version = version 

1952 

1953 def process( 

1954 self, 

1955 cli_parser: CliParser[Namespace], 

1956 flag: Flag | None, 

1957 arguments: Argument | list[Argument], 

1958 ns: Namespace, 

1959 ): 

1960 import yuio.io 

1961 

1962 if self.version: 

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

1964 else: 

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

1966 sys.exit(0) 

1967 

1968 

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

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

1971 """ 

1972 An option that prints bug report. 

1973 

1974 """ 

1975 

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

1977 """ 

1978 Settings for bug report generation. 

1979 

1980 """ 

1981 

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

1983 """ 

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

1985 

1986 """ 

1987 

1988 def __init__( 

1989 self, 

1990 *, 

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

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

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

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

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

1996 help_group: HelpGroup | None = MISC_GROUP, 

1997 allow_abbrev: bool = True, 

1998 ): 

1999 super().__init__( 

2000 flags=flags, 

2001 allow_inline_arg=False, 

2002 allow_implicit_inline_arg=False, 

2003 nargs=0, 

2004 allow_no_args=True, 

2005 required=False, 

2006 metavar=(), 

2007 mutex_group=None, 

2008 usage=usage, 

2009 help=help, 

2010 help_group=help_group, 

2011 show_if_inherited=False, 

2012 allow_abbrev=allow_abbrev, 

2013 dest="_bug_report", 

2014 default_desc=None, 

2015 ) 

2016 

2017 self.settings = settings 

2018 self.app = app 

2019 

2020 def process( 

2021 self, 

2022 cli_parser: CliParser[Namespace], 

2023 flag: Flag | None, 

2024 arguments: Argument | list[Argument], 

2025 ns: Namespace, 

2026 ): 

2027 import yuio.dbg 

2028 

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

2030 sys.exit(0) 

2031 

2032 

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

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

2035 """ 

2036 An option that installs autocompletion. 

2037 

2038 """ 

2039 

2040 _SHELLS = [ 

2041 "all", 

2042 "uninstall", 

2043 "bash", 

2044 "zsh", 

2045 "fish", 

2046 "pwsh", 

2047 ] 

2048 

2049 def __init__( 

2050 self, 

2051 *, 

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

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

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

2055 help_group: HelpGroup | None = MISC_GROUP, 

2056 allow_abbrev: bool = True, 

2057 ): 

2058 if help is None: 

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

2060 help = ( 

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

2062 f"Supported shells: {shells}." 

2063 ) 

2064 super().__init__( 

2065 flags=flags, 

2066 allow_inline_arg=True, 

2067 allow_implicit_inline_arg=True, 

2068 nargs=1, 

2069 allow_no_args=True, 

2070 required=False, 

2071 metavar="<shell>", 

2072 mutex_group=None, 

2073 usage=usage, 

2074 help=help, 

2075 help_group=help_group, 

2076 show_if_inherited=False, 

2077 allow_abbrev=allow_abbrev, 

2078 dest="_completions", 

2079 default_desc=None, 

2080 ) 

2081 

2082 def process( 

2083 self, 

2084 cli_parser: CliParser[Namespace], 

2085 flag: Flag | None, 

2086 arguments: Argument | list[Argument], 

2087 ns: Namespace, 

2088 ): 

2089 if isinstance(arguments, list): 

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

2091 else: 

2092 argument = arguments.value 

2093 

2094 if argument not in self._SHELLS: 

2095 raise ArgumentError( 

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

2097 argument, 

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

2099 flag=flag, 

2100 arguments=arguments, 

2101 n_arg=0, 

2102 ) 

2103 

2104 root = cli_parser._root_command 

2105 help_parser = cli_parser._help_parser 

2106 

2107 if argument == "uninstall": 

2108 compdata = "" 

2109 else: 

2110 serializer = yuio.complete._ProgramSerializer() 

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

2112 compdata = serializer.dump() 

2113 

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

2115 

2116 sys.exit(0) 

2117 

2118 def _dump( 

2119 self, 

2120 command: Command[_t.Any], 

2121 serializer: yuio.complete._ProgramSerializer, 

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

2123 help_parser: yuio.doc.DocParser, 

2124 ): 

2125 seen_flags: set[str] = set() 

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

2127 

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

2129 for option in command.options: 

2130 completer, is_many = option.get_completer() 

2131 help = option.help 

2132 if help is not yuio.DISABLED: 

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

2134 ctx.width = 60 

2135 parsed_help = _parse_option_help(option, help_parser, ctx) 

2136 if parsed_help: 

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

2138 if not lines: 

2139 help = "" 

2140 elif len(lines) == 1: 

2141 help = str(lines[0]) 

2142 else: 

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

2144 else: 

2145 help = "" 

2146 serializer.add_option( 

2147 flags=option.flags, 

2148 nargs=option.nargs, 

2149 metavar=option.metavar, 

2150 help=help, 

2151 completer=completer, 

2152 is_many=is_many, 

2153 ) 

2154 if option.flags is not yuio.POSITIONAL: 

2155 seen_flags |= seen_flags 

2156 seen_options.append(option) 

2157 

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

2159 for option in parent_options: 

2160 assert option.flags is not yuio.POSITIONAL 

2161 

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

2163 if not flags: 

2164 continue 

2165 

2166 completer, is_many = option.get_completer() 

2167 help = option.help 

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

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

2170 # the best approach here. 

2171 help = yuio.DISABLED 

2172 nargs = option.nargs 

2173 if option.allow_no_args: 

2174 if nargs == 1: 

2175 nargs = "?" 

2176 elif nargs == "+": 

2177 nargs = "*" 

2178 serializer.add_option( 

2179 flags=flags, 

2180 nargs=nargs, 

2181 metavar=option.metavar, 

2182 help=help, 

2183 completer=completer, 

2184 is_many=is_many, 

2185 ) 

2186 

2187 seen_flags |= seen_flags 

2188 seen_options.append(option) 

2189 

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

2191 subcommand_serializer = serializer.add_subcommand( 

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

2193 ) 

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

2195 

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

2197 return ( 

2198 yuio.complete.Choice( 

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

2200 ), 

2201 False, 

2202 ) 

2203 

2204 

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

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

2207 """ 

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

2209 

2210 """ 

2211 

2212 def __init__( 

2213 self, 

2214 *, 

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

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

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

2218 help_group: HelpGroup | None = MISC_GROUP, 

2219 allow_abbrev: bool = True, 

2220 ): 

2221 super().__init__( 

2222 flags=flags, 

2223 allow_inline_arg=True, 

2224 allow_implicit_inline_arg=True, 

2225 nargs=0, 

2226 allow_no_args=True, 

2227 required=False, 

2228 metavar=(), 

2229 mutex_group=None, 

2230 usage=usage, 

2231 help=help, 

2232 help_group=help_group, 

2233 show_if_inherited=True, 

2234 allow_abbrev=allow_abbrev, 

2235 dest="_help", 

2236 default_desc=None, 

2237 ) 

2238 

2239 def process( 

2240 self, 

2241 cli_parser: CliParser[Namespace], 

2242 flag: Flag | None, 

2243 arguments: Argument | list[Argument], 

2244 ns: Namespace, 

2245 ): 

2246 import yuio.io 

2247 import yuio.string 

2248 

2249 if isinstance(arguments, list): 

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

2251 else: 

2252 argument = arguments.value 

2253 

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

2255 raise ArgumentError( 

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

2257 argument, 

2258 yuio.string.Or( 

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

2260 ), 

2261 flag=flag, 

2262 arguments=arguments, 

2263 n_arg=0, 

2264 ) 

2265 

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

2267 inherited_options = [] 

2268 seen_inherited_options = set() 

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

2270 if opt not in seen_inherited_options: 

2271 seen_inherited_options.add(opt) 

2272 inherited_options.append(opt) 

2273 formatter.add_command( 

2274 " ".join(cli_parser._current_path), 

2275 cli_parser._current_command, 

2276 list(inherited_options), 

2277 ) 

2278 

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

2280 sys.exit(0) 

2281 

2282 

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

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

2285 """ 

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

2287 

2288 """ 

2289 

2290 name: str 

2291 """ 

2292 Canonical name of this command. 

2293 

2294 """ 

2295 

2296 desc: str 

2297 """ 

2298 Long description for a command. 

2299 

2300 """ 

2301 

2302 help: str | yuio.Disabled 

2303 """ 

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

2305 

2306 """ 

2307 

2308 epilog: str 

2309 """ 

2310 Long description printed after command help. 

2311 

2312 """ 

2313 

2314 usage: str 

2315 """ 

2316 Override for usage section of CLI help. 

2317 

2318 """ 

2319 

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

2321 """ 

2322 Options for this command. 

2323 

2324 """ 

2325 

2326 subcommands: dict[str, Command[Namespace]] 

2327 """ 

2328 Last positional option can be a sub-command. 

2329 

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

2331 

2332 """ 

2333 

2334 subcommand_required: bool 

2335 """ 

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

2337 are given, this attribute is ignored. 

2338 

2339 """ 

2340 

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

2342 """ 

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

2344 

2345 """ 

2346 

2347 dest: str 

2348 """ 

2349 Where to save subcommand's name. 

2350 

2351 """ 

2352 

2353 ns_dest: str 

2354 """ 

2355 Where to save subcommand's namespace. 

2356 

2357 """ 

2358 

2359 metavar: str = "<subcommand>" 

2360 """ 

2361 Meta variable used for subcommand option. 

2362 

2363 """ 

2364 

2365 

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

2367class _SubCommandOption(ValueOption[str]): 

2368 subcommands: dict[str, Command[Namespace]] 

2369 """ 

2370 All subcommands. 

2371 

2372 """ 

2373 

2374 ns_dest: str 

2375 """ 

2376 Where to save subcommand's namespace. 

2377 

2378 """ 

2379 

2380 ns_ctor: _t.Callable[[], Namespace] 

2381 """ 

2382 A constructor that will be called to create namespace for subcommand's arguments. 

2383 

2384 """ 

2385 

2386 def __init__( 

2387 self, 

2388 *, 

2389 subcommands: dict[str, Command[Namespace]], 

2390 subcommand_required: bool, 

2391 ns_dest: str, 

2392 ns_ctor: _t.Callable[[], Namespace], 

2393 metavar: str = "<subcommand>", 

2394 help_group: HelpGroup | None = SUBCOMMANDS_GROUP, 

2395 show_if_inherited: bool = False, 

2396 dest: str, 

2397 ): 

2398 subcommand_names = [ 

2399 f"``{name}``" 

2400 for name, subcommand in subcommands.items() 

2401 if name == subcommand.name and subcommand.help is not yuio.DISABLED 

2402 ] 

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

2404 

2405 super().__init__( 

2406 flags=yuio.POSITIONAL, 

2407 allow_inline_arg=False, 

2408 allow_implicit_inline_arg=False, 

2409 nargs=1, 

2410 allow_no_args=not subcommand_required, 

2411 required=False, 

2412 metavar=metavar, 

2413 mutex_group=None, 

2414 usage=True, 

2415 help=help, 

2416 help_group=help_group, 

2417 show_if_inherited=show_if_inherited, 

2418 dest=dest, 

2419 merge=None, 

2420 default=yuio.MISSING, 

2421 allow_abbrev=False, 

2422 default_desc=None, 

2423 ) 

2424 

2425 self.subcommands = subcommands 

2426 self.ns_dest = ns_dest 

2427 self.ns_ctor = ns_ctor 

2428 

2429 assert self.dest 

2430 assert self.ns_dest 

2431 

2432 def process( 

2433 self, 

2434 cli_parser: CliParser[Namespace], 

2435 flag: Flag | None, 

2436 arguments: Argument | list[Argument], 

2437 ns: Namespace, 

2438 ): 

2439 assert isinstance(arguments, list) 

2440 if not arguments: 

2441 return 

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

2443 if subcommand is None: 

2444 raise ArgumentError( 

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

2446 arguments[0].value, 

2447 yuio.string.Or( 

2448 ( 

2449 name 

2450 for name, subcommand in self.subcommands.items() 

2451 if subcommand.help != yuio.DISABLED 

2452 ), 

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

2454 ), 

2455 arguments=arguments, 

2456 ) 

2457 ns[self.dest] = subcommand.name 

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

2459 cli_parser._load_command(subcommand, new_ns) 

2460 

2461 

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

2463class _BoundOption: 

2464 wrapped: Option[_t.Any] 

2465 ns: Namespace 

2466 seen: bool = False 

2467 

2468 @property 

2469 def usage(self): 

2470 return self.wrapped.usage 

2471 

2472 @property 

2473 def flags(self): 

2474 return self.wrapped.flags 

2475 

2476 @property 

2477 def nargs(self): 

2478 return self.wrapped.nargs 

2479 

2480 @property 

2481 def allow_no_args(self): 

2482 return self.wrapped.allow_no_args 

2483 

2484 @property 

2485 def allow_inline_arg(self): 

2486 return self.wrapped.allow_inline_arg 

2487 

2488 @property 

2489 def allow_implicit_inline_arg(self): 

2490 return self.wrapped.allow_implicit_inline_arg 

2491 

2492 @property 

2493 def mutex_group(self): 

2494 return self.wrapped.mutex_group 

2495 

2496 @property 

2497 def required(self): 

2498 return self.wrapped.required 

2499 

2500 @property 

2501 def allow_abbrev(self): 

2502 return self.wrapped.allow_abbrev 

2503 

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

2505 return self.wrapped.nth_metavar(n) 

2506 

2507 

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

2509 """ 

2510 CLI arguments parser. 

2511 

2512 :param command: 

2513 root command. 

2514 :param allow_abbrev: 

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

2516 :param help_parser: 

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

2518 that've failed to parse. 

2519 

2520 """ 

2521 

2522 def __init__( 

2523 self, 

2524 command: Command[NamespaceT], 

2525 /, 

2526 *, 

2527 help_parser: yuio.doc.DocParser, 

2528 allow_abbrev: bool, 

2529 ): 

2530 self._root_command = command 

2531 self._allow_abbrev = allow_abbrev 

2532 self._help_parser = help_parser 

2533 

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

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

2536 assert self._current_flag is None 

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

2538 

2539 self._inherited_options.update( 

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

2541 ) 

2542 self._inherited_options.update( 

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

2544 ) 

2545 self._current_path.append(command.name) 

2546 

2547 # Update known flags and positionals. 

2548 self._positionals = [] 

2549 seen_flags: set[str] = set() 

2550 for option in command.options: 

2551 bound_option = _BoundOption(option, ns) 

2552 if option.flags is yuio.POSITIONAL: 

2553 if option.mutex_group is not None: 

2554 raise TypeError( 

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

2556 "in mutually exclusive groups" 

2557 ) 

2558 if option.nargs == 0: 

2559 raise TypeError( 

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

2561 ) 

2562 self._positionals.append(bound_option) 

2563 else: 

2564 if option.mutex_group is not None: 

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

2566 if not option.flags: 

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

2568 for flag in option.flags: 

2569 if flag in seen_flags: 

2570 raise TypeError( 

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

2572 ) 

2573 seen_flags.add(flag) 

2574 self._inherited_options.pop(flag, None) 

2575 _check_flag(flag) 

2576 if _is_short(flag): 

2577 dest = self._known_short_flags 

2578 else: 

2579 dest = self._known_long_flags 

2580 if flag in dest: 

2581 warnings.warn( 

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

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

2584 CliWarning, 

2585 ) 

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

2587 dest[flag] = bound_option 

2588 if command.subcommands: 

2589 self._positionals.append(_BoundOption(_make_subcommand(command), ns)) 

2590 self._current_command = command 

2591 self._current_positional = 0 

2592 

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

2594 """ 

2595 Parse arguments and invoke their actions. 

2596 

2597 :param args: 

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

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

2600 :returns: 

2601 namespace with parsed arguments. 

2602 :raises: 

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

2604 

2605 """ 

2606 

2607 if args is None: 

2608 args = sys.argv[1:] 

2609 

2610 try: 

2611 return self._parse(args) 

2612 except ArgumentError as e: 

2613 e.commandline = args 

2614 e.prog = self._root_command.name 

2615 e.subcommands = self._current_path 

2616 e.help_parser = self._help_parser 

2617 raise 

2618 

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

2620 self._current_command = self._root_command 

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

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

2623 

2624 self._seen_mutex_groups: dict[ 

2625 MutuallyExclusiveGroup, tuple[_BoundOption, Flag] 

2626 ] = {} 

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

2628 

2629 self._current_index = 0 

2630 

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

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

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

2634 self._current_positional: int = 0 

2635 

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

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

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

2639 

2640 self._post_process: dict[ 

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

2642 ] = {} 

2643 

2644 root_ns = self._root_command.ns_ctor() 

2645 self._load_command(self._root_command, root_ns) 

2646 

2647 allow_flags = True 

2648 

2649 for i, arg in enumerate(args): 

2650 self._current_index = i 

2651 

2652 # Handle `--`. 

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

2654 self._flush_flag() 

2655 allow_flags = False 

2656 continue 

2657 

2658 # Check what we have here. 

2659 if allow_flags: 

2660 result = self._detect_flag(arg) 

2661 else: 

2662 result = None 

2663 

2664 if result is None: 

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

2666 self._handle_positional(arg) 

2667 else: 

2668 # This is a flag. 

2669 options, inline_arg = result 

2670 self._handle_flags(options, inline_arg) 

2671 

2672 self._finalize() 

2673 

2674 return root_ns 

2675 

2676 def _finalize(self): 

2677 self._flush_flag() 

2678 

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

2680 self._finalize_unused_flag(flag, option) 

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

2682 self._finalize_unused_flag(flag, option) 

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

2684 self._flush_positional() 

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

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

2687 raise ArgumentError( 

2688 "%s %s must be provided", 

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

2690 yuio.string.Or( 

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

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

2693 ), 

2694 ) 

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

2696 try: 

2697 option.wrapped.post_process( 

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

2699 ) 

2700 except ArgumentError as e: 

2701 if e.arguments is None: 

2702 e.arguments = arguments 

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

2704 e.flag = flags[e.n_arg] 

2705 if e.option is None: 

2706 e.option = option.wrapped 

2707 raise 

2708 except yuio.parse.ParsingError as e: 

2709 flag = None 

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

2711 flag = flags[e.n_arg] 

2712 raise ArgumentError.from_parsing_error( 

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

2714 ) 

2715 

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

2717 if option.required and not option.seen: 

2718 raise ArgumentError( 

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

2720 flag, 

2721 ) 

2722 

2723 def _detect_flag( 

2724 self, arg: str 

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

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

2727 # This is a positional. 

2728 return None 

2729 

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

2731 # This is a long flag. 

2732 return self._parse_long_flag(arg) 

2733 else: 

2734 return self._detect_short_flag(arg) 

2735 

2736 def _parse_long_flag( 

2737 self, arg: str 

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

2739 if "=" in arg: 

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

2741 else: 

2742 flag, inline_arg = arg, None 

2743 flag = self._make_flag(flag) 

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

2745 if inline_arg is not None: 

2746 inline_arg = self._make_arg( 

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

2748 ) 

2749 return [(long_opt, flag)], inline_arg 

2750 

2751 # Try as abbreviated long flags. 

2752 candidates: list[str] = [] 

2753 if self._allow_abbrev: 

2754 for candidate in self._known_long_flags: 

2755 if candidate.startswith(flag.value): 

2756 candidates.append(candidate) 

2757 if len(candidates) == 1: 

2758 candidate = candidates[0] 

2759 opt = self._known_long_flags[candidate] 

2760 if not opt.allow_abbrev: 

2761 raise ArgumentError( 

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

2763 flag, 

2764 candidate, 

2765 flag=self._make_flag(""), 

2766 ) 

2767 flag = self._make_flag(candidate) 

2768 if inline_arg is not None: 

2769 inline_arg = self._make_arg( 

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

2771 ) 

2772 return [(opt, flag)], inline_arg 

2773 

2774 if candidates: 

2775 raise ArgumentError( 

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

2777 flag, 

2778 yuio.string.Or( 

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

2780 ), 

2781 flag=self._make_flag(""), 

2782 ) 

2783 else: 

2784 raise ArgumentError( 

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

2786 flag, 

2787 flag=self._make_flag(""), 

2788 ) 

2789 

2790 def _detect_short_flag( 

2791 self, arg: str 

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

2793 # Try detecting short flags first. 

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

2795 inline_arg = None 

2796 inline_arg_pos = 0 

2797 unknown_ch = None 

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

2799 if ch == "=": 

2800 # Short flag with explicit argument. 

2801 inline_arg_pos = i + 2 

2802 inline_arg = arg[inline_arg_pos:] 

2803 break 

2804 elif short_opts and ( 

2805 short_opts[-1][0].allow_implicit_inline_arg 

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

2807 ): 

2808 # Short flag with implicit argument. 

2809 inline_arg_pos = i + 1 

2810 inline_arg = arg[inline_arg_pos:] 

2811 break 

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

2813 # Short flag, arguments may follow. 

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

2815 else: 

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

2817 unknown_ch = ch 

2818 break 

2819 if short_opts and not unknown_ch: 

2820 if inline_arg is not None: 

2821 inline_arg = self._make_arg( 

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

2823 ) 

2824 return short_opts, inline_arg 

2825 

2826 # Try as signed int. 

2827 if re.match(_NUM_RE, arg): 

2828 # This is a positional. 

2829 return None 

2830 

2831 if unknown_ch and len(arg) > 2: 

2832 raise ArgumentError( 

2833 "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>", 

2834 unknown_ch, 

2835 arg, 

2836 flag=self._make_flag(""), 

2837 ) 

2838 else: 

2839 raise ArgumentError( 

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

2841 arg, 

2842 flag=self._make_flag(""), 

2843 ) 

2844 

2845 def _make_arg( 

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

2847 ): 

2848 return Argument( 

2849 arg, 

2850 index=self._current_index, 

2851 pos=pos, 

2852 metavar=opt.nth_metavar(0), 

2853 flag=flag, 

2854 ) 

2855 

2856 def _make_flag(self, arg: str): 

2857 return Flag(arg, self._current_index) 

2858 

2859 def _handle_positional(self, arg: str): 

2860 if self._current_flag is not None: 

2861 opt, flag = self._current_flag 

2862 # This is an argument for a flag option. 

2863 self._current_flag_args.append( 

2864 Argument( 

2865 arg, 

2866 index=self._current_index, 

2867 pos=0, 

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

2869 flag=flag, 

2870 ) 

2871 ) 

2872 nargs = opt.nargs 

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

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

2875 else: 

2876 # This is an argument for a positional option. 

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

2878 raise ArgumentError( 

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

2880 arg, 

2881 arguments=Argument( 

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

2883 ), 

2884 ) 

2885 current_positional = self._positionals[self._current_positional] 

2886 self._current_positional_args.append( 

2887 Argument( 

2888 arg, 

2889 index=self._current_index, 

2890 pos=0, 

2891 metavar=current_positional.nth_metavar( 

2892 len(self._current_positional_args) 

2893 ), 

2894 flag=None, 

2895 ) 

2896 ) 

2897 nargs = current_positional.nargs 

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

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

2900 

2901 def _handle_flags( 

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

2903 ): 

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

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

2906 self._flush_flag() 

2907 

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

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

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

2911 

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

2913 opt, name = options[-1] 

2914 if inline_arg is not None: 

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

2916 self._eval_option(opt, name, inline_arg) 

2917 else: 

2918 self._push_flag(opt, name) 

2919 

2920 def _flush_positional(self): 

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

2922 return 

2923 opt, args = ( 

2924 self._positionals[self._current_positional], 

2925 self._current_positional_args, 

2926 ) 

2927 

2928 self._current_positional += 1 

2929 self._current_positional_args = [] 

2930 

2931 self._eval_option(opt, None, args) 

2932 

2933 def _flush_flag(self): 

2934 if self._current_flag is None: 

2935 return 

2936 

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

2938 

2939 self._current_flag = None 

2940 self._current_flag_args = [] 

2941 

2942 self._eval_option(opt, name, args) 

2943 

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

2945 assert self._current_flag is None 

2946 

2947 if opt.nargs == 0: 

2948 # Flag without arguments, handle it right now. 

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

2950 else: 

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

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

2953 self._current_flag = (opt, flag) 

2954 self._current_flag_args = [] 

2955 

2956 def _eval_option( 

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

2958 ): 

2959 if opt.mutex_group is not None: 

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

2961 raise ArgumentError( 

2962 "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>", 

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

2964 seen[1], 

2965 ) 

2966 self._seen_mutex_groups[opt.mutex_group] = ( 

2967 opt, 

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

2969 ) 

2970 

2971 if isinstance(arguments, list): 

2972 _check_nargs(opt, flag, arguments) 

2973 elif not opt.allow_inline_arg: 

2974 raise ArgumentError( 

2975 "This flag can't have arguments", 

2976 flag=flag, 

2977 arguments=arguments, 

2978 option=opt.wrapped, 

2979 ) 

2980 

2981 opt.seen = True 

2982 try: 

2983 opt.wrapped.process( 

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

2985 ) 

2986 except ArgumentError as e: 

2987 if e.flag is None: 

2988 e.flag = flag 

2989 if e.arguments is None: 

2990 e.arguments = arguments 

2991 if e.option is None: 

2992 e.option = opt.wrapped 

2993 raise 

2994 except yuio.parse.ParsingError as e: 

2995 raise ArgumentError.from_parsing_error( 

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

2997 ) 

2998 

2999 if not isinstance(arguments, list): 

3000 arguments = [arguments] 

3001 if opt not in self._post_process: 

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

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

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

3005 

3006 

3007def _check_flag(flag: str): 

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

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

3010 if len(flag) == 2: 

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

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

3013 elif len(flag) == 1: 

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

3015 else: 

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

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

3018 

3019 

3020def _is_short(flag: str): 

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

3022 

3023 

3024def _make_subcommand(command: Command[Namespace]): 

3025 return _SubCommandOption( 

3026 metavar=command.metavar, 

3027 subcommands=command.subcommands, 

3028 subcommand_required=command.subcommand_required, 

3029 dest=command.dest, 

3030 ns_dest=command.ns_dest, 

3031 ns_ctor=command.ns_ctor, 

3032 ) 

3033 

3034 

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

3036 if not args and opt.allow_no_args: 

3037 return 

3038 match opt.nargs: 

3039 case "+": 

3040 if not args: 

3041 if opt.flags is yuio.POSITIONAL: 

3042 raise ArgumentError( 

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

3044 opt.nth_metavar(0), 

3045 flag=flag, 

3046 option=opt.wrapped, 

3047 ) 

3048 else: 

3049 raise ArgumentError( 

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

3051 flag=flag, 

3052 option=opt.wrapped, 

3053 ) 

3054 case n: 

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

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

3057 raise ArgumentError( 

3058 "Missing required positional%s %s", 

3059 s, 

3060 yuio.string.JoinStr( 

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

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

3063 ), 

3064 flag=flag, 

3065 option=opt.wrapped, 

3066 ) 

3067 elif len(args) != n: 

3068 s = "" if n == 1 else "s" 

3069 raise ArgumentError( 

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

3071 n, 

3072 s, 

3073 len(args), 

3074 flag=flag, 

3075 option=opt.wrapped, 

3076 ) 

3077 

3078 

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

3080 s = s.translate(_UNPRINTABLE_TRANS) 

3081 

3082 if not s: 

3083 return "''", (1, 1) 

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

3085 return s, pos 

3086 

3087 start, end = pos 

3088 

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

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

3091 

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

3093 

3094 

3095def _quote(s: str): 

3096 s = s.translate(_UNPRINTABLE_TRANS) 

3097 

3098 if not s: 

3099 return "''" 

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

3101 return s 

3102 else: 

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

3104 

3105 

3106class _HelpFormatter: 

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

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

3109 self.parser = parser 

3110 self.all = all 

3111 

3112 def add_command( 

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

3114 ): 

3115 self._add_usage(prog, cmd, inherited) 

3116 if cmd.desc: 

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

3118 self._add_options(cmd) 

3119 self._add_subcommands(cmd) 

3120 self._add_flags(cmd, inherited) 

3121 if cmd.epilog: 

3122 self.nodes.append(_SetIndentation()) 

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

3124 

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

3126 return self.format(ctx) 

3127 

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

3129 res = _ColorizedString() 

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

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

3132 ) 

3133 sep = False 

3134 for line in lines: 

3135 if sep: 

3136 res.append_str("\n") 

3137 res.append_colorized_str(line) 

3138 sep = True 

3139 return res 

3140 

3141 def _add_usage( 

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

3143 ): 

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

3145 

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

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

3148 for opt in cmd.options: 

3149 if opt.flags is not yuio.POSITIONAL: 

3150 continue 

3151 if opt.help is yuio.DISABLED: 

3152 continue 

3153 group = opt.help_group or ARGS_GROUP 

3154 if group.help is yuio.DISABLED: 

3155 continue 

3156 if group not in groups: 

3157 groups[group] = [] 

3158 groups[group].append(opt) 

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

3160 assert group.help is not yuio.DISABLED 

3161 self.nodes.append( 

3162 yuio.doc.Heading( 

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

3164 ) 

3165 ) 

3166 if group.help: 

3167 self.nodes.append( 

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

3169 ) 

3170 arg_group = _HelpArgGroup(items=[]) 

3171 for opt in options: 

3172 assert opt.help is not yuio.DISABLED 

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

3174 self.nodes.append(arg_group) 

3175 

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

3177 subcommands: dict[Command[Namespace], list[str]] = {} 

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

3179 if subcommand.help is yuio.DISABLED: 

3180 continue 

3181 if subcommand not in subcommands: 

3182 subcommands[subcommand] = [name] 

3183 else: 

3184 subcommands[subcommand].append(name) 

3185 if not subcommands: 

3186 return 

3187 group = SUBCOMMANDS_GROUP 

3188 self.nodes.append( 

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

3190 ) 

3191 if group.help: 

3192 self.nodes.append( 

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

3194 ) 

3195 arg_group = _HelpArgGroup(items=[]) 

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

3197 assert subcommand.help is not yuio.DISABLED 

3198 arg_group.items.append(_HelpSubCommand(names, subcommand.help)) 

3199 self.nodes.append(arg_group) 

3200 

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

3202 groups: dict[ 

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

3204 ] = {} 

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

3206 if not opt.flags: 

3207 continue 

3208 if opt.help is yuio.DISABLED: 

3209 continue 

3210 group = opt.help_group or OPTS_GROUP 

3211 if group.help is yuio.DISABLED: 

3212 continue 

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

3214 if group not in groups: 

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

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

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

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

3219 required, optional, n_inherited = groups[group] 

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

3221 else: 

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

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

3224 assert group.help is not yuio.DISABLED 

3225 

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

3227 continue 

3228 

3229 self.nodes.append( 

3230 yuio.doc.Heading( 

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

3232 ) 

3233 ) 

3234 

3235 if group.collapse and not self.all: 

3236 all_flags: set[str] = set() 

3237 for opt in required or optional: 

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

3239 if len(all_flags) == 1: 

3240 prefix = all_flags.pop() 

3241 else: 

3242 prefix = _commonprefix(all_flags) 

3243 if not prefix: 

3244 prefix = "--*" 

3245 elif prefix.endswith("-"): 

3246 prefix += "*" 

3247 else: 

3248 prefix += "-*" 

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

3250 self.nodes.append( 

3251 _CollapsedOpt( 

3252 flags=[prefix], 

3253 items=[help], 

3254 ) 

3255 ) 

3256 continue 

3257 

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

3259 self.nodes.append( 

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

3261 ) 

3262 arg_group = _HelpArgGroup(items=[]) 

3263 for opt in required: 

3264 assert opt.help is not yuio.DISABLED 

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

3266 for opt in optional: 

3267 assert opt.help is not yuio.DISABLED 

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

3269 if n_inherited > 0: 

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

3271 self.nodes.append(arg_group) 

3272 

3273 

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

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

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

3277 

3278 res = _ColorizedString() 

3279 is_punctuation = False 

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

3281 if is_punctuation: 

3282 res.append_color(punct_color) 

3283 else: 

3284 res.append_color(metavar_color) 

3285 res.append_str(part) 

3286 is_punctuation = not is_punctuation 

3287 

3288 return res 

3289 

3290 

3291_ARGS_COLUMN_WIDTH = 26 

3292_ARGS_COLUMN_WIDTH_NARROW = 8 

3293 

3294 

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

3296 def __init__( 

3297 self, 

3298 parser: yuio.doc.DocParser, 

3299 ctx: yuio.string.ReprContext, 

3300 /, 

3301 *, 

3302 all: bool = False, 

3303 ): 

3304 self.parser = parser 

3305 self.all = all 

3306 

3307 self._heading_indent = contextlib.ExitStack() 

3308 self._args_column_width = ( 

3309 _ARGS_COLUMN_WIDTH if ctx.width >= 50 else _ARGS_COLUMN_WIDTH_NARROW 

3310 ) 

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

3312 

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

3314 

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

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

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

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

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

3320 

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

3322 if not self._allow_headings: 

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

3324 self._format_Text(node) 

3325 return 

3326 

3327 if node.level == 1: 

3328 self._heading_indent.close() 

3329 

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

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

3332 node.items.append(":") 

3333 

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

3335 with ( 

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

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

3338 ): 

3339 self._format_Text(node) 

3340 

3341 if node.level == 1: 

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

3343 elif self._separate_paragraphs: 

3344 self._line(self._indent) 

3345 

3346 self._is_first_line = True 

3347 

3348 def _format_SetIndentation(self, node: _SetIndentation): 

3349 self._heading_indent.close() 

3350 self._is_first_line = True 

3351 if node.indent: 

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

3353 

3354 def _format_Usage(self, node: _Usage): 

3355 if node.prefix: 

3356 prefix = _ColorizedString( 

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

3358 node.prefix, 

3359 self.base_color, 

3360 " ", 

3361 ) 

3362 else: 

3363 prefix = _ColorizedString() 

3364 

3365 usage = _ColorizedString() 

3366 if node.cmd.usage: 

3367 sh_usage_highlighter, sh_usage_syntax_name = yuio.hl.get_highlighter( 

3368 "sh-usage" 

3369 ) 

3370 

3371 usage = sh_usage_highlighter.highlight( 

3372 node.cmd.usage.rstrip(), 

3373 theme=self.ctx.theme, 

3374 syntax=sh_usage_syntax_name, 

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

3376 else: 

3377 usage = self._build_usage(node) 

3378 

3379 with self._with_indent(None, prefix): 

3380 self._line( 

3381 usage.indent( 

3382 indent=self._indent, 

3383 continuation_indent=self._continuation_indent, 

3384 ) 

3385 ) 

3386 

3387 def _build_usage(self, node: _Usage): 

3388 flags_and_groups: list[ 

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

3390 ] = [] 

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

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

3393 has_grouped_flags = False 

3394 

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

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

3397 if is_inherited and ( 

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

3399 ): 

3400 continue 

3401 if opt.help is yuio.DISABLED: 

3402 continue 

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

3404 continue 

3405 if opt.flags is yuio.POSITIONAL: 

3406 positionals.append(opt) 

3407 elif opt.usage is yuio.COLLAPSE: 

3408 has_grouped_flags = True 

3409 elif not opt.usage: 

3410 pass 

3411 elif opt.mutex_group: 

3412 if opt.mutex_group not in groups: 

3413 group_items = [] 

3414 groups[opt.mutex_group] = group_items 

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

3416 else: 

3417 group_items = groups[opt.mutex_group] 

3418 group_items.append(opt) 

3419 else: 

3420 flags_and_groups.append(opt) 

3421 

3422 res = _ColorizedString() 

3423 res.append_color(self.prog_color) 

3424 res.append_str(node.prog) 

3425 

3426 if has_grouped_flags: 

3427 res.append_color(self.base_color) 

3428 res.append_str(" ") 

3429 res.append_color(self.flag_color) 

3430 res.append_str("<options>") 

3431 

3432 res.append_color(self.base_color) 

3433 

3434 in_opt_short_group = False 

3435 for flag_or_group in flags_and_groups: 

3436 match flag_or_group: 

3437 case (group, flags): 

3438 res.append_color(self.base_color) 

3439 res.append_str(" ") 

3440 res.append_color(self.punct_color) 

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

3442 sep = False 

3443 for flag in flags: 

3444 if sep: 

3445 res.append_str("|") 

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

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

3448 sep = True 

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

3450 case flag: 

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

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

3453 can_group = False 

3454 

3455 if can_group: 

3456 if not in_opt_short_group: 

3457 res.append_color(self.base_color) 

3458 res.append_str(" ") 

3459 res.append_color(self.punct_color) 

3460 res.append_str("[") 

3461 res.append_color(self.flag_color) 

3462 res.append_str("-") 

3463 in_opt_short_group = True 

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

3465 res.append_str(letter) 

3466 continue 

3467 

3468 if in_opt_short_group: 

3469 res.append_color(self.punct_color) 

3470 res.append_str("]") 

3471 in_opt_short_group = False 

3472 

3473 res.append_color(self.base_color) 

3474 res.append_str(" ") 

3475 

3476 if not flag.required: 

3477 res.append_color(self.punct_color) 

3478 res.append_str("[") 

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

3480 if not flag.required: 

3481 res.append_color(self.punct_color) 

3482 res.append_str("]") 

3483 

3484 if in_opt_short_group: 

3485 res.append_color(self.punct_color) 

3486 res.append_str("]") 

3487 in_opt_short_group = False 

3488 

3489 for positional in positionals: 

3490 res.append_color(self.base_color) 

3491 res.append_str(" ") 

3492 res.append_colorized_str( 

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

3494 ) 

3495 

3496 if node.cmd.subcommands: 

3497 res.append_str(" ") 

3498 if not node.cmd.subcommand_required: 

3499 res.append_color(self.punct_color) 

3500 res.append_str("[") 

3501 res.append_colorized_str( 

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

3503 self.base_color 

3504 ) 

3505 ) 

3506 res.append_color(self.base_color) 

3507 res.append_str(" ") 

3508 res.append_color(self.metavar_color) 

3509 res.append_str("...") 

3510 if not node.cmd.subcommand_required: 

3511 res.append_color(self.punct_color) 

3512 res.append_str("]") 

3513 

3514 return res 

3515 

3516 def _format_HelpOpt(self, node: _HelpOpt): 

3517 lead = _ColorizedString() 

3518 if node.arg.primary_short_flag: 

3519 lead.append_color(self.flag_color) 

3520 lead.append_str(node.arg.primary_short_flag) 

3521 sep = True 

3522 else: 

3523 lead.append_color(self.base_color) 

3524 lead.append_str(" ") 

3525 sep = False 

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

3527 if sep: 

3528 lead.append_color(self.punct_color) 

3529 lead.append_str(", ") 

3530 lead.append_color(self.flag_color) 

3531 lead.append_str(flag) 

3532 sep = True 

3533 

3534 lead.append_colorized_str( 

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

3536 ) 

3537 

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

3539 

3540 if help is None: 

3541 self._line(self._indent + lead) 

3542 return 

3543 

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

3545 self._line(self._indent + lead) 

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

3547 else: 

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

3549 

3550 with indent_ctx: 

3551 self._format(help) 

3552 

3553 def _format_HelpArg(self, node: _HelpArg): 

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

3555 self.base_color 

3556 ) 

3557 

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

3559 

3560 if help is None: 

3561 self._line(self._indent + lead) 

3562 return 

3563 

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

3565 self._line(self._indent + lead) 

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

3567 else: 

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

3569 

3570 with indent_ctx: 

3571 self._format(help) 

3572 

3573 def _format_HelpSubCommand(self, node: _HelpSubCommand): 

3574 lead = _ColorizedString() 

3575 sep = False 

3576 for name in node.names: 

3577 if sep: 

3578 lead.append_color(self.punct_color) 

3579 lead.append_str(", ") 

3580 lead.append_color(self.flag_color) 

3581 lead.append_str(name) 

3582 sep = True 

3583 

3584 help = node.help 

3585 

3586 if not help: 

3587 self._line(self._indent + lead) 

3588 return 

3589 

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

3591 self._line(self._indent + lead) 

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

3593 else: 

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

3595 

3596 with indent_ctx: 

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

3598 

3599 def _format_CollapsedOpt(self, node: _CollapsedOpt): 

3600 if not node.flags: 

3601 self._format_Container(node) 

3602 return 

3603 

3604 lead = _ColorizedString() 

3605 sep = False 

3606 for flag in node.flags: 

3607 if sep: 

3608 lead.append_color(self.punct_color) 

3609 lead.append_str(", ") 

3610 lead.append_color(self.flag_color) 

3611 lead.append_str(flag) 

3612 sep = True 

3613 

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

3615 self._line(self._indent + lead) 

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

3617 else: 

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

3619 

3620 with indent_ctx: 

3621 self._separate_paragraphs = False 

3622 self._allow_headings = False 

3623 self._format_Container(node) 

3624 self._separate_paragraphs = True 

3625 self._allow_headings = True 

3626 

3627 def _format_InheritedOpts(self, node: _InheritedOpts): 

3628 raw = _ColorizedString() 

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

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

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

3632 self._line(raw) 

3633 

3634 def _format_HelpArgGroup(self, node: _HelpArgGroup): 

3635 self._separate_paragraphs = False 

3636 self._allow_headings = False 

3637 self._format_Container(node) 

3638 self._separate_paragraphs = True 

3639 self._allow_headings = True 

3640 

3641 def _make_lead_padding(self, lead: _ColorizedString): 

3642 color = self.base_color 

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

3644 

3645 

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

3647class _SetIndentation(yuio.doc.AstBase): 

3648 indent: str = "" 

3649 

3650 

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

3652class _Usage(yuio.doc.AstBase): 

3653 prog: str 

3654 cmd: Command[Namespace] 

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

3656 prefix: str = "Usage:" 

3657 

3658 

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

3660class _HelpOpt(yuio.doc.AstBase): 

3661 arg: Option[_t.Any] 

3662 

3663 

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

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

3666 flags: list[str] 

3667 

3668 

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

3670class _HelpArg(yuio.doc.AstBase): 

3671 arg: Option[_t.Any] 

3672 

3673 

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

3675class _InheritedOpts(yuio.doc.AstBase): 

3676 n_inherited: int 

3677 

3678 

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

3680class _HelpSubCommand(yuio.doc.AstBase): 

3681 names: list[str] 

3682 help: str | None 

3683 

3684 

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

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

3687 pass 

3688 

3689 

3690class _ShortUsageFormatter: 

3691 def __init__( 

3692 self, 

3693 parser: yuio.doc.DocParser, 

3694 subcommands: list[str] | None, 

3695 option: Option[_t.Any], 

3696 ): 

3697 self.parser = parser 

3698 self.subcommands = subcommands 

3699 self.option = option 

3700 

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

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

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

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

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

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

3707 

3708 res = _ColorizedString() 

3709 res.append_color(heading_color) 

3710 res.append_str("Help: ") 

3711 

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

3713 sep = False 

3714 if self.option.primary_short_flag: 

3715 res.append_color(flag_color) 

3716 res.append_str(self.option.primary_short_flag) 

3717 sep = True 

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

3719 if sep: 

3720 res.append_color(punct_color) 

3721 res.append_str(", ") 

3722 res.append_color(flag_color) 

3723 res.append_str(flag) 

3724 sep = True 

3725 

3726 res.append_colorized_str( 

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

3728 ) 

3729 

3730 res.append_color(heading_color) 

3731 res.append_str("\n") 

3732 res.append_color(note_color) 

3733 

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

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

3736 formatter = _CliFormatter(self.parser, ctx) 

3737 sep = False 

3738 for line in formatter.format( 

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

3740 ): 

3741 if sep: 

3742 res.append_str("\n") 

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

3744 sep = True 

3745 

3746 return res 

3747 

3748 

3749def _parse_option_help( 

3750 option: Option[_t.Any], 

3751 parser: yuio.doc.DocParser, 

3752 ctx: yuio.string.ReprContext, 

3753 /, 

3754 *, 

3755 all: bool = False, 

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

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

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

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

3760 

3761 return help if help.items else None