Coverage for yuio / cli.py: 76%

1388 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:41 +0000

1# Yuio project, MIT license. 

2# 

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

4# 

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

6# just keep this copyright line please :3 

7 

8""" 

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:`~ValueOption.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 string 

191import sys 

192import warnings 

193from dataclasses import dataclass 

194 

195import yuio 

196import yuio.color 

197import yuio.complete 

198import yuio.md 

199import yuio.parse 

200import yuio.string 

201import yuio.util 

202from yuio.string import ColorizedString as _ColorizedString 

203from yuio.util import _UNPRINTABLE_TRANS 

204 

205from typing import TYPE_CHECKING 

206 

207if TYPE_CHECKING: 

208 import typing_extensions as _t 

209else: 

210 from yuio import _typing as _t 

211 

212if TYPE_CHECKING: 

213 import yuio.app 

214 import yuio.config 

215 import yuio.dbg 

216 

217__all__ = [ 

218 "ARGS_GROUP", 

219 "MISC_GROUP", 

220 "OPTS_GROUP", 

221 "SUBCOMMANDS_GROUP", 

222 "Argument", 

223 "ArgumentError", 

224 "BoolOption", 

225 "BugReportOption", 

226 "CliParser", 

227 "CliWarning", 

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 [ 

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

350 self.metavar, 

351 ] 

352 ) 

353 

354 

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

356class Flag: 

357 value: str 

358 """ 

359 Name of the flag. 

360 

361 """ 

362 

363 index: int 

364 """ 

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

366 

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

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

369 

370 """ 

371 

372 def __str__(self) -> str: 

373 return self.value 

374 

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

376 return _ColorizedString( 

377 [ 

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

379 self.value, 

380 ] 

381 ) 

382 

383 

384class ArgumentError(yuio.PrettyException, ValueError): 

385 """ 

386 Error that happened during argument parsing. 

387 

388 """ 

389 

390 @_t.overload 

391 def __init__( 

392 self, 

393 msg: _t.LiteralString, 

394 /, 

395 *args, 

396 flag: Flag | None = None, 

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

398 n_arg: int | None = None, 

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

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

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

402 ): ... 

403 @_t.overload 

404 def __init__( 

405 self, 

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

407 /, 

408 *, 

409 flag: Flag | None = None, 

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

411 n_arg: int | None = None, 

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

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

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

415 ): ... 

416 def __init__( 

417 self, 

418 *args, 

419 flag: Flag | None = None, 

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

421 n_arg: int | None = None, 

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

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

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

425 ): 

426 super().__init__(*args) 

427 

428 self.flag: Flag | None = flag 

429 """ 

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

431 by a positional argument. 

432 

433 """ 

434 

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

436 """ 

437 Arguments that caused this error. 

438 

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

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

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

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

443 

444 .. note:: 

445 

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

447 the latter contains formatting arguments and is defined 

448 in the :class:`BaseException` class. 

449 

450 """ 

451 

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

453 """ 

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

455 and end indices). 

456 

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

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

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

460 

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

462 then this position is relative to that argument. 

463 

464 Otherwise, position is ignored. 

465 

466 """ 

467 

468 self.n_arg: int | None = n_arg 

469 """ 

470 Index of the argument that caused the error. 

471 

472 """ 

473 

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

475 """ 

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

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

478 for validation. 

479 

480 """ 

481 

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

483 """ 

484 Option which caused failure. 

485 

486 """ 

487 

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

489 self.prog: str | None = None 

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

491 

492 @classmethod 

493 def from_parsing_error( 

494 cls, 

495 e: yuio.parse.ParsingError, 

496 /, 

497 *, 

498 flag: Flag | None = None, 

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

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

501 ): 

502 """ 

503 Convert parsing error to argument error. 

504 

505 """ 

506 

507 return cls( 

508 *e.args, 

509 flag=flag, 

510 arguments=arguments, 

511 n_arg=e.n_arg, 

512 pos=e.pos, 

513 path=e.path, 

514 option=option, 

515 ) 

516 

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

518 colorable = yuio.string.WithBaseColor( 

519 super().to_colorable(), 

520 base_color="msg/text:error", 

521 ) 

522 

523 msg = [] 

524 args = [] 

525 sep = False 

526 

527 if self.flag and self.flag.value: 

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

529 args.append(self.flag.value) 

530 sep = True 

531 

532 argument = None 

533 if isinstance(self.arguments, list): 

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

535 argument = self.arguments[self.n_arg] 

536 else: 

537 argument = self.arguments 

538 

539 if argument and argument.metavar: 

540 if sep: 

541 msg.append(", ") 

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

543 args.append(argument.metavar) 

544 

545 if self.path: 

546 if sep: 

547 msg.append(", ") 

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

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

550 

551 if sep: 

552 msg.insert(0, "Error ") 

553 msg.append(":") 

554 

555 colorable = yuio.string.Stack( 

556 yuio.string.WithBaseColor( 

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

558 base_color="msg/text:failure", 

559 ), 

560 yuio.string.Indent(colorable), 

561 ) 

562 else: 

563 colorable = yuio.string.WithBaseColor( 

564 colorable, 

565 base_color="msg/text:failure", 

566 ) 

567 

568 if commandline := self._make_commandline(): 

569 colorable = yuio.string.Stack( 

570 commandline, 

571 colorable, 

572 ) 

573 

574 if usage := self._make_usage(): 

575 colorable = yuio.string.Stack( 

576 colorable, 

577 usage, 

578 ) 

579 

580 return colorable 

581 

582 def _make_commandline(self): 

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

584 return None 

585 

586 argument = None 

587 if isinstance(self.arguments, list): 

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

589 argument = self.arguments[self.n_arg] 

590 else: 

591 argument = self.arguments 

592 

593 if argument: 

594 arg_index = argument.index 

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

596 if self.pos: 

597 arg_pos = ( 

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

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

600 ) 

601 elif self.flag: 

602 arg_index = self.flag.index 

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

604 else: 

605 return None 

606 

607 text = self.prog 

608 text += " " 

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

610 if arg_index > 0: 

611 text += " " 

612 

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

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

615 

616 text += center 

617 text += " " 

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

619 

620 if text: 

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

622 else: 

623 return None 

624 

625 def _make_usage(self): 

626 if not self.option or not self.option.help: 

627 return None 

628 else: 

629 return _ShortUsageFormatter(self.subcommands, self.option) 

630 

631 

632class Namespace(_t.Protocol): 

633 """ 

634 Protocol for namespace implementations. 

635 

636 """ 

637 

638 @abc.abstractmethod 

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

640 

641 @abc.abstractmethod 

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

643 

644 @abc.abstractmethod 

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

646 

647 

648@yuio.string.repr_from_rich 

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

650 """ 

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

652 

653 """ 

654 

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

656 self.__config = config 

657 

658 @property 

659 def config(self) -> ConfigT: 

660 """ 

661 Wrapped config instance. 

662 

663 """ 

664 

665 return self.__config 

666 

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

668 root, key = self.__split_key(key) 

669 try: 

670 return getattr(root, key) 

671 except AttributeError as e: 

672 raise KeyError(str(e)) from None 

673 

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

675 root, key = self.__split_key(key) 

676 try: 

677 return setattr(root, key, value) 

678 except AttributeError as e: 

679 raise KeyError(str(e)) from None 

680 

681 def __contains__(self, key: str): 

682 root, key = self.__split_key(key) 

683 return key in root.__dict__ 

684 

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

686 root = self.__config 

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

688 for parent in parents: 

689 root = getattr(root, parent) 

690 return root, key 

691 

692 def __rich_repr__(self): 

693 yield None, self.__config 

694 

695 

696@dataclass(eq=False) 

697class HelpGroup: 

698 """ 

699 Group of flags in CLI help. 

700 

701 """ 

702 

703 title: str 

704 """ 

705 Title for this group. 

706 

707 """ 

708 

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

710 """ 

711 Help message for an option. 

712 

713 """ 

714 

715 @property 

716 def title_lines(self) -> list[str]: 

717 title = self.title 

718 if title and "\n" not in title and title[-1] not in string.punctuation: 

719 title += ":" 

720 return title.splitlines() 

721 

722 

723ARGS_GROUP = HelpGroup("Arguments:") 

724""" 

725Help group for positional arguments. 

726 

727""" 

728 

729SUBCOMMANDS_GROUP = HelpGroup("Subcommands:") 

730""" 

731Help group for subcommands. 

732 

733""" 

734 

735OPTS_GROUP = HelpGroup("Options:") 

736""" 

737Help group for flags. 

738 

739""" 

740 

741MISC_GROUP = HelpGroup("Misc options:") 

742""" 

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

744 

745""" 

746 

747 

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

749class MutuallyExclusiveGroup: 

750 """ 

751 A sentinel for creating mutually exclusive groups. 

752 

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

754 be mutually exclusive. 

755 

756 """ 

757 

758 required: bool = False 

759 """ 

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

761 

762 """ 

763 

764 

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

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

767 """ 

768 Base class for a CLI option. 

769 

770 """ 

771 

772 flags: list[str] | yuio.Positional 

773 """ 

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

775 :data:`yuio.POSITIONAL`. 

776 

777 """ 

778 

779 allow_inline_arg: bool 

780 """ 

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

782 

783 Inline arguments are handled separately from normal arguments, 

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

785 

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

787 no effect on them. 

788 

789 """ 

790 

791 allow_implicit_inline_arg: bool 

792 """ 

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

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

