Coverage for yuio / cli.py: 76%
1388 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« 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
8"""
9Low-level interface to CLI parser.
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`.
17This module is inspired by :mod:`argparse`, but there are differences:
19- all flags should start with ``-``, other symbols are not supported (at least
20 for now);
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;
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;
30- namespaces are abstracted away by the :class:`Namespace` protocol, which has an
31 interface similar to :class:`dict`;
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.
36 For example, consider this program:
38 .. code-block:: python
40 import argparse
42 parser = argparse.ArgumentParser()
43 parser.add_argument("-v", "--verbose", action="count")
44 subparsers = parser.add_subparsers()
45 subcommand = subparsers.add_parser("subcommand")
47 Argparse will not recognize :flag:`--verbose` if it's specified
48 after :flag:`subcommand`, but :mod:`yuio.cli` handles this just fine:
50 .. code-block:: console
52 $ prog subcommand --verbose
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``.
58 See :ref:`flags-with-optional-values` for details;
60- the above point also allows us to disambiguate positional arguments
61 and arguments with ``nargs="*"``:
63 .. code-block:: console
65 $ prog --array='a b c' subcommand
67 See :ref:`flags-with-multiple-values` for details;
69- this parser tracks information about argument positions and offsets, allowing
70 it to display rich error messages;
72- we expose more knobs to tweak help formatting; see functions on :class:`Option`
73 for details.
76Commands and sub-commands
77-------------------------
79.. autoclass:: Command
80 :members:
83Flags and positionals
84---------------------
86.. autoclass:: Option
87 :members:
89.. autoclass:: ValueOption
90 :members:
92.. autoclass:: ParserOption
93 :members:
95.. autoclass:: BoolOption
96 :members:
98.. autoclass:: ParseOneOption
99 :members:
101.. autoclass:: ParseManyOption
102 :members:
104.. autoclass:: StoreConstOption
105 :members:
107.. autoclass:: StoreFalseOption
108 :members:
110.. autoclass:: StoreTrueOption
111 :members:
113.. autoclass:: CountOption
114 :members:
116.. autoclass:: VersionOption
117 :members:
119.. autoclass:: HelpOption
120 :members:
123Namespace
124---------
126.. autoclass:: Namespace
128 .. automethod:: __getitem__
130 .. automethod:: __setitem__
132 .. automethod:: __contains__
134.. autoclass:: ConfigNamespace
135 :members:
138CLI parser
139----------
141.. autoclass:: CliParser
142 :members:
144.. autoclass:: Argument
145 :members:
147.. autoclass:: Flag
148 :members:
150.. autoclass:: ArgumentError
151 :members:
153.. type:: NArgs
154 :canonical: int | typing.Literal["+"]
156 Type alias for :attr:`~Option.nargs`.
158 .. note::
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``.
164Option grouping
165---------------
167.. autoclass:: MutuallyExclusiveGroup
168 :members:
170.. autoclass:: HelpGroup
171 :members:
173.. autodata:: ARGS_GROUP
175.. autodata:: SUBCOMMANDS_GROUP
177.. autodata:: OPTS_GROUP
179.. autodata:: MISC_GROUP
181"""
183from __future__ import annotations
185import abc
186import contextlib
187import dataclasses
188import functools
189import re
190import string
191import sys
192import warnings
193from dataclasses import dataclass
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
205from typing import TYPE_CHECKING
207if TYPE_CHECKING:
208 import typing_extensions as _t
209else:
210 from yuio import _typing as _t
212if TYPE_CHECKING:
213 import yuio.app
214 import yuio.config
215 import yuio.dbg
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]
249T = _t.TypeVar("T")
250T_cov = _t.TypeVar("T_cov", covariant=True)
252_SHORT_FLAG_RE = r"^-[a-zA-Z0-9]$"
253_LONG_FLAG_RE = r"^--[a-zA-Z0-9_+/-]+$"
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"""
267NArgs: _t.TypeAlias = int | _t.Literal["+"]
268"""
269Type alias for nargs.
271.. note::
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``.
276"""
278NamespaceT = _t.TypeVar("NamespaceT", bound="Namespace")
279ConfigT = _t.TypeVar("ConfigT", bound="yuio.config.Config")
282class CliWarning(yuio.YuioWarning):
283 pass
286@dataclass(frozen=True, slots=True)
287class Argument:
288 """
289 Represents a CLI argument, or its part.
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.
295 :example:
296 Consider the following command arguments:
298 .. code-block:: text
300 --arg=value
302 Argument ``"value"`` will be represented as:
304 .. code-block:: python
306 Argument(value="value", index=0, pos=6, flag="--arg", metavar=...)
308 """
310 value: str
311 """
312 Contents of the argument.
314 """
316 index: int
317 """
318 Index of this argument in the array that was passed to :meth:`CliParser.parse`.
320 Note that this array does not include executable name, so indexes are shifted
321 relative to :data:`sys.argv`.
323 """
325 pos: int
326 """
327 Position of the :attr:`~Argument.value` relative to the original argument string.
329 """
331 metavar: str
332 """
333 Meta variable for this argument.
335 """
337 flag: Flag | None
338 """
339 If this argument belongs to a flag, this attribute will contain flag's name.
341 """
343 def __str__(self) -> str:
344 return self.metavar
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 )
355@dataclass(frozen=True, slots=True)
356class Flag:
357 value: str
358 """
359 Name of the flag.
361 """
363 index: int
364 """
365 Index of this flag in the array that was passed to :meth:`CliParser.parse`.
367 Note that this array does not include executable name, so indexes are shifted
368 relative to :data:`sys.argv`.
370 """
372 def __str__(self) -> str:
373 return self.value
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 )
384class ArgumentError(yuio.PrettyException, ValueError):
385 """
386 Error that happened during argument parsing.
388 """
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)
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.
433 """
435 self.arguments: Argument | list[Argument] | None = arguments
436 """
437 Arguments that caused this error.
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.
444 .. note::
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.
450 """
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).
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`.
461 If :attr:`~ArgumentError.arguments` is given as a single argument (not a list),
462 then this position is relative to that argument.
464 Otherwise, position is ignored.
466 """
468 self.n_arg: int | None = n_arg
469 """
470 Index of the argument that caused the error.
472 """
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.
480 """
482 self.option: Option[_t.Any] | None = option
483 """
484 Option which caused failure.
486 """
488 self.commandline: list[str] | None = None
489 self.prog: str | None = None
490 self.subcommands: list[str] | None = None
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.
505 """
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 )
517 def to_colorable(self) -> yuio.string.Colorable:
518 colorable = yuio.string.WithBaseColor(
519 super().to_colorable(),
520 base_color="msg/text:error",
521 )
523 msg = []
524 args = []
525 sep = False
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
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
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)
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))
551 if sep:
552 msg.insert(0, "Error ")
553 msg.append(":")
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 )
568 if commandline := self._make_commandline():
569 colorable = yuio.string.Stack(
570 commandline,
571 colorable,
572 )
574 if usage := self._make_usage():
575 colorable = yuio.string.Stack(
576 colorable,
577 usage,
578 )
580 return colorable
582 def _make_commandline(self):
583 if not self.prog or not self.commandline:
584 return None
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
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
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 += " "
613 center, pos = _quote_and_adjust_pos(self.commandline[arg_index], arg_pos)
614 pos = (pos[0] + len(text), pos[1] + len(text))
616 text += center
617 text += " "
618 text += " ".join(_quote(arg) for arg in self.commandline[arg_index + 1 :])
620 if text:
621 return yuio.parse._CodeRenderer(text, pos, as_cli=True)
622 else:
623 return None
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)
632class Namespace(_t.Protocol):
633 """
634 Protocol for namespace implementations.
636 """
638 @abc.abstractmethod
639 def __getitem__(self, key: str, /) -> _t.Any: ...
641 @abc.abstractmethod
642 def __setitem__(self, key: str, value: _t.Any, /): ...
644 @abc.abstractmethod
645 def __contains__(self, key: str, /) -> bool: ...
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.
653 """
655 def __init__(self, config: ConfigT) -> None:
656 self.__config = config
658 @property
659 def config(self) -> ConfigT:
660 """
661 Wrapped config instance.
663 """
665 return self.__config
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
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
681 def __contains__(self, key: str):
682 root, key = self.__split_key(key)
683 return key in root.__dict__
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
692 def __rich_repr__(self):
693 yield None, self.__config
696@dataclass(eq=False)
697class HelpGroup:
698 """
699 Group of flags in CLI help.
701 """
703 title: str
704 """
705 Title for this group.
707 """
709 help: str | yuio.Disabled = dataclasses.field(default="", kw_only=True)
710 """
711 Help message for an option.
713 """
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()
723ARGS_GROUP = HelpGroup("Arguments:")
724"""
725Help group for positional arguments.
727"""
729SUBCOMMANDS_GROUP = HelpGroup("Subcommands:")
730"""
731Help group for subcommands.
733"""
735OPTS_GROUP = HelpGroup("Options:")
736"""
737Help group for flags.
739"""
741MISC_GROUP = HelpGroup("Misc options:")
742"""
743Help group for misc flags such as :flag:`--help` or :flag:`--version`.
745"""
748@dataclass(kw_only=True, eq=False)
749class MutuallyExclusiveGroup:
750 """
751 A sentinel for creating mutually exclusive groups.
753 Pass an instance of this class all :func:`~yuio.app.field`\\ s that should
754 be mutually exclusive.
756 """
758 required: bool = False
759 """
760 Require that one of the mutually exclusive options is always given.
762 """
765@dataclass(eq=False, kw_only=True)
766class Option(abc.ABC, _t.Generic[T_cov]):
767 """
768 Base class for a CLI option.
770 """
772 flags: list[str] | yuio.Positional
773 """
774 Flags corresponding to this option. Positional options have flags set to
775 :data:`yuio.POSITIONAL`.
777 """
779 allow_inline_arg: bool
780 """
781 Whether to allow specifying argument inline (i.e. :flag:`--foo=bar`).
783 Inline arguments are handled separately from normal arguments,
784 and :attr:`~Option.nargs` setting does not affect them.
786 Positional options can't take inline arguments, so this attribute has
787 no effect on them.
789 """
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`).
796 Inline arguments are handled separately from normal arguments,
797 and :attr:`~Option.nargs` setting does not affect them.
799 Positional options can't take inline arguments, so this attribute has
800 no effect on them.
802 """
804 nargs: NArgs
805 """
806 How many arguments this option takes.
808 """
810 allow_no_args: bool
811 """
812 Whether to allow passing no arguments even if :attr:`~Option.nargs` requires some.
814 """
816 required: bool
817 """
818 Makes this option required. The parsing will fail if this option is not
819 encountered among CLI arguments.
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.
826 """
828 metavar: str | tuple[str, ...]
829 """
830 Option's meta variable, used for displaying help messages.
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.
836 """
838 mutex_group: None | MutuallyExclusiveGroup
839 """
840 Mutually exclusive group for this option. Positional options can't have
841 mutex groups.
843 """
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.
850 """
852 help: str | yuio.Disabled
853 """
854 Help message for an option.
856 """
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.
864 """
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.
871 """
873 allow_abbrev: bool
874 """
875 Allow abbreviation for this option.
877 """
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.
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.
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.
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.
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`.
916 """
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)]
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)]
932 @functools.cached_property
933 def primary_short_flag(self) -> str | None:
934 """
935 Short flag that will be displayed in CLI help.
937 """
939 if short_flags := self.short_flags:
940 return short_flags[0]
941 else:
942 return None
944 @functools.cached_property
945 def primary_long_flags(self) -> list[str] | None:
946 """
947 Long flags that will be displayed in CLI help.
949 """
951 if long_flags := self.long_flags:
952 return [long_flags[0]]
953 else:
954 return None
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.
962 :param ctx:
963 repr context for formatting help.
964 :param all:
965 whether :flag:`--help=all` was specified.
966 :returns:
967 markdown AST.
969 """
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))
975 return help if help.items else None
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.
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.
991 """
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
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.
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.
1024 """
1026 res = _ColorizedString()
1028 if not self.nargs:
1029 return res
1031 res.append_color(ctx.get_color("hl/punct:sh-usage"))
1032 if self.flags:
1033 res.append_str(" ")
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("]")
1057 return res
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.
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.
1073 """
1075 res = _ColorizedString()
1077 base_color = ctx.get_color("msg/text:code/sh-usage")
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
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))
1095 if res:
1096 res.append_str(".")
1098 return res
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`.
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.
1114 """
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
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.
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.
1146 """
1148 return None
1150 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1151 return None, False
1153 def nth_metavar(self, n: int) -> str:
1154 """
1155 Get metavar for n-th argument for this option.
1157 """
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
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.
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.
1179 """
1181 dest: str
1182 """
1183 Key where to store parsed argument.
1185 """
1187 merge: _t.Callable[[T, T], T] | None
1188 """
1189 Function to merge previous and new value.
1191 """
1193 default: object
1194 """
1195 Default value that will be used if this flag is not given.
1197 Used for formatting help, does not affect actual parsing.
1199 """
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.
1206 """
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
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.
1219 """
1221 parser: yuio.parse.Parser[T]
1222 """
1223 A parser used to parse option's arguments.
1225 """
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`.
1234 If any of the :attr:`~BoolOption.pos_flags` are given without arguments, it works like
1235 :class:`StoreTrueOption`.
1237 If any of the :attr:`~BoolOption.neg_flags` are given, it works like
1238 :class:`StoreFalseOption`.
1240 If any of the :attr:`~BoolOption.pos_flags` are given with an inline argument,
1241 the argument is parsed as a :class:`bool`.
1243 .. note::
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:
1250 .. code-block:: console
1252 $ prog --json subcommand # Ok
1253 $ prog --json=true subcommand # Ok
1254 $ prog --json true subcommand # Not allowed
1256 :example:
1257 .. code-block:: python
1259 option = yuio.cli.BoolOption(
1260 pos_flags=["--json"],
1261 neg_flags=["--no-json"],
1262 dest=...,
1263 )
1265 .. code-block:: console
1267 $ prog --json # Set `dest` to `True`
1268 $ prog --no-json # Set `dest` to `False`
1269 $ prog --json=$value # Set `dest` to parsed `$value`
1271 """
1273 pos_flags: list[str]
1274 """
1275 List of flag names that enable this boolean option. Should be non-empty.
1277 """
1279 neg_flags: list[str]
1280 """
1281 List of flag names that disable this boolean option.
1283 """
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
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 )
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)
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
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
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
1378 primary_flags = set(self.primary_long_flags or [])
1379 if self.primary_short_flag:
1380 primary_flags.add(self.primary_short_flag)
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
1418 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1419 return (self.parser.completer(), False)
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.
1427 """
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 )
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)
1487 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1488 return (self.parser.completer(), False)
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.
1496 """
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()
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
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 )
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
1569 self.set(ns, value)
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
1596 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1597 return (self.parser.completer(), True)
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.
1605 """
1607 const: T
1608 """
1609 Constant that will be stored.
1611 """
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
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 )
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
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.
1668 """
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 )
1699 def format_metavar(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
1700 return _ColorizedString((ctx.get_color("hl/flag:sh-usage"), "..."))
1703@dataclass(eq=False, kw_only=True)
1704class StoreTrueOption(StoreConstOption[bool]):
1705 """
1706 An option that stores :data:`True` to namespace.
1708 """
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 )
1740@dataclass(eq=False, kw_only=True)
1741class StoreFalseOption(StoreConstOption[bool]):
1742 """
1743 An option that stores :data:`False` to namespace.
1745 """
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 )
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.
1782 """
1784 version: str
1785 """
1786 Version to print.
1788 """
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 )
1816 self.version = version
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
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)
1834@dataclass(eq=False, kw_only=True)
1835class BugReportOption(Option[_t.Never]):
1836 """
1837 An option that prints bug report.
1839 """
1841 settings: yuio.dbg.ReportSettings | bool | None
1842 """
1843 Settings for bug report generation.
1845 """
1847 app: yuio.app.App[_t.Any] | None
1848 """
1849 Main app of the project, used to extract project's version and dependencies.
1851 """
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 )
1880 self.settings = settings
1881 self.app = app
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
1892 yuio.dbg.print_report(settings=self.settings, app=self.app)
1893 sys.exit(0)
1896@dataclass(eq=False, kw_only=True)
1897class CompletionOption(Option[_t.Never]):
1898 """
1899 An option that installs autocompletion.
1901 """
1903 _SHELLS = [
1904 "all",
1905 "uninstall",
1906 "bash",
1907 "zsh",
1908 "fish",
1909 "pwsh",
1910 ]
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 )
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
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 )
1965 root = cli_parser._root_command
1967 if argument == "uninstall":
1968 compdata = ""
1969 else:
1970 serializer = yuio.complete._ProgramSerializer()
1971 self._dump(root, serializer, [])
1972 compdata = serializer.dump()
1974 yuio.complete._write_completions(compdata, root.name, argument)
1976 sys.exit(0)
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]] = []
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)
2002 # Add parent options if their flags were not shadowed.
2003 for option in parent_options:
2004 assert option.flags is not yuio.POSITIONAL
2006 flags = [flag for flag in option.flags if flag not in seen_flags]
2007 if not flags:
2008 continue
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 )
2031 seen_flags |= seen_flags
2032 seen_options.append(option)
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)
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 )
2049@dataclass(eq=False, kw_only=True)
2050class HelpOption(Option[_t.Never]):
2051 """
2052 An option that prints help message and stops the program.
2054 """
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 )
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
2091 if isinstance(arguments, list):
2092 argument = arguments[0].value if arguments else ""
2093 else:
2094 argument = arguments.value
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 )
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 )
2121 yuio.io.raw(formatter, add_newline=True, to_stdout=True)
2122 sys.exit(0)
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.
2130 """
2132 name: str
2133 """
2134 Canonical name of this command.
2136 """
2138 desc: str
2139 """
2140 Long description for a command.
2142 """
2144 help: str | yuio.Disabled
2145 """
2146 Help message for this command, displayed when listing subcommands.
2148 """
2150 epilog: str
2151 """
2152 Long description printed after command help.
2154 """
2156 usage: str | None
2157 """
2158 Override for usage section of CLI help.
2160 """
2162 options: list[Option[_t.Any]]
2163 """
2164 Options for this command.
2166 """
2168 subcommands: dict[str, Command[Namespace]]
2169 """
2170 Last positional option can be a sub-command.
2172 This is a map from subcommand's name or alias to subcommand's implementation.
2174 """
2176 subcommand_required: bool
2177 """
2178 Whether subcommand is required or optional. If no :attr:`~Command.subcommands`
2179 are given, this attribute is ignored.
2181 """
2183 ns_ctor: _t.Callable[[], NamespaceT]
2184 """
2185 A constructor that will be called to create namespace for command's arguments.
2187 """
2189 dest: str
2190 """
2191 Where to save subcommand's name.
2193 """
2195 ns_dest: str
2196 """
2197 Where to save subcommand's namespace.
2199 """
2201 metavar: str = "<subcommand>"
2202 """
2203 Meta variable used for subcommand option.
2205 """
2208@dataclass(eq=False, kw_only=True)
2209class _SubCommandOption(ValueOption[str]):
2210 subcommands: dict[str, Command[Namespace]]
2211 """
2212 All subcommands.
2214 """
2216 ns_dest: str
2217 """
2218 Where to save subcommand's namespace.
2220 """
2222 ns_ctor: _t.Callable[[], Namespace]
2223 """
2224 A constructor that will be called to create namespace for subcommand's arguments.
2226 """
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)}"
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 )
2266 self.subcommands = subcommands
2267 self.ns_dest = ns_dest
2268 self.ns_ctor = ns_ctor
2270 assert self.dest
2271 assert self.ns_dest
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)
2303@dataclass(eq=False, match_args=False, slots=True)
2304class _BoundOption:
2305 wrapped: Option[_t.Any]
2306 ns: Namespace
2307 seen: bool = False
2309 @property
2310 def usage(self):
2311 return self.wrapped.usage
2313 @property
2314 def flags(self):
2315 return self.wrapped.flags
2317 @property
2318 def nargs(self):
2319 return self.wrapped.nargs
2321 @property
2322 def allow_no_args(self):
2323 return self.wrapped.allow_no_args
2325 @property
2326 def allow_inline_arg(self):
2327 return self.wrapped.allow_inline_arg
2329 @property
2330 def allow_implicit_inline_arg(self):
2331 return self.wrapped.allow_implicit_inline_arg
2333 @property
2334 def mutex_group(self):
2335 return self.wrapped.mutex_group
2337 @property
2338 def required(self):
2339 return self.wrapped.required
2341 @property
2342 def allow_abbrev(self):
2343 return self.wrapped.allow_abbrev
2345 def nth_metavar(self, n: int) -> str:
2346 return self.wrapped.nth_metavar(n)
2349class CliParser(_t.Generic[NamespaceT]):
2350 """
2351 CLI arguments parser.
2353 :param command:
2354 root command.
2355 :param allow_abbrev:
2356 allow abbreviating CLI flags if that doesn't create ambiguity.
2358 """
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
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)
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)
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
2429 def parse(self, args: list[str] | None = None) -> NamespaceT:
2430 """
2431 Parse arguments and invoke their actions.
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`.
2441 """
2443 if args is None:
2444 args = sys.argv[1:]
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
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]] = {}
2459 self._seen_mutex_groups: dict[
2460 MutuallyExclusiveGroup, tuple[_BoundOption, Flag]
2461 ] = {}
2462 self._mutex_groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {}
2464 self._current_index = 0
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
2471 self._current_flag: tuple[_BoundOption, Flag] | None = None
2472 self._current_flag_args: list[Argument] = []
2473 self._current_positional_args: list[Argument] = []
2475 root_ns = self._root_command.ns_ctor()
2476 self._load_command(self._root_command, root_ns)
2478 allow_flags = True
2480 for i, arg in enumerate(args):
2481 self._current_index = i
2483 # Handle `--`.
2484 if arg == "--" and allow_flags:
2485 self._flush_flag()
2486 allow_flags = False
2487 continue
2489 # Check what we have here.
2490 if allow_flags:
2491 result = self._detect_flag(arg)
2492 else:
2493 result = None
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)
2503 self._finalize()
2505 return root_ns
2507 def _finalize(self):
2508 self._flush_flag()
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 )
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 )
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
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)
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
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
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 )
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
2637 # Try as signed int.
2638 if re.match(_NUM_RE, arg):
2639 # This is a positional.
2640 return None
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 )
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 )
2667 def _make_flag(self, arg: str):
2668 return Flag(arg, self._current_index)
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.
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()
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, [])
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)
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 )
2739 self._current_positional += 1
2740 self._current_positional_args = []
2742 self._eval_option(opt, None, args)
2744 def _flush_flag(self):
2745 if self._current_flag is None:
2746 return
2748 (opt, name), args = (self._current_flag, self._current_flag_args)
2750 self._current_flag = None
2751 self._current_flag_args = []
2753 self._eval_option(opt, name, args)
2755 def _push_flag(self, opt: _BoundOption, flag: Flag):
2756 assert self._current_flag is None
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 = []
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 )
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 )
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 )
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}")
2824def _is_short(flag: str):
2825 return flag.startswith("-") and len(flag) == 2 and flag != "--"
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 )
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 )
2883def _quote_and_adjust_pos(s: str, pos: tuple[int, int]):
2884 s = s.translate(_UNPRINTABLE_TRANS)
2886 if not s:
2887 return "''", (1, 1)
2888 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII):
2889 return s, pos
2891 start, end = pos
2893 start_shift = 1 + s[:start].count("'") * 4
2894 end_shift = start_shift + s[start:end].count("'") * 4
2896 return "'" + s.replace("'", "'\"'\"'") + "'", (start + start_shift, end + end_shift)
2899def _quote(s: str):
2900 s = s.translate(_UNPRINTABLE_TRANS)
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("'", "'\"'\"'") + "'"
2910class _HelpFormatter:
2911 def __init__(self, all: bool = False) -> None:
2912 self.nodes: list[yuio.md.AstBase] = []
2913 self.all = all
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)
2928 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
2929 return self.format(ctx)
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
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))
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)
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)
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)
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")
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
3047 return res
3050_ARGS_COLUMN_WIDTH = 26
3051_ARGS_COLUMN_WIDTH_NARROW = 8
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
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 )
3069 super().__init__(ctx, allow_headings=True)
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")
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 )
3091 def _format_Heading(self, node: yuio.md.Heading):
3092 if node.level == 1:
3093 self._heading_indent.close()
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 )
3102 if node.level == 1:
3103 self._heading_indent.enter_context(self._with_indent(None, " "))
3104 else:
3105 self._line(self._indent)
3107 self._is_first_line = True
3109 def _format_ResetIndentation(self, node: _ResetIndentation):
3110 self._heading_indent.close()
3111 self._is_first_line = True
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 )
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")
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)
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 )
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
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)
3172 res = _ColorizedString()
3173 res.append_color(self.prog_color)
3174 res.append_str(node.prog)
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>")
3182 res.append_color(self.base_color)
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
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
3218 if in_opt_short_group:
3219 res.append_color(self.punct_color)
3220 res.append_str("]")
3221 in_opt_short_group = False
3223 res.append_color(self.base_color)
3224 res.append_str(" ")
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("]")
3234 if in_opt_short_group:
3235 res.append_color(self.punct_color)
3236 res.append_str("]")
3237 in_opt_short_group = False
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 )
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("]")
3264 return res
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
3284 lead.append_colorized_str(
3285 node.arg.format_metavar(self.ctx).with_base_color(self.base_color)
3286 )
3288 help = node.arg.parse_help(self.ctx, all=self.all)
3290 if help is None:
3291 self._line(self._indent + lead)
3292 return
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))
3300 with indent_ctx:
3301 self._format(help)
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 )
3308 help = node.arg.parse_help(self.ctx)
3310 if help is None:
3311 self._line(self._indent + lead)
3312 return
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))
3320 with indent_ctx:
3321 self._format(help)
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
3334 help = node.help
3336 if not help:
3337 self._line(self._indent + lead)
3338 return
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))
3346 with indent_ctx:
3347 self._format(yuio.md.parse(help, allow_headings=False))
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)
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
3362 def _make_lead_padding(self, lead: _ColorizedString):
3363 color = self.base_color
3364 return lead + color + " " * (self._args_column_width - lead.width)
3367@dataclass(eq=False, match_args=False, slots=True)
3368class _ResetIndentation(yuio.md.AstBase):
3369 pass
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:"
3380@dataclass(eq=False, match_args=False, slots=True)
3381class _HelpOpt(yuio.md.AstBase):
3382 arg: Option[_t.Any]
3385@dataclass(eq=False, match_args=False, slots=True)
3386class _HelpArg(yuio.md.AstBase):
3387 arg: Option[_t.Any]
3390@dataclass(eq=False, match_args=False, slots=True)
3391class _InheritedOpts(yuio.md.AstBase):
3392 n_inherited: int
3395@dataclass(eq=False, match_args=False, slots=True)
3396class _HelpSubCommand(yuio.md.AstBase):
3397 names: list[str]
3398 help: str | None
3401@dataclass(eq=False, match_args=False, slots=True)
3402class _HelpArgGroup(yuio.md.Container[yuio.md.AstBase]):
3403 pass
3406class _ShortUsageFormatter:
3407 def __init__(self, subcommands: list[str] | None, option: Option[_t.Any]):
3408 self.subcommands = subcommands
3409 self.option = option
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")
3418 res = _ColorizedString()
3419 res.append_color(heading_color)
3420 res.append_str("Help: ")
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
3436 res.append_colorized_str(
3437 self.option.format_metavar(ctx).with_base_color(code_color)
3438 )
3440 res.append_color(heading_color)
3441 res.append_str("\n")
3442 res.append_color(note_color)
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
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")
3469 return res