Coverage for yuio / cli.py: 75%
1524 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
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:`~Option.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 sys
191import warnings
192from dataclasses import dataclass
194import yuio
195import yuio.complete
196import yuio.doc
197import yuio.hl
198import yuio.parse
199import yuio.string
200from yuio.string import ColorizedString as _ColorizedString
201from yuio.util import _UNPRINTABLE_TRANS
202from yuio.util import commonprefix as _commonprefix
204from typing import TYPE_CHECKING
206if TYPE_CHECKING:
207 import typing_extensions as _t
208else:
209 from yuio import _typing as _t
211if TYPE_CHECKING:
212 import yuio.app
213 import yuio.config
214 import yuio.dbg
216__all__ = [
217 "ARGS_GROUP",
218 "MISC_GROUP",
219 "OPTS_GROUP",
220 "SUBCOMMANDS_GROUP",
221 "Argument",
222 "ArgumentError",
223 "BoolOption",
224 "BugReportOption",
225 "CliParser",
226 "CliWarning",
227 "CollectOption",
228 "Command",
229 "CompletionOption",
230 "ConfigNamespace",
231 "CountOption",
232 "Flag",
233 "HelpGroup",
234 "HelpOption",
235 "MutuallyExclusiveGroup",
236 "NArgs",
237 "Namespace",
238 "Option",
239 "ParseManyOption",
240 "ParseOneOption",
241 "ParserOption",
242 "StoreConstOption",
243 "StoreFalseOption",
244 "StoreTrueOption",
245 "ValueOption",
246 "VersionOption",
247]
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 ctx.get_color("msg/text:code/sh-usage hl/flag:sh-usage"),
349 self.metavar,
350 )
353@dataclass(frozen=True, slots=True)
354class Flag:
355 value: str
356 """
357 Name of the flag.
359 """
361 index: int
362 """
363 Index of this flag in the array that was passed to :meth:`CliParser.parse`.
365 Note that this array does not include executable name, so indexes are shifted
366 relative to :data:`sys.argv`.
368 """
370 def __str__(self) -> str:
371 return self.value
373 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
374 return _ColorizedString(
375 ctx.get_color("msg/text:code/sh-usage hl/flag:sh-usage"),
376 self.value,
377 )
380class ArgumentError(yuio.PrettyException, ValueError):
381 """
382 Error that happened during argument parsing.
384 """
386 @_t.overload
387 def __init__(
388 self,
389 msg: _t.LiteralString,
390 /,
391 *args,
392 flag: Flag | None = None,
393 arguments: Argument | list[Argument] | None = None,
394 n_arg: int | None = None,
395 pos: tuple[int, int] | None = None,
396 path: list[tuple[_t.Any, str | None]] | None = None,
397 option: Option[_t.Any] | None = None,
398 ): ...
399 @_t.overload
400 def __init__(
401 self,
402 msg: yuio.string.Colorable | None = None,
403 /,
404 *,
405 flag: Flag | None = None,
406 arguments: Argument | list[Argument] | None = None,
407 n_arg: int | None = None,
408 pos: tuple[int, int] | None = None,
409 path: list[tuple[_t.Any, str | None]] | None = None,
410 option: Option[_t.Any] | None = None,
411 ): ...
412 def __init__(
413 self,
414 *args,
415 flag: Flag | None = None,
416 arguments: Argument | list[Argument] | None = None,
417 n_arg: int | None = None,
418 pos: tuple[int, int] | None = None,
419 path: list[tuple[_t.Any, str | None]] | None = None,
420 option: Option[_t.Any] | None = None,
421 ):
422 super().__init__(*args)
424 self.flag: Flag | None = flag
425 """
426 Flag that caused this error. Can be :data:`None` if error is caused
427 by a positional argument.
429 """
431 self.arguments: Argument | list[Argument] | None = arguments
432 """
433 Arguments that caused this error.
435 This can be a single argument, or multiple arguments. In the later case,
436 :attr:`~yuio.parse.ParsingError.n_arg` should correspond to the argument
437 that failed to parse. If :attr:`~yuio.parse.ParsingError.n_arg`
438 is :data:`None`, then all arguments are treated as faulty.
440 .. note::
442 Don't confuse :attr:`~ArgumentError.arguments` and :attr:`~BaseException.args`:
443 the latter contains formatting arguments and is defined
444 in the :class:`BaseException` class.
446 """
448 self.pos: tuple[int, int] | None = pos
449 """
450 Position in the original string in which this error has occurred (start
451 and end indices).
453 If :attr:`~ArgumentError.n_arg` is set, and :attr:`~ArgumentError.arguments`
454 is given as a list, then this position is relative to the argument
455 at index :attr:`~ArgumentError.n_arg`.
457 If :attr:`~ArgumentError.arguments` is given as a single argument (not a list),
458 then this position is relative to that argument.
460 Otherwise, position is ignored.
462 """
464 self.n_arg: int | None = n_arg
465 """
466 Index of the argument that caused the error.
468 """
470 self.path: list[tuple[_t.Any, str | None]] | None = path
471 """
472 Same as in :attr:`ParsingError.path <yuio.parse.ParsingError.path>`.
473 Can be present if parser uses :meth:`~yuio.parse.Parser.parse_config`
474 for validation.
476 """
478 self.option: Option[_t.Any] | None = option
479 """
480 Option which caused failure.
482 """
484 self.commandline: list[str] | None = None
485 self.prog: str | None = None
486 self.subcommands: list[str] | None = None
487 self.help_parser: yuio.doc.DocParser | None = None
489 @classmethod
490 def from_parsing_error(
491 cls,
492 e: yuio.parse.ParsingError,
493 /,
494 *,
495 flag: Flag | None = None,
496 arguments: Argument | list[Argument] | None = None,
497 option: Option[_t.Any] | None = None,
498 ):
499 """
500 Convert parsing error to argument error.
502 """
504 return cls(
505 *e.args,
506 flag=flag,
507 arguments=arguments,
508 n_arg=e.n_arg,
509 pos=e.pos,
510 path=e.path,
511 option=option,
512 )
514 def to_colorable(self) -> yuio.string.Colorable:
515 colorable = yuio.string.WithBaseColor(
516 super().to_colorable(),
517 base_color="msg/text:error",
518 )
520 msg = []
521 args = []
522 sep = False
524 if self.flag and self.flag.value:
525 msg.append("in flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>")
526 args.append(self.flag.value)
527 sep = True
529 argument = None
530 if isinstance(self.arguments, list):
531 if self.n_arg is not None and self.n_arg < len(self.arguments):
532 argument = self.arguments[self.n_arg]
533 else:
534 argument = self.arguments
536 if argument and argument.metavar:
537 if sep:
538 msg.append(", ")
539 msg.append("in <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>")
540 args.append(argument.metavar)
542 if self.path:
543 if sep:
544 msg.append(", ")
545 msg.append("in <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>")
546 args.append(yuio.parse._PathRenderer(self.path))
548 if sep:
549 msg.insert(0, "Error ")
550 msg.append(":")
552 colorable = yuio.string.Stack(
553 yuio.string.WithBaseColor(
554 yuio.string.Format("".join(msg), *args),
555 base_color="msg/text:failure",
556 ),
557 yuio.string.Indent(colorable),
558 )
559 else:
560 colorable = yuio.string.WithBaseColor(
561 colorable,
562 base_color="msg/text:failure",
563 )
565 if commandline := self._make_commandline():
566 colorable = yuio.string.Stack(
567 commandline,
568 colorable,
569 )
571 if usage := self._make_usage():
572 colorable = yuio.string.Stack(
573 colorable,
574 usage,
575 )
577 return colorable
579 def _make_commandline(self):
580 if not self.prog or not self.commandline:
581 return None
583 argument = None
584 if isinstance(self.arguments, list):
585 if self.n_arg is not None and self.n_arg < len(self.arguments):
586 argument = self.arguments[self.n_arg]
587 else:
588 argument = self.arguments
590 if argument:
591 arg_index = argument.index
592 arg_pos = (argument.pos, argument.pos + len(argument.value))
593 if self.pos:
594 arg_pos = (
595 arg_pos[0] + self.pos[0],
596 min(arg_pos[1], arg_pos[0] + self.pos[1]),
597 )
598 elif self.flag:
599 arg_index = self.flag.index
600 arg_pos = (0, len(self.commandline[arg_index]))
601 else:
602 return None
604 text = self.prog
605 text += " "
606 text += " ".join(_quote(arg) for arg in self.commandline[:arg_index])
607 if arg_index > 0:
608 text += " "
610 center, pos = _quote_and_adjust_pos(self.commandline[arg_index], arg_pos)
611 pos = (pos[0] + len(text), pos[1] + len(text))
613 text += center
614 text += " "
615 text += " ".join(_quote(arg) for arg in self.commandline[arg_index + 1 :])
617 if text:
618 return yuio.parse._CodeRenderer(text, pos, as_cli=True)
619 else:
620 return None
622 def _make_usage(self):
623 if not self.option or not self.option.help or not self.help_parser:
624 return None
625 else:
626 return _ShortUsageFormatter(self.help_parser, self.subcommands, self.option)
629class Namespace(_t.Protocol):
630 """
631 Protocol for namespace implementations.
633 """
635 @abc.abstractmethod
636 def __getitem__(self, key: str, /) -> _t.Any: ...
638 @abc.abstractmethod
639 def __setitem__(self, key: str, value: _t.Any, /): ...
641 @abc.abstractmethod
642 def __contains__(self, key: str, /) -> bool: ...
645@yuio.string.repr_from_rich
646class ConfigNamespace(Namespace, _t.Generic[ConfigT]):
647 """
648 Wrapper that makes :class:`~yuio.config.Config` instances behave like namespaces.
650 """
652 def __init__(self, config: ConfigT) -> None:
653 self.__config = config
655 @property
656 def config(self) -> ConfigT:
657 """
658 Wrapped config instance.
660 """
662 return self.__config
664 def __getitem__(self, key: str) -> _t.Any:
665 root, key = self.__split_key(key)
666 try:
667 return getattr(root, key)
668 except AttributeError as e:
669 raise KeyError(str(e)) from None
671 def __setitem__(self, key: str, value: _t.Any):
672 root, key = self.__split_key(key)
673 try:
674 return setattr(root, key, value)
675 except AttributeError as e:
676 raise KeyError(str(e)) from None
678 def __contains__(self, key: str):
679 root, key = self.__split_key(key)
680 return key in root.__dict__
682 def __split_key(self, key: str) -> tuple[yuio.config.Config, str]:
683 root = self.__config
684 *parents, key = key.split(".")
685 for parent in parents:
686 root = getattr(root, parent)
687 return root, key
689 def __rich_repr__(self):
690 yield None, self.__config
693@dataclass(eq=False)
694class HelpGroup:
695 """
696 Group of flags in CLI help.
698 """
700 title: str
701 """
702 Title for this group.
704 """
706 help: str | yuio.Disabled = dataclasses.field(default="", kw_only=True)
707 """
708 Help message for an option.
710 """
712 collapse: bool = dataclasses.field(default=False, kw_only=True)
713 """
714 Hide options from this group in CLI help, but show group's title and help.
716 """
718 _slug: str | None = dataclasses.field(default=None, kw_only=True)
721ARGS_GROUP = HelpGroup("Arguments")
722"""
723Help group for positional arguments.
725"""
727SUBCOMMANDS_GROUP = HelpGroup("Subcommands")
728"""
729Help group for subcommands.
731"""
733OPTS_GROUP = HelpGroup("Options")
734"""
735Help group for flags.
737"""
739MISC_GROUP = HelpGroup("Misc options")
740"""
741Help group for misc flags such as :flag:`--help` or :flag:`--version`.
743"""
746@dataclass(kw_only=True, eq=False)
747class MutuallyExclusiveGroup:
748 """
749 A sentinel for creating mutually exclusive groups.
751 Pass an instance of this class all :func:`~yuio.app.field`\\ s that should
752 be mutually exclusive.
754 """
756 required: bool = False
757 """
758 Require that one of the mutually exclusive options is always given.
760 """
763@dataclass(eq=False, kw_only=True)
764class Option(abc.ABC, _t.Generic[T_cov]):
765 """
766 Base class for a CLI option.
768 """
770 flags: list[str] | yuio.Positional
771 """
772 Flags corresponding to this option. Positional options have flags set to
773 :data:`yuio.POSITIONAL`.
775 """
777 allow_inline_arg: bool
778 """
779 Whether to allow specifying argument inline (i.e. :flag:`--foo=bar`).
781 Inline arguments are handled separately from normal arguments,
782 and :attr:`~Option.nargs` setting does not affect them.
784 Positional options can't take inline arguments, so this attribute has
785 no effect on them.
787 """
789 allow_implicit_inline_arg: bool
790 """
791 Whether to allow specifying argument inline with short flags without equals sign
792 (i.e. :flag:`-fValue`).
794 Inline arguments are handled separately from normal arguments,
795 and :attr:`~Option.nargs` setting does not affect them.
797 Positional options can't take inline arguments, so this attribute has
798 no effect on them.
800 """
802 nargs: NArgs
803 """
804 How many arguments this option takes.
806 """
808 allow_no_args: bool
809 """
810 Whether to allow passing no arguments even if :attr:`~Option.nargs` requires some.
812 """
814 required: bool
815 """
816 Makes this option required. The parsing will fail if this option is not
817 encountered among CLI arguments.
819 Note that positional arguments are always parsed; if no positionals are given,
820 all positional options are processed with zero arguments, at which point they'll
821 fail :attr:`~Option.nargs` check. Thus, :attr:`~Option.required` has no effect
822 on positionals.
824 """
826 metavar: str | tuple[str, ...]
827 """
828 Option's meta variable, used for displaying help messages.
830 If :attr:`~Option.nargs` is an integer, this can be a tuple of strings,
831 one for each argument. If :attr:`~Option.nargs` is zero, this can be an empty
832 tuple.
834 """
836 mutex_group: None | MutuallyExclusiveGroup
837 """
838 Mutually exclusive group for this option. Positional options can't have
839 mutex groups.
841 """
843 usage: yuio.Collapse | bool
844 """
845 Specifies whether this option should be displayed in CLI usage. Positional options
846 are always displayed, regardless of this setting.
848 """
850 help: str | yuio.Disabled
851 """
852 Help message for an option.
854 """
856 help_group: HelpGroup | None
857 """
858 Group for this flag, default is :data:`OPTS_GROUP` for flags and :data:`ARGS_GROUP`
859 for positionals. Positionals are flags are never mixed together; if they appear
860 in the same group, the group title will be repeated twice.
862 """
864 default_desc: str | None
865 """
866 Overrides description of default value.
868 """
870 show_if_inherited: bool
871 """
872 Force-show this flag if it's inherited from parent command. Positionals can't be
873 inherited because subcommand argument always goes last.
875 """
877 allow_abbrev: bool
878 """
879 Allow abbreviation for this option.
881 """
883 dest: str
884 """
885 Key where to store parsed argument.
887 """
889 @abc.abstractmethod
890 def process(
891 self,
892 cli_parser: CliParser[Namespace],
893 flag: Flag | None,
894 arguments: Argument | list[Argument],
895 ns: Namespace,
896 ):
897 """
898 Process this argument.
900 This method is called every time an option is encountered
901 on the command line. It should parse option's args and merge them
902 with previous values, if there are any.
904 When option's arguments are passed separately (i.e. :flag:`--opt arg1 arg2 ...`),
905 `args` is given as a list. List's length is checked against
906 :attr:`~Option.nargs` before this method is called.
908 When option's arguments are passed as an inline value (i.e. :flag:`--long=arg`
909 or :flag:`-Sarg`), the `args` is given as a string. :attr:`~Option.nargs`
910 are not checked in this case, giving you an opportunity to handle inline option
911 however you like.
913 :param cli_parser:
914 CLI parser instance that's doing the parsing. Not to be confused with
915 :class:`yuio.parse.Parser`.
916 :param flag:
917 flag that set this option. This will be set to :data:`None`
918 for positional arguments.
919 :param arguments:
920 option arguments, see above.
921 :param ns:
922 namespace where parsed arguments should be stored.
923 :raises:
924 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
926 """
928 def post_process(
929 self,
930 cli_parser: CliParser[Namespace],
931 arguments: list[Argument],
932 ns: Namespace,
933 ):
934 """
935 Called once at the end of parsing to post-process all arguments.
937 :param cli_parser:
938 CLI parser instance that's doing the parsing. Not to be confused with
939 :class:`yuio.parse.Parser`.
940 :param arguments:
941 option arguments that were ever passed to this option.
942 :param ns:
943 namespace where parsed arguments should be stored.
944 :raises:
945 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
947 """
949 @functools.cached_property
950 def short_flags(self) -> list[str] | None:
951 if self.flags is yuio.POSITIONAL:
952 return None
953 else:
954 return [flag for flag in self.flags if _is_short(flag)]
956 @functools.cached_property
957 def long_flags(self) -> list[str] | None:
958 if self.flags is yuio.POSITIONAL:
959 return None
960 else:
961 return [flag for flag in self.flags if not _is_short(flag)]
963 @functools.cached_property
964 def primary_short_flag(self) -> str | None:
965 """
966 Short flag that will be displayed in CLI help.
968 """
970 if short_flags := self.short_flags:
971 return short_flags[0]
972 else:
973 return None
975 @functools.cached_property
976 def primary_long_flags(self) -> list[str] | None:
977 """
978 Long flags that will be displayed in CLI help.
980 """
982 if long_flags := self.long_flags:
983 return [long_flags[0]]
984 else:
985 return None
987 def format_usage(
988 self,
989 ctx: yuio.string.ReprContext,
990 /,
991 ) -> tuple[_ColorizedString, bool]:
992 """
993 Allows customizing how this option looks in CLI usage.
995 :param ctx:
996 repr context for formatting help.
997 :returns:
998 a string that will be used to represent this option in program's
999 usage section.
1001 """
1003 can_group = False
1004 res = _ColorizedString()
1005 if self.flags is not yuio.POSITIONAL and self.flags:
1006 flag = self.primary_short_flag
1007 if flag:
1008 can_group = True
1009 elif self.primary_long_flags:
1010 flag = self.primary_long_flags[0]
1011 else:
1012 flag = self.flags[0]
1013 res.append_color(ctx.get_color("hl/flag:sh-usage"))
1014 res.append_str(flag)
1015 if metavar := self.format_metavar(ctx):
1016 res.append_colorized_str(metavar)
1017 can_group = False
1018 return res, can_group
1020 def format_metavar(
1021 self,
1022 ctx: yuio.string.ReprContext,
1023 /,
1024 ) -> _ColorizedString:
1025 """
1026 Allows customizing how this option looks in CLI help.
1028 :param ctx:
1029 repr context for formatting help.
1030 :returns:
1031 a string that will be appended to the list of option's flags
1032 to format an entry for this option in CLI help message.
1034 """
1036 res = _ColorizedString()
1038 if not self.nargs:
1039 return res
1041 res.append_color(ctx.get_color("hl/punct:sh-usage"))
1042 if self.flags:
1043 res.append_str(" ")
1045 if self.nargs == "+":
1046 if self.allow_no_args:
1047 res.append_str("[")
1048 res.append_colorized_str(_format_metavar(self.nth_metavar(0), ctx))
1049 if self.allow_no_args:
1050 res.append_str(" ...]")
1051 else:
1052 res.append_str(" [")
1053 res.append_colorized_str(_format_metavar(self.nth_metavar(0), ctx))
1054 res.append_str(" ...]")
1055 elif isinstance(self.nargs, int) and self.nargs:
1056 if self.allow_no_args:
1057 res.append_str("[")
1058 sep = False
1059 for i in range(self.nargs):
1060 if sep:
1061 res.append_str(" ")
1062 res.append_colorized_str(_format_metavar(self.nth_metavar(i), ctx))
1063 sep = True
1064 if self.allow_no_args:
1065 res.append_str("]")
1067 return res
1069 def format_help_tail(
1070 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1071 ) -> _ColorizedString:
1072 """
1073 Format additional content that will be added to the end of the help message,
1074 such as aliases, default value, etc.
1076 :param ctx:
1077 repr context for formatting help.
1078 :param all:
1079 whether :flag:`--help=all` was specified.
1080 :returns:
1081 a string that will be appended to the main help message.
1083 """
1085 base_color = ctx.get_color("msg/text:help/tail msg/text:code/sh-usage")
1087 res = _ColorizedString(base_color)
1089 if alias_flags := self.format_alias_flags(ctx, all=all):
1090 es = "" if len(alias_flags) == 1 else "es"
1091 res.append_str(f"Alias{es}: ")
1092 sep = False
1093 for alias_flag in alias_flags:
1094 if isinstance(alias_flag, tuple):
1095 alias_flag = alias_flag[0]
1096 if sep:
1097 res.append_str(", ")
1098 res.append_colorized_str(alias_flag.with_base_color(base_color))
1099 sep = True
1101 if default := self.format_default(ctx, all=all):
1102 if res:
1103 res.append_str("; ")
1104 res.append_str("Default: ")
1105 res.append_colorized_str(default.with_base_color(base_color))
1107 if res:
1108 res.append_str(".")
1110 return res
1112 def format_alias_flags(
1113 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1114 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None:
1115 """
1116 Format alias flags that weren't included in :attr:`~Option.primary_short_flag`
1117 and :attr:`~Option.primary_long_flags`.
1119 :param ctx:
1120 repr context for formatting help.
1121 :param all:
1122 whether :flag:`--help=all` was specified.
1123 :returns:
1124 a list of strings, one per each alias.
1126 """
1128 if self.flags is yuio.POSITIONAL:
1129 return None
1130 primary_flags = set(self.primary_long_flags or [])
1131 if self.primary_short_flag:
1132 primary_flags.add(self.primary_short_flag)
1133 aliases: list[_ColorizedString | tuple[_ColorizedString, str]] = []
1134 flag_color = ctx.get_color("hl/flag:sh-usage")
1135 for flag in self.flags:
1136 if flag not in primary_flags:
1137 res = _ColorizedString()
1138 res.start_no_wrap()
1139 res.append_color(flag_color)
1140 res.append_str(flag)
1141 res.end_no_wrap()
1142 aliases.append(res)
1143 return aliases
1145 def format_default(
1146 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1147 ) -> _ColorizedString | None:
1148 """
1149 Format default value that will be included in the CLI help.
1151 :param ctx:
1152 repr context for formatting help.
1153 :param all:
1154 whether :flag:`--help=all` was specified.
1155 :returns:
1156 a string that will be appended to the main help message.
1158 """
1160 if self.default_desc is not None:
1161 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code"))
1163 return None
1165 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1166 return None, False
1168 def nth_metavar(self, n: int) -> str:
1169 """
1170 Get metavar for n-th argument for this option.
1172 """
1174 if not self.metavar:
1175 return "<argument>"
1176 if isinstance(self.metavar, tuple):
1177 if n >= len(self.metavar):
1178 return self.metavar[-1]
1179 else:
1180 return self.metavar[n]
1181 else:
1182 return self.metavar
1185@dataclass(eq=False, kw_only=True)
1186class ValueOption(Option[T], _t.Generic[T]):
1187 """
1188 Base class for options that parse arguments and assign them to namespace.
1190 This base handles assigning parsed value to the target destination and merging
1191 values if option is invoked multiple times. Call ``self.set(ns, value)`` from
1192 :meth:`Option.process` to set result of option processing.
1194 """
1196 merge: _t.Callable[[T, T], T] | None
1197 """
1198 Function to merge previous and new value.
1200 """
1202 default: object
1203 """
1204 Default value that will be used if this flag is not given.
1206 Used for formatting help, does not affect actual parsing.
1208 """
1210 def set(self, ns: Namespace, value: T):
1211 """
1212 Save new value. If :attr:`~ValueOption.merge` is given, automatically
1213 merge old and new value.
1215 """
1217 if self.merge and self.dest in ns:
1218 ns[self.dest] = self.merge(ns[self.dest], value)
1219 else:
1220 ns[self.dest] = value
1223@dataclass(eq=False, kw_only=True)
1224class ParserOption(ValueOption[T], _t.Generic[T]):
1225 """
1226 Base class for options that use :mod:`yuio.parse` to process arguments.
1228 """
1230 parser: yuio.parse.Parser[T]
1231 """
1232 A parser used to parse option's arguments.
1234 """
1236 def format_default(
1237 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1238 ) -> _ColorizedString | None:
1239 if self.default_desc is not None:
1240 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code"))
1242 if self.default is yuio.MISSING or self.default is None:
1243 return None
1245 try:
1246 return ctx.hl(self.parser.describe_value(self.default)).with_base_color(
1247 ctx.get_color("code")
1248 )
1249 except TypeError:
1250 return ctx.repr(self.default)
1253@dataclass(eq=False, kw_only=True)
1254class BoolOption(ParserOption[bool]):
1255 """
1256 An option that combines :class:`StoreTrueOption`, :class:`StoreFalseOption`,
1257 and :class:`ParseOneOption`.
1259 If any of the :attr:`~BoolOption.pos_flags` are given without arguments, it works like
1260 :class:`StoreTrueOption`.
1262 If any of the :attr:`~BoolOption.neg_flags` are given, it works like
1263 :class:`StoreFalseOption`.
1265 If any of the :attr:`~BoolOption.pos_flags` are given with an inline argument,
1266 the argument is parsed as a :class:`bool`.
1268 .. note::
1270 Bool option has :attr:`~Option.nargs` set to ``0``, so non-inline arguments
1271 (i.e. :flag:`--json false`) are not recognized. You should always use inline
1272 argument to set boolean flag's value (i.e. :flag:`--json=false`). This avoids
1273 ambiguity in cases like the following:
1275 .. code-block:: console
1277 $ prog --json subcommand # Ok
1278 $ prog --json=true subcommand # Ok
1279 $ prog --json true subcommand # Not allowed
1281 :example:
1282 .. code-block:: python
1284 option = yuio.cli.BoolOption(
1285 pos_flags=["--json"],
1286 neg_flags=["--no-json"],
1287 dest=...,
1288 )
1290 .. code-block:: console
1292 $ prog --json # Set `dest` to `True`
1293 $ prog --no-json # Set `dest` to `False`
1294 $ prog --json=$value # Set `dest` to parsed `$value`
1296 """
1298 pos_flags: list[str]
1299 """
1300 List of flag names that enable this boolean option. Should be non-empty.
1302 """
1304 neg_flags: list[str]
1305 """
1306 List of flag names that disable this boolean option.
1308 """
1310 def __init__(
1311 self,
1312 *,
1313 pos_flags: list[str],
1314 neg_flags: list[str],
1315 required: bool = False,
1316 mutex_group: None | MutuallyExclusiveGroup = None,
1317 usage: yuio.Collapse | bool = True,
1318 help: str | yuio.Disabled = "",
1319 help_group: HelpGroup | None = None,
1320 show_if_inherited: bool = False,
1321 dest: str,
1322 parser: yuio.parse.Parser[bool] | None = None,
1323 merge: _t.Callable[[bool, bool], bool] | None = None,
1324 default: bool | yuio.Missing = yuio.MISSING,
1325 allow_abbrev: bool = True,
1326 default_desc: str | None = None,
1327 ):
1328 self.pos_flags = pos_flags
1329 self.neg_flags = neg_flags
1331 super().__init__(
1332 flags=pos_flags + neg_flags,
1333 allow_inline_arg=True,
1334 allow_implicit_inline_arg=False,
1335 nargs=0,
1336 allow_no_args=True,
1337 required=required,
1338 metavar=(),
1339 mutex_group=mutex_group,
1340 usage=usage,
1341 help=help,
1342 help_group=help_group,
1343 show_if_inherited=show_if_inherited,
1344 dest=dest,
1345 merge=merge,
1346 default=default,
1347 parser=parser or yuio.parse.Bool(),
1348 allow_abbrev=allow_abbrev,
1349 default_desc=default_desc,
1350 )
1352 def process(
1353 self,
1354 cli_parser: CliParser[Namespace],
1355 flag: Flag | None,
1356 arguments: Argument | list[Argument],
1357 ns: Namespace,
1358 ):
1359 if flag and flag.value in self.neg_flags:
1360 if arguments:
1361 raise ArgumentError(
1362 "This flag can't have arguments", flag=flag, arguments=arguments
1363 )
1364 value = False
1365 elif isinstance(arguments, Argument):
1366 value = self.parser.parse(arguments.value)
1367 else:
1368 value = True
1369 self.set(ns, value)
1371 @functools.cached_property
1372 def primary_short_flag(self):
1373 if self.flags is yuio.POSITIONAL:
1374 return None
1375 if self.default is True:
1376 flags = self.neg_flags
1377 else:
1378 flags = self.pos_flags
1379 for flag in flags:
1380 if _is_short(flag):
1381 return flag
1382 return None
1384 @functools.cached_property
1385 def primary_long_flags(self):
1386 flags = []
1387 if self.default is not True:
1388 for flag in self.pos_flags:
1389 if not _is_short(flag):
1390 flags.append(flag)
1391 break
1392 if self.default is not False:
1393 for flag in self.neg_flags:
1394 if not _is_short(flag):
1395 flags.append(flag)
1396 break
1397 return flags
1399 def format_alias_flags(
1400 self, ctx: yuio.string.ReprContext, *, all: bool = False
1401 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None:
1402 if self.flags is yuio.POSITIONAL:
1403 return None
1405 primary_flags = set(self.primary_long_flags or [])
1406 if self.primary_short_flag:
1407 primary_flags.add(self.primary_short_flag)
1409 aliases: list[_ColorizedString | tuple[_ColorizedString, str]] = []
1410 flag_color = ctx.get_color("hl/flag:sh-usage")
1411 if all:
1412 alias_candidates = self.pos_flags + self.neg_flags
1413 else:
1414 alias_candidates = []
1415 if self.default is not True:
1416 alias_candidates += self.pos_flags
1417 if self.default is not False:
1418 alias_candidates += self.neg_flags
1419 for flag in alias_candidates:
1420 if flag not in primary_flags:
1421 res = _ColorizedString()
1422 res.start_no_wrap()
1423 res.append_color(flag_color)
1424 res.append_str(flag)
1425 res.end_no_wrap()
1426 aliases.append(res)
1427 if self.pos_flags and all:
1428 primary_pos_flag = self.pos_flags[0]
1429 for pos_flag in self.pos_flags:
1430 if not _is_short(pos_flag):
1431 primary_pos_flag = pos_flag
1432 break
1433 punct_color = ctx.get_color("hl/punct:sh-usage")
1434 metavar_color = ctx.get_color("hl/metavar:sh-usage")
1435 res = _ColorizedString()
1436 res.start_no_wrap()
1437 res.append_color(flag_color)
1438 res.append_str(primary_pos_flag)
1439 res.end_no_wrap()
1440 res.append_color(punct_color)
1441 res.append_str("={")
1442 res.append_color(metavar_color)
1443 res.append_str("true")
1444 res.append_color(punct_color)
1445 res.append_str("|")
1446 res.append_color(metavar_color)
1447 res.append_str("false")
1448 res.append_color(punct_color)
1449 res.append_str("}")
1450 aliases.append(res)
1451 return aliases
1453 def format_default(
1454 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1455 ) -> _ColorizedString | None:
1456 if self.default_desc is not None:
1457 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code"))
1459 return None
1461 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1462 return (
1463 yuio.complete.Choice(
1464 [
1465 yuio.complete.Option("true"),
1466 yuio.complete.Option("false"),
1467 ]
1468 ),
1469 False,
1470 )
1473@dataclass(eq=False, kw_only=True)
1474class ParseOneOption(ParserOption[T], _t.Generic[T]):
1475 """
1476 An option with a single argument that uses Yuio parser.
1478 """
1480 def __init__(
1481 self,
1482 *,
1483 flags: list[str] | yuio.Positional,
1484 required: bool = False,
1485 mutex_group: None | MutuallyExclusiveGroup = None,
1486 usage: yuio.Collapse | bool = True,
1487 help: str | yuio.Disabled = "",
1488 help_group: HelpGroup | None = None,
1489 show_if_inherited: bool = False,
1490 dest: str,
1491 parser: yuio.parse.Parser[T],
1492 merge: _t.Callable[[T, T], T] | None = None,
1493 default: T | yuio.Missing = yuio.MISSING,
1494 allow_abbrev: bool = True,
1495 default_desc: str | None = None,
1496 ):
1497 super().__init__(
1498 flags=flags,
1499 allow_inline_arg=True,
1500 allow_implicit_inline_arg=True,
1501 nargs=1,
1502 allow_no_args=default is not yuio.MISSING and flags is yuio.POSITIONAL,
1503 required=required,
1504 metavar=parser.describe_or_def(),
1505 mutex_group=mutex_group,
1506 usage=usage,
1507 help=help,
1508 help_group=help_group,
1509 show_if_inherited=show_if_inherited,
1510 dest=dest,
1511 merge=merge,
1512 default=default,
1513 parser=parser,
1514 allow_abbrev=allow_abbrev,
1515 default_desc=default_desc,
1516 )
1518 def process(
1519 self,
1520 cli_parser: CliParser[Namespace],
1521 flag: Flag | None,
1522 arguments: Argument | list[Argument],
1523 ns: Namespace,
1524 ):
1525 if isinstance(arguments, list):
1526 if not arguments and self.allow_no_args:
1527 return # Don't set value so that app falls back to default.
1528 arguments = arguments[0]
1529 try:
1530 self.set(ns, self.parser.parse(arguments.value))
1531 except yuio.parse.ParsingError as e:
1532 e.n_arg = 0
1533 raise
1535 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1536 return (self.parser.completer(), False)
1539@dataclass(eq=False, kw_only=True)
1540class ParseManyOption(ParserOption[T], _t.Generic[T]):
1541 """
1542 An option with multiple arguments that uses Yuio parser.
1544 """
1546 def __init__(
1547 self,
1548 *,
1549 flags: list[str] | yuio.Positional,
1550 required: bool = False,
1551 mutex_group: None | MutuallyExclusiveGroup = None,
1552 usage: yuio.Collapse | bool = True,
1553 help: str | yuio.Disabled = "",
1554 help_group: HelpGroup | None = None,
1555 show_if_inherited: bool = False,
1556 dest: str,
1557 parser: yuio.parse.Parser[T],
1558 merge: _t.Callable[[T, T], T] | None = None,
1559 default: T | yuio.Missing = yuio.MISSING,
1560 allow_abbrev: bool = True,
1561 default_desc: str | None = None,
1562 ):
1563 assert parser.supports_parse_many()
1565 nargs = parser.get_nargs()
1566 allow_no_args = default is not yuio.MISSING and flags is yuio.POSITIONAL
1567 if nargs == "*":
1568 nargs = "+"
1569 allow_no_args = True
1571 super().__init__(
1572 flags=flags,
1573 allow_inline_arg=True,
1574 allow_implicit_inline_arg=True,
1575 nargs=nargs,
1576 allow_no_args=allow_no_args,
1577 required=required,
1578 metavar=parser.describe_many(),
1579 mutex_group=mutex_group,
1580 usage=usage,
1581 help=help,
1582 help_group=help_group,
1583 show_if_inherited=show_if_inherited,
1584 dest=dest,
1585 merge=merge,
1586 default=default,
1587 parser=parser,
1588 allow_abbrev=allow_abbrev,
1589 default_desc=default_desc,
1590 )
1592 def process(
1593 self,
1594 cli_parser: CliParser[Namespace],
1595 flag: Flag | None,
1596 arguments: Argument | list[Argument],
1597 ns: Namespace,
1598 ):
1599 if (
1600 not arguments
1601 and self.allow_no_args
1602 and self.default is not yuio.MISSING
1603 and self.flags is yuio.POSITIONAL
1604 ):
1605 return # Don't set value so that app falls back to default.
1607 if isinstance(arguments, list):
1608 self.set(ns, self.parser.parse_many([arg.value for arg in arguments]))
1609 else:
1610 self.set(ns, self.parser.parse(arguments.value))
1612 def format_alias_flags(
1613 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1614 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None:
1615 aliases = super().format_alias_flags(ctx, all=all) or []
1616 if all:
1617 flag = self.primary_short_flag
1618 if not flag and self.primary_long_flags:
1619 flag = self.primary_long_flags[0]
1620 if not flag and self.flags:
1621 flag = self.flags[0]
1622 if flag:
1623 res = _ColorizedString()
1624 res.start_no_wrap()
1625 res.append_color(ctx.get_color("hl/flag:sh-usage"))
1626 res.append_str(flag)
1627 res.end_no_wrap()
1628 res.append_color(ctx.get_color("hl/punct:sh-usage"))
1629 res.append_str("=")
1630 res.append_color(ctx.get_color("hl/str:sh-usage"))
1631 res.append_str("'")
1632 res.append_str(self.parser.describe_or_def())
1633 res.append_str("'")
1634 comment = (
1635 "can be given as a single argument with delimiter-separated list."
1636 )
1637 aliases.append((res, comment))
1638 return aliases
1640 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1641 return (self.parser.completer(), True)
1644@dataclass(eq=False, kw_only=True)
1645class CollectOption(ParserOption[T], _t.Generic[T]):
1646 """
1647 An option with single argument that collects all of its instances and passes them
1648 to :meth:`Parser.parse_many <yuio.parse.Parser.parse_many>`.
1650 """
1652 def __init__(
1653 self,
1654 *,
1655 flags: list[str] | yuio.Positional,
1656 required: bool = False,
1657 mutex_group: None | MutuallyExclusiveGroup = None,
1658 usage: yuio.Collapse | bool = True,
1659 help: str | yuio.Disabled = "",
1660 help_group: HelpGroup | None = None,
1661 show_if_inherited: bool = False,
1662 dest: str,
1663 parser: yuio.parse.Parser[T],
1664 merge: _t.Callable[[T, T], T] | None = None,
1665 default: T | yuio.Missing = yuio.MISSING,
1666 allow_abbrev: bool = True,
1667 default_desc: str | None = None,
1668 ):
1669 assert parser.supports_parse_many()
1671 if flags is yuio.POSITIONAL:
1672 raise TypeError(
1673 "ParseManyOneByOneOption can't be used with positional arguments"
1674 )
1676 nargs = parser.get_nargs()
1677 if nargs not in ["*", "+"]:
1678 raise TypeError(
1679 "ParseManyOneByOneOption can't be used with parser "
1680 "that limits length of its collection"
1681 )
1683 super().__init__(
1684 flags=flags,
1685 allow_inline_arg=True,
1686 allow_implicit_inline_arg=True,
1687 nargs=1,
1688 allow_no_args=False,
1689 required=required,
1690 metavar=parser.describe_many(),
1691 mutex_group=mutex_group,
1692 usage=usage,
1693 help=help,
1694 help_group=help_group,
1695 show_if_inherited=show_if_inherited,
1696 dest=dest,
1697 merge=merge,
1698 default=default,
1699 parser=parser,
1700 allow_abbrev=allow_abbrev,
1701 default_desc=default_desc,
1702 )
1704 def process(
1705 self,
1706 cli_parser: CliParser[Namespace],
1707 flag: Flag | None,
1708 arguments: Argument | list[Argument],
1709 ns: Namespace,
1710 ):
1711 pass
1713 def post_process(
1714 self,
1715 cli_parser: CliParser[Namespace],
1716 arguments: list[Argument],
1717 ns: Namespace,
1718 ):
1719 self.set(ns, self.parser.parse_many([arg.value for arg in arguments]))
1721 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1722 return (self.parser.completer(), True)
1725@dataclass(eq=False, kw_only=True)
1726class StoreConstOption(ValueOption[T], _t.Generic[T]):
1727 """
1728 An option with no arguments that stores a constant to namespace.
1730 """
1732 const: T
1733 """
1734 Constant that will be stored.
1736 """
1738 def __init__(
1739 self,
1740 *,
1741 flags: list[str],
1742 required: bool = False,
1743 mutex_group: None | MutuallyExclusiveGroup = None,
1744 usage: yuio.Collapse | bool = True,
1745 help: str | yuio.Disabled = "",
1746 help_group: HelpGroup | None = None,
1747 show_if_inherited: bool = False,
1748 dest: str,
1749 merge: _t.Callable[[T, T], T] | None = None,
1750 default: T | yuio.Missing = yuio.MISSING,
1751 const: T,
1752 allow_abbrev: bool = True,
1753 default_desc: str | None = None,
1754 ):
1755 self.const = const
1757 super().__init__(
1758 flags=flags,
1759 allow_inline_arg=False,
1760 allow_implicit_inline_arg=False,
1761 nargs=0,
1762 allow_no_args=True,
1763 required=required,
1764 metavar=(),
1765 mutex_group=mutex_group,
1766 usage=usage,
1767 help=help,
1768 help_group=help_group,
1769 show_if_inherited=show_if_inherited,
1770 dest=dest,
1771 merge=merge,
1772 default=default,
1773 allow_abbrev=allow_abbrev,
1774 default_desc=default_desc,
1775 )
1777 def process(
1778 self,
1779 cli_parser: CliParser[Namespace],
1780 flag: Flag | None,
1781 arguments: Argument | list[Argument],
1782 ns: Namespace,
1783 ):
1784 if self.merge and self.dest in ns:
1785 ns[self.dest] = self.merge(ns[self.dest], self.const)
1786 else:
1787 ns[self.dest] = self.const
1790@dataclass(eq=False, kw_only=True)
1791class CountOption(StoreConstOption[int]):
1792 """
1793 An option that counts number of its appearances on the command line.
1795 """
1797 def __init__(
1798 self,
1799 *,
1800 flags: list[str],
1801 required: bool = False,
1802 mutex_group: None | MutuallyExclusiveGroup = None,
1803 usage: yuio.Collapse | bool = True,
1804 help: str | yuio.Disabled = "",
1805 help_group: HelpGroup | None = None,
1806 show_if_inherited: bool = False,
1807 dest: str,
1808 default: int | yuio.Missing = yuio.MISSING,
1809 allow_abbrev: bool = True,
1810 default_desc: str | None = None,
1811 ):
1812 super().__init__(
1813 flags=flags,
1814 required=required,
1815 mutex_group=mutex_group,
1816 usage=usage,
1817 help=help,
1818 help_group=help_group,
1819 show_if_inherited=show_if_inherited,
1820 dest=dest,
1821 merge=lambda x, y: x + y,
1822 default=default,
1823 const=1,
1824 allow_abbrev=allow_abbrev,
1825 default_desc=default_desc,
1826 )
1828 def format_metavar(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
1829 return _ColorizedString((ctx.get_color("hl/flag:sh-usage"), "..."))
1832@dataclass(eq=False, kw_only=True)
1833class StoreTrueOption(StoreConstOption[bool]):
1834 """
1835 An option that stores :data:`True` to namespace.
1837 """
1839 def __init__(
1840 self,
1841 *,
1842 flags: list[str],
1843 required: bool = False,
1844 mutex_group: None | MutuallyExclusiveGroup = None,
1845 usage: yuio.Collapse | bool = True,
1846 help: str | yuio.Disabled = "",
1847 help_group: HelpGroup | None = None,
1848 show_if_inherited: bool = False,
1849 dest: str,
1850 default: bool | yuio.Missing = yuio.MISSING,
1851 allow_abbrev: bool = True,
1852 default_desc: str | None = None,
1853 ):
1854 super().__init__(
1855 flags=flags,
1856 required=required,
1857 mutex_group=mutex_group,
1858 usage=usage,
1859 help=help,
1860 help_group=help_group,
1861 show_if_inherited=show_if_inherited,
1862 dest=dest,
1863 merge=None,
1864 default=default,
1865 const=True,
1866 allow_abbrev=allow_abbrev,
1867 default_desc=default_desc,
1868 )
1871@dataclass(eq=False, kw_only=True)
1872class StoreFalseOption(StoreConstOption[bool]):
1873 """
1874 An option that stores :data:`False` to namespace.
1876 """
1878 def __init__(
1879 self,
1880 *,
1881 flags: list[str],
1882 required: bool = False,
1883 mutex_group: None | MutuallyExclusiveGroup = None,
1884 usage: yuio.Collapse | bool = True,
1885 help: str | yuio.Disabled = "",
1886 help_group: HelpGroup | None = None,
1887 show_if_inherited: bool = False,
1888 dest: str,
1889 default: bool | yuio.Missing = yuio.MISSING,
1890 allow_abbrev: bool = True,
1891 default_desc: str | None = None,
1892 ):
1893 super().__init__(
1894 flags=flags,
1895 required=required,
1896 mutex_group=mutex_group,
1897 usage=usage,
1898 help=help,
1899 help_group=help_group,
1900 show_if_inherited=show_if_inherited,
1901 dest=dest,
1902 merge=None,
1903 default=default,
1904 const=False,
1905 allow_abbrev=allow_abbrev,
1906 default_desc=default_desc,
1907 )
1910@dataclass(eq=False, kw_only=True)
1911class VersionOption(Option[_t.Never]):
1912 """
1913 An option that prints app's version and stops the program.
1915 """
1917 version: str
1918 """
1919 Version to print.
1921 """
1923 def __init__(
1924 self,
1925 *,
1926 version: str,
1927 flags: list[str] = ["-V", "--version"],
1928 usage: yuio.Collapse | bool = yuio.COLLAPSE,
1929 help: str | yuio.Disabled = "Print program version and exit.",
1930 help_group: HelpGroup | None = MISC_GROUP,
1931 allow_abbrev: bool = True,
1932 ):
1933 super().__init__(
1934 flags=flags,
1935 allow_inline_arg=False,
1936 allow_implicit_inline_arg=False,
1937 nargs=0,
1938 allow_no_args=True,
1939 required=False,
1940 metavar=(),
1941 mutex_group=None,
1942 usage=usage,
1943 help=help,
1944 help_group=help_group,
1945 show_if_inherited=False,
1946 allow_abbrev=allow_abbrev,
1947 dest="_version",
1948 default_desc=None,
1949 )
1951 self.version = version
1953 def process(
1954 self,
1955 cli_parser: CliParser[Namespace],
1956 flag: Flag | None,
1957 arguments: Argument | list[Argument],
1958 ns: Namespace,
1959 ):
1960 import yuio.io
1962 if self.version:
1963 yuio.io.raw(self.version, add_newline=True, to_stdout=True)
1964 else:
1965 yuio.io.raw("<unknown version>", add_newline=True, to_stdout=True)
1966 sys.exit(0)
1969@dataclass(eq=False, kw_only=True)
1970class BugReportOption(Option[_t.Never]):
1971 """
1972 An option that prints bug report.
1974 """
1976 settings: yuio.dbg.ReportSettings | bool | None
1977 """
1978 Settings for bug report generation.
1980 """
1982 app: yuio.app.App[_t.Any] | None
1983 """
1984 Main app of the project, used to extract project's version and dependencies.
1986 """
1988 def __init__(
1989 self,
1990 *,
1991 settings: yuio.dbg.ReportSettings | bool | None = None,
1992 app: yuio.app.App[_t.Any] | None = None,
1993 flags: list[str] = ["--bug-report"],
1994 usage: yuio.Collapse | bool = yuio.COLLAPSE,
1995 help: str | yuio.Disabled = "Print environment data for bug report and exit.",
1996 help_group: HelpGroup | None = MISC_GROUP,
1997 allow_abbrev: bool = True,
1998 ):
1999 super().__init__(
2000 flags=flags,
2001 allow_inline_arg=False,
2002 allow_implicit_inline_arg=False,
2003 nargs=0,
2004 allow_no_args=True,
2005 required=False,
2006 metavar=(),
2007 mutex_group=None,
2008 usage=usage,
2009 help=help,
2010 help_group=help_group,
2011 show_if_inherited=False,
2012 allow_abbrev=allow_abbrev,
2013 dest="_bug_report",
2014 default_desc=None,
2015 )
2017 self.settings = settings
2018 self.app = app
2020 def process(
2021 self,
2022 cli_parser: CliParser[Namespace],
2023 flag: Flag | None,
2024 arguments: Argument | list[Argument],
2025 ns: Namespace,
2026 ):
2027 import yuio.dbg
2029 yuio.dbg.print_report(settings=self.settings, app=self.app)
2030 sys.exit(0)
2033@dataclass(eq=False, kw_only=True)
2034class CompletionOption(Option[_t.Never]):
2035 """
2036 An option that installs autocompletion.
2038 """
2040 _SHELLS = [
2041 "all",
2042 "uninstall",
2043 "bash",
2044 "zsh",
2045 "fish",
2046 "pwsh",
2047 ]
2049 def __init__(
2050 self,
2051 *,
2052 flags: list[str] = ["--completions"],
2053 usage: yuio.Collapse | bool = yuio.COLLAPSE,
2054 help: str | yuio.Disabled | None = None,
2055 help_group: HelpGroup | None = MISC_GROUP,
2056 allow_abbrev: bool = True,
2057 ):
2058 if help is None:
2059 shells = yuio.string.Or(f"``{shell}``" for shell in self._SHELLS)
2060 help = (
2061 "Install or update autocompletion scripts and exit.\n\n"
2062 f"Supported shells: {shells}."
2063 )
2064 super().__init__(
2065 flags=flags,
2066 allow_inline_arg=True,
2067 allow_implicit_inline_arg=True,
2068 nargs=1,
2069 allow_no_args=True,
2070 required=False,
2071 metavar="<shell>",
2072 mutex_group=None,
2073 usage=usage,
2074 help=help,
2075 help_group=help_group,
2076 show_if_inherited=False,
2077 allow_abbrev=allow_abbrev,
2078 dest="_completions",
2079 default_desc=None,
2080 )
2082 def process(
2083 self,
2084 cli_parser: CliParser[Namespace],
2085 flag: Flag | None,
2086 arguments: Argument | list[Argument],
2087 ns: Namespace,
2088 ):
2089 if isinstance(arguments, list):
2090 argument = arguments[0].value if arguments else "all"
2091 else:
2092 argument = arguments.value
2094 if argument not in self._SHELLS:
2095 raise ArgumentError(
2096 "Unknown shell `%r`, should be %s",
2097 argument,
2098 yuio.string.Or(self._SHELLS),
2099 flag=flag,
2100 arguments=arguments,
2101 n_arg=0,
2102 )
2104 root = cli_parser._root_command
2105 help_parser = cli_parser._help_parser
2107 if argument == "uninstall":
2108 compdata = ""
2109 else:
2110 serializer = yuio.complete._ProgramSerializer()
2111 self._dump(root, serializer, [], help_parser)
2112 compdata = serializer.dump()
2114 yuio.complete._write_completions(compdata, root.name, argument)
2116 sys.exit(0)
2118 def _dump(
2119 self,
2120 command: Command[_t.Any],
2121 serializer: yuio.complete._ProgramSerializer,
2122 parent_options: list[Option[_t.Any]],
2123 help_parser: yuio.doc.DocParser,
2124 ):
2125 seen_flags: set[str] = set()
2126 seen_options: list[Option[_t.Any]] = []
2128 # Add command's options, keep track of flags from the current command.
2129 for option in command.options:
2130 completer, is_many = option.get_completer()
2131 help = option.help
2132 if help is not yuio.DISABLED:
2133 ctx = yuio.string.ReprContext.make_dummy(is_unicode=False)
2134 ctx.width = 60
2135 parsed_help = _parse_option_help(option, help_parser, ctx)
2136 if parsed_help:
2137 lines = _CliFormatter(help_parser, ctx).format(parsed_help)
2138 if not lines:
2139 help = ""
2140 elif len(lines) == 1:
2141 help = str(lines[0])
2142 else:
2143 help = str(lines[0]) + ("..." if lines[1] else "")
2144 else:
2145 help = ""
2146 serializer.add_option(
2147 flags=option.flags,
2148 nargs=option.nargs,
2149 metavar=option.metavar,
2150 help=help,
2151 completer=completer,
2152 is_many=is_many,
2153 )
2154 if option.flags is not yuio.POSITIONAL:
2155 seen_flags |= seen_flags
2156 seen_options.append(option)
2158 # Add parent options if their flags were not shadowed.
2159 for option in parent_options:
2160 assert option.flags is not yuio.POSITIONAL
2162 flags = [flag for flag in option.flags if flag not in seen_flags]
2163 if not flags:
2164 continue
2166 completer, is_many = option.get_completer()
2167 help = option.help
2168 if help is not yuio.DISABLED and not option.show_if_inherited:
2169 # TODO: not sure if disabling help for inherited options is
2170 # the best approach here.
2171 help = yuio.DISABLED
2172 nargs = option.nargs
2173 if option.allow_no_args:
2174 if nargs == 1:
2175 nargs = "?"
2176 elif nargs == "+":
2177 nargs = "*"
2178 serializer.add_option(
2179 flags=flags,
2180 nargs=nargs,
2181 metavar=option.metavar,
2182 help=help,
2183 completer=completer,
2184 is_many=is_many,
2185 )
2187 seen_flags |= seen_flags
2188 seen_options.append(option)
2190 for name, subcommand in command.subcommands.items():
2191 subcommand_serializer = serializer.add_subcommand(
2192 name=name, is_alias=name != subcommand.name, help=subcommand.help
2193 )
2194 self._dump(subcommand, subcommand_serializer, seen_options, help_parser)
2196 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
2197 return (
2198 yuio.complete.Choice(
2199 [yuio.complete.Option(shell) for shell in self._SHELLS]
2200 ),
2201 False,
2202 )
2205@dataclass(eq=False, kw_only=True)
2206class HelpOption(Option[_t.Never]):
2207 """
2208 An option that prints help message and stops the program.
2210 """
2212 def __init__(
2213 self,
2214 *,
2215 flags: list[str] = ["-h", "--help"],
2216 usage: yuio.Collapse | bool = yuio.COLLAPSE,
2217 help: str | yuio.Disabled = "Print this message and exit.",
2218 help_group: HelpGroup | None = MISC_GROUP,
2219 allow_abbrev: bool = True,
2220 ):
2221 super().__init__(
2222 flags=flags,
2223 allow_inline_arg=True,
2224 allow_implicit_inline_arg=True,
2225 nargs=0,
2226 allow_no_args=True,
2227 required=False,
2228 metavar=(),
2229 mutex_group=None,
2230 usage=usage,
2231 help=help,
2232 help_group=help_group,
2233 show_if_inherited=True,
2234 allow_abbrev=allow_abbrev,
2235 dest="_help",
2236 default_desc=None,
2237 )
2239 def process(
2240 self,
2241 cli_parser: CliParser[Namespace],
2242 flag: Flag | None,
2243 arguments: Argument | list[Argument],
2244 ns: Namespace,
2245 ):
2246 import yuio.io
2247 import yuio.string
2249 if isinstance(arguments, list):
2250 argument = arguments[0].value if arguments else ""
2251 else:
2252 argument = arguments.value
2254 if argument not in ("all", ""):
2255 raise ArgumentError(
2256 "Unknown help scope <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, should be %s",
2257 argument,
2258 yuio.string.Or(
2259 ["all"], color="msg/text:code/sh-usage hl/flag:sh-usage"
2260 ),
2261 flag=flag,
2262 arguments=arguments,
2263 n_arg=0,
2264 )
2266 formatter = _HelpFormatter(cli_parser._help_parser, all=argument == "all")
2267 inherited_options = []
2268 seen_inherited_options = set()
2269 for opt in cli_parser._inherited_options.values():
2270 if opt not in seen_inherited_options:
2271 seen_inherited_options.add(opt)
2272 inherited_options.append(opt)
2273 formatter.add_command(
2274 " ".join(cli_parser._current_path),
2275 cli_parser._current_command,
2276 list(inherited_options),
2277 )
2279 yuio.io.raw(formatter, add_newline=True, to_stdout=True)
2280 sys.exit(0)
2283@dataclass(kw_only=True, eq=False, match_args=False)
2284class Command(_t.Generic[NamespaceT]):
2285 """
2286 Data about CLI interface of a single command or subcommand.
2288 """
2290 name: str
2291 """
2292 Canonical name of this command.
2294 """
2296 desc: str
2297 """
2298 Long description for a command.
2300 """
2302 help: str | yuio.Disabled
2303 """
2304 Help message for this command, displayed when listing subcommands.
2306 """
2308 epilog: str
2309 """
2310 Long description printed after command help.
2312 """
2314 usage: str
2315 """
2316 Override for usage section of CLI help.
2318 """
2320 options: list[Option[_t.Any]]
2321 """
2322 Options for this command.
2324 """
2326 subcommands: dict[str, Command[Namespace]]
2327 """
2328 Last positional option can be a sub-command.
2330 This is a map from subcommand's name or alias to subcommand's implementation.
2332 """
2334 subcommand_required: bool
2335 """
2336 Whether subcommand is required or optional. If no :attr:`~Command.subcommands`
2337 are given, this attribute is ignored.
2339 """
2341 ns_ctor: _t.Callable[[], NamespaceT]
2342 """
2343 A constructor that will be called to create namespace for command's arguments.
2345 """
2347 dest: str
2348 """
2349 Where to save subcommand's name.
2351 """
2353 ns_dest: str
2354 """
2355 Where to save subcommand's namespace.
2357 """
2359 metavar: str = "<subcommand>"
2360 """
2361 Meta variable used for subcommand option.
2363 """
2366@dataclass(eq=False, kw_only=True)
2367class _SubCommandOption(ValueOption[str]):
2368 subcommands: dict[str, Command[Namespace]]
2369 """
2370 All subcommands.
2372 """
2374 ns_dest: str
2375 """
2376 Where to save subcommand's namespace.
2378 """
2380 ns_ctor: _t.Callable[[], Namespace]
2381 """
2382 A constructor that will be called to create namespace for subcommand's arguments.
2384 """
2386 def __init__(
2387 self,
2388 *,
2389 subcommands: dict[str, Command[Namespace]],
2390 subcommand_required: bool,
2391 ns_dest: str,
2392 ns_ctor: _t.Callable[[], Namespace],
2393 metavar: str = "<subcommand>",
2394 help_group: HelpGroup | None = SUBCOMMANDS_GROUP,
2395 show_if_inherited: bool = False,
2396 dest: str,
2397 ):
2398 subcommand_names = [
2399 f"``{name}``"
2400 for name, subcommand in subcommands.items()
2401 if name == subcommand.name and subcommand.help is not yuio.DISABLED
2402 ]
2403 help = f"Available subcommands: {yuio.string.Or(subcommand_names)}"
2405 super().__init__(
2406 flags=yuio.POSITIONAL,
2407 allow_inline_arg=False,
2408 allow_implicit_inline_arg=False,
2409 nargs=1,
2410 allow_no_args=not subcommand_required,
2411 required=False,
2412 metavar=metavar,
2413 mutex_group=None,
2414 usage=True,
2415 help=help,
2416 help_group=help_group,
2417 show_if_inherited=show_if_inherited,
2418 dest=dest,
2419 merge=None,
2420 default=yuio.MISSING,
2421 allow_abbrev=False,
2422 default_desc=None,
2423 )
2425 self.subcommands = subcommands
2426 self.ns_dest = ns_dest
2427 self.ns_ctor = ns_ctor
2429 assert self.dest
2430 assert self.ns_dest
2432 def process(
2433 self,
2434 cli_parser: CliParser[Namespace],
2435 flag: Flag | None,
2436 arguments: Argument | list[Argument],
2437 ns: Namespace,
2438 ):
2439 assert isinstance(arguments, list)
2440 if not arguments:
2441 return
2442 subcommand = self.subcommands.get(arguments[0].value)
2443 if subcommand is None:
2444 raise ArgumentError(
2445 "Unknown subcommand <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, can be %s",
2446 arguments[0].value,
2447 yuio.string.Or(
2448 (
2449 name
2450 for name, subcommand in self.subcommands.items()
2451 if subcommand.help != yuio.DISABLED
2452 ),
2453 color="msg/text:code/sh-usage hl/flag:sh-usage",
2454 ),
2455 arguments=arguments,
2456 )
2457 ns[self.dest] = subcommand.name
2458 ns[self.ns_dest] = new_ns = subcommand.ns_ctor()
2459 cli_parser._load_command(subcommand, new_ns)
2462@dataclass(eq=False, match_args=False, slots=True)
2463class _BoundOption:
2464 wrapped: Option[_t.Any]
2465 ns: Namespace
2466 seen: bool = False
2468 @property
2469 def usage(self):
2470 return self.wrapped.usage
2472 @property
2473 def flags(self):
2474 return self.wrapped.flags
2476 @property
2477 def nargs(self):
2478 return self.wrapped.nargs
2480 @property
2481 def allow_no_args(self):
2482 return self.wrapped.allow_no_args
2484 @property
2485 def allow_inline_arg(self):
2486 return self.wrapped.allow_inline_arg
2488 @property
2489 def allow_implicit_inline_arg(self):
2490 return self.wrapped.allow_implicit_inline_arg
2492 @property
2493 def mutex_group(self):
2494 return self.wrapped.mutex_group
2496 @property
2497 def required(self):
2498 return self.wrapped.required
2500 @property
2501 def allow_abbrev(self):
2502 return self.wrapped.allow_abbrev
2504 def nth_metavar(self, n: int) -> str:
2505 return self.wrapped.nth_metavar(n)
2508class CliParser(_t.Generic[NamespaceT]):
2509 """
2510 CLI arguments parser.
2512 :param command:
2513 root command.
2514 :param allow_abbrev:
2515 allow abbreviating CLI flags if that doesn't create ambiguity.
2516 :param help_parser:
2517 help parser that will be used to parse and display help for options
2518 that've failed to parse.
2520 """
2522 def __init__(
2523 self,
2524 command: Command[NamespaceT],
2525 /,
2526 *,
2527 help_parser: yuio.doc.DocParser,
2528 allow_abbrev: bool,
2529 ):
2530 self._root_command = command
2531 self._allow_abbrev = allow_abbrev
2532 self._help_parser = help_parser
2534 def _load_command(self, command: Command[_t.Any], ns: Namespace):
2535 # All pending flags and positionals should've been flushed by now.
2536 assert self._current_flag is None
2537 assert self._current_positional == len(self._positionals)
2539 self._inherited_options.update(
2540 {flag: opt.wrapped for flag, opt in self._known_long_flags.items()}
2541 )
2542 self._inherited_options.update(
2543 {flag: opt.wrapped for flag, opt in self._known_short_flags.items()}
2544 )
2545 self._current_path.append(command.name)
2547 # Update known flags and positionals.
2548 self._positionals = []
2549 seen_flags: set[str] = set()
2550 for option in command.options:
2551 bound_option = _BoundOption(option, ns)
2552 if option.flags is yuio.POSITIONAL:
2553 if option.mutex_group is not None:
2554 raise TypeError(
2555 f"{option}: positional arguments can't appear "
2556 "in mutually exclusive groups"
2557 )
2558 if option.nargs == 0:
2559 raise TypeError(
2560 f"{option}: positional arguments can't nave nargs=0"
2561 )
2562 self._positionals.append(bound_option)
2563 else:
2564 if option.mutex_group is not None:
2565 self._mutex_groups.setdefault(option.mutex_group, []).append(option)
2566 if not option.flags:
2567 raise TypeError(f"{option}: option has no flags")
2568 for flag in option.flags:
2569 if flag in seen_flags:
2570 raise TypeError(
2571 f"got multiple options with the same flag {flag}"
2572 )
2573 seen_flags.add(flag)
2574 self._inherited_options.pop(flag, None)
2575 _check_flag(flag)
2576 if _is_short(flag):
2577 dest = self._known_short_flags
2578 else:
2579 dest = self._known_long_flags
2580 if flag in dest:
2581 warnings.warn(
2582 f"flag {flag} from subcommand {command.name} shadows "
2583 f"the same flag from command {self._current_command.name}",
2584 CliWarning,
2585 )
2586 self._finalize_unused_flag(flag, dest[flag])
2587 dest[flag] = bound_option
2588 if command.subcommands:
2589 self._positionals.append(_BoundOption(_make_subcommand(command), ns))
2590 self._current_command = command
2591 self._current_positional = 0
2593 def parse(self, args: list[str] | None = None) -> NamespaceT:
2594 """
2595 Parse arguments and invoke their actions.
2597 :param args:
2598 CLI arguments, not including the program name (i.e. the first argument).
2599 If :data:`None`, use :data:`sys.argv` instead.
2600 :returns:
2601 namespace with parsed arguments.
2602 :raises:
2603 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
2605 """
2607 if args is None:
2608 args = sys.argv[1:]
2610 try:
2611 return self._parse(args)
2612 except ArgumentError as e:
2613 e.commandline = args
2614 e.prog = self._root_command.name
2615 e.subcommands = self._current_path
2616 e.help_parser = self._help_parser
2617 raise
2619 def _parse(self, args: list[str]) -> NamespaceT:
2620 self._current_command = self._root_command
2621 self._current_path: list[str] = []
2622 self._inherited_options: dict[str, Option[_t.Any]] = {}
2624 self._seen_mutex_groups: dict[
2625 MutuallyExclusiveGroup, tuple[_BoundOption, Flag]
2626 ] = {}
2627 self._mutex_groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {}
2629 self._current_index = 0
2631 self._known_long_flags: dict[str, _BoundOption] = {}
2632 self._known_short_flags: dict[str, _BoundOption] = {}
2633 self._positionals: list[_BoundOption] = []
2634 self._current_positional: int = 0
2636 self._current_flag: tuple[_BoundOption, Flag] | None = None
2637 self._current_flag_args: list[Argument] = []
2638 self._current_positional_args: list[Argument] = []
2640 self._post_process: dict[
2641 _BoundOption, tuple[list[Argument], list[Flag | None]]
2642 ] = {}
2644 root_ns = self._root_command.ns_ctor()
2645 self._load_command(self._root_command, root_ns)
2647 allow_flags = True
2649 for i, arg in enumerate(args):
2650 self._current_index = i
2652 # Handle `--`.
2653 if arg == "--" and allow_flags:
2654 self._flush_flag()
2655 allow_flags = False
2656 continue
2658 # Check what we have here.
2659 if allow_flags:
2660 result = self._detect_flag(arg)
2661 else:
2662 result = None
2664 if result is None:
2665 # This not a flag. Can be an argument to a positional/flag option.
2666 self._handle_positional(arg)
2667 else:
2668 # This is a flag.
2669 options, inline_arg = result
2670 self._handle_flags(options, inline_arg)
2672 self._finalize()
2674 return root_ns
2676 def _finalize(self):
2677 self._flush_flag()
2679 for flag, option in self._known_long_flags.items():
2680 self._finalize_unused_flag(flag, option)
2681 for flag, option in self._known_short_flags.items():
2682 self._finalize_unused_flag(flag, option)
2683 while self._current_positional < len(self._positionals):
2684 self._flush_positional()
2685 for group, options in self._mutex_groups.items():
2686 if group.required and group not in self._seen_mutex_groups:
2687 raise ArgumentError(
2688 "%s %s must be provided",
2689 "Either" if len(options) > 1 else "Flag",
2690 yuio.string.Or(
2691 (option.flags[0] for option in options if option.flags),
2692 color="msg/text:code/sh-usage hl/flag:sh-usage",
2693 ),
2694 )
2695 for option, (arguments, flags) in self._post_process.items():
2696 try:
2697 option.wrapped.post_process(
2698 _t.cast(CliParser[Namespace], self), arguments, option.ns
2699 )
2700 except ArgumentError as e:
2701 if e.arguments is None:
2702 e.arguments = arguments
2703 if e.flag is None and e.n_arg is not None and 0 <= e.n_arg < len(flags):
2704 e.flag = flags[e.n_arg]
2705 if e.option is None:
2706 e.option = option.wrapped
2707 raise
2708 except yuio.parse.ParsingError as e:
2709 flag = None
2710 if e.n_arg is not None and 0 <= e.n_arg < len(flags):
2711 flag = flags[e.n_arg]
2712 raise ArgumentError.from_parsing_error(
2713 e, flag=flag, arguments=arguments, option=option.wrapped
2714 )
2716 def _finalize_unused_flag(self, flag: str, option: _BoundOption):
2717 if option.required and not option.seen:
2718 raise ArgumentError(
2719 "Missing required flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2720 flag,
2721 )
2723 def _detect_flag(
2724 self, arg: str
2725 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None:
2726 if not arg.startswith("-") or len(arg) <= 1:
2727 # This is a positional.
2728 return None
2730 if arg.startswith("--"):
2731 # This is a long flag.
2732 return self._parse_long_flag(arg)
2733 else:
2734 return self._detect_short_flag(arg)
2736 def _parse_long_flag(
2737 self, arg: str
2738 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None:
2739 if "=" in arg:
2740 flag, inline_arg = arg.split("=", maxsplit=1)
2741 else:
2742 flag, inline_arg = arg, None
2743 flag = self._make_flag(flag)
2744 if long_opt := self._known_long_flags.get(flag.value):
2745 if inline_arg is not None:
2746 inline_arg = self._make_arg(
2747 long_opt, inline_arg, len(flag.value) + 1, flag
2748 )
2749 return [(long_opt, flag)], inline_arg
2751 # Try as abbreviated long flags.
2752 candidates: list[str] = []
2753 if self._allow_abbrev:
2754 for candidate in self._known_long_flags:
2755 if candidate.startswith(flag.value):
2756 candidates.append(candidate)
2757 if len(candidates) == 1:
2758 candidate = candidates[0]
2759 opt = self._known_long_flags[candidate]
2760 if not opt.allow_abbrev:
2761 raise ArgumentError(
2762 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, did you mean %s?",
2763 flag,
2764 candidate,
2765 flag=self._make_flag(""),
2766 )
2767 flag = self._make_flag(candidate)
2768 if inline_arg is not None:
2769 inline_arg = self._make_arg(
2770 opt, inline_arg, len(flag.value) + 1, flag
2771 )
2772 return [(opt, flag)], inline_arg
2774 if candidates:
2775 raise ArgumentError(
2776 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, can be %s",
2777 flag,
2778 yuio.string.Or(
2779 candidates, color="msg/text:code/sh-usage hl/flag:sh-usage"
2780 ),
2781 flag=self._make_flag(""),
2782 )
2783 else:
2784 raise ArgumentError(
2785 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2786 flag,
2787 flag=self._make_flag(""),
2788 )
2790 def _detect_short_flag(
2791 self, arg: str
2792 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None:
2793 # Try detecting short flags first.
2794 short_opts: list[tuple[_BoundOption, Flag]] = []
2795 inline_arg = None
2796 inline_arg_pos = 0
2797 unknown_ch = None
2798 for i, ch in enumerate(arg[1:]):
2799 if ch == "=":
2800 # Short flag with explicit argument.
2801 inline_arg_pos = i + 2
2802 inline_arg = arg[inline_arg_pos:]
2803 break
2804 elif short_opts and (
2805 short_opts[-1][0].allow_implicit_inline_arg
2806 or short_opts[-1][0].nargs != 0
2807 ):
2808 # Short flag with implicit argument.
2809 inline_arg_pos = i + 1
2810 inline_arg = arg[inline_arg_pos:]
2811 break
2812 elif short_opt := self._known_short_flags.get("-" + ch):
2813 # Short flag, arguments may follow.
2814 short_opts.append((short_opt, self._make_flag("-" + ch)))
2815 else:
2816 # Unknown short flag. Will try parsing as abbreviated long flag next.
2817 unknown_ch = ch
2818 break
2819 if short_opts and not unknown_ch:
2820 if inline_arg is not None:
2821 inline_arg = self._make_arg(
2822 short_opts[-1][0], inline_arg, inline_arg_pos, short_opts[-1][1]
2823 )
2824 return short_opts, inline_arg
2826 # Try as signed int.
2827 if re.match(_NUM_RE, arg):
2828 # This is a positional.
2829 return None
2831 if unknown_ch and len(arg) > 2:
2832 raise ArgumentError(
2833 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>-%s</c> in argument <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2834 unknown_ch,
2835 arg,
2836 flag=self._make_flag(""),
2837 )
2838 else:
2839 raise ArgumentError(
2840 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2841 arg,
2842 flag=self._make_flag(""),
2843 )
2845 def _make_arg(
2846 self, opt: _BoundOption, arg: str, pos: int, flag: Flag | None = None
2847 ):
2848 return Argument(
2849 arg,
2850 index=self._current_index,
2851 pos=pos,
2852 metavar=opt.nth_metavar(0),
2853 flag=flag,
2854 )
2856 def _make_flag(self, arg: str):
2857 return Flag(arg, self._current_index)
2859 def _handle_positional(self, arg: str):
2860 if self._current_flag is not None:
2861 opt, flag = self._current_flag
2862 # This is an argument for a flag option.
2863 self._current_flag_args.append(
2864 Argument(
2865 arg,
2866 index=self._current_index,
2867 pos=0,
2868 metavar=opt.nth_metavar(len(self._current_flag_args)),
2869 flag=flag,
2870 )
2871 )
2872 nargs = opt.nargs
2873 if isinstance(nargs, int) and len(self._current_flag_args) == nargs:
2874 self._flush_flag() # This flag is full.
2875 else:
2876 # This is an argument for a positional option.
2877 if self._current_positional >= len(self._positionals):
2878 raise ArgumentError(
2879 "Unexpected positional argument <c msg/text:code/sh-usage hl/flag:sh-usage>%r</c>",
2880 arg,
2881 arguments=Argument(
2882 arg, index=self._current_index, pos=0, metavar="", flag=None
2883 ),
2884 )
2885 current_positional = self._positionals[self._current_positional]
2886 self._current_positional_args.append(
2887 Argument(
2888 arg,
2889 index=self._current_index,
2890 pos=0,
2891 metavar=current_positional.nth_metavar(
2892 len(self._current_positional_args)
2893 ),
2894 flag=None,
2895 )
2896 )
2897 nargs = current_positional.nargs
2898 if isinstance(nargs, int) and len(self._current_positional_args) == nargs:
2899 self._flush_positional() # This positional is full.
2901 def _handle_flags(
2902 self, options: list[tuple[_BoundOption, Flag]], inline_arg: Argument | None
2903 ):
2904 # If we've seen another flag before this one, and we were waiting
2905 # for that flag's arguments, flush them now.
2906 self._flush_flag()
2908 # Handle short flags in multi-arg sequence, i.e. `-li` -> `-l -i`
2909 for opt, name in options[:-1]:
2910 self._eval_option(opt, name, [])
2912 # Handle the last short flag in multi-arg sequence.
2913 opt, name = options[-1]
2914 if inline_arg is not None:
2915 # Flag with an inline argument, i.e. `-Xfoo`/`-X=foo` -> `-X foo`
2916 self._eval_option(opt, name, inline_arg)
2917 else:
2918 self._push_flag(opt, name)
2920 def _flush_positional(self):
2921 if self._current_positional >= len(self._positionals):
2922 return
2923 opt, args = (
2924 self._positionals[self._current_positional],
2925 self._current_positional_args,
2926 )
2928 self._current_positional += 1
2929 self._current_positional_args = []
2931 self._eval_option(opt, None, args)
2933 def _flush_flag(self):
2934 if self._current_flag is None:
2935 return
2937 (opt, name), args = (self._current_flag, self._current_flag_args)
2939 self._current_flag = None
2940 self._current_flag_args = []
2942 self._eval_option(opt, name, args)
2944 def _push_flag(self, opt: _BoundOption, flag: Flag):
2945 assert self._current_flag is None
2947 if opt.nargs == 0:
2948 # Flag without arguments, handle it right now.
2949 self._eval_option(opt, flag, [])
2950 else:
2951 # Flag with possible arguments, save it. If we see a non-flag later,
2952 # it will be added to this flag's arguments.
2953 self._current_flag = (opt, flag)
2954 self._current_flag_args = []
2956 def _eval_option(
2957 self, opt: _BoundOption, flag: Flag | None, arguments: Argument | list[Argument]
2958 ):
2959 if opt.mutex_group is not None:
2960 if seen := self._seen_mutex_groups.get(opt.mutex_group):
2961 raise ArgumentError(
2962 "Flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c> can't be given together with flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2963 flag or self._make_flag(opt.nth_metavar(0)),
2964 seen[1],
2965 )
2966 self._seen_mutex_groups[opt.mutex_group] = (
2967 opt,
2968 flag or self._make_flag(opt.nth_metavar(0)),
2969 )
2971 if isinstance(arguments, list):
2972 _check_nargs(opt, flag, arguments)
2973 elif not opt.allow_inline_arg:
2974 raise ArgumentError(
2975 "This flag can't have arguments",
2976 flag=flag,
2977 arguments=arguments,
2978 option=opt.wrapped,
2979 )
2981 opt.seen = True
2982 try:
2983 opt.wrapped.process(
2984 _t.cast(CliParser[Namespace], self), flag, arguments, opt.ns
2985 )
2986 except ArgumentError as e:
2987 if e.flag is None:
2988 e.flag = flag
2989 if e.arguments is None:
2990 e.arguments = arguments
2991 if e.option is None:
2992 e.option = opt.wrapped
2993 raise
2994 except yuio.parse.ParsingError as e:
2995 raise ArgumentError.from_parsing_error(
2996 e, flag=flag, arguments=arguments, option=opt.wrapped
2997 )
2999 if not isinstance(arguments, list):
3000 arguments = [arguments]
3001 if opt not in self._post_process:
3002 self._post_process[opt] = ([], [])
3003 self._post_process[opt][0].extend(arguments)
3004 self._post_process[opt][1].extend([flag] * len(arguments))
3007def _check_flag(flag: str):
3008 if not flag.startswith("-"):
3009 raise TypeError(f"flag {flag!r} should start with `-`")
3010 if len(flag) == 2:
3011 if not re.match(_SHORT_FLAG_RE, flag):
3012 raise TypeError(f"invalid short flag {flag!r}")
3013 elif len(flag) == 1:
3014 raise TypeError(f"flag {flag!r} is too short")
3015 else:
3016 if not re.match(_LONG_FLAG_RE, flag):
3017 raise TypeError(f"invalid long flag {flag!r}")
3020def _is_short(flag: str):
3021 return flag.startswith("-") and len(flag) == 2 and flag != "--"
3024def _make_subcommand(command: Command[Namespace]):
3025 return _SubCommandOption(
3026 metavar=command.metavar,
3027 subcommands=command.subcommands,
3028 subcommand_required=command.subcommand_required,
3029 dest=command.dest,
3030 ns_dest=command.ns_dest,
3031 ns_ctor=command.ns_ctor,
3032 )
3035def _check_nargs(opt: _BoundOption, flag: Flag | None, args: list[Argument]):
3036 if not args and opt.allow_no_args:
3037 return
3038 match opt.nargs:
3039 case "+":
3040 if not args:
3041 if opt.flags is yuio.POSITIONAL:
3042 raise ArgumentError(
3043 "Missing required positional <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
3044 opt.nth_metavar(0),
3045 flag=flag,
3046 option=opt.wrapped,
3047 )
3048 else:
3049 raise ArgumentError(
3050 "Expected at least `1` argument, got `0`",
3051 flag=flag,
3052 option=opt.wrapped,
3053 )
3054 case n:
3055 if len(args) < n and (opt.flags is yuio.POSITIONAL):
3056 s = "" if n - len(args) == 1 else "s"
3057 raise ArgumentError(
3058 "Missing required positional%s %s",
3059 s,
3060 yuio.string.JoinStr(
3061 [opt.nth_metavar(i) for i in range(len(args), n)],
3062 color="msg/text:code/sh-usage hl/flag:sh-usage",
3063 ),
3064 flag=flag,
3065 option=opt.wrapped,
3066 )
3067 elif len(args) != n:
3068 s = "" if n == 1 else "s"
3069 raise ArgumentError(
3070 "Expected `%s` argument%s, got `%s`",
3071 n,
3072 s,
3073 len(args),
3074 flag=flag,
3075 option=opt.wrapped,
3076 )
3079def _quote_and_adjust_pos(s: str, pos: tuple[int, int]):
3080 s = s.translate(_UNPRINTABLE_TRANS)
3082 if not s:
3083 return "''", (1, 1)
3084 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII):
3085 return s, pos
3087 start, end = pos
3089 start_shift = 1 + s[:start].count("'") * 4
3090 end_shift = start_shift + s[start:end].count("'") * 4
3092 return "'" + s.replace("'", "'\"'\"'") + "'", (start + start_shift, end + end_shift)
3095def _quote(s: str):
3096 s = s.translate(_UNPRINTABLE_TRANS)
3098 if not s:
3099 return "''"
3100 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII):
3101 return s
3102 else:
3103 return "'" + s.replace("'", "'\"'\"'") + "'"
3106class _HelpFormatter:
3107 def __init__(self, parser: yuio.doc.DocParser, all: bool = False) -> None:
3108 self.nodes: list[yuio.doc.AstBase] = []
3109 self.parser = parser
3110 self.all = all
3112 def add_command(
3113 self, prog: str, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /
3114 ):
3115 self._add_usage(prog, cmd, inherited)
3116 if cmd.desc:
3117 self.nodes.extend(self.parser.parse(cmd.desc).items)
3118 self._add_options(cmd)
3119 self._add_subcommands(cmd)
3120 self._add_flags(cmd, inherited)
3121 if cmd.epilog:
3122 self.nodes.append(_SetIndentation())
3123 self.nodes.extend(self.parser.parse(cmd.epilog).items)
3125 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
3126 return self.format(ctx)
3128 def format(self, ctx: yuio.string.ReprContext):
3129 res = _ColorizedString()
3130 lines = _CliFormatter(self.parser, ctx, all=self.all).format(
3131 yuio.doc.Document(items=self.nodes)
3132 )
3133 sep = False
3134 for line in lines:
3135 if sep:
3136 res.append_str("\n")
3137 res.append_colorized_str(line)
3138 sep = True
3139 return res
3141 def _add_usage(
3142 self, prog: str, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /
3143 ):
3144 self.nodes.append(_Usage(prog=prog, cmd=cmd, inherited=inherited))
3146 def _add_options(self, cmd: Command[Namespace], /):
3147 groups: dict[HelpGroup, list[Option[_t.Any]]] = {}
3148 for opt in cmd.options:
3149 if opt.flags is not yuio.POSITIONAL:
3150 continue
3151 if opt.help is yuio.DISABLED:
3152 continue
3153 group = opt.help_group or ARGS_GROUP
3154 if group.help is yuio.DISABLED:
3155 continue
3156 if group not in groups:
3157 groups[group] = []
3158 groups[group].append(opt)
3159 for group, options in groups.items():
3160 assert group.help is not yuio.DISABLED
3161 self.nodes.append(
3162 yuio.doc.Heading(
3163 items=self.parser.parse_paragraph(group.title), level=1
3164 )
3165 )
3166 if group.help:
3167 self.nodes.append(
3168 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3169 )
3170 arg_group = _HelpArgGroup(items=[])
3171 for opt in options:
3172 assert opt.help is not yuio.DISABLED
3173 arg_group.items.append(_HelpArg(opt))
3174 self.nodes.append(arg_group)
3176 def _add_subcommands(self, cmd: Command[Namespace], /):
3177 subcommands: dict[Command[Namespace], list[str]] = {}
3178 for name, subcommand in cmd.subcommands.items():
3179 if subcommand.help is yuio.DISABLED:
3180 continue
3181 if subcommand not in subcommands:
3182 subcommands[subcommand] = [name]
3183 else:
3184 subcommands[subcommand].append(name)
3185 if not subcommands:
3186 return
3187 group = SUBCOMMANDS_GROUP
3188 self.nodes.append(
3189 yuio.doc.Heading(items=self.parser.parse_paragraph(group.title), level=1)
3190 )
3191 if group.help:
3192 self.nodes.append(
3193 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3194 )
3195 arg_group = _HelpArgGroup(items=[])
3196 for subcommand, names in subcommands.items():
3197 assert subcommand.help is not yuio.DISABLED
3198 arg_group.items.append(_HelpSubCommand(names, subcommand.help))
3199 self.nodes.append(arg_group)
3201 def _add_flags(self, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /):
3202 groups: dict[
3203 HelpGroup, tuple[list[Option[_t.Any]], list[Option[_t.Any]], int]
3204 ] = {}
3205 for i, opt in enumerate(cmd.options + inherited):
3206 if not opt.flags:
3207 continue
3208 if opt.help is yuio.DISABLED:
3209 continue
3210 group = opt.help_group or OPTS_GROUP
3211 if group.help is yuio.DISABLED:
3212 continue
3213 is_inherited = i >= len(cmd.options)
3214 if group not in groups:
3215 groups[group] = ([], [], 0)
3216 if opt.required or (opt.mutex_group and opt.mutex_group.required):
3217 groups[group][0].append(opt)
3218 elif is_inherited and not opt.show_if_inherited and not self.all:
3219 required, optional, n_inherited = groups[group]
3220 groups[group] = required, optional, n_inherited + 1
3221 else:
3222 groups[group][1].append(opt)
3223 for group, (required, optional, n_inherited) in groups.items():
3224 assert group.help is not yuio.DISABLED
3226 if group.collapse and not self.all and not (required or optional):
3227 continue
3229 self.nodes.append(
3230 yuio.doc.Heading(
3231 items=self.parser.parse_paragraph(group.title), level=1
3232 )
3233 )
3235 if group.collapse and not self.all:
3236 all_flags: set[str] = set()
3237 for opt in required or optional:
3238 all_flags.update(opt.primary_long_flags or [])
3239 if len(all_flags) == 1:
3240 prefix = all_flags.pop()
3241 else:
3242 prefix = _commonprefix(all_flags)
3243 if not prefix:
3244 prefix = "--*"
3245 elif prefix.endswith("-"):
3246 prefix += "*"
3247 else:
3248 prefix += "-*"
3249 help = yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3250 self.nodes.append(
3251 _CollapsedOpt(
3252 flags=[prefix],
3253 items=[help],
3254 )
3255 )
3256 continue
3258 if group.help and (required or optional):
3259 self.nodes.append(
3260 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3261 )
3262 arg_group = _HelpArgGroup(items=[])
3263 for opt in required:
3264 assert opt.help is not yuio.DISABLED
3265 arg_group.items.append(_HelpOpt(opt))
3266 for opt in optional:
3267 assert opt.help is not yuio.DISABLED
3268 arg_group.items.append(_HelpOpt(opt))
3269 if n_inherited > 0:
3270 arg_group.items.append(_InheritedOpts(n_inherited=n_inherited))
3271 self.nodes.append(arg_group)
3274def _format_metavar(metavar: str, ctx: yuio.string.ReprContext):
3275 punct_color = ctx.get_color("hl/punct:sh-usage")
3276 metavar_color = ctx.get_color("hl/metavar:sh-usage")
3278 res = _ColorizedString()
3279 is_punctuation = False
3280 for part in re.split(r"((?:[{}()[\]\\;!&|]|\s)+)", metavar):
3281 if is_punctuation:
3282 res.append_color(punct_color)
3283 else:
3284 res.append_color(metavar_color)
3285 res.append_str(part)
3286 is_punctuation = not is_punctuation
3288 return res
3291_ARGS_COLUMN_WIDTH = 26
3292_ARGS_COLUMN_WIDTH_NARROW = 8
3295class _CliFormatter(yuio.doc.Formatter): # type: ignore
3296 def __init__(
3297 self,
3298 parser: yuio.doc.DocParser,
3299 ctx: yuio.string.ReprContext,
3300 /,
3301 *,
3302 all: bool = False,
3303 ):
3304 self.parser = parser
3305 self.all = all
3307 self._heading_indent = contextlib.ExitStack()
3308 self._args_column_width = (
3309 _ARGS_COLUMN_WIDTH if ctx.width >= 50 else _ARGS_COLUMN_WIDTH_NARROW
3310 )
3311 ctx.width = min(ctx.width, 80)
3313 super().__init__(ctx, allow_headings=True)
3315 self.base_color = self.ctx.get_color("msg/text:code/sh-usage")
3316 self.prog_color = self.base_color | self.ctx.get_color("hl/prog:sh-usage")
3317 self.punct_color = self.base_color | self.ctx.get_color("hl/punct:sh-usage")
3318 self.metavar_color = self.base_color | self.ctx.get_color("hl/metavar:sh-usage")
3319 self.flag_color = self.base_color | self.ctx.get_color("hl/flag:sh-usage")
3321 def _format_Heading(self, node: yuio.doc.Heading):
3322 if not self._allow_headings:
3323 with self._with_color("msg/text:paragraph"):
3324 self._format_Text(node)
3325 return
3327 if node.level == 1:
3328 self._heading_indent.close()
3330 raw_heading = "".join(map(str, node.items))
3331 if raw_heading and raw_heading[-1].isalnum():
3332 node.items.append(":")
3334 decoration = self.ctx.get_msg_decoration("heading/section")
3335 with (
3336 self._with_indent("msg/decoration:heading/section", decoration),
3337 self._with_color("msg/text:heading/section"),
3338 ):
3339 self._format_Text(node)
3341 if node.level == 1:
3342 self._heading_indent.enter_context(self._with_indent(None, " "))
3343 elif self._separate_paragraphs:
3344 self._line(self._indent)
3346 self._is_first_line = True
3348 def _format_SetIndentation(self, node: _SetIndentation):
3349 self._heading_indent.close()
3350 self._is_first_line = True
3351 if node.indent:
3352 self._heading_indent.enter_context(self._with_indent(None, node.indent))
3354 def _format_Usage(self, node: _Usage):
3355 if node.prefix:
3356 prefix = _ColorizedString(
3357 self.ctx.get_color("msg/text:heading/section"),
3358 node.prefix,
3359 self.base_color,
3360 " ",
3361 )
3362 else:
3363 prefix = _ColorizedString()
3365 usage = _ColorizedString()
3366 if node.cmd.usage:
3367 sh_usage_highlighter, sh_usage_syntax_name = yuio.hl.get_highlighter(
3368 "sh-usage"
3369 )
3371 usage = sh_usage_highlighter.highlight(
3372 node.cmd.usage.rstrip(),
3373 theme=self.ctx.theme,
3374 syntax=sh_usage_syntax_name,
3375 ).percent_format({"prog": node.prog}, self.ctx)
3376 else:
3377 usage = self._build_usage(node)
3379 with self._with_indent(None, prefix):
3380 self._line(
3381 usage.indent(
3382 indent=self._indent,
3383 continuation_indent=self._continuation_indent,
3384 )
3385 )
3387 def _build_usage(self, node: _Usage):
3388 flags_and_groups: list[
3389 Option[_t.Any] | tuple[MutuallyExclusiveGroup, list[Option[_t.Any]]]
3390 ] = []
3391 positionals: list[Option[_t.Any]] = []
3392 groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {}
3393 has_grouped_flags = False
3395 for i, opt in enumerate(node.cmd.options + node.inherited):
3396 is_inherited = i >= len(node.cmd.options)
3397 if is_inherited and (
3398 not opt.show_if_inherited or opt.flags is yuio.POSITIONAL
3399 ):
3400 continue
3401 if opt.help is yuio.DISABLED:
3402 continue
3403 if opt.help_group is not None and opt.help_group.help is yuio.DISABLED:
3404 continue
3405 if opt.flags is yuio.POSITIONAL:
3406 positionals.append(opt)
3407 elif opt.usage is yuio.COLLAPSE:
3408 has_grouped_flags = True
3409 elif not opt.usage:
3410 pass
3411 elif opt.mutex_group:
3412 if opt.mutex_group not in groups:
3413 group_items = []
3414 groups[opt.mutex_group] = group_items
3415 flags_and_groups.append((opt.mutex_group, group_items))
3416 else:
3417 group_items = groups[opt.mutex_group]
3418 group_items.append(opt)
3419 else:
3420 flags_and_groups.append(opt)
3422 res = _ColorizedString()
3423 res.append_color(self.prog_color)
3424 res.append_str(node.prog)
3426 if has_grouped_flags:
3427 res.append_color(self.base_color)
3428 res.append_str(" ")
3429 res.append_color(self.flag_color)
3430 res.append_str("<options>")
3432 res.append_color(self.base_color)
3434 in_opt_short_group = False
3435 for flag_or_group in flags_and_groups:
3436 match flag_or_group:
3437 case (group, flags):
3438 res.append_color(self.base_color)
3439 res.append_str(" ")
3440 res.append_color(self.punct_color)
3441 res.append_str("(" if group.required else "[")
3442 sep = False
3443 for flag in flags:
3444 if sep:
3445 res.append_str("|")
3446 usage, _ = flag.format_usage(self.ctx)
3447 res.append_colorized_str(usage.with_base_color(self.base_color))
3448 sep = True
3449 res.append_str(")" if group.required else "]")
3450 case flag:
3451 usage, can_group = flag.format_usage(self.ctx)
3452 if not flag.primary_short_flag or flag.nargs != 0 or flag.required:
3453 can_group = False
3455 if can_group:
3456 if not in_opt_short_group:
3457 res.append_color(self.base_color)
3458 res.append_str(" ")
3459 res.append_color(self.punct_color)
3460 res.append_str("[")
3461 res.append_color(self.flag_color)
3462 res.append_str("-")
3463 in_opt_short_group = True
3464 letter = (flag.primary_short_flag or "")[1:]
3465 res.append_str(letter)
3466 continue
3468 if in_opt_short_group:
3469 res.append_color(self.punct_color)
3470 res.append_str("]")
3471 in_opt_short_group = False
3473 res.append_color(self.base_color)
3474 res.append_str(" ")
3476 if not flag.required:
3477 res.append_color(self.punct_color)
3478 res.append_str("[")
3479 res.append_colorized_str(usage.with_base_color(self.base_color))
3480 if not flag.required:
3481 res.append_color(self.punct_color)
3482 res.append_str("]")
3484 if in_opt_short_group:
3485 res.append_color(self.punct_color)
3486 res.append_str("]")
3487 in_opt_short_group = False
3489 for positional in positionals:
3490 res.append_color(self.base_color)
3491 res.append_str(" ")
3492 res.append_colorized_str(
3493 positional.format_usage(self.ctx)[0].with_base_color(self.base_color)
3494 )
3496 if node.cmd.subcommands:
3497 res.append_str(" ")
3498 if not node.cmd.subcommand_required:
3499 res.append_color(self.punct_color)
3500 res.append_str("[")
3501 res.append_colorized_str(
3502 _format_metavar(node.cmd.metavar, self.ctx).with_base_color(
3503 self.base_color
3504 )
3505 )
3506 res.append_color(self.base_color)
3507 res.append_str(" ")
3508 res.append_color(self.metavar_color)
3509 res.append_str("...")
3510 if not node.cmd.subcommand_required:
3511 res.append_color(self.punct_color)
3512 res.append_str("]")
3514 return res
3516 def _format_HelpOpt(self, node: _HelpOpt):
3517 lead = _ColorizedString()
3518 if node.arg.primary_short_flag:
3519 lead.append_color(self.flag_color)
3520 lead.append_str(node.arg.primary_short_flag)
3521 sep = True
3522 else:
3523 lead.append_color(self.base_color)
3524 lead.append_str(" ")
3525 sep = False
3526 for flag in node.arg.primary_long_flags or []:
3527 if sep:
3528 lead.append_color(self.punct_color)
3529 lead.append_str(", ")
3530 lead.append_color(self.flag_color)
3531 lead.append_str(flag)
3532 sep = True
3534 lead.append_colorized_str(
3535 node.arg.format_metavar(self.ctx).with_base_color(self.base_color)
3536 )
3538 help = _parse_option_help(node.arg, self.parser, self.ctx, all=self.all)
3540 if help is None:
3541 self._line(self._indent + lead)
3542 return
3544 if lead.width + 2 > self._args_column_width:
3545 self._line(self._indent + lead)
3546 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3547 else:
3548 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3550 with indent_ctx:
3551 self._format(help)
3553 def _format_HelpArg(self, node: _HelpArg):
3554 lead = _format_metavar(node.arg.nth_metavar(0), self.ctx).with_base_color(
3555 self.base_color
3556 )
3558 help = _parse_option_help(node.arg, self.parser, self.ctx, all=self.all)
3560 if help is None:
3561 self._line(self._indent + lead)
3562 return
3564 if lead.width + 2 > self._args_column_width:
3565 self._line(self._indent + lead)
3566 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3567 else:
3568 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3570 with indent_ctx:
3571 self._format(help)
3573 def _format_HelpSubCommand(self, node: _HelpSubCommand):
3574 lead = _ColorizedString()
3575 sep = False
3576 for name in node.names:
3577 if sep:
3578 lead.append_color(self.punct_color)
3579 lead.append_str(", ")
3580 lead.append_color(self.flag_color)
3581 lead.append_str(name)
3582 sep = True
3584 help = node.help
3586 if not help:
3587 self._line(self._indent + lead)
3588 return
3590 if lead.width + 2 > self._args_column_width:
3591 self._line(self._indent + lead)
3592 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3593 else:
3594 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3596 with indent_ctx:
3597 self._format(self.parser.parse(help))
3599 def _format_CollapsedOpt(self, node: _CollapsedOpt):
3600 if not node.flags:
3601 self._format_Container(node)
3602 return
3604 lead = _ColorizedString()
3605 sep = False
3606 for flag in node.flags:
3607 if sep:
3608 lead.append_color(self.punct_color)
3609 lead.append_str(", ")
3610 lead.append_color(self.flag_color)
3611 lead.append_str(flag)
3612 sep = True
3614 if lead.width + 2 > self._args_column_width:
3615 self._line(self._indent + lead)
3616 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3617 else:
3618 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3620 with indent_ctx:
3621 self._separate_paragraphs = False
3622 self._allow_headings = False
3623 self._format_Container(node)
3624 self._separate_paragraphs = True
3625 self._allow_headings = True
3627 def _format_InheritedOpts(self, node: _InheritedOpts):
3628 raw = _ColorizedString()
3629 s = "" if node.n_inherited == 1 else "s"
3630 raw.append_color(self.ctx.get_color("secondary_color"))
3631 raw.append_str(f" +{node.n_inherited} global option{s}, --help=all to show")
3632 self._line(raw)
3634 def _format_HelpArgGroup(self, node: _HelpArgGroup):
3635 self._separate_paragraphs = False
3636 self._allow_headings = False
3637 self._format_Container(node)
3638 self._separate_paragraphs = True
3639 self._allow_headings = True
3641 def _make_lead_padding(self, lead: _ColorizedString):
3642 color = self.base_color
3643 return lead + color + " " * (self._args_column_width - lead.width)
3646@dataclass(eq=False, match_args=False, slots=True)
3647class _SetIndentation(yuio.doc.AstBase):
3648 indent: str = ""
3651@dataclass(eq=False, match_args=False, slots=True)
3652class _Usage(yuio.doc.AstBase):
3653 prog: str
3654 cmd: Command[Namespace]
3655 inherited: list[Option[_t.Any]]
3656 prefix: str = "Usage:"
3659@dataclass(eq=False, match_args=False, slots=True)
3660class _HelpOpt(yuio.doc.AstBase):
3661 arg: Option[_t.Any]
3664@dataclass(eq=False, match_args=False, slots=True)
3665class _CollapsedOpt(yuio.doc.Container[yuio.doc.AstBase]):
3666 flags: list[str]
3669@dataclass(eq=False, match_args=False, slots=True)
3670class _HelpArg(yuio.doc.AstBase):
3671 arg: Option[_t.Any]
3674@dataclass(eq=False, match_args=False, slots=True)
3675class _InheritedOpts(yuio.doc.AstBase):
3676 n_inherited: int
3679@dataclass(eq=False, match_args=False, slots=True)
3680class _HelpSubCommand(yuio.doc.AstBase):
3681 names: list[str]
3682 help: str | None
3685@dataclass(eq=False, match_args=False, slots=True)
3686class _HelpArgGroup(yuio.doc.Container[yuio.doc.AstBase]):
3687 pass
3690class _ShortUsageFormatter:
3691 def __init__(
3692 self,
3693 parser: yuio.doc.DocParser,
3694 subcommands: list[str] | None,
3695 option: Option[_t.Any],
3696 ):
3697 self.parser = parser
3698 self.subcommands = subcommands
3699 self.option = option
3701 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
3702 note_color = ctx.get_color("msg/text:error/note")
3703 heading_color = ctx.get_color("msg/text:heading/note")
3704 code_color = ctx.get_color("msg/text:code/sh-usage")
3705 punct_color = code_color | ctx.get_color("hl/punct:sh-usage")
3706 flag_color = code_color | ctx.get_color("hl/flag:sh-usage")
3708 res = _ColorizedString()
3709 res.append_color(heading_color)
3710 res.append_str("Help: ")
3712 if self.option.flags is not yuio.POSITIONAL:
3713 sep = False
3714 if self.option.primary_short_flag:
3715 res.append_color(flag_color)
3716 res.append_str(self.option.primary_short_flag)
3717 sep = True
3718 for flag in self.option.primary_long_flags or []:
3719 if sep:
3720 res.append_color(punct_color)
3721 res.append_str(", ")
3722 res.append_color(flag_color)
3723 res.append_str(flag)
3724 sep = True
3726 res.append_colorized_str(
3727 self.option.format_metavar(ctx).with_base_color(code_color)
3728 )
3730 res.append_color(heading_color)
3731 res.append_str("\n")
3732 res.append_color(note_color)
3734 if help := _parse_option_help(self.option, self.parser, ctx):
3735 with ctx.with_settings(width=ctx.width - 2):
3736 formatter = _CliFormatter(self.parser, ctx)
3737 sep = False
3738 for line in formatter.format(
3739 _HelpArgGroup(items=[_SetIndentation(" "), help])
3740 ):
3741 if sep:
3742 res.append_str("\n")
3743 res.append_colorized_str(line.with_base_color(note_color))
3744 sep = True
3746 return res
3749def _parse_option_help(
3750 option: Option[_t.Any],
3751 parser: yuio.doc.DocParser,
3752 ctx: yuio.string.ReprContext,
3753 /,
3754 *,
3755 all: bool = False,
3756) -> yuio.doc.AstBase | None:
3757 help = parser.parse(option.help or "")
3758 if help_tail := option.format_help_tail(ctx, all=all):
3759 help.items.append(yuio.doc.Raw(raw=help_tail))
3761 return help if help.items else None