795 

796 Inline arguments are handled separately from normal arguments, 

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

798 

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

800 no effect on them. 

801 

802 """ 

803 

804 nargs: NArgs 

805 """ 

806 How many arguments this option takes. 

807 

808 """ 

809 

810 allow_no_args: bool 

811 """ 

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

813 

814 """ 

815 

816 required: bool 

817 """ 

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

819 encountered among CLI arguments. 

820 

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

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

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

824 on positionals. 

825 

826 """ 

827 

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

829 """ 

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

831 

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

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

834 tuple. 

835 

836 """ 

837 

838 mutex_group: None | MutuallyExclusiveGroup 

839 """ 

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

841 mutex groups. 

842 

843 """ 

844 

845 usage: yuio.Group | bool 

846 """ 

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

848 are always displayed, regardless of this setting. 

849 

850 """ 

851 

852 help: str | yuio.Disabled 

853 """ 

854 Help message for an option. 

855 

856 """ 

857 

858 help_group: HelpGroup | None 

859 """ 

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

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

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

863 

864 """ 

865 

866 show_if_inherited: bool 

867 """ 

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

869 inherited because subcommand argument always goes last. 

870 

871 """ 

872 

873 allow_abbrev: bool 

874 """ 

875 Allow abbreviation for this option. 

876 

877 """ 

878 

879 @abc.abstractmethod 

880 def process( 

881 self, 

882 cli_parser: CliParser[Namespace], 

883 flag: Flag | None, 

884 arguments: Argument | list[Argument], 

885 ns: Namespace, 

886 ): 

887 """ 

888 Process this argument. 

889 

890 This method is called every time an option is encountered 

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

892 with previous values, if there are any. 

893 

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

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

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

897 

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

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

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

901 however you like. 

902 

903 :param cli_parser: 

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

905 :class:`yuio.parse.Parser`. 

906 :param flag: 

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

908 for positional arguments. 

909 :param args: 

910 option arguments, see above. 

911 :param ns: 

912 namespace where parsed arguments should be stored. 

913 :raises: 

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

915 

916 """ 

917 

918 @functools.cached_property 

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

920 if self.flags is yuio.POSITIONAL: 

921 return None 

922 else: 

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

924 

925 @functools.cached_property 

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

927 if self.flags is yuio.POSITIONAL: 

928 return None 

929 else: 

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

931 

932 @functools.cached_property 

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

934 """ 

935 Short flag that will be displayed in CLI help. 

936 

937 """ 

938 

939 if short_flags := self.short_flags: 

940 return short_flags[0] 

941 else: 

942 return None 

943 

944 @functools.cached_property 

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

946 """ 

947 Long flags that will be displayed in CLI help. 

948 

949 """ 

950 

951 if long_flags := self.long_flags: 

952 return [long_flags[0]] 

953 else: 

954 return None 

955 

956 def parse_help( 

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

958 ) -> yuio.md.AstBase | None: 

959 """ 

960 Parse help for displaying it in LI help. 

961 

962 :param ctx: 

963 repr context for formatting help. 

964 :param all: 

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

966 :returns: 

967 markdown AST. 

968 

969 """ 

970 

971 help = yuio.md.parse(self.help or "", allow_headings=False) 

972 if help_tail := self.format_help_tail(ctx, all=all): 

973 help.items.append(yuio.md.Raw(raw=help_tail)) 

974 

975 return help if help.items else None 

976 

977 def format_usage( 

978 self, 

979 ctx: yuio.string.ReprContext, 

980 /, 

981 ) -> tuple[_ColorizedString, bool]: 

982 """ 

983 Allows customizing how this option looks in CLI usage. 

984 

985 :param ctx: 

986 repr context for formatting help. 

987 :returns: 

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

989 usage section. 

990 

991 """ 

992 

993 can_group = False 

994 res = _ColorizedString() 

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

996 flag = self.primary_short_flag 

997 if flag: 

998 can_group = True 

999 elif self.primary_long_flags: 

1000 flag = self.primary_long_flags[0] 

1001 else: 

1002 flag = self.flags[0] 

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

1004 res.append_str(flag) 

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

1006 res.append_colorized_str(metavar) 

1007 can_group = False 

1008 return res, can_group 

1009 

1010 def format_metavar( 

1011 self, 

1012 ctx: yuio.string.ReprContext, 

1013 /, 

1014 ) -> _ColorizedString: 

1015 """ 

1016 Allows customizing how this option looks in CLI help. 

1017 

1018 :param ctx: 

1019 repr context for formatting help. 

1020 :returns: 

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

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

1023 

1024 """ 

1025 

1026 res = _ColorizedString() 

1027 

1028 if not self.nargs: 

1029 return res 

1030 

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

1032 if self.flags: 

1033 res.append_str(" ") 

1034 

1035 if self.nargs == "+": 

1036 if self.allow_no_args: 

1037 res.append_str("[") 

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

1039 if self.allow_no_args: 

1040 res.append_str(" ...]") 

1041 else: 

1042 res.append_str(" [") 

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

1044 res.append_str(" ...]") 

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

1046 if self.allow_no_args: 

1047 res.append_str("[") 

1048 sep = False 

1049 for i in range(self.nargs): 

1050 if sep: 

1051 res.append_str(" ") 

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

1053 sep = True 

1054 if self.allow_no_args: 

1055 res.append_str("]") 

1056 

1057 return res 

1058 

1059 def format_help_tail( 

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

1061 ) -> _ColorizedString: 

1062 """ 

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

1064 such as aliases, default value, etc. 

1065 

1066 :param ctx: 

1067 repr context for formatting help. 

1068 :param all: 

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

1070 :returns: 

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

1072 

1073 """ 

1074 

1075 res = _ColorizedString() 

1076 

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

1078 

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

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

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

1082 sep = False 

1083 for alias_flag in alias_flags: 

1084 if sep: 

1085 res.append_str(", ") 

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

1087 sep = True 

1088 

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

1090 if res: 

1091 res.append_str("; ") 

1092 res.append_str("Default: ") 

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

1094 

1095 if res: 

1096 res.append_str(".") 

1097 

1098 return res 

1099 

1100 def format_alias_flags( 

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

1102 ) -> list[_ColorizedString] | None: 

1103 """ 

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

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

1106 

1107 :param ctx: 

1108 repr context for formatting help. 

1109 :param all: 

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

1111 :returns: 

1112 a list of strings, one per each alias. 

1113 

1114 """ 

1115 

1116 if self.flags is yuio.POSITIONAL: 

1117 return None 

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

1119 if self.primary_short_flag: 

1120 primary_flags.add(self.primary_short_flag) 

1121 aliases: list[_ColorizedString] = [] 

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

1123 for flag in self.flags: 

1124 if flag not in primary_flags: 

1125 res = _ColorizedString() 

1126 res.start_no_wrap() 

1127 res.append_color(flag_color) 

1128 res.append_str(flag) 

1129 res.end_no_wrap() 

1130 aliases.append(res) 

1131 return aliases 

1132 

1133 def format_default( 

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

1135 ) -> _ColorizedString | None: 

1136 """ 

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

1138 

1139 :param ctx: 

1140 repr context for formatting help. 

1141 :param all: 

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

1143 :returns: 

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

1145 

1146 """ 

1147 

1148 return None 

1149 

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

1151 return None, False 

1152 

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

1154 """ 

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

1156 

1157 """ 

1158 

1159 if not self.metavar: 

1160 return "<argument>" 

1161 if isinstance(self.metavar, tuple): 

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

1163 return self.metavar[-1] 

1164 else: 

1165 return self.metavar[n] 

1166 else: 

1167 return self.metavar 

1168 

1169 

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

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

1172 """ 

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

1174 

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

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

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

1178 

1179 """ 

1180 

1181 dest: str 

1182 """ 

1183 Key where to store parsed argument. 

1184 

1185 """ 

1186 

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

1188 """ 

1189 Function to merge previous and new value. 

1190 

1191 """ 

1192 

1193 default: object 

1194 """ 

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

1196 

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

1198 

1199 """ 

1200 

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

1202 """ 

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

1204 merge old and new value. 

1205 

1206 """ 

1207 

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

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

1210 else: 

1211 ns[self.dest] = value 

1212 

1213 

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

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

1216 """ 

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

1218 

1219 """ 

1220 

1221 parser: yuio.parse.Parser[T] 

1222 """ 

1223 A parser used to parse option's arguments. 

1224 

1225 """ 

1226 

1227 

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

1229class BoolOption(ParserOption[bool]): 

1230 """ 

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

1232 and :class:`ParseOneOption`. 

1233 

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

1235 :class:`StoreTrueOption`. 

1236 

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

1238 :class:`StoreFalseOption`. 

1239 

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

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

1242 

1243 .. note:: 

1244 

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

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

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

1248 ambiguity in cases like the following: 

1249 

1250 .. code-block:: console 

1251 

1252 $ prog --json subcommand # Ok 

1253 $ prog --json=true subcommand # Ok 

1254 $ prog --json true subcommand # Not allowed 

1255 

1256 :example: 

1257 .. code-block:: python 

1258 

1259 option = yuio.cli.BoolOption( 

1260 pos_flags=["--json"], 

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

1262 dest=..., 

1263 ) 

1264 

1265 .. code-block:: console 

1266 

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

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

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

1270 

1271 """ 

1272 

1273 pos_flags: list[str] 

1274 """ 

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

1276 

1277 """ 

1278 

1279 neg_flags: list[str] 

1280 """ 

1281 List of flag names that disable this boolean option. 

1282 

1283 """ 

1284 

1285 def __init__( 

1286 self, 

1287 *, 

1288 pos_flags: list[str], 

1289 neg_flags: list[str], 

1290 required: bool = False, 

1291 mutex_group: None | MutuallyExclusiveGroup = None, 

1292 usage: yuio.Group | bool = True, 

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

1294 help_group: HelpGroup | None = None, 

1295 show_if_inherited: bool = False, 

1296 dest: str, 

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

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

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

1300 allow_abbrev: bool = True, 

1301 ): 

1302 self.pos_flags = pos_flags 

1303 self.neg_flags = neg_flags 

1304 

1305 super().__init__( 

1306 flags=pos_flags + neg_flags, 

1307 allow_inline_arg=True, 

1308 allow_implicit_inline_arg=False, 

1309 nargs=0, 

1310 allow_no_args=True, 

1311 required=required, 

1312 metavar=(), 

1313 mutex_group=mutex_group, 

1314 usage=usage, 

1315 help=help, 

1316 help_group=help_group, 

1317 show_if_inherited=show_if_inherited, 

1318 dest=dest, 

1319 merge=merge, 

1320 default=default, 

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

1322 allow_abbrev=allow_abbrev, 

1323 ) 

1324 

1325 def process( 

1326 self, 

1327 cli_parser: CliParser[Namespace], 

1328 flag: Flag | None, 

1329 arguments: Argument | list[Argument], 

1330 ns: Namespace, 

1331 ): 

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

1333 if arguments: 

1334 raise ArgumentError( 

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

1336 ) 

1337 value = False 

1338 elif isinstance(arguments, Argument): 

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

1340 else: 

1341 value = True 

1342 self.set(ns, value) 

1343 

1344 @functools.cached_property 

1345 def primary_short_flag(self): 

1346 if self.flags is yuio.POSITIONAL: 

1347 return None 

1348 if self.default is True: 

1349 flags = self.neg_flags 

1350 else: 

1351 flags = self.pos_flags 

1352 for flag in flags: 

1353 if _is_short(flag): 

1354 return flag 

1355 return None 

1356 

1357 @functools.cached_property 

1358 def primary_long_flags(self): 

1359 flags = [] 

1360 if self.default is not True: 

1361 for flag in self.pos_flags: 

1362 if not _is_short(flag): 

1363 flags.append(flag) 

1364 break 

1365 if self.default is not False: 

1366 for flag in self.neg_flags: 

1367 if not _is_short(flag): 

1368 flags.append(flag) 

1369 break 

1370 return flags 

1371 

1372 def format_alias_flags( 

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

1374 ) -> list[_ColorizedString] | None: 

1375 if self.flags is yuio.POSITIONAL: 

1376 return None 

1377 

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

1379 if self.primary_short_flag: 

1380 primary_flags.add(self.primary_short_flag) 

1381 

1382 aliases: list[_ColorizedString] = [] 

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

1384 for flag in self.pos_flags + self.neg_flags: 

1385 if flag not in primary_flags: 

1386 res = _ColorizedString() 

1387 res.start_no_wrap() 

1388 res.append_color(flag_color) 

1389 res.append_str(flag) 

1390 res.end_no_wrap() 

1391 aliases.append(res) 

1392 if self.pos_flags: 

1393 primary_pos_flag = self.pos_flags[0] 

1394 for pos_flag in self.pos_flags: 

1395 if not _is_short(pos_flag): 

1396 primary_pos_flag = pos_flag 

1397 break 

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

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

1400 res = _ColorizedString() 

1401 res.start_no_wrap() 

1402 res.append_color(flag_color) 

1403 res.append_str(primary_pos_flag) 

1404 res.end_no_wrap() 

1405 res.append_color(punct_color) 

1406 res.append_str("={") 

1407 res.append_color(metavar_color) 

1408 res.append_str("true") 

1409 res.append_color(punct_color) 

1410 res.append_str("|") 

1411 res.append_color(metavar_color) 

1412 res.append_str("false") 

1413 res.append_color(punct_color) 

1414 res.append_str("}") 

1415 aliases.append(res) 

1416 return aliases 

1417 

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

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

1420 

1421 

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

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

1424 """ 

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

1426 

1427 """ 

1428 

1429 def __init__( 

1430 self, 

1431 *, 

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

1433 required: bool = False, 

1434 mutex_group: None | MutuallyExclusiveGroup = None, 

1435 usage: yuio.Group | bool = True, 

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

1437 help_group: HelpGroup | None = None, 

1438 show_if_inherited: bool = False, 

1439 dest: str, 

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

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

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

1443 allow_abbrev: bool = True, 

1444 ): 

1445 super().__init__( 

1446 flags=flags, 

1447 allow_inline_arg=True, 

1448 allow_implicit_inline_arg=True, 

1449 nargs=1, 

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

1451 required=required, 

1452 metavar=parser.describe_or_def(), 

1453 mutex_group=mutex_group, 

1454 usage=usage, 

1455 help=help, 

1456 help_group=help_group, 

1457 show_if_inherited=show_if_inherited, 

1458 dest=dest, 

1459 merge=merge, 

1460 default=default, 

1461 parser=parser, 

1462 allow_abbrev=allow_abbrev, 

1463 ) 

1464 

1465 def process( 

1466 self, 

1467 cli_parser: CliParser[Namespace], 

1468 flag: Flag | None, 

1469 arguments: Argument | list[Argument], 

1470 ns: Namespace, 

1471 ): 

1472 if isinstance(arguments, list): 

1473 if not arguments and self.allow_no_args: 

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

1475 arguments = arguments[0] 

1476 try: 

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

1478 except yuio.parse.ParsingError as e: 

1479 raise ArgumentError.from_parsing_error( 

1480 e, 

1481 flag=flag, 

1482 arguments=arguments, 

1483 option=self, 

1484 ) from None 

1485 self.set(ns, value) 

1486 

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

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

1489 

1490 

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

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

1493 """ 

1494 An option with multiple arguments that uses Yuio parser. 

1495 

1496 """ 

1497 

1498 def __init__( 

1499 self, 

1500 *, 

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

1502 required: bool = False, 

1503 mutex_group: None | MutuallyExclusiveGroup = None, 

1504 usage: yuio.Group | bool = True, 

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

1506 help_group: HelpGroup | None = None, 

1507 show_if_inherited: bool = False, 

1508 dest: str, 

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

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

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

1512 allow_abbrev: bool = True, 

1513 ): 

1514 assert parser.supports_parse_many() 

1515 

1516 nargs = parser.get_nargs() 

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

1518 if nargs == "*": 

1519 nargs = "+" 

1520 allow_no_args = True 

1521 

1522 super().__init__( 

1523 flags=flags, 

1524 allow_inline_arg=True, 

1525 allow_implicit_inline_arg=True, 

1526 nargs=nargs, 

1527 allow_no_args=allow_no_args, 

1528 required=required, 

1529 metavar=parser.describe_many(), 

1530 mutex_group=mutex_group, 

1531 usage=usage, 

1532 help=help, 

1533 help_group=help_group, 

1534 show_if_inherited=show_if_inherited, 

1535 dest=dest, 

1536 merge=merge, 

1537 default=default, 

1538 parser=parser, 

1539 allow_abbrev=allow_abbrev, 

1540 ) 

1541 

1542 def process( 

1543 self, 

1544 cli_parser: CliParser[Namespace], 

1545 flag: Flag | None, 

1546 arguments: Argument | list[Argument], 

1547 ns: Namespace, 

1548 ): 

1549 if ( 

1550 not arguments 

1551 and self.allow_no_args 

1552 and self.default is not yuio.MISSING 

1553 and self.flags is yuio.POSITIONAL 

1554 ): 

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

1556 try: 

1557 if isinstance(arguments, list): 

1558 value = self.parser.parse_many([arg.value for arg in arguments]) 

1559 else: 

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

1561 except yuio.parse.ParsingError as e: 

1562 raise ArgumentError.from_parsing_error( 

1563 e, 

1564 flag=flag, 

1565 arguments=arguments, 

1566 option=self, 

1567 ) from None 

1568 

1569 self.set(ns, value) 

1570 

1571 def format_alias_flags( 

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

1573 ) -> list[_ColorizedString] | None: 

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

1575 if all: 

1576 flag = self.primary_short_flag 

1577 if not flag and self.primary_long_flags: 

1578 flag = self.primary_long_flags[0] 

1579 if not flag and self.flags: 

1580 flag = self.flags[0] 

1581 if flag: 

1582 res = _ColorizedString() 

1583 res.start_no_wrap() 

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

1585 res.append_str(flag) 

1586 res.end_no_wrap() 

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

1588 res.append_str("=") 

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

1590 res.append_str("'") 

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

1592 res.append_str("'") 

1593 aliases.append(res) 

1594 return aliases 

1595 

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

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

1598 

1599 

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

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

1602 """ 

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

1604 

1605 """ 

1606 

1607 const: T 

1608 """ 

1609 Constant that will be stored. 

1610 

1611 """ 

1612 

1613 def __init__( 

1614 self, 

1615 *, 

1616 flags: list[str], 

1617 required: bool = False, 

1618 mutex_group: None | MutuallyExclusiveGroup = None, 

1619 usage: yuio.Group | bool = True, 

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

1621 help_group: HelpGroup | None = None, 

1622 show_if_inherited: bool = False, 

1623 dest: str, 

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

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

1626 const: T, 

1627 allow_abbrev: bool = True, 

1628 ): 

1629 self.const = const 

1630 

1631 super().__init__( 

1632 flags=flags, 

1633 allow_inline_arg=False, 

1634 allow_implicit_inline_arg=False, 

1635 nargs=0, 

1636 allow_no_args=True, 

1637 required=required, 

1638 metavar=(), 

1639 mutex_group=mutex_group, 

1640 usage=usage, 

1641 help=help, 

1642 help_group=help_group, 

1643 show_if_inherited=show_if_inherited, 

1644 dest=dest, 

1645 merge=merge, 

1646 default=default, 

1647 allow_abbrev=allow_abbrev, 

1648 ) 

1649 

1650 def process( 

1651 self, 

1652 cli_parser: CliParser[Namespace], 

1653 flag: Flag | None, 

1654 arguments: Argument | list[Argument], 

1655 ns: Namespace, 

1656 ): 

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

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

1659 else: 

1660 ns[self.dest] = self.const 

1661 

1662 

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

1664class CountOption(StoreConstOption[int]): 

1665 """ 

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

1667 

1668 """ 

1669 

1670 def __init__( 

1671 self, 

1672 *, 

1673 flags: list[str], 

1674 required: bool = False, 

1675 mutex_group: None | MutuallyExclusiveGroup = None, 

1676 usage: yuio.Group | bool = True, 

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

1678 help_group: HelpGroup | None = None, 

1679 show_if_inherited: bool = False, 

1680 dest: str, 

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

1682 allow_abbrev: bool = True, 

1683 ): 

1684 super().__init__( 

1685 flags=flags, 

1686 required=required, 

1687 mutex_group=mutex_group, 

1688 usage=usage, 

1689 help=help, 

1690 help_group=help_group, 

1691 show_if_inherited=show_if_inherited, 

1692 dest=dest, 

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

1694 default=default, 

1695 const=1, 

1696 allow_abbrev=allow_abbrev, 

1697 ) 

1698 

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

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

1701 

1702 

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

1704class StoreTrueOption(StoreConstOption[bool]): 

1705 """ 

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

1707 

1708 """ 

1709 

1710 def __init__( 

1711 self, 

1712 *, 

1713 flags: list[str], 

1714 required: bool = False, 

1715 mutex_group: None | MutuallyExclusiveGroup = None, 

1716 usage: yuio.Group | bool = True, 

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

1718 help_group: HelpGroup | None = None, 

1719 show_if_inherited: bool = False, 

1720 dest: str, 

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

1722 allow_abbrev: bool = True, 

1723 ): 

1724 super().__init__( 

1725 flags=flags, 

1726 required=required, 

1727 mutex_group=mutex_group, 

1728 usage=usage, 

1729 help=help, 

1730 help_group=help_group, 

1731 show_if_inherited=show_if_inherited, 

1732 dest=dest, 

1733 merge=None, 

1734 default=default, 

1735 const=True, 

1736 allow_abbrev=allow_abbrev, 

1737 ) 

1738 

1739 

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

1741class StoreFalseOption(StoreConstOption[bool]): 

1742 """ 

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

1744 

1745 """ 

1746 

1747 def __init__( 

1748 self, 

1749 *, 

1750 flags: list[str], 

1751 required: bool = False, 

1752 mutex_group: None | MutuallyExclusiveGroup = None, 

1753 usage: yuio.Group | bool = True, 

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

1755 help_group: HelpGroup | None = None, 

1756 show_if_inherited: bool = False, 

1757 dest: str, 

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

1759 allow_abbrev: bool = True, 

1760 ): 

1761 super().__init__( 

1762 flags=flags, 

1763 required=required, 

1764 mutex_group=mutex_group, 

1765 usage=usage, 

1766 help=help, 

1767 help_group=help_group, 

1768 show_if_inherited=show_if_inherited, 

1769 dest=dest, 

1770 merge=None, 

1771 default=default, 

1772 const=False, 

1773 allow_abbrev=allow_abbrev, 

1774 ) 

1775 

1776 

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

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

1779 """ 

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

1781 

1782 """ 

1783 

1784 version: str 

1785 """ 

1786 Version to print. 

1787 

1788 """ 

1789 

1790 def __init__( 

1791 self, 

1792 *, 

1793 version: str, 

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

1795 usage: yuio.Group | bool = yuio.GROUP, 

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

1797 help_group: HelpGroup | None = MISC_GROUP, 

1798 allow_abbrev: bool = True, 

1799 ): 

1800 super().__init__( 

1801 flags=flags, 

1802 allow_inline_arg=False, 

1803 allow_implicit_inline_arg=False, 

1804 nargs=0, 

1805 allow_no_args=True, 

1806 required=False, 

1807 metavar=(), 

1808 mutex_group=None, 

1809 usage=usage, 

1810 help=help, 

1811 help_group=help_group, 

1812 show_if_inherited=False, 

1813 allow_abbrev=allow_abbrev, 

1814 ) 

1815 

1816 self.version = version 

1817 

1818 def process( 

1819 self, 

1820 cli_parser: CliParser[Namespace], 

1821 flag: Flag | None, 

1822 arguments: Argument | list[Argument], 

1823 ns: Namespace, 

1824 ): 

1825 import yuio.io 

1826 

1827 if self.version: 

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

1829 else: 

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

1831 sys.exit(0) 

1832 

1833 

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

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

1836 """ 

1837 An option that prints bug report. 

1838 

1839 """ 

1840 

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

1842 """ 

1843 Settings for bug report generation. 

1844 

1845 """ 

1846 

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

1848 """ 

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

1850 

1851 """ 

1852 

1853 def __init__( 

1854 self, 

1855 *, 

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

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

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

1859 usage: yuio.Group | bool = yuio.GROUP, 

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

1861 help_group: HelpGroup | None = MISC_GROUP, 

1862 allow_abbrev: bool = True, 

1863 ): 

1864 super().__init__( 

1865 flags=flags, 

1866 allow_inline_arg=False, 

1867 allow_implicit_inline_arg=False, 

1868 nargs=0, 

1869 allow_no_args=True, 

1870 required=False, 

1871 metavar=(), 

1872 mutex_group=None, 

1873 usage=usage, 

1874 help=help, 

1875 help_group=help_group, 

1876 show_if_inherited=False, 

1877 allow_abbrev=allow_abbrev, 

1878 ) 

1879 

1880 self.settings = settings 

1881 self.app = app 

1882 

1883 def process( 

1884 self, 

1885 cli_parser: CliParser[Namespace], 

1886 flag: Flag | None, 

1887 arguments: Argument | list[Argument], 

1888 ns: Namespace, 

1889 ): 

1890 import yuio.dbg 

1891 

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

1893 sys.exit(0) 

1894 

1895 

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

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

1898 """ 

1899 An option that installs autocompletion. 

1900 

1901 """ 

1902 

1903 _SHELLS = [ 

1904 "all", 

1905 "uninstall", 

1906 "bash", 

1907 "zsh", 

1908 "fish", 

1909 "pwsh", 

1910 ] 

1911 

1912 def __init__( 

1913 self, 

1914 *, 

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

1916 usage: yuio.Group | bool = yuio.GROUP, 

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

1918 help_group: HelpGroup | None = MISC_GROUP, 

1919 allow_abbrev: bool = True, 

1920 ): 

1921 if help is None: 

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

1923 help = ( 

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

1925 f"Supported shells: {shells}." 

1926 ) 

1927 super().__init__( 

1928 flags=flags, 

1929 allow_inline_arg=True, 

1930 allow_implicit_inline_arg=True, 

1931 nargs=1, 

1932 allow_no_args=True, 

1933 required=False, 

1934 metavar="<shell>", 

1935 mutex_group=None, 

1936 usage=usage, 

1937 help=help, 

1938 help_group=help_group, 

1939 show_if_inherited=False, 

1940 allow_abbrev=allow_abbrev, 

1941 ) 

1942 

1943 def process( 

1944 self, 

1945 cli_parser: CliParser[Namespace], 

1946 flag: Flag | None, 

1947 arguments: Argument | list[Argument], 

1948 ns: Namespace, 

1949 ): 

1950 if isinstance(arguments, list): 

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

1952 else: 

1953 argument = arguments.value 

1954 

1955 if argument not in self._SHELLS: 

1956 raise ArgumentError( 

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

1958 argument, 

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

1960 flag=flag, 

1961 arguments=arguments, 

1962 n_arg=0, 

1963 ) 

1964 

1965 root = cli_parser._root_command 

1966 

1967 if argument == "uninstall": 

1968 compdata = "" 

1969 else: 

1970 serializer = yuio.complete._ProgramSerializer() 

1971 self._dump(root, serializer, []) 

1972 compdata = serializer.dump() 

1973 

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

1975 

1976 sys.exit(0) 

1977 

1978 def _dump( 

1979 self, 

1980 command: Command[_t.Any], 

1981 serializer: yuio.complete._ProgramSerializer, 

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

1983 ): 

1984 seen_flags: set[str] = set() 

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

1986 

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

1988 for option in command.options: 

1989 completer, is_many = option.get_completer() 

1990 serializer.add_option( 

1991 flags=option.flags, 

1992 nargs=option.nargs, 

1993 metavar=option.metavar, 

1994 help=option.help, 

1995 completer=completer, 

1996 is_many=is_many, 

1997 ) 

1998 if option.flags is not yuio.POSITIONAL: 

1999 seen_flags |= seen_flags 

2000 seen_options.append(option) 

2001 

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

2003 for option in parent_options: 

2004 assert option.flags is not yuio.POSITIONAL 

2005 

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

2007 if not flags: 

2008 continue 

2009 

2010 completer, is_many = option.get_completer() 

2011 help = option.help 

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

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

2014 # the best approach here. 

2015 help = yuio.DISABLED 

2016 nargs = option.nargs 

2017 if option.allow_no_args: 

2018 if nargs == 1: 

2019 nargs = "?" 

2020 elif nargs == "+": 

2021 nargs = "*" 

2022 serializer.add_option( 

2023 flags=flags, 

2024 nargs=nargs, 

2025 metavar=option.metavar, 

2026 help=help, 

2027 completer=completer, 

2028 is_many=is_many, 

2029 ) 

2030 

2031 seen_flags |= seen_flags 

2032 seen_options.append(option) 

2033 

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

2035 subcommand_serializer = serializer.add_subcommand( 

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

2037 ) 

2038 self._dump(subcommand, subcommand_serializer, seen_options) 

2039 

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

2041 return ( 

2042 yuio.complete.Choice( 

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

2044 ), 

2045 False, 

2046 ) 

2047 

2048 

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

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

2051 """ 

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

2053 

2054 """ 

2055 

2056 def __init__( 

2057 self, 

2058 *, 

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

2060 usage: yuio.Group | bool = yuio.GROUP, 

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

2062 help_group: HelpGroup | None = MISC_GROUP, 

2063 allow_abbrev: bool = True, 

2064 ): 

2065 super().__init__( 

2066 flags=flags, 

2067 allow_inline_arg=True, 

2068 allow_implicit_inline_arg=True, 

2069 nargs=0, 

2070 allow_no_args=True, 

2071 required=False, 

2072 metavar=(), 

2073 mutex_group=None, 

2074 usage=usage, 

2075 help=help, 

2076 help_group=help_group, 

2077 show_if_inherited=True, 

2078 allow_abbrev=allow_abbrev, 

2079 ) 

2080 

2081 def process( 

2082 self, 

2083 cli_parser: CliParser[Namespace], 

2084 flag: Flag | None, 

2085 arguments: Argument | list[Argument], 

2086 ns: Namespace, 

2087 ): 

2088 import yuio.io 

2089 import yuio.string 

2090 

2091 if isinstance(arguments, list): 

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

2093 else: 

2094 argument = arguments.value 

2095 

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

2097 raise ArgumentError( 

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

2099 argument, 

2100 yuio.string.Or( 

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

2102 ), 

2103 flag=flag, 

2104 arguments=arguments, 

2105 n_arg=0, 

2106 ) 

2107 

2108 formatter = _HelpFormatter(all=argument == "all") 

2109 inherited_options = [] 

2110 seen_inherited_options = set() 

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

2112 if opt not in seen_inherited_options: 

2113 seen_inherited_options.add(opt) 

2114 inherited_options.append(opt) 

2115 formatter.add_command( 

2116 " ".join(cli_parser._current_path), 

2117 cli_parser._current_command, 

2118 list(inherited_options), 

2119 ) 

2120 

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

2122 sys.exit(0) 

2123 

2124 

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

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

2127 """ 

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

2129 

2130 """ 

2131 

2132 name: str 

2133 """ 

2134 Canonical name of this command. 

2135 

2136 """ 

2137 

2138 desc: str 

2139 """ 

2140 Long description for a command. 

2141 

2142 """ 

2143 

2144 help: str | yuio.Disabled 

2145 """ 

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

2147 

2148 """ 

2149 

2150 epilog: str 

2151 """ 

2152 Long description printed after command help. 

2153 

2154 """ 

2155 

2156 usage: str | None 

2157 """ 

2158 Override for usage section of CLI help. 

2159 

2160 """ 

2161 

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

2163 """ 

2164 Options for this command. 

2165 

2166 """ 

2167 

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

2169 """ 

2170 Last positional option can be a sub-command. 

2171 

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

2173 

2174 """ 

2175 

2176 subcommand_required: bool 

2177 """ 

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

2179 are given, this attribute is ignored. 

2180 

2181 """ 

2182 

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

2184 """ 

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

2186 

2187 """ 

2188 

2189 dest: str 

2190 """ 

2191 Where to save subcommand's name. 

2192 

2193 """ 

2194 

2195 ns_dest: str 

2196 """ 

2197 Where to save subcommand's namespace. 

2198 

2199 """ 

2200 

2201 metavar: str = "<subcommand>" 

2202 """ 

2203 Meta variable used for subcommand option. 

2204 

2205 """ 

2206 

2207 

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

2209class _SubCommandOption(ValueOption[str]): 

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

2211 """ 

2212 All subcommands. 

2213 

2214 """ 

2215 

2216 ns_dest: str 

2217 """ 

2218 Where to save subcommand's namespace. 

2219 

2220 """ 

2221 

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

2223 """ 

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

2225 

2226 """ 

2227 

2228 def __init__( 

2229 self, 

2230 *, 

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

2232 subcommand_required: bool, 

2233 ns_dest: str, 

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

2235 metavar: str = "<subcommand>", 

2236 help_group: HelpGroup | None = SUBCOMMANDS_GROUP, 

2237 show_if_inherited: bool = False, 

2238 dest: str, 

2239 ): 

2240 subcommand_names = [ 

2241 f"`{name}`" 

2242 for name, subcommand in subcommands.items() 

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

2244 ] 

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

2246 

2247 super().__init__( 

2248 flags=yuio.POSITIONAL, 

2249 allow_inline_arg=False, 

2250 allow_implicit_inline_arg=False, 

2251 nargs=1, 

2252 allow_no_args=not subcommand_required, 

2253 required=False, 

2254 metavar=metavar, 

2255 mutex_group=None, 

2256 usage=True, 

2257 help=help, 

2258 help_group=help_group, 

2259 show_if_inherited=show_if_inherited, 

2260 dest=dest, 

2261 merge=None, 

2262 default=yuio.MISSING, 

2263 allow_abbrev=False, 

2264 ) 

2265 

2266 self.subcommands = subcommands 

2267 self.ns_dest = ns_dest 

2268 self.ns_ctor = ns_ctor 

2269 

2270 assert self.dest 

2271 assert self.ns_dest 

2272 

2273 def process( 

2274 self, 

2275 cli_parser: CliParser[Namespace], 

2276 flag: Flag | None, 

2277 arguments: Argument | list[Argument], 

2278 ns: Namespace, 

2279 ): 

2280 assert isinstance(arguments, list) 

2281 if not arguments: 

2282 return 

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

2284 if subcommand is None: 

2285 raise ArgumentError( 

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

2287 arguments[0].value, 

2288 yuio.string.Or( 

2289 ( 

2290 name 

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

2292 if subcommand.help != yuio.DISABLED 

2293 ), 

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

2295 ), 

2296 arguments=arguments, 

2297 ) 

2298 ns[self.dest] = subcommand.name 

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

2300 cli_parser._load_command(subcommand, new_ns) 

2301 

2302 

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

2304class _BoundOption: 

2305 wrapped: Option[_t.Any] 

2306 ns: Namespace 

2307 seen: bool = False 

2308 

2309 @property 

2310 def usage(self): 

2311 return self.wrapped.usage 

2312 

2313 @property 

2314 def flags(self): 

2315 return self.wrapped.flags 

2316 

2317 @property 

2318 def nargs(self): 

2319 return self.wrapped.nargs 

2320 

2321 @property 

2322 def allow_no_args(self): 

2323 return self.wrapped.allow_no_args 

2324 

2325 @property 

2326 def allow_inline_arg(self): 

2327 return self.wrapped.allow_inline_arg 

2328 

2329 @property 

2330 def allow_implicit_inline_arg(self): 

2331 return self.wrapped.allow_implicit_inline_arg 

2332 

2333 @property 

2334 def mutex_group(self): 

2335 return self.wrapped.mutex_group 

2336 

2337 @property 

2338 def required(self): 

2339 return self.wrapped.required 

2340 

2341 @property 

2342 def allow_abbrev(self): 

2343 return self.wrapped.allow_abbrev 

2344 

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

2346 return self.wrapped.nth_metavar(n) 

2347 

2348 

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

2350 """ 

2351 CLI arguments parser. 

2352 

2353 :param command: 

2354 root command. 

2355 :param allow_abbrev: 

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

2357 

2358 """ 

2359 

2360 def __init__( 

2361 self, 

2362 command: Command[NamespaceT], 

2363 /, 

2364 *, 

2365 allow_abbrev: bool = False, 

2366 ): 

2367 self._root_command = command 

2368 self._allow_abbrev = allow_abbrev 

2369 

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

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

2372 assert self._current_flag is None 

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

2374 

2375 self._inherited_options.update( 

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

2377 ) 

2378 self._inherited_options.update( 

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

2380 ) 

2381 self._current_path.append(command.name) 

2382 

2383 # Update known flags and positionals. 

2384 self._positionals = [] 

2385 seen_flags: set[str] = set() 

2386 for option in command.options: 

2387 bound_option = _BoundOption(option, ns) 

2388 if option.flags is yuio.POSITIONAL: 

2389 if option.mutex_group is not None: 

2390 raise TypeError( 

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

2392 "in mutually exclusive groups" 

2393 ) 

2394 if option.nargs == 0: 

2395 raise TypeError( 

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

2397 ) 

2398 self._positionals.append(bound_option) 

2399 else: 

2400 if option.mutex_group is not None: 

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

2402 if not option.flags: 

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

2404 for flag in option.flags: 

2405 if flag in seen_flags: 

2406 raise TypeError( 

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

2408 ) 

2409 seen_flags.add(flag) 

2410 self._inherited_options.pop(flag, None) 

2411 _check_flag(flag) 

2412 if _is_short(flag): 

2413 dest = self._known_short_flags 

2414 else: 

2415 dest = self._known_long_flags 

2416 if flag in dest: 

2417 warnings.warn( 

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

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

2420 CliWarning, 

2421 ) 

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

2423 dest[flag] = bound_option 

2424 if command.subcommands: 

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

2426 self._current_command = command 

2427 self._current_positional = 0 

2428 

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

2430 """ 

2431 Parse arguments and invoke their actions. 

2432 

2433 :param args: 

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

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

2436 :returns: 

2437 namespace with parsed arguments. 

2438 :raises: 

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

2440 

2441 """ 

2442 

2443 if args is None: 

2444 args = sys.argv[1:] 

2445 

2446 try: 

2447 return self._parse(args) 

2448 except ArgumentError as e: 

2449 e.commandline = args 

2450 e.prog = self._root_command.name 

2451 e.subcommands = self._current_path 

2452 raise 

2453 

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

2455 self._current_command = self._root_command 

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

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

2458 

2459 self._seen_mutex_groups: dict[ 

2460 MutuallyExclusiveGroup, tuple[_BoundOption, Flag] 

2461 ] = {} 

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

2463 

2464 self._current_index = 0 

2465 

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

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

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

2469 self._current_positional: int = 0 

2470 

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

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

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

2474 

2475 root_ns = self._root_command.ns_ctor() 

2476 self._load_command(self._root_command, root_ns) 

2477 

2478 allow_flags = True 

2479 

2480 for i, arg in enumerate(args): 

2481 self._current_index = i 

2482 

2483 # Handle `--`. 

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

2485 self._flush_flag() 

2486 allow_flags = False 

2487 continue 

2488 

2489 # Check what we have here. 

2490 if allow_flags: 

2491 result = self._detect_flag(arg) 

2492 else: 

2493 result = None 

2494 

2495 if result is None: 

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

2497 self._handle_positional(arg) 

2498 else: 

2499 # This is a flag. 

2500 options, inline_arg = result 

2501 self._handle_flags(options, inline_arg) 

2502 

2503 self._finalize() 

2504 

2505 return root_ns 

2506 

2507 def _finalize(self): 

2508 self._flush_flag() 

2509 

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

2511 self._finalize_unused_flag(flag, option) 

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

2513 self._finalize_unused_flag(flag, option) 

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

2515 self._flush_positional() 

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

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

2518 raise ArgumentError( 

2519 "%s %s must be provided", 

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

2521 yuio.string.Or( 

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

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

2524 ), 

2525 ) 

2526 

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

2528 if option.required and not option.seen: 

2529 raise ArgumentError( 

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

2531 flag, 

2532 ) 

2533 

2534 def _detect_flag( 

2535 self, arg: str 

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

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

2538 # This is a positional. 

2539 return None 

2540 

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

2542 # This is a long flag. 

2543 return self._parse_long_flag(arg) 

2544 else: 

2545 return self._detect_short_flag(arg) 

2546 

2547 def _parse_long_flag( 

2548 self, arg: str 

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

2550 if "=" in arg: 

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

2552 else: 

2553 flag, inline_arg = arg, None 

2554 flag = self._make_flag(flag) 

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

2556 if inline_arg is not None: 

2557 inline_arg = self._make_arg( 

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

2559 ) 

2560 return [(long_opt, flag)], inline_arg 

2561 

2562 # Try as abbreviated long flags. 

2563 candidates: list[str] = [] 

2564 if self._allow_abbrev: 

2565 for candidate in self._known_long_flags: 

2566 if candidate.startswith(flag.value): 

2567 candidates.append(candidate) 

2568 if len(candidates) == 1: 

2569 candidate = candidates[0] 

2570 opt = self._known_long_flags[candidate] 

2571 if not opt.allow_abbrev: 

2572 raise ArgumentError( 

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

2574 flag, 

2575 candidate, 

2576 flag=self._make_flag(""), 

2577 ) 

2578 flag = self._make_flag(candidate) 

2579 if inline_arg is not None: 

2580 inline_arg = self._make_arg( 

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

2582 ) 

2583 return [(opt, flag)], inline_arg 

2584 

2585 if candidates: 

2586 raise ArgumentError( 

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

2588 flag, 

2589 yuio.string.Or( 

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

2591 ), 

2592 flag=self._make_flag(""), 

2593 ) 

2594 else: 

2595 raise ArgumentError( 

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

2597 flag, 

2598 flag=self._make_flag(""), 

2599 ) 

2600 

2601 def _detect_short_flag( 

2602 self, arg: str 

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

2604 # Try detecting short flags first. 

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

2606 inline_arg = None 

2607 inline_arg_pos = 0 

2608 unknown_ch = None 

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

2610 if ch == "=": 

2611 # Short flag with explicit argument. 

2612 inline_arg_pos = i + 2 

2613 inline_arg = arg[inline_arg_pos:] 

2614 break 

2615 elif short_opts and ( 

2616 short_opts[-1][0].allow_implicit_inline_arg 

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

2618 ): 

2619 # Short flag with implicit argument. 

2620 inline_arg_pos = i + 1 

2621 inline_arg = arg[inline_arg_pos:] 

2622 break 

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

2624 # Short flag, arguments may follow. 

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

2626 else: 

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

2628 unknown_ch = ch 

2629 break 

2630 if short_opts and not unknown_ch: 

2631 if inline_arg is not None: 

2632 inline_arg = self._make_arg( 

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

2634 ) 

2635 return short_opts, inline_arg 

2636 

2637 # Try as signed int. 

2638 if re.match(_NUM_RE, arg): 

2639 # This is a positional. 

2640 return None 

2641 

2642 if unknown_ch and len(arg) > 2: 

2643 raise ArgumentError( 

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

2645 unknown_ch, 

2646 arg, 

2647 flag=self._make_flag(""), 

2648 ) 

2649 else: 

2650 raise ArgumentError( 

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

2652 arg, 

2653 flag=self._make_flag(""), 

2654 ) 

2655 

2656 def _make_arg( 

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

2658 ): 

2659 return Argument( 

2660 arg, 

2661 index=self._current_index, 

2662 pos=pos, 

2663 metavar=opt.nth_metavar(0), 

2664 flag=flag, 

2665 ) 

2666 

2667 def _make_flag(self, arg: str): 

2668 return Flag(arg, self._current_index) 

2669 

2670 def _handle_positional(self, arg: str): 

2671 if self._current_flag is not None: 

2672 opt, flag = self._current_flag 

2673 # This is an argument for a flag option. 

2674 self._current_flag_args.append( 

2675 Argument( 

2676 arg, 

2677 index=self._current_index, 

2678 pos=0, 

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

2680 flag=flag, 

2681 ) 

2682 ) 

2683 nargs = opt.nargs 

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

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

2686 else: 

2687 # This is an argument for a positional option. 

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

2689 raise ArgumentError( 

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

2691 arg, 

2692 arguments=Argument( 

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

2694 ), 

2695 ) 

2696 current_positional = self._positionals[self._current_positional] 

2697 self._current_positional_args.append( 

2698 Argument( 

2699 arg, 

2700 index=self._current_index, 

2701 pos=0, 

2702 metavar=current_positional.nth_metavar( 

2703 len(self._current_positional_args) 

2704 ), 

2705 flag=None, 

2706 ) 

2707 ) 

2708 nargs = current_positional.nargs 

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

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

2711 

2712 def _handle_flags( 

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

2714 ): 

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

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

2717 self._flush_flag() 

2718 

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

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

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

2722 

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

2724 opt, name = options[-1] 

2725 if inline_arg is not None: 

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

2727 self._eval_option(opt, name, inline_arg) 

2728 else: 

2729 self._push_flag(opt, name) 

2730 

2731 def _flush_positional(self): 

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

2733 return 

2734 opt, args = ( 

2735 self._positionals[self._current_positional], 

2736 self._current_positional_args, 

2737 ) 

2738 

2739 self._current_positional += 1 

2740 self._current_positional_args = [] 

2741 

2742 self._eval_option(opt, None, args) 

2743 

2744 def _flush_flag(self): 

2745 if self._current_flag is None: 

2746 return 

2747 

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

2749 

2750 self._current_flag = None 

2751 self._current_flag_args = [] 

2752 

2753 self._eval_option(opt, name, args) 

2754 

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

2756 assert self._current_flag is None 

2757 

2758 if opt.nargs == 0: 

2759 # Flag without arguments, handle it right now. 

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

2761 else: 

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

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

2764 self._current_flag = (opt, flag) 

2765 self._current_flag_args = [] 

2766 

2767 def _eval_option( 

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

2769 ): 

2770 if opt.mutex_group is not None: 

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

2772 raise ArgumentError( 

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

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

2775 seen[1], 

2776 ) 

2777 self._seen_mutex_groups[opt.mutex_group] = ( 

2778 opt, 

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

2780 ) 

2781 

2782 if isinstance(arguments, list): 

2783 _check_nargs(opt, flag, arguments) 

2784 elif not opt.allow_inline_arg: 

2785 raise ArgumentError( 

2786 "This flag can't have arguments", 

2787 flag=flag, 

2788 arguments=arguments, 

2789 option=opt.wrapped, 

2790 ) 

2791 

2792 opt.seen = True 

2793 try: 

2794 opt.wrapped.process( 

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

2796 ) 

2797 except ArgumentError as e: 

2798 if e.flag is None: 

2799 e.flag = flag 

2800 if e.arguments is None: 

2801 e.arguments = arguments 

2802 if e.option is None: 

2803 e.option = opt.wrapped 

2804 raise 

2805 except yuio.parse.ParsingError as e: 

2806 raise ArgumentError.from_parsing_error( 

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

2808 ) 

2809 

2810 

2811def _check_flag(flag: str): 

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

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

2814 if len(flag) == 2: 

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

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

2817 elif len(flag) == 1: 

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

2819 else: 

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

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

2822 

2823 

2824def _is_short(flag: str): 

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

2826 

2827 

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

2829 return _SubCommandOption( 

2830 metavar=command.metavar, 

2831 subcommands=command.subcommands, 

2832 subcommand_required=command.subcommand_required, 

2833 dest=command.dest, 

2834 ns_dest=command.ns_dest, 

2835 ns_ctor=command.ns_ctor, 

2836 ) 

2837 

2838 

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

2840 if not args and opt.allow_no_args: 

2841 return 

2842 match opt.nargs: 

2843 case "+": 

2844 if not args: 

2845 if opt.flags is yuio.POSITIONAL: 

2846 raise ArgumentError( 

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

2848 opt.nth_metavar(0), 

2849 flag=flag, 

2850 option=opt.wrapped, 

2851 ) 

2852 else: 

2853 raise ArgumentError( 

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

2855 flag=flag, 

2856 option=opt.wrapped, 

2857 ) 

2858 case n: 

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

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

2861 raise ArgumentError( 

2862 "Missing required positional%s %s", 

2863 s, 

2864 yuio.string.JoinStr( 

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

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

2867 ), 

2868 flag=flag, 

2869 option=opt.wrapped, 

2870 ) 

2871 elif len(args) != n: 

2872 s = "" if n == 1 else "s" 

2873 raise ArgumentError( 

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

2875 n, 

2876 s, 

2877 len(args), 

2878 flag=flag, 

2879 option=opt.wrapped, 

2880 ) 

2881 

2882 

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

2884 s = s.translate(_UNPRINTABLE_TRANS) 

2885 

2886 if not s: 

2887 return "''", (1, 1) 

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

2889 return s, pos 

2890 

2891 start, end = pos 

2892 

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

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

2895 

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

2897 

2898 

2899def _quote(s: str): 

2900 s = s.translate(_UNPRINTABLE_TRANS) 

2901 

2902 if not s: 

2903 return "''" 

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

2905 return s 

2906 else: 

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

2908 

2909 

2910class _HelpFormatter: 

2911 def __init__(self, all: bool = False) -> None: 

2912 self.nodes: list[yuio.md.AstBase] = [] 

2913 self.all = all 

2914 

2915 def add_command( 

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

2917 ): 

2918 self._add_usage(prog, cmd, inherited) 

2919 if cmd.desc: 

2920 self.nodes.extend(yuio.md.parse(cmd.desc).items) 

2921 self._add_options(cmd) 

2922 self._add_subcommands(cmd) 

2923 self._add_flags(cmd, inherited) 

2924 if cmd.epilog: 

2925 self.nodes.append(_ResetIndentation()) 

2926 self.nodes.extend(yuio.md.parse(cmd.epilog).items) 

2927 

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

2929 return self.format(ctx) 

2930 

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

2932 res = _ColorizedString() 

2933 lines = _CliMdFormatter(ctx, all=self.all).format_node( 

2934 yuio.md.Document(items=self.nodes) 

2935 ) 

2936 sep = False 

2937 for line in lines: 

2938 if sep: 

2939 res.append_str("\n") 

2940 res.append_colorized_str(line) 

2941 sep = True 

2942 return res 

2943 

2944 def _add_usage( 

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

2946 ): 

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

2948 

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

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

2951 for opt in cmd.options: 

2952 if opt.flags is not yuio.POSITIONAL: 

2953 continue 

2954 if opt.help is yuio.DISABLED: 

2955 continue 

2956 group = opt.help_group or ARGS_GROUP 

2957 if group.help is yuio.DISABLED: 

2958 continue 

2959 if group not in groups: 

2960 groups[group] = [] 

2961 groups[group].append(opt) 

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

2963 assert group.help is not yuio.DISABLED 

2964 self.nodes.append(yuio.md.Heading(lines=group.title_lines, level=1)) 

2965 if group.help: 

2966 self.nodes.extend(yuio.md.parse(group.help, allow_headings=False).items) 

2967 arg_group = _HelpArgGroup(items=[]) 

2968 for opt in options: 

2969 assert opt.help is not yuio.DISABLED 

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

2971 self.nodes.append(arg_group) 

2972 

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

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

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

2976 if subcommand.help is yuio.DISABLED: 

2977 continue 

2978 if subcommand not in subcommands: 

2979 subcommands[subcommand] = [name] 

2980 else: 

2981 subcommands[subcommand].append(name) 

2982 if not subcommands: 

2983 return 

2984 group = SUBCOMMANDS_GROUP 

2985 self.nodes.append(yuio.md.Heading(lines=group.title_lines, level=1)) 

2986 if group.help: 

2987 self.nodes.extend(yuio.md.parse(group.help, allow_headings=False).items) 

2988 arg_group = _HelpArgGroup(items=[]) 

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

2990 assert subcommand.help is not yuio.DISABLED 

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

2992 self.nodes.append(arg_group) 

2993 

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

2995 groups: dict[ 

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

2997 ] = {} 

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

2999 if not opt.flags: 

3000 continue 

3001 if opt.help is yuio.DISABLED: 

3002 continue 

3003 group = opt.help_group or OPTS_GROUP 

3004 if group.help is yuio.DISABLED: 

3005 continue 

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

3007 if group not in groups: 

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

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

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

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

3012 required, optional, n_inherited = groups[group] 

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

3014 else: 

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

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

3017 assert group.help is not yuio.DISABLED 

3018 self.nodes.append(yuio.md.Heading(lines=group.title_lines, level=1)) 

3019 if group.help: 

3020 self.nodes.extend(yuio.md.parse(group.help, allow_headings=False).items) 

3021 arg_group = _HelpArgGroup(items=[]) 

3022 for opt in required: 

3023 assert opt.help is not yuio.DISABLED 

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

3025 for opt in optional: 

3026 assert opt.help is not yuio.DISABLED 

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

3028 if n_inherited > 0: 

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

3030 self.nodes.append(arg_group) 

3031 

3032 

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

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

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

3036 

3037 res = _ColorizedString() 

3038 is_punctuation = False 

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

3040 if is_punctuation: 

3041 res.append_color(punct_color) 

3042 else: 

3043 res.append_color(metavar_color) 

3044 res.append_str(part) 

3045 is_punctuation = not is_punctuation 

3046 

3047 return res 

3048 

3049 

3050_ARGS_COLUMN_WIDTH = 26 

3051_ARGS_COLUMN_WIDTH_NARROW = 8 

3052 

3053 

3054class _CliMdFormatter(yuio.md.MdFormatter): # type: ignore 

3055 def __init__( 

3056 self, 

3057 ctx: yuio.string.ReprContext, 

3058 /, 

3059 *, 

3060 all: bool = False, 

3061 ): 

3062 self.all = all 

3063 

3064 self._heading_indent = contextlib.ExitStack() 

3065 self._args_column_width = ( 

3066 _ARGS_COLUMN_WIDTH if ctx.width >= 80 else _ARGS_COLUMN_WIDTH_NARROW 

3067 ) 

3068 

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

3070 

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

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

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

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

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

3076 

3077 def colorize( 

3078 self, 

3079 s: str, 

3080 /, 

3081 *, 

3082 default_color: yuio.color.Color | str = yuio.color.Color.NONE, 

3083 ): 

3084 return yuio.string.colorize( 

3085 s, 

3086 default_color=default_color, 

3087 parse_cli_flags_in_backticks=True, 

3088 ctx=self.ctx, 

3089 ) 

3090 

3091 def _format_Heading(self, node: yuio.md.Heading): 

3092 if node.level == 1: 

3093 self._heading_indent.close() 

3094 

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

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

3097 self._format_Text( 

3098 node, 

3099 default_color=self.ctx.get_color("msg/text:heading/section"), 

3100 ) 

3101 

3102 if node.level == 1: 

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

3104 else: 

3105 self._line(self._indent) 

3106 

3107 self._is_first_line = True 

3108 

3109 def _format_ResetIndentation(self, node: _ResetIndentation): 

3110 self._heading_indent.close() 

3111 self._is_first_line = True 

3112 

3113 def _format_Usage(self, node: _Usage): 

3114 prefix = _ColorizedString( 

3115 [ 

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

3117 node.prefix, 

3118 self.base_color, 

3119 " ", 

3120 ] 

3121 ) 

3122 

3123 usage = _ColorizedString() 

3124 if node.cmd.usage: 

3125 usage = yuio.util.dedent(node.cmd.usage).rstrip() 

3126 sh_usage_highlighter = yuio.md.SyntaxHighlighter.get_highlighter("sh-usage") 

3127 

3128 usage = sh_usage_highlighter.highlight( 

3129 self.ctx.theme, 

3130 usage, 

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

3132 else: 

3133 usage = self._build_usage(node) 

3134 

3135 with self._with_indent(None, prefix): 

3136 self._line( 

3137 usage.indent( 

3138 indent=self._indent, 

3139 continuation_indent=self._continuation_indent, 

3140 ) 

3141 ) 

3142 

3143 def _build_usage(self, node: _Usage): 

3144 flags_and_groups: list[ 

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

3146 ] = [] 

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

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

3149 has_grouped_flags = False 

3150 

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

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

3153 if is_inherited and not opt.show_if_inherited: 

3154 continue 

3155 if opt.flags is yuio.POSITIONAL: 

3156 positionals.append(opt) 

3157 elif opt.usage is yuio.GROUP: 

3158 has_grouped_flags = True 

3159 elif not opt.usage: 

3160 pass 

3161 elif opt.mutex_group: 

3162 if opt.mutex_group not in groups: 

3163 group_items = [] 

3164 groups[opt.mutex_group] = group_items 

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

3166 else: 

3167 group_items = groups[opt.mutex_group] 

3168 group_items.append(opt) 

3169 else: 

3170 flags_and_groups.append(opt) 

3171 

3172 res = _ColorizedString() 

3173 res.append_color(self.prog_color) 

3174 res.append_str(node.prog) 

3175 

3176 if has_grouped_flags: 

3177 res.append_color(self.base_color) 

3178 res.append_str(" ") 

3179 res.append_color(self.flag_color) 

3180 res.append_str("<options>") 

3181 

3182 res.append_color(self.base_color) 

3183 

3184 in_opt_short_group = False 

3185 for flag_or_group in flags_and_groups: 

3186 match flag_or_group: 

3187 case (group, flags): 

3188 res.append_color(self.base_color) 

3189 res.append_str(" ") 

3190 res.append_color(self.punct_color) 

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

3192 sep = False 

3193 for flag in flags: 

3194 if sep: 

3195 res.append_str("|") 

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

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

3198 sep = True 

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

3200 case flag: 

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

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

3203 can_group = False 

3204 

3205 if can_group: 

3206 if not in_opt_short_group: 

3207 res.append_color(self.base_color) 

3208 res.append_str(" ") 

3209 res.append_color(self.punct_color) 

3210 res.append_str("[") 

3211 res.append_color(self.flag_color) 

3212 res.append_str("-") 

3213 in_opt_short_group = True 

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

3215 res.append_str(letter) 

3216 continue 

3217 

3218 if in_opt_short_group: 

3219 res.append_color(self.punct_color) 

3220 res.append_str("]") 

3221 in_opt_short_group = False 

3222 

3223 res.append_color(self.base_color) 

3224 res.append_str(" ") 

3225 

3226 if not flag.required: 

3227 res.append_color(self.punct_color) 

3228 res.append_str("[") 

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

3230 if not flag.required: 

3231 res.append_color(self.punct_color) 

3232 res.append_str("]") 

3233 

3234 if in_opt_short_group: 

3235 res.append_color(self.punct_color) 

3236 res.append_str("]") 

3237 in_opt_short_group = False 

3238 

3239 for positional in positionals: 

3240 res.append_color(self.base_color) 

3241 res.append_str(" ") 

3242 res.append_colorized_str( 

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

3244 ) 

3245 

3246 if node.cmd.subcommands: 

3247 res.append_str(" ") 

3248 if not node.cmd.subcommand_required: 

3249 res.append_color(self.punct_color) 

3250 res.append_str("[") 

3251 res.append_colorized_str( 

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

3253 self.base_color 

3254 ) 

3255 ) 

3256 res.append_color(self.base_color) 

3257 res.append_str(" ") 

3258 res.append_color(self.metavar_color) 

3259 res.append_str("...") 

3260 if not node.cmd.subcommand_required: 

3261 res.append_color(self.punct_color) 

3262 res.append_str("]") 

3263 

3264 return res 

3265 

3266 def _format_HelpOpt(self, node: _HelpOpt): 

3267 lead = _ColorizedString() 

3268 if node.arg.primary_short_flag: 

3269 lead.append_color(self.flag_color) 

3270 lead.append_str(node.arg.primary_short_flag) 

3271 sep = True 

3272 else: 

3273 lead.append_color(self.base_color) 

3274 lead.append_str(" ") 

3275 sep = False 

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

3277 if sep: 

3278 lead.append_color(self.punct_color) 

3279 lead.append_str(", ") 

3280 lead.append_color(self.flag_color) 

3281 lead.append_str(flag) 

3282 sep = True 

3283 

3284 lead.append_colorized_str( 

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

3286 ) 

3287 

3288 help = node.arg.parse_help(self.ctx, all=self.all) 

3289 

3290 if help is None: 

3291 self._line(self._indent + lead) 

3292 return 

3293 

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

3295 self._line(self._indent + lead) 

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

3297 else: 

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

3299 

3300 with indent_ctx: 

3301 self._format(help) 

3302 

3303 def _format_HelpArg(self, node: _HelpArg): 

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

3305 self.base_color 

3306 ) 

3307 

3308 help = node.arg.parse_help(self.ctx) 

3309 

3310 if help is None: 

3311 self._line(self._indent + lead) 

3312 return 

3313 

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

3315 self._line(self._indent + lead) 

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

3317 else: 

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

3319 

3320 with indent_ctx: 

3321 self._format(help) 

3322 

3323 def _format_HelpSubCommand(self, node: _HelpSubCommand): 

3324 lead = _ColorizedString() 

3325 sep = False 

3326 for name in node.names: 

3327 if sep: 

3328 lead.append_color(self.punct_color) 

3329 lead.append_str(", ") 

3330 lead.append_color(self.flag_color) 

3331 lead.append_str(name) 

3332 sep = True 

3333 

3334 help = node.help 

3335 

3336 if not help: 

3337 self._line(self._indent + lead) 

3338 return 

3339 

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

3341 self._line(self._indent + lead) 

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

3343 else: 

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

3345 

3346 with indent_ctx: 

3347 self._format(yuio.md.parse(help, allow_headings=False)) 

3348 

3349 def _format_InheritedOpts(self, node: _InheritedOpts): 

3350 raw = _ColorizedString() 

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

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

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

3354 self._line(raw) 

3355 

3356 def _format_HelpArgGroup(self, node: _HelpArgGroup): 

3357 self._separate_paragraphs = False 

3358 for item in node.items: 

3359 self._format(item) 

3360 self._separate_paragraphs = True 

3361 

3362 def _make_lead_padding(self, lead: _ColorizedString): 

3363 color = self.base_color 

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

3365 

3366 

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

3368class _ResetIndentation(yuio.md.AstBase): 

3369 pass 

3370 

3371 

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

3373class _Usage(yuio.md.AstBase): 

3374 prog: str 

3375 cmd: Command[Namespace] 

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

3377 prefix: str = "Usage:" 

3378 

3379 

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

3381class _HelpOpt(yuio.md.AstBase): 

3382 arg: Option[_t.Any] 

3383 

3384 

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

3386class _HelpArg(yuio.md.AstBase): 

3387 arg: Option[_t.Any] 

3388 

3389 

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

3391class _InheritedOpts(yuio.md.AstBase): 

3392 n_inherited: int 

3393 

3394 

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

3396class _HelpSubCommand(yuio.md.AstBase): 

3397 names: list[str] 

3398 help: str | None 

3399 

3400 

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

3402class _HelpArgGroup(yuio.md.Container[yuio.md.AstBase]): 

3403 pass 

3404 

3405 

3406class _ShortUsageFormatter: 

3407 def __init__(self, subcommands: list[str] | None, option: Option[_t.Any]): 

3408 self.subcommands = subcommands 

3409 self.option = option 

3410 

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

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

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

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

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

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

3417 

3418 res = _ColorizedString() 

3419 res.append_color(heading_color) 

3420 res.append_str("Help: ") 

3421 

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

3423 sep = False 

3424 if self.option.primary_short_flag: 

3425 res.append_color(flag_color) 

3426 res.append_str(self.option.primary_short_flag) 

3427 sep = True 

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

3429 if sep: 

3430 res.append_color(punct_color) 

3431 res.append_str(", ") 

3432 res.append_color(flag_color) 

3433 res.append_str(flag) 

3434 sep = True 

3435 

3436 res.append_colorized_str( 

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

3438 ) 

3439 

3440 res.append_color(heading_color) 

3441 res.append_str("\n") 

3442 res.append_color(note_color) 

3443 

3444 if help := self.option.parse_help(ctx): 

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

3446 formatter = _CliMdFormatter(ctx) 

3447 sep = False 

3448 for line in formatter.format_node(_HelpArgGroup(items=[help])): 

3449 if sep: 

3450 res.append_str("\n") 

3451 res.append_str(" ") 

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

3453 sep = True 

3454 

3455 # if self.subcommands: 

3456 # prog_color = code_color | ctx.get_color("hl/prog:sh-usage") 

3457 # res.append_str("\n") 

3458 # res.append_color(heading_color) 

3459 # res.append_str("Note: ") 

3460 # res.append_color(prog_color) 

3461 # res.append_str(" ".join(self.subcommands)) 

3462 # res.append_color(code_color) 

3463 # res.append_str(" ") 

3464 # res.append_color(flag_color) 

3465 # res.append_str("--help") 

3466 # res.append_color(note_color) 

3467 # res.append_str(" for usage and details") 

3468 

3469 return res