Coverage for yuio / cli.py: 69%
1534 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
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:
82.. autoclass:: LazyCommand
83 :members:
86Flags and positionals
87---------------------
89.. autoclass:: Option
90 :members:
92.. autoclass:: ValueOption
93 :members:
95.. autoclass:: ParserOption
96 :members:
98.. autoclass:: BoolOption
99 :members:
101.. autoclass:: ParseOneOption
102 :members:
104.. autoclass:: ParseManyOption
105 :members:
107.. autoclass:: StoreConstOption
108 :members:
110.. autoclass:: StoreFalseOption
111 :members:
113.. autoclass:: StoreTrueOption
114 :members:
116.. autoclass:: CountOption
117 :members:
119.. autoclass:: VersionOption
120 :members:
122.. autoclass:: HelpOption
123 :members:
126Namespace
127---------
129.. autoclass:: Namespace
131 .. automethod:: __getitem__
133 .. automethod:: __setitem__
135 .. automethod:: __contains__
137.. autoclass:: ConfigNamespace
138 :members:
141CLI parser
142----------
144.. autoclass:: CliParser
145 :members:
147.. autoclass:: Argument
148 :members:
150.. autoclass:: Flag
151 :members:
153.. autoclass:: ArgumentError
154 :members:
156.. type:: NArgs
157 :canonical: int | typing.Literal["+"]
159 Type alias for :attr:`~Option.nargs`.
161 .. note::
163 ``"*"`` from argparse is equivalent to ``nargs="+"`` with ``allow_no_args=True``;
164 ``"?"`` from argparse is equivalent to ``nargs=1`` with ``allow_no_args=True``.
167Option grouping
168---------------
170.. autoclass:: MutuallyExclusiveGroup
171 :members:
173.. autoclass:: HelpGroup
174 :members:
176.. autodata:: ARGS_GROUP
178.. autodata:: SUBCOMMANDS_GROUP
180.. autodata:: OPTS_GROUP
182.. autodata:: MISC_GROUP
184"""
186from __future__ import annotations
188import abc
189import contextlib
190import dataclasses
191import functools
192import re
193import sys
194import warnings
195from dataclasses import dataclass
197import yuio
198import yuio.complete
199import yuio.doc
200import yuio.hl
201import yuio.parse
202import yuio.string
203from yuio.string import ColorizedString as _ColorizedString
204from yuio.util import _UNPRINTABLE_TRANS
205from yuio.util import commonprefix as _commonprefix
207from typing import TYPE_CHECKING
209if TYPE_CHECKING:
210 import typing_extensions as _t
211else:
212 from yuio import _typing as _t
214if TYPE_CHECKING:
215 import yuio.app
216 import yuio.config
217 import yuio.dbg
219__all__ = [
220 "ARGS_GROUP",
221 "MISC_GROUP",
222 "OPTS_GROUP",
223 "SUBCOMMANDS_GROUP",
224 "Argument",
225 "ArgumentError",
226 "BoolOption",
227 "BugReportOption",
228 "CliParser",
229 "CliWarning",
230 "CollectOption",
231 "Command",
232 "CompletionOption",
233 "ConfigNamespace",
234 "CountOption",
235 "Flag",
236 "HelpGroup",
237 "HelpOption",
238 "LazyCommand",
239 "MutuallyExclusiveGroup",
240 "NArgs",
241 "Namespace",
242 "Option",
243 "ParseManyOption",
244 "ParseOneOption",
245 "ParserOption",
246 "StoreConstOption",
247 "StoreFalseOption",
248 "StoreTrueOption",
249 "ValueOption",
250 "VersionOption",
251]
253T = _t.TypeVar("T")
254T_cov = _t.TypeVar("T_cov", covariant=True)
256_SHORT_FLAG_RE = r"^-[a-zA-Z0-9]$"
257_LONG_FLAG_RE = r"^--[a-zA-Z0-9_+/.-]+$"
259_NUM_RE = r"""(?x)
260 ^
261 [+-]?
262 (?:
263 (?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?
264 |0[bB][01]+
265 |0[oO][0-7]+
266 |0[xX][0-9a-fA-F]+
267 )
268 $
269"""
271NArgs: _t.TypeAlias = int | _t.Literal["+"]
272"""
273Type alias for nargs.
275.. note::
277 ``"*"`` from argparse is equivalent to ``nargs="+"`` with ``allow_no_args=True``;
278 ``"?"`` from argparse is equivalent to ``nargs=1`` with ``allow_no_args=True``.
280"""
282NamespaceT = _t.TypeVar("NamespaceT", bound="Namespace")
283ConfigT = _t.TypeVar("ConfigT", bound="yuio.config.Config")
286class CliWarning(yuio.YuioWarning):
287 pass
290@dataclass(frozen=True, slots=True)
291class Argument:
292 """
293 Represents a CLI argument, or its part.
295 For positionals, this will contain the entire argument. For inline values,
296 this will contain value substring and its position relative to the full
297 value.
299 :example:
300 Consider the following command arguments:
302 .. code-block:: text
304 --arg=value
306 Argument ``"value"`` will be represented as:
308 .. code-block:: python
310 Argument(value="value", index=0, pos=6, flag="--arg", metavar=...)
312 """
314 value: str
315 """
316 Contents of the argument.
318 """
320 index: int
321 """
322 Index of this argument in the array that was passed to :meth:`CliParser.parse`.
324 Note that this array does not include executable name, so indexes are shifted
325 relative to :data:`sys.argv`.
327 """
329 pos: int
330 """
331 Position of the :attr:`~Argument.value` relative to the original argument string.
333 """
335 metavar: str
336 """
337 Meta variable for this argument.
339 """
341 flag: Flag | None
342 """
343 If this argument belongs to a flag, this attribute will contain flag's name.
345 """
347 def __str__(self) -> str:
348 return self.metavar
350 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
351 return _ColorizedString(
352 ctx.get_color("msg/text:code/sh-usage hl/flag:sh-usage"),
353 self.metavar,
354 )
357@dataclass(frozen=True, slots=True)
358class Flag:
359 value: str
360 """
361 Name of the flag.
363 """
365 index: int
366 """
367 Index of this flag in the array that was passed to :meth:`CliParser.parse`.
369 Note that this array does not include executable name, so indexes are shifted
370 relative to :data:`sys.argv`.
372 """
374 def __str__(self) -> str:
375 return self.value
377 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
378 return _ColorizedString(
379 ctx.get_color("msg/text:code/sh-usage hl/flag:sh-usage"),
380 self.value,
381 )
384class ArgumentError(yuio.PrettyException, ValueError):
385 """
386 Error that happened during argument parsing.
388 """
390 @_t.overload
391 def __init__(
392 self,
393 msg: _t.LiteralString,
394 /,
395 *args,
396 flag: Flag | None = None,
397 arguments: Argument | list[Argument] | None = None,
398 n_arg: int | None = None,
399 pos: tuple[int, int] | None = None,
400 path: list[tuple[_t.Any, str | None]] | None = None,
401 option: Option[_t.Any] | None = None,
402 ): ...
403 @_t.overload
404 def __init__(
405 self,
406 msg: yuio.string.Colorable | None = None,
407 /,
408 *,
409 flag: Flag | None = None,
410 arguments: Argument | list[Argument] | None = None,
411 n_arg: int | None = None,
412 pos: tuple[int, int] | None = None,
413 path: list[tuple[_t.Any, str | None]] | None = None,
414 option: Option[_t.Any] | None = None,
415 ): ...
416 def __init__(
417 self,
418 *args,
419 flag: Flag | None = None,
420 arguments: Argument | list[Argument] | None = None,
421 n_arg: int | None = None,
422 pos: tuple[int, int] | None = None,
423 path: list[tuple[_t.Any, str | None]] | None = None,
424 option: Option[_t.Any] | None = None,
425 ):
426 super().__init__(*args)
428 self.flag: Flag | None = flag
429 """
430 Flag that caused this error. Can be :data:`None` if error is caused
431 by a positional argument.
433 """
435 self.arguments: Argument | list[Argument] | None = arguments
436 """
437 Arguments that caused this error.
439 This can be a single argument, or multiple arguments. In the later case,
440 :attr:`~yuio.parse.ParsingError.n_arg` should correspond to the argument
441 that failed to parse. If :attr:`~yuio.parse.ParsingError.n_arg`
442 is :data:`None`, then all arguments are treated as faulty.
444 .. note::
446 Don't confuse :attr:`~ArgumentError.arguments` and :attr:`~BaseException.args`:
447 the latter contains formatting arguments and is defined
448 in the :class:`BaseException` class.
450 """
452 self.pos: tuple[int, int] | None = pos
453 """
454 Position in the original string in which this error has occurred (start
455 and end indices).
457 If :attr:`~ArgumentError.n_arg` is set, and :attr:`~ArgumentError.arguments`
458 is given as a list, then this position is relative to the argument
459 at index :attr:`~ArgumentError.n_arg`.
461 If :attr:`~ArgumentError.arguments` is given as a single argument (not a list),
462 then this position is relative to that argument.
464 Otherwise, position is ignored.
466 """
468 self.n_arg: int | None = n_arg
469 """
470 Index of the argument that caused the error.
472 """
474 self.path: list[tuple[_t.Any, str | None]] | None = path
475 """
476 Same as in :attr:`ParsingError.path <yuio.parse.ParsingError.path>`.
477 Can be present if parser uses :meth:`~yuio.parse.Parser.parse_config`
478 for validation.
480 """
482 self.option: Option[_t.Any] | None = option
483 """
484 Option which caused failure.
486 """
488 self.commandline: list[str] | None = None
489 self.prog: str | None = None
490 self.subcommands: list[str] | None = None
491 self.help_parser: yuio.doc.DocParser | None = None
493 @classmethod
494 def from_parsing_error(
495 cls,
496 e: yuio.parse.ParsingError,
497 /,
498 *,
499 flag: Flag | None = None,
500 arguments: Argument | list[Argument] | None = None,
501 option: Option[_t.Any] | None = None,
502 ):
503 """
504 Convert parsing error to argument error.
506 """
508 return cls(
509 *e.args,
510 flag=flag,
511 arguments=arguments,
512 n_arg=e.n_arg,
513 pos=e.pos,
514 path=e.path,
515 option=option,
516 )
518 def to_colorable(self) -> yuio.string.Colorable:
519 colorable = yuio.string.WithBaseColor(
520 super().to_colorable(),
521 base_color="msg/text:error",
522 )
524 msg = []
525 args = []
526 sep = False
528 if self.flag and self.flag.value:
529 msg.append("in flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>")
530 args.append(self.flag.value)
531 sep = True
533 argument = None
534 if isinstance(self.arguments, list):
535 if self.n_arg is not None and self.n_arg < len(self.arguments):
536 argument = self.arguments[self.n_arg]
537 else:
538 argument = self.arguments
540 if argument and argument.metavar:
541 if sep:
542 msg.append(", ")
543 msg.append("in <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>")
544 args.append(argument.metavar)
546 if self.path:
547 if sep:
548 msg.append(", ")
549 msg.append("in <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>")
550 args.append(yuio.parse._PathRenderer(self.path))
552 if sep:
553 msg.insert(0, "Error ")
554 msg.append(":")
556 colorable = yuio.string.Stack(
557 yuio.string.WithBaseColor(
558 yuio.string.Format("".join(msg), *args),
559 base_color="msg/text:failure",
560 ),
561 yuio.string.Indent(colorable),
562 )
563 else:
564 colorable = yuio.string.WithBaseColor(
565 colorable,
566 base_color="msg/text:failure",
567 )
569 if commandline := self._make_commandline():
570 colorable = yuio.string.Stack(
571 commandline,
572 colorable,
573 )
575 if usage := self._make_usage():
576 colorable = yuio.string.Stack(
577 colorable,
578 usage,
579 )
581 return colorable
583 def _make_commandline(self):
584 if not self.prog or not self.commandline:
585 return None
587 argument = None
588 if isinstance(self.arguments, list):
589 if self.n_arg is not None and self.n_arg < len(self.arguments):
590 argument = self.arguments[self.n_arg]
591 else:
592 argument = self.arguments
594 if argument:
595 arg_index = argument.index
596 arg_pos = (argument.pos, argument.pos + len(argument.value))
597 if self.pos:
598 arg_pos = (
599 arg_pos[0] + self.pos[0],
600 min(arg_pos[1], arg_pos[0] + self.pos[1]),
601 )
602 elif self.flag:
603 arg_index = self.flag.index
604 arg_pos = (0, len(self.commandline[arg_index]))
605 else:
606 return None
608 text = self.prog
609 text += " "
610 text += " ".join(_quote(arg) for arg in self.commandline[:arg_index])
611 if arg_index > 0:
612 text += " "
614 center, pos = _quote_and_adjust_pos(self.commandline[arg_index], arg_pos)
615 pos = (pos[0] + len(text), pos[1] + len(text))
617 text += center
618 text += " "
619 text += " ".join(_quote(arg) for arg in self.commandline[arg_index + 1 :])
621 if text:
622 return yuio.parse._CodeRenderer(text, pos, as_cli=True)
623 else:
624 return None
626 def _make_usage(self):
627 if not self.option or not self.option.help or not self.help_parser:
628 return None
629 else:
630 return _ShortUsageFormatter(self.help_parser, self.subcommands, self.option)
633class Namespace(_t.Protocol):
634 """
635 Protocol for namespace implementations.
637 """
639 @abc.abstractmethod
640 def __getitem__(self, key: str, /) -> _t.Any: ...
642 @abc.abstractmethod
643 def __setitem__(self, key: str, value: _t.Any, /): ...
645 @abc.abstractmethod
646 def __contains__(self, key: str, /) -> bool: ...
649@yuio.string.repr_from_rich
650class ConfigNamespace(Namespace, _t.Generic[ConfigT]):
651 """
652 Wrapper that makes :class:`~yuio.config.Config` instances behave like namespaces.
654 """
656 def __init__(self, config: ConfigT) -> None:
657 self.__config = config
659 @property
660 def config(self) -> ConfigT:
661 """
662 Wrapped config instance.
664 """
666 return self.__config
668 def __getitem__(self, key: str) -> _t.Any:
669 root, key = self.__split_key(key)
670 try:
671 return getattr(root, key)
672 except AttributeError as e:
673 raise KeyError(str(e)) from None
675 def __setitem__(self, key: str, value: _t.Any):
676 root, key = self.__split_key(key)
677 try:
678 return setattr(root, key, value)
679 except AttributeError as e:
680 raise KeyError(str(e)) from None
682 def __contains__(self, key: str):
683 root, key = self.__split_key(key)
684 return key in root.__dict__
686 def __split_key(self, key: str) -> tuple[yuio.config.Config, str]:
687 root = self.__config
688 *parents, key = key.split(".")
689 for parent in parents:
690 root = getattr(root, parent)
691 return root, key
693 def __rich_repr__(self):
694 yield None, self.__config
697@dataclass(eq=False)
698class HelpGroup:
699 """
700 Group of flags in CLI help.
702 """
704 title: str
705 """
706 Title for this group.
708 """
710 help: str | yuio.Disabled = dataclasses.field(default="", kw_only=True)
711 """
712 Help message for an option.
714 """
716 collapse: bool = dataclasses.field(default=False, kw_only=True)
717 """
718 Hide options from this group in CLI help, but show group's title and help.
720 """
722 _slug: str | None = dataclasses.field(default=None, kw_only=True)
725ARGS_GROUP = HelpGroup("Arguments")
726"""
727Help group for positional arguments.
729"""
731SUBCOMMANDS_GROUP = HelpGroup("Subcommands")
732"""
733Help group for subcommands.
735"""
737OPTS_GROUP = HelpGroup("Options")
738"""
739Help group for flags.
741"""
743MISC_GROUP = HelpGroup("Misc options")
744"""
745Help group for misc flags such as :flag:`--help` or :flag:`--version`.
747"""
750@dataclass(kw_only=True, eq=False)
751class MutuallyExclusiveGroup:
752 """
753 A sentinel for creating mutually exclusive groups.
755 Pass an instance of this class all :func:`~yuio.app.field`\\ s that should
756 be mutually exclusive.
758 """
760 required: bool = False
761 """
762 Require that one of the mutually exclusive options is always given.
764 """
767@dataclass(eq=False, kw_only=True)
768class Option(abc.ABC, _t.Generic[T_cov]):
769 """
770 Base class for a CLI option.
772 """
774 flags: list[str] | yuio.Positional
775 """
776 Flags corresponding to this option. Positional options have flags set to
777 :data:`yuio.POSITIONAL`.
779 """
781 allow_inline_arg: bool
782 """
783 Whether to allow specifying argument inline (i.e. :flag:`--foo=bar`).
785 Inline arguments are handled separately from normal arguments,
786 and :attr:`~Option.nargs` setting does not affect them.
788 Positional options can't take inline arguments, so this attribute has
789 no effect on them.
791 """
793 allow_implicit_inline_arg: bool
794 """
795 Whether to allow specifying argument inline with short flags without equals sign
796 (i.e. :flag:`-fValue`).
798 Inline arguments are handled separately from normal arguments,
799 and :attr:`~Option.nargs` setting does not affect them.
801 Positional options can't take inline arguments, so this attribute has
802 no effect on them.
804 """
806 nargs: NArgs
807 """
808 How many arguments this option takes.
810 """
812 allow_no_args: bool
813 """
814 Whether to allow passing no arguments even if :attr:`~Option.nargs` requires some.
816 """
818 required: bool
819 """
820 Makes this option required. The parsing will fail if this option is not
821 encountered among CLI arguments.
823 Note that positional arguments are always parsed; if no positionals are given,
824 all positional options are processed with zero arguments, at which point they'll
825 fail :attr:`~Option.nargs` check. Thus, :attr:`~Option.required` has no effect
826 on positionals.
828 """
830 metavar: str | tuple[str, ...]
831 """
832 Option's meta variable, used for displaying help messages.
834 If :attr:`~Option.nargs` is an integer, this can be a tuple of strings,
835 one for each argument. If :attr:`~Option.nargs` is zero, this can be an empty
836 tuple.
838 """
840 mutex_group: None | MutuallyExclusiveGroup
841 """
842 Mutually exclusive group for this option. Positional options can't have
843 mutex groups.
845 """
847 usage: yuio.Collapse | bool
848 """
849 Specifies whether this option should be displayed in CLI usage. Positional options
850 are always displayed, regardless of this setting.
852 """
854 help: str | yuio.Disabled
855 """
856 Help message for an option.
858 """
860 help_group: HelpGroup | None
861 """
862 Group for this flag, default is :data:`OPTS_GROUP` for flags and :data:`ARGS_GROUP`
863 for positionals. Positionals are flags are never mixed together; if they appear
864 in the same group, the group title will be repeated twice.
866 """
868 default_desc: str | None
869 """
870 Overrides description of default value.
872 """
874 show_if_inherited: bool
875 """
876 Force-show this flag if it's inherited from parent command. Positionals can't be
877 inherited because subcommand argument always goes last.
879 """
881 allow_abbrev: bool
882 """
883 Allow abbreviation for this option.
885 """
887 dest: str
888 """
889 Key where to store parsed argument.
891 """
893 @abc.abstractmethod
894 def process(
895 self,
896 cli_parser: CliParser[Namespace],
897 flag: Flag | None,
898 arguments: Argument | list[Argument],
899 ns: Namespace,
900 ):
901 """
902 Process this argument.
904 This method is called every time an option is encountered
905 on the command line. It should parse option's args and merge them
906 with previous values, if there are any.
908 When option's arguments are passed separately (i.e. :flag:`--opt arg1 arg2 ...`),
909 `args` is given as a list. List's length is checked against
910 :attr:`~Option.nargs` before this method is called.
912 When option's arguments are passed as an inline value (i.e. :flag:`--long=arg`
913 or :flag:`-Sarg`), the `args` is given as a string. :attr:`~Option.nargs`
914 are not checked in this case, giving you an opportunity to handle inline option
915 however you like.
917 :param cli_parser:
918 CLI parser instance that's doing the parsing. Not to be confused with
919 :class:`yuio.parse.Parser`.
920 :param flag:
921 flag that set this option. This will be set to :data:`None`
922 for positional arguments.
923 :param arguments:
924 option arguments, see above.
925 :param ns:
926 namespace where parsed arguments should be stored.
927 :raises:
928 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
930 """
932 def post_process(
933 self,
934 cli_parser: CliParser[Namespace],
935 arguments: list[Argument],
936 ns: Namespace,
937 ):
938 """
939 Called once at the end of parsing to post-process all arguments.
941 :param cli_parser:
942 CLI parser instance that's doing the parsing. Not to be confused with
943 :class:`yuio.parse.Parser`.
944 :param arguments:
945 option arguments that were ever passed to this option.
946 :param ns:
947 namespace where parsed arguments should be stored.
948 :raises:
949 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
951 """
953 @functools.cached_property
954 def short_flags(self) -> list[str] | None:
955 if self.flags is yuio.POSITIONAL:
956 return None
957 else:
958 return [flag for flag in self.flags if _is_short(flag)]
960 @functools.cached_property
961 def long_flags(self) -> list[str] | None:
962 if self.flags is yuio.POSITIONAL:
963 return None
964 else:
965 return [flag for flag in self.flags if not _is_short(flag)]
967 @functools.cached_property
968 def primary_short_flag(self) -> str | None:
969 """
970 Short flag that will be displayed in CLI help.
972 """
974 if short_flags := self.short_flags:
975 return short_flags[0]
976 else:
977 return None
979 @functools.cached_property
980 def primary_long_flags(self) -> list[str] | None:
981 """
982 Long flags that will be displayed in CLI help.
984 """
986 if long_flags := self.long_flags:
987 return [long_flags[0]]
988 else:
989 return None
991 def format_usage(
992 self,
993 ctx: yuio.string.ReprContext,
994 /,
995 ) -> tuple[_ColorizedString, bool]:
996 """
997 Allows customizing how this option looks in CLI usage.
999 :param ctx:
1000 repr context for formatting help.
1001 :returns:
1002 a string that will be used to represent this option in program's
1003 usage section.
1005 """
1007 can_group = False
1008 res = _ColorizedString()
1009 if self.flags is not yuio.POSITIONAL and self.flags:
1010 flag = self.primary_short_flag
1011 if flag:
1012 can_group = True
1013 elif self.primary_long_flags:
1014 flag = self.primary_long_flags[0]
1015 else:
1016 flag = self.flags[0]
1017 res.append_color(ctx.get_color("hl/flag:sh-usage"))
1018 res.append_str(flag)
1019 if metavar := self.format_metavar(ctx):
1020 res.append_colorized_str(metavar)
1021 can_group = False
1022 return res, can_group
1024 def format_metavar(
1025 self,
1026 ctx: yuio.string.ReprContext,
1027 /,
1028 ) -> _ColorizedString:
1029 """
1030 Allows customizing how this option looks in CLI help.
1032 :param ctx:
1033 repr context for formatting help.
1034 :returns:
1035 a string that will be appended to the list of option's flags
1036 to format an entry for this option in CLI help message.
1038 """
1040 res = _ColorizedString()
1042 if not self.nargs:
1043 return res
1045 res.append_color(ctx.get_color("hl/punct:sh-usage"))
1046 if self.flags:
1047 res.append_str(" ")
1049 if self.nargs == "+":
1050 if self.allow_no_args:
1051 res.append_str("[")
1052 res.append_colorized_str(_format_metavar(self.nth_metavar(0), ctx))
1053 if self.allow_no_args:
1054 res.append_str(" ...]")
1055 else:
1056 res.append_str(" [")
1057 res.append_colorized_str(_format_metavar(self.nth_metavar(0), ctx))
1058 res.append_str(" ...]")
1059 elif isinstance(self.nargs, int) and self.nargs:
1060 if self.allow_no_args:
1061 res.append_str("[")
1062 sep = False
1063 for i in range(self.nargs):
1064 if sep:
1065 res.append_str(" ")
1066 res.append_colorized_str(_format_metavar(self.nth_metavar(i), ctx))
1067 sep = True
1068 if self.allow_no_args:
1069 res.append_str("]")
1071 return res
1073 def format_help_tail(
1074 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1075 ) -> _ColorizedString:
1076 """
1077 Format additional content that will be added to the end of the help message,
1078 such as aliases, default value, etc.
1080 :param ctx:
1081 repr context for formatting help.
1082 :param all:
1083 whether :flag:`--help=all` was specified.
1084 :returns:
1085 a string that will be appended to the main help message.
1087 """
1089 base_color = ctx.get_color("msg/text:help/tail msg/text:code/sh-usage")
1091 res = _ColorizedString(base_color)
1093 if alias_flags := self.format_alias_flags(ctx, all=all):
1094 es = "" if len(alias_flags) == 1 else "es"
1095 res.append_str(f"Alias{es}: ")
1096 sep = False
1097 for alias_flag in alias_flags:
1098 if isinstance(alias_flag, tuple):
1099 alias_flag = alias_flag[0]
1100 if sep:
1101 res.append_str(", ")
1102 res.append_colorized_str(alias_flag.with_base_color(base_color))
1103 sep = True
1105 if default := self.format_default(ctx, all=all):
1106 if res:
1107 res.append_str("; ")
1108 res.append_str("Default: ")
1109 res.append_colorized_str(default.with_base_color(base_color))
1111 if res:
1112 res.append_str(".")
1114 return res
1116 def format_alias_flags(
1117 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1118 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None:
1119 """
1120 Format alias flags that weren't included in :attr:`~Option.primary_short_flag`
1121 and :attr:`~Option.primary_long_flags`.
1123 :param ctx:
1124 repr context for formatting help.
1125 :param all:
1126 whether :flag:`--help=all` was specified.
1127 :returns:
1128 a list of strings, one per each alias.
1130 """
1132 if self.flags is yuio.POSITIONAL:
1133 return None
1134 primary_flags = set(self.primary_long_flags or [])
1135 if self.primary_short_flag:
1136 primary_flags.add(self.primary_short_flag)
1137 aliases: list[_ColorizedString | tuple[_ColorizedString, str]] = []
1138 flag_color = ctx.get_color("hl/flag:sh-usage")
1139 for flag in self.flags:
1140 if flag not in primary_flags:
1141 res = _ColorizedString()
1142 res.start_no_wrap()
1143 res.append_color(flag_color)
1144 res.append_str(flag)
1145 res.end_no_wrap()
1146 aliases.append(res)
1147 return aliases
1149 def format_default(
1150 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1151 ) -> _ColorizedString | None:
1152 """
1153 Format default value that will be included in the CLI help.
1155 :param ctx:
1156 repr context for formatting help.
1157 :param all:
1158 whether :flag:`--help=all` was specified.
1159 :returns:
1160 a string that will be appended to the main help message.
1162 """
1164 if self.default_desc is not None:
1165 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code"))
1167 return None
1169 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1170 return None, False
1172 def nth_metavar(self, n: int) -> str:
1173 """
1174 Get metavar for n-th argument for this option.
1176 """
1178 if not self.metavar:
1179 return "<argument>"
1180 if isinstance(self.metavar, tuple):
1181 if n >= len(self.metavar):
1182 return self.metavar[-1]
1183 else:
1184 return self.metavar[n]
1185 else:
1186 return self.metavar
1189@dataclass(eq=False, kw_only=True)
1190class ValueOption(Option[T], _t.Generic[T]):
1191 """
1192 Base class for options that parse arguments and assign them to namespace.
1194 This base handles assigning parsed value to the target destination and merging
1195 values if option is invoked multiple times. Call ``self.set(ns, value)`` from
1196 :meth:`Option.process` to set result of option processing.
1198 """
1200 merge: _t.Callable[[T, T], T] | None
1201 """
1202 Function to merge previous and new value.
1204 """
1206 default: object
1207 """
1208 Default value that will be used if this flag is not given.
1210 Used for formatting help, does not affect actual parsing.
1212 """
1214 def set(self, ns: Namespace, value: T):
1215 """
1216 Save new value. If :attr:`~ValueOption.merge` is given, automatically
1217 merge old and new value.
1219 """
1221 if self.merge and self.dest in ns:
1222 ns[self.dest] = self.merge(ns[self.dest], value)
1223 else:
1224 ns[self.dest] = value
1227@dataclass(eq=False, kw_only=True)
1228class ParserOption(ValueOption[T], _t.Generic[T]):
1229 """
1230 Base class for options that use :mod:`yuio.parse` to process arguments.
1232 """
1234 parser: yuio.parse.Parser[T]
1235 """
1236 A parser used to parse option's arguments.
1238 """
1240 def format_default(
1241 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1242 ) -> _ColorizedString | None:
1243 if self.default_desc is not None:
1244 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code"))
1246 if self.default is yuio.MISSING or self.default is None:
1247 return None
1249 try:
1250 return ctx.hl(self.parser.describe_value(self.default)).with_base_color(
1251 ctx.get_color("code")
1252 )
1253 except TypeError:
1254 return ctx.repr(self.default)
1257@dataclass(eq=False, kw_only=True)
1258class BoolOption(ParserOption[bool]):
1259 """
1260 An option that combines :class:`StoreTrueOption`, :class:`StoreFalseOption`,
1261 and :class:`ParseOneOption`.
1263 If any of the :attr:`~BoolOption.pos_flags` are given without arguments, it works like
1264 :class:`StoreTrueOption`.
1266 If any of the :attr:`~BoolOption.neg_flags` are given, it works like
1267 :class:`StoreFalseOption`.
1269 If any of the :attr:`~BoolOption.pos_flags` are given with an inline argument,
1270 the argument is parsed as a :class:`bool`.
1272 .. note::
1274 Bool option has :attr:`~Option.nargs` set to ``0``, so non-inline arguments
1275 (i.e. :flag:`--json false`) are not recognized. You should always use inline
1276 argument to set boolean flag's value (i.e. :flag:`--json=false`). This avoids
1277 ambiguity in cases like the following:
1279 .. code-block:: console
1281 $ prog --json subcommand # Ok
1282 $ prog --json=true subcommand # Ok
1283 $ prog --json true subcommand # Not allowed
1285 :example:
1286 .. code-block:: python
1288 option = yuio.cli.BoolOption(
1289 pos_flags=["--json"],
1290 neg_flags=["--no-json"],
1291 dest=...,
1292 )
1294 .. code-block:: console
1296 $ prog --json # Set `dest` to `True`
1297 $ prog --no-json # Set `dest` to `False`
1298 $ prog --json=$value # Set `dest` to parsed `$value`
1300 """
1302 pos_flags: list[str]
1303 """
1304 List of flag names that enable this boolean option. Should be non-empty.
1306 """
1308 neg_flags: list[str]
1309 """
1310 List of flag names that disable this boolean option.
1312 """
1314 def __init__(
1315 self,
1316 *,
1317 pos_flags: list[str],
1318 neg_flags: list[str],
1319 required: bool = False,
1320 mutex_group: None | MutuallyExclusiveGroup = None,
1321 usage: yuio.Collapse | bool = True,
1322 help: str | yuio.Disabled = "",
1323 help_group: HelpGroup | None = None,
1324 show_if_inherited: bool = False,
1325 dest: str,
1326 parser: yuio.parse.Parser[bool] | None = None,
1327 merge: _t.Callable[[bool, bool], bool] | None = None,
1328 default: bool | yuio.Missing = yuio.MISSING,
1329 allow_abbrev: bool = True,
1330 default_desc: str | None = None,
1331 ):
1332 self.pos_flags = pos_flags
1333 self.neg_flags = neg_flags
1335 super().__init__(
1336 flags=pos_flags + neg_flags,
1337 allow_inline_arg=True,
1338 allow_implicit_inline_arg=False,
1339 nargs=0,
1340 allow_no_args=True,
1341 required=required,
1342 metavar=(),
1343 mutex_group=mutex_group,
1344 usage=usage,
1345 help=help,
1346 help_group=help_group,
1347 show_if_inherited=show_if_inherited,
1348 dest=dest,
1349 merge=merge,
1350 default=default,
1351 parser=parser or yuio.parse.Bool(),
1352 allow_abbrev=allow_abbrev,
1353 default_desc=default_desc,
1354 )
1356 def process(
1357 self,
1358 cli_parser: CliParser[Namespace],
1359 flag: Flag | None,
1360 arguments: Argument | list[Argument],
1361 ns: Namespace,
1362 ):
1363 if flag and flag.value in self.neg_flags:
1364 if arguments:
1365 raise ArgumentError(
1366 "This flag can't have arguments", flag=flag, arguments=arguments
1367 )
1368 value = False
1369 elif isinstance(arguments, Argument):
1370 value = self.parser.parse(arguments.value)
1371 else:
1372 value = True
1373 self.set(ns, value)
1375 @functools.cached_property
1376 def primary_short_flag(self):
1377 if self.flags is yuio.POSITIONAL:
1378 return None
1379 if self.default is True:
1380 flags = self.neg_flags
1381 else:
1382 flags = self.pos_flags
1383 for flag in flags:
1384 if _is_short(flag):
1385 return flag
1386 return None
1388 @functools.cached_property
1389 def primary_long_flags(self):
1390 flags = []
1391 if self.default is not True:
1392 for flag in self.pos_flags:
1393 if not _is_short(flag):
1394 flags.append(flag)
1395 break
1396 if self.default is not False:
1397 for flag in self.neg_flags:
1398 if not _is_short(flag):
1399 flags.append(flag)
1400 break
1401 return flags
1403 def format_alias_flags(
1404 self, ctx: yuio.string.ReprContext, *, all: bool = False
1405 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None:
1406 if self.flags is yuio.POSITIONAL:
1407 return None
1409 primary_flags = set(self.primary_long_flags or [])
1410 if self.primary_short_flag:
1411 primary_flags.add(self.primary_short_flag)
1413 aliases: list[_ColorizedString | tuple[_ColorizedString, str]] = []
1414 flag_color = ctx.get_color("hl/flag:sh-usage")
1415 if all:
1416 alias_candidates = self.pos_flags + self.neg_flags
1417 else:
1418 alias_candidates = []
1419 if self.default is not True:
1420 alias_candidates += self.pos_flags
1421 if self.default is not False:
1422 alias_candidates += self.neg_flags
1423 for flag in alias_candidates:
1424 if flag not in primary_flags:
1425 res = _ColorizedString()
1426 res.start_no_wrap()
1427 res.append_color(flag_color)
1428 res.append_str(flag)
1429 res.end_no_wrap()
1430 aliases.append(res)
1431 if self.pos_flags and all:
1432 primary_pos_flag = self.pos_flags[0]
1433 for pos_flag in self.pos_flags:
1434 if not _is_short(pos_flag):
1435 primary_pos_flag = pos_flag
1436 break
1437 punct_color = ctx.get_color("hl/punct:sh-usage")
1438 metavar_color = ctx.get_color("hl/metavar:sh-usage")
1439 res = _ColorizedString()
1440 res.start_no_wrap()
1441 res.append_color(flag_color)
1442 res.append_str(primary_pos_flag)
1443 res.end_no_wrap()
1444 res.append_color(punct_color)
1445 res.append_str("={")
1446 res.append_color(metavar_color)
1447 res.append_str("true")
1448 res.append_color(punct_color)
1449 res.append_str("|")
1450 res.append_color(metavar_color)
1451 res.append_str("false")
1452 res.append_color(punct_color)
1453 res.append_str("}")
1454 aliases.append(res)
1455 return aliases
1457 def format_default(
1458 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1459 ) -> _ColorizedString | None:
1460 if self.default_desc is not None:
1461 return ctx.hl(self.default_desc).with_base_color(ctx.get_color("code"))
1463 return None
1465 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1466 return (
1467 yuio.complete.Choice(
1468 [
1469 yuio.complete.Option("true"),
1470 yuio.complete.Option("false"),
1471 ]
1472 ),
1473 False,
1474 )
1477@dataclass(eq=False, kw_only=True)
1478class ParseOneOption(ParserOption[T], _t.Generic[T]):
1479 """
1480 An option with a single argument that uses Yuio parser.
1482 """
1484 def __init__(
1485 self,
1486 *,
1487 flags: list[str] | yuio.Positional,
1488 required: bool = False,
1489 mutex_group: None | MutuallyExclusiveGroup = None,
1490 usage: yuio.Collapse | bool = True,
1491 help: str | yuio.Disabled = "",
1492 help_group: HelpGroup | None = None,
1493 show_if_inherited: bool = False,
1494 dest: str,
1495 parser: yuio.parse.Parser[T],
1496 merge: _t.Callable[[T, T], T] | None = None,
1497 default: T | yuio.Missing = yuio.MISSING,
1498 allow_abbrev: bool = True,
1499 default_desc: str | None = None,
1500 ):
1501 super().__init__(
1502 flags=flags,
1503 allow_inline_arg=True,
1504 allow_implicit_inline_arg=True,
1505 nargs=1,
1506 allow_no_args=default is not yuio.MISSING and flags is yuio.POSITIONAL,
1507 required=required,
1508 metavar=parser.describe_or_def(),
1509 mutex_group=mutex_group,
1510 usage=usage,
1511 help=help,
1512 help_group=help_group,
1513 show_if_inherited=show_if_inherited,
1514 dest=dest,
1515 merge=merge,
1516 default=default,
1517 parser=parser,
1518 allow_abbrev=allow_abbrev,
1519 default_desc=default_desc,
1520 )
1522 def process(
1523 self,
1524 cli_parser: CliParser[Namespace],
1525 flag: Flag | None,
1526 arguments: Argument | list[Argument],
1527 ns: Namespace,
1528 ):
1529 if isinstance(arguments, list):
1530 if not arguments and self.allow_no_args:
1531 return # Don't set value so that app falls back to default.
1532 arguments = arguments[0]
1533 try:
1534 self.set(ns, self.parser.parse(arguments.value))
1535 except yuio.parse.ParsingError as e:
1536 e.n_arg = 0
1537 raise
1539 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1540 return (self.parser.completer(), False)
1543@dataclass(eq=False, kw_only=True)
1544class ParseManyOption(ParserOption[T], _t.Generic[T]):
1545 """
1546 An option with multiple arguments that uses Yuio parser.
1548 """
1550 def __init__(
1551 self,
1552 *,
1553 flags: list[str] | yuio.Positional,
1554 required: bool = False,
1555 mutex_group: None | MutuallyExclusiveGroup = None,
1556 usage: yuio.Collapse | bool = True,
1557 help: str | yuio.Disabled = "",
1558 help_group: HelpGroup | None = None,
1559 show_if_inherited: bool = False,
1560 dest: str,
1561 parser: yuio.parse.Parser[T],
1562 merge: _t.Callable[[T, T], T] | None = None,
1563 default: T | yuio.Missing = yuio.MISSING,
1564 allow_abbrev: bool = True,
1565 default_desc: str | None = None,
1566 ):
1567 assert parser.supports_parse_many()
1569 nargs = parser.get_nargs()
1570 allow_no_args = default is not yuio.MISSING and flags is yuio.POSITIONAL
1571 if nargs == "*":
1572 nargs = "+"
1573 allow_no_args = True
1575 super().__init__(
1576 flags=flags,
1577 allow_inline_arg=True,
1578 allow_implicit_inline_arg=True,
1579 nargs=nargs,
1580 allow_no_args=allow_no_args,
1581 required=required,
1582 metavar=parser.describe_many(),
1583 mutex_group=mutex_group,
1584 usage=usage,
1585 help=help,
1586 help_group=help_group,
1587 show_if_inherited=show_if_inherited,
1588 dest=dest,
1589 merge=merge,
1590 default=default,
1591 parser=parser,
1592 allow_abbrev=allow_abbrev,
1593 default_desc=default_desc,
1594 )
1596 def process(
1597 self,
1598 cli_parser: CliParser[Namespace],
1599 flag: Flag | None,
1600 arguments: Argument | list[Argument],
1601 ns: Namespace,
1602 ):
1603 if (
1604 not arguments
1605 and self.allow_no_args
1606 and self.default is not yuio.MISSING
1607 and self.flags is yuio.POSITIONAL
1608 ):
1609 return # Don't set value so that app falls back to default.
1611 if isinstance(arguments, list):
1612 self.set(ns, self.parser.parse_many([arg.value for arg in arguments]))
1613 else:
1614 self.set(ns, self.parser.parse(arguments.value))
1616 def format_alias_flags(
1617 self, ctx: yuio.string.ReprContext, /, *, all: bool = False
1618 ) -> list[_ColorizedString | tuple[_ColorizedString, str]] | None:
1619 aliases = super().format_alias_flags(ctx, all=all) or []
1620 if all:
1621 flag = self.primary_short_flag
1622 if not flag and self.primary_long_flags:
1623 flag = self.primary_long_flags[0]
1624 if not flag and self.flags:
1625 flag = self.flags[0]
1626 if flag:
1627 res = _ColorizedString()
1628 res.start_no_wrap()
1629 res.append_color(ctx.get_color("hl/flag:sh-usage"))
1630 res.append_str(flag)
1631 res.end_no_wrap()
1632 res.append_color(ctx.get_color("hl/punct:sh-usage"))
1633 res.append_str("=")
1634 res.append_color(ctx.get_color("hl/str:sh-usage"))
1635 res.append_str("'")
1636 res.append_str(self.parser.describe_or_def())
1637 res.append_str("'")
1638 comment = (
1639 "can be given as a single argument with delimiter-separated list."
1640 )
1641 aliases.append((res, comment))
1642 return aliases
1644 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1645 return (self.parser.completer(), True)
1648@dataclass(eq=False, kw_only=True)
1649class CollectOption(ParserOption[T], _t.Generic[T]):
1650 """
1651 An option with single argument that collects all of its instances and passes them
1652 to :meth:`Parser.parse_many <yuio.parse.Parser.parse_many>`.
1654 """
1656 def __init__(
1657 self,
1658 *,
1659 flags: list[str] | yuio.Positional,
1660 required: bool = False,
1661 mutex_group: None | MutuallyExclusiveGroup = None,
1662 usage: yuio.Collapse | bool = True,
1663 help: str | yuio.Disabled = "",
1664 help_group: HelpGroup | None = None,
1665 show_if_inherited: bool = False,
1666 dest: str,
1667 parser: yuio.parse.Parser[T],
1668 merge: _t.Callable[[T, T], T] | None = None,
1669 default: T | yuio.Missing = yuio.MISSING,
1670 allow_abbrev: bool = True,
1671 default_desc: str | None = None,
1672 ):
1673 assert parser.supports_parse_many()
1675 if flags is yuio.POSITIONAL:
1676 raise TypeError(
1677 "ParseManyOneByOneOption can't be used with positional arguments"
1678 )
1680 nargs = parser.get_nargs()
1681 if nargs not in ["*", "+"]:
1682 raise TypeError(
1683 "ParseManyOneByOneOption can't be used with parser "
1684 "that limits length of its collection"
1685 )
1687 super().__init__(
1688 flags=flags,
1689 allow_inline_arg=True,
1690 allow_implicit_inline_arg=True,
1691 nargs=1,
1692 allow_no_args=False,
1693 required=required,
1694 metavar=parser.describe_many(),
1695 mutex_group=mutex_group,
1696 usage=usage,
1697 help=help,
1698 help_group=help_group,
1699 show_if_inherited=show_if_inherited,
1700 dest=dest,
1701 merge=merge,
1702 default=default,
1703 parser=parser,
1704 allow_abbrev=allow_abbrev,
1705 default_desc=default_desc,
1706 )
1708 def process(
1709 self,
1710 cli_parser: CliParser[Namespace],
1711 flag: Flag | None,
1712 arguments: Argument | list[Argument],
1713 ns: Namespace,
1714 ):
1715 pass
1717 def post_process(
1718 self,
1719 cli_parser: CliParser[Namespace],
1720 arguments: list[Argument],
1721 ns: Namespace,
1722 ):
1723 self.set(ns, self.parser.parse_many([arg.value for arg in arguments]))
1725 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1726 return (self.parser.completer(), True)
1729@dataclass(eq=False, kw_only=True)
1730class StoreConstOption(ValueOption[T], _t.Generic[T]):
1731 """
1732 An option with no arguments that stores a constant to namespace.
1734 """
1736 const: T
1737 """
1738 Constant that will be stored.
1740 """
1742 def __init__(
1743 self,
1744 *,
1745 flags: list[str],
1746 required: bool = False,
1747 mutex_group: None | MutuallyExclusiveGroup = None,
1748 usage: yuio.Collapse | bool = True,
1749 help: str | yuio.Disabled = "",
1750 help_group: HelpGroup | None = None,
1751 show_if_inherited: bool = False,
1752 dest: str,
1753 merge: _t.Callable[[T, T], T] | None = None,
1754 default: T | yuio.Missing = yuio.MISSING,
1755 const: T,
1756 allow_abbrev: bool = True,
1757 default_desc: str | None = None,
1758 ):
1759 self.const = const
1761 super().__init__(
1762 flags=flags,
1763 allow_inline_arg=False,
1764 allow_implicit_inline_arg=False,
1765 nargs=0,
1766 allow_no_args=True,
1767 required=required,
1768 metavar=(),
1769 mutex_group=mutex_group,
1770 usage=usage,
1771 help=help,
1772 help_group=help_group,
1773 show_if_inherited=show_if_inherited,
1774 dest=dest,
1775 merge=merge,
1776 default=default,
1777 allow_abbrev=allow_abbrev,
1778 default_desc=default_desc,
1779 )
1781 def process(
1782 self,
1783 cli_parser: CliParser[Namespace],
1784 flag: Flag | None,
1785 arguments: Argument | list[Argument],
1786 ns: Namespace,
1787 ):
1788 if self.merge and self.dest in ns:
1789 ns[self.dest] = self.merge(ns[self.dest], self.const)
1790 else:
1791 ns[self.dest] = self.const
1794@dataclass(eq=False, kw_only=True)
1795class CountOption(StoreConstOption[int]):
1796 """
1797 An option that counts number of its appearances on the command line.
1799 """
1801 def __init__(
1802 self,
1803 *,
1804 flags: list[str],
1805 required: bool = False,
1806 mutex_group: None | MutuallyExclusiveGroup = None,
1807 usage: yuio.Collapse | bool = True,
1808 help: str | yuio.Disabled = "",
1809 help_group: HelpGroup | None = None,
1810 show_if_inherited: bool = False,
1811 dest: str,
1812 default: int | yuio.Missing = yuio.MISSING,
1813 allow_abbrev: bool = True,
1814 default_desc: str | None = None,
1815 ):
1816 super().__init__(
1817 flags=flags,
1818 required=required,
1819 mutex_group=mutex_group,
1820 usage=usage,
1821 help=help,
1822 help_group=help_group,
1823 show_if_inherited=show_if_inherited,
1824 dest=dest,
1825 merge=lambda x, y: x + y,
1826 default=default,
1827 const=1,
1828 allow_abbrev=allow_abbrev,
1829 default_desc=default_desc,
1830 )
1832 def format_metavar(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
1833 return _ColorizedString((ctx.get_color("hl/flag:sh-usage"), "..."))
1836@dataclass(eq=False, kw_only=True)
1837class StoreTrueOption(StoreConstOption[bool]):
1838 """
1839 An option that stores :data:`True` to namespace.
1841 """
1843 def __init__(
1844 self,
1845 *,
1846 flags: list[str],
1847 required: bool = False,
1848 mutex_group: None | MutuallyExclusiveGroup = None,
1849 usage: yuio.Collapse | bool = True,
1850 help: str | yuio.Disabled = "",
1851 help_group: HelpGroup | None = None,
1852 show_if_inherited: bool = False,
1853 dest: str,
1854 default: bool | yuio.Missing = yuio.MISSING,
1855 allow_abbrev: bool = True,
1856 default_desc: str | None = None,
1857 ):
1858 super().__init__(
1859 flags=flags,
1860 required=required,
1861 mutex_group=mutex_group,
1862 usage=usage,
1863 help=help,
1864 help_group=help_group,
1865 show_if_inherited=show_if_inherited,
1866 dest=dest,
1867 merge=None,
1868 default=default,
1869 const=True,
1870 allow_abbrev=allow_abbrev,
1871 default_desc=default_desc,
1872 )
1875@dataclass(eq=False, kw_only=True)
1876class StoreFalseOption(StoreConstOption[bool]):
1877 """
1878 An option that stores :data:`False` to namespace.
1880 """
1882 def __init__(
1883 self,
1884 *,
1885 flags: list[str],
1886 required: bool = False,
1887 mutex_group: None | MutuallyExclusiveGroup = None,
1888 usage: yuio.Collapse | bool = True,
1889 help: str | yuio.Disabled = "",
1890 help_group: HelpGroup | None = None,
1891 show_if_inherited: bool = False,
1892 dest: str,
1893 default: bool | yuio.Missing = yuio.MISSING,
1894 allow_abbrev: bool = True,
1895 default_desc: str | None = None,
1896 ):
1897 super().__init__(
1898 flags=flags,
1899 required=required,
1900 mutex_group=mutex_group,
1901 usage=usage,
1902 help=help,
1903 help_group=help_group,
1904 show_if_inherited=show_if_inherited,
1905 dest=dest,
1906 merge=None,
1907 default=default,
1908 const=False,
1909 allow_abbrev=allow_abbrev,
1910 default_desc=default_desc,
1911 )
1914@dataclass(eq=False, kw_only=True)
1915class VersionOption(Option[_t.Never]):
1916 """
1917 An option that prints app's version and stops the program.
1919 """
1921 version: str
1922 """
1923 Version to print.
1925 """
1927 def __init__(
1928 self,
1929 *,
1930 version: str,
1931 flags: list[str] = ["-V", "--version"],
1932 usage: yuio.Collapse | bool = yuio.COLLAPSE,
1933 help: str | yuio.Disabled = "Print program version and exit.",
1934 help_group: HelpGroup | None = MISC_GROUP,
1935 allow_abbrev: bool = True,
1936 ):
1937 super().__init__(
1938 flags=flags,
1939 allow_inline_arg=False,
1940 allow_implicit_inline_arg=False,
1941 nargs=0,
1942 allow_no_args=True,
1943 required=False,
1944 metavar=(),
1945 mutex_group=None,
1946 usage=usage,
1947 help=help,
1948 help_group=help_group,
1949 show_if_inherited=False,
1950 allow_abbrev=allow_abbrev,
1951 dest="_version",
1952 default_desc=None,
1953 )
1955 self.version = version
1957 def process(
1958 self,
1959 cli_parser: CliParser[Namespace],
1960 flag: Flag | None,
1961 arguments: Argument | list[Argument],
1962 ns: Namespace,
1963 ):
1964 import yuio.io
1966 if self.version:
1967 yuio.io.raw(self.version, add_newline=True, to_stdout=True)
1968 else:
1969 yuio.io.raw("<unknown version>", add_newline=True, to_stdout=True)
1970 sys.exit(0)
1973@dataclass(eq=False, kw_only=True)
1974class BugReportOption(Option[_t.Never]):
1975 """
1976 An option that prints bug report.
1978 """
1980 settings: yuio.dbg.ReportSettings | bool | None
1981 """
1982 Settings for bug report generation.
1984 """
1986 app: yuio.app.App[_t.Any] | None
1987 """
1988 Main app of the project, used to extract project's version and dependencies.
1990 """
1992 def __init__(
1993 self,
1994 *,
1995 settings: yuio.dbg.ReportSettings | bool | None = None,
1996 app: yuio.app.App[_t.Any] | None = None,
1997 flags: list[str] = ["--bug-report"],
1998 usage: yuio.Collapse | bool = yuio.COLLAPSE,
1999 help: str | yuio.Disabled = "Print environment data for bug report and exit.",
2000 help_group: HelpGroup | None = MISC_GROUP,
2001 allow_abbrev: bool = True,
2002 ):
2003 super().__init__(
2004 flags=flags,
2005 allow_inline_arg=False,
2006 allow_implicit_inline_arg=False,
2007 nargs=0,
2008 allow_no_args=True,
2009 required=False,
2010 metavar=(),
2011 mutex_group=None,
2012 usage=usage,
2013 help=help,
2014 help_group=help_group,
2015 show_if_inherited=False,
2016 allow_abbrev=allow_abbrev,
2017 dest="_bug_report",
2018 default_desc=None,
2019 )
2021 self.settings = settings
2022 self.app = app
2024 def process(
2025 self,
2026 cli_parser: CliParser[Namespace],
2027 flag: Flag | None,
2028 arguments: Argument | list[Argument],
2029 ns: Namespace,
2030 ):
2031 import yuio.dbg
2033 yuio.dbg.print_report(settings=self.settings, app=self.app)
2034 sys.exit(0)
2037@dataclass(eq=False, kw_only=True)
2038class CompletionOption(Option[_t.Never]):
2039 """
2040 An option that installs autocompletion.
2042 """
2044 _SHELLS = [
2045 "all",
2046 "uninstall",
2047 "bash",
2048 "zsh",
2049 "fish",
2050 "pwsh",
2051 ]
2053 def __init__(
2054 self,
2055 *,
2056 flags: list[str] = ["--completions"],
2057 usage: yuio.Collapse | bool = yuio.COLLAPSE,
2058 help: str | yuio.Disabled | None = None,
2059 help_group: HelpGroup | None = MISC_GROUP,
2060 allow_abbrev: bool = True,
2061 ):
2062 if help is None:
2063 shells = yuio.string.Or(f"``{shell}``" for shell in self._SHELLS)
2064 help = (
2065 "Install or update autocompletion scripts and exit.\n\n"
2066 f"Supported shells: {shells}."
2067 )
2068 super().__init__(
2069 flags=flags,
2070 allow_inline_arg=True,
2071 allow_implicit_inline_arg=True,
2072 nargs=1,
2073 allow_no_args=True,
2074 required=False,
2075 metavar="<shell>",
2076 mutex_group=None,
2077 usage=usage,
2078 help=help,
2079 help_group=help_group,
2080 show_if_inherited=False,
2081 allow_abbrev=allow_abbrev,
2082 dest="_completions",
2083 default_desc=None,
2084 )
2086 def process(
2087 self,
2088 cli_parser: CliParser[Namespace],
2089 flag: Flag | None,
2090 arguments: Argument | list[Argument],
2091 ns: Namespace,
2092 ):
2093 if isinstance(arguments, list):
2094 argument = arguments[0].value if arguments else "all"
2095 else:
2096 argument = arguments.value
2098 if argument not in self._SHELLS:
2099 raise ArgumentError(
2100 "Unknown shell `%r`, should be %s",
2101 argument,
2102 yuio.string.Or(self._SHELLS),
2103 flag=flag,
2104 arguments=arguments,
2105 n_arg=0,
2106 )
2108 root = cli_parser._root_command
2109 help_parser = cli_parser._help_parser
2111 if argument == "uninstall":
2112 compdata = ""
2113 else:
2114 serializer = yuio.complete._ProgramSerializer()
2115 self._dump(root, serializer, [], help_parser)
2116 compdata = serializer.dump()
2118 yuio.complete._write_completions(compdata, root.name, argument)
2120 sys.exit(0)
2122 def _dump(
2123 self,
2124 command: Command[_t.Any],
2125 serializer: yuio.complete._ProgramSerializer,
2126 parent_options: list[Option[_t.Any]],
2127 help_parser: yuio.doc.DocParser,
2128 ):
2129 seen_flags: set[str] = set()
2130 seen_options: list[Option[_t.Any]] = []
2132 # Add command's options, keep track of flags from the current command.
2133 for option in command.options:
2134 completer, is_many = option.get_completer()
2135 help = option.help
2136 if help is not yuio.DISABLED:
2137 ctx = yuio.string.ReprContext.make_dummy(is_unicode=False)
2138 ctx.width = 60
2139 parsed_help = _parse_option_help(option, help_parser, ctx)
2140 if parsed_help:
2141 lines = _CliFormatter(help_parser, ctx).format(parsed_help)
2142 if not lines:
2143 help = ""
2144 elif len(lines) == 1:
2145 help = str(lines[0])
2146 else:
2147 help = str(lines[0]) + ("..." if lines[1] else "")
2148 else:
2149 help = ""
2150 serializer.add_option(
2151 flags=option.flags,
2152 nargs=option.nargs,
2153 metavar=option.metavar,
2154 help=help,
2155 completer=completer,
2156 is_many=is_many,
2157 )
2158 if option.flags is not yuio.POSITIONAL:
2159 seen_flags |= seen_flags
2160 seen_options.append(option)
2162 # Add parent options if their flags were not shadowed.
2163 for option in parent_options:
2164 assert option.flags is not yuio.POSITIONAL
2166 flags = [flag for flag in option.flags if flag not in seen_flags]
2167 if not flags:
2168 continue
2170 completer, is_many = option.get_completer()
2171 help = option.help
2172 if help is not yuio.DISABLED and not option.show_if_inherited:
2173 # TODO: not sure if disabling help for inherited options is
2174 # the best approach here.
2175 help = yuio.DISABLED
2176 nargs = option.nargs
2177 if option.allow_no_args:
2178 if nargs == 1:
2179 nargs = "?"
2180 elif nargs == "+":
2181 nargs = "*"
2182 serializer.add_option(
2183 flags=flags,
2184 nargs=nargs,
2185 metavar=option.metavar,
2186 help=help,
2187 completer=completer,
2188 is_many=is_many,
2189 )
2191 seen_flags |= seen_flags
2192 seen_options.append(option)
2194 for name, subcommand in command.subcommands.items():
2195 subcommand = subcommand.load()
2196 subcommand_serializer = serializer.add_subcommand(
2197 name=name, is_alias=name != subcommand.name, help=subcommand.help
2198 )
2199 self._dump(subcommand, subcommand_serializer, seen_options, help_parser)
2201 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
2202 return (
2203 yuio.complete.Choice(
2204 [yuio.complete.Option(shell) for shell in self._SHELLS]
2205 ),
2206 False,
2207 )
2210@dataclass(eq=False, kw_only=True)
2211class HelpOption(Option[_t.Never]):
2212 """
2213 An option that prints help message and stops the program.
2215 """
2217 def __init__(
2218 self,
2219 *,
2220 flags: list[str] = ["-h", "--help"],
2221 usage: yuio.Collapse | bool = yuio.COLLAPSE,
2222 help: str | yuio.Disabled = "Print this message and exit.",
2223 help_group: HelpGroup | None = MISC_GROUP,
2224 allow_abbrev: bool = True,
2225 ):
2226 super().__init__(
2227 flags=flags,
2228 allow_inline_arg=True,
2229 allow_implicit_inline_arg=True,
2230 nargs=0,
2231 allow_no_args=True,
2232 required=False,
2233 metavar=(),
2234 mutex_group=None,
2235 usage=usage,
2236 help=help,
2237 help_group=help_group,
2238 show_if_inherited=True,
2239 allow_abbrev=allow_abbrev,
2240 dest="_help",
2241 default_desc=None,
2242 )
2244 def process(
2245 self,
2246 cli_parser: CliParser[Namespace],
2247 flag: Flag | None,
2248 arguments: Argument | list[Argument],
2249 ns: Namespace,
2250 ):
2251 import yuio.io
2252 import yuio.string
2254 if isinstance(arguments, list):
2255 argument = arguments[0].value if arguments else ""
2256 else:
2257 argument = arguments.value
2259 if argument not in ("all", ""):
2260 raise ArgumentError(
2261 "Unknown help scope <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, should be %s",
2262 argument,
2263 yuio.string.Or(
2264 ["all"], color="msg/text:code/sh-usage hl/flag:sh-usage"
2265 ),
2266 flag=flag,
2267 arguments=arguments,
2268 n_arg=0,
2269 )
2271 formatter = _HelpFormatter(cli_parser._help_parser, all=argument == "all")
2272 inherited_options = []
2273 seen_inherited_options = set()
2274 for opt in cli_parser._inherited_options.values():
2275 if opt not in seen_inherited_options:
2276 seen_inherited_options.add(opt)
2277 inherited_options.append(opt)
2278 formatter.add_command(
2279 " ".join(cli_parser._current_path),
2280 cli_parser._current_command,
2281 list(inherited_options),
2282 )
2284 yuio.io.raw(formatter, add_newline=True, to_stdout=True)
2285 sys.exit(0)
2288@dataclass(kw_only=True, eq=False, match_args=False)
2289class LazyCommand(_t.Generic[NamespaceT]):
2290 """
2291 Lazy loader for data about CLI interface of a single command or subcommand.
2293 """
2295 help: str | yuio.Disabled | None
2296 """
2297 Help message for this command, displayed when listing subcommands.
2299 """
2301 loader: _t.Callable[[], Command[NamespaceT]]
2302 """
2303 Callback that loads the rest of the command data.
2305 """
2307 __loaded = None
2309 def load(self):
2310 """
2311 Load full command data.
2313 """
2315 if self.__loaded is None:
2316 self.__loaded = self.loader()
2317 return self.__loaded
2319 def get_help(self) -> str | yuio.Disabled:
2320 """
2321 Get or load command help.
2323 """
2325 if self.help is None:
2326 self.help = self.load().help
2327 return self.help
2330@dataclass(kw_only=True, eq=False, match_args=False)
2331class Command(_t.Generic[NamespaceT]):
2332 """
2333 Data about CLI interface of a single command or subcommand.
2335 """
2337 name: str
2338 """
2339 Canonical name of this command.
2341 """
2343 desc: str
2344 """
2345 Long description for a command.
2347 """
2349 help: str | yuio.Disabled
2350 """
2351 Help message for this command, displayed when listing subcommands.
2353 """
2355 epilog: str
2356 """
2357 Long description printed after command help.
2359 """
2361 usage: str
2362 """
2363 Override for usage section of CLI help.
2365 """
2367 options: list[Option[_t.Any]]
2368 """
2369 Options for this command.
2371 """
2373 subcommands: dict[str, Command[Namespace] | LazyCommand[Namespace]]
2374 """
2375 Last positional option can be a sub-command.
2377 This is a map from subcommand's name or alias to subcommand's implementation.
2379 """
2381 subcommand_required: bool
2382 """
2383 Whether subcommand is required or optional. If no :attr:`~Command.subcommands`
2384 are given, this attribute is ignored.
2386 """
2388 ns_ctor: _t.Callable[[], NamespaceT]
2389 """
2390 A constructor that will be called to create namespace for command's arguments.
2392 """
2394 dest: str
2395 """
2396 Where to save subcommand's name.
2398 """
2400 ns_dest: str
2401 """
2402 Where to save subcommand's namespace.
2404 """
2406 metavar: str = "<subcommand>"
2407 """
2408 Meta variable used for subcommand option.
2410 """
2412 def load(self):
2413 return self
2415 def get_help(self):
2416 return self.help
2419@dataclass(eq=False, kw_only=True)
2420class _SubCommandOption(ValueOption[str]):
2421 command: Command[Namespace]
2422 """
2423 All subcommands.
2425 """
2427 def __init__(
2428 self,
2429 *,
2430 command: Command[Namespace],
2431 metavar: str = "<subcommand>",
2432 help_group: HelpGroup | None = SUBCOMMANDS_GROUP,
2433 show_if_inherited: bool = False,
2434 ):
2435 # subcommand_names = [
2436 # f"``{name}``"
2437 # for name, subcommand in subcommands.items()
2438 # if name == subcommand.name and subcommand.help is not yuio.DISABLED
2439 # ]
2440 # help = f"Available subcommands: {yuio.string.Or(subcommand_names)}"
2442 self.command = command
2444 super().__init__(
2445 flags=yuio.POSITIONAL,
2446 allow_inline_arg=False,
2447 allow_implicit_inline_arg=False,
2448 nargs=1,
2449 allow_no_args=not command.subcommand_required,
2450 required=False,
2451 metavar=metavar,
2452 mutex_group=None,
2453 usage=True,
2454 help="",
2455 help_group=help_group,
2456 show_if_inherited=show_if_inherited,
2457 dest=self.command.dest,
2458 merge=None,
2459 default=yuio.MISSING,
2460 allow_abbrev=False,
2461 default_desc=None,
2462 )
2464 def process(
2465 self,
2466 cli_parser: CliParser[Namespace],
2467 flag: Flag | None,
2468 arguments: Argument | list[Argument],
2469 ns: Namespace,
2470 ):
2471 assert isinstance(arguments, list)
2472 if not arguments:
2473 return
2474 subcommand = self.command.subcommands.get(arguments[0].value)
2475 if subcommand is None:
2476 raise ArgumentError(
2477 "Unknown subcommand <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, can be %s",
2478 arguments[0].value,
2479 yuio.string.Or(
2480 (
2481 name
2482 for name, subcommand in self.command.subcommands.items()
2483 if subcommand.help != yuio.DISABLED
2484 ),
2485 color="msg/text:code/sh-usage hl/flag:sh-usage",
2486 ),
2487 arguments=arguments,
2488 )
2489 subcommand = subcommand.load()
2490 ns[self.dest] = subcommand.name
2491 ns[self.command.ns_dest] = new_ns = subcommand.ns_ctor()
2492 cli_parser._load_command(subcommand, new_ns)
2495@dataclass(eq=False, match_args=False, slots=True)
2496class _BoundOption:
2497 wrapped: Option[_t.Any]
2498 ns: Namespace
2499 seen: bool = False
2501 @property
2502 def usage(self):
2503 return self.wrapped.usage
2505 @property
2506 def flags(self):
2507 return self.wrapped.flags
2509 @property
2510 def nargs(self):
2511 return self.wrapped.nargs
2513 @property
2514 def allow_no_args(self):
2515 return self.wrapped.allow_no_args
2517 @property
2518 def allow_inline_arg(self):
2519 return self.wrapped.allow_inline_arg
2521 @property
2522 def allow_implicit_inline_arg(self):
2523 return self.wrapped.allow_implicit_inline_arg
2525 @property
2526 def mutex_group(self):
2527 return self.wrapped.mutex_group
2529 @property
2530 def required(self):
2531 return self.wrapped.required
2533 @property
2534 def allow_abbrev(self):
2535 return self.wrapped.allow_abbrev
2537 def nth_metavar(self, n: int) -> str:
2538 return self.wrapped.nth_metavar(n)
2541class CliParser(_t.Generic[NamespaceT]):
2542 """
2543 CLI arguments parser.
2545 :param command:
2546 root command.
2547 :param allow_abbrev:
2548 allow abbreviating CLI flags if that doesn't create ambiguity.
2549 :param help_parser:
2550 help parser that will be used to parse and display help for options
2551 that've failed to parse.
2553 """
2555 def __init__(
2556 self,
2557 command: Command[NamespaceT],
2558 /,
2559 *,
2560 help_parser: yuio.doc.DocParser,
2561 allow_abbrev: bool,
2562 ):
2563 self._root_command = command
2564 self._allow_abbrev = allow_abbrev
2565 self._help_parser = help_parser
2567 def _load_command(self, command: Command[_t.Any], ns: Namespace):
2568 # All pending flags and positionals should've been flushed by now.
2569 assert self._current_flag is None
2570 assert self._current_positional == len(self._positionals)
2572 self._inherited_options.update(
2573 {flag: opt.wrapped for flag, opt in self._known_long_flags.items()}
2574 )
2575 self._inherited_options.update(
2576 {flag: opt.wrapped for flag, opt in self._known_short_flags.items()}
2577 )
2578 self._current_path.append(command.name)
2580 # Update known flags and positionals.
2581 self._positionals = []
2582 seen_flags: set[str] = set()
2583 for option in command.options:
2584 bound_option = _BoundOption(option, ns)
2585 if option.flags is yuio.POSITIONAL:
2586 if option.mutex_group is not None:
2587 raise TypeError(
2588 f"{option}: positional arguments can't appear "
2589 "in mutually exclusive groups"
2590 )
2591 if option.nargs == 0:
2592 raise TypeError(
2593 f"{option}: positional arguments can't nave nargs=0"
2594 )
2595 self._positionals.append(bound_option)
2596 else:
2597 if option.mutex_group is not None:
2598 self._mutex_groups.setdefault(option.mutex_group, []).append(option)
2599 if not option.flags:
2600 raise TypeError(f"{option}: option has no flags")
2601 for flag in option.flags:
2602 if flag in seen_flags:
2603 raise TypeError(
2604 f"got multiple options with the same flag {flag}"
2605 )
2606 seen_flags.add(flag)
2607 self._inherited_options.pop(flag, None)
2608 _check_flag(flag)
2609 if _is_short(flag):
2610 dest = self._known_short_flags
2611 else:
2612 dest = self._known_long_flags
2613 if flag in dest:
2614 warnings.warn(
2615 f"flag {flag} from subcommand {command.name} shadows "
2616 f"the same flag from command {self._current_command.name}",
2617 CliWarning,
2618 )
2619 self._finalize_unused_flag(flag, dest[flag])
2620 dest[flag] = bound_option
2621 if command.subcommands:
2622 self._positionals.append(
2623 _BoundOption(_SubCommandOption(command=command), ns)
2624 )
2625 self._current_command = command
2626 self._current_positional = 0
2628 def parse(self, args: list[str] | None = None) -> NamespaceT:
2629 """
2630 Parse arguments and invoke their actions.
2632 :param args:
2633 CLI arguments, not including the program name (i.e. the first argument).
2634 If :data:`None`, use :data:`sys.argv` instead.
2635 :returns:
2636 namespace with parsed arguments.
2637 :raises:
2638 :class:`ArgumentError`, :class:`~yuio.parse.ParsingError`.
2640 """
2642 if args is None:
2643 args = sys.argv[1:]
2645 try:
2646 return self._parse(args)
2647 except ArgumentError as e:
2648 e.commandline = args
2649 e.prog = self._root_command.name
2650 e.subcommands = self._current_path
2651 e.help_parser = self._help_parser
2652 raise
2654 def _parse(self, args: list[str]) -> NamespaceT:
2655 self._current_command = self._root_command
2656 self._current_path: list[str] = []
2657 self._inherited_options: dict[str, Option[_t.Any]] = {}
2659 self._seen_mutex_groups: dict[
2660 MutuallyExclusiveGroup, tuple[_BoundOption, Flag]
2661 ] = {}
2662 self._mutex_groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {}
2664 self._current_index = 0
2666 self._known_long_flags: dict[str, _BoundOption] = {}
2667 self._known_short_flags: dict[str, _BoundOption] = {}
2668 self._positionals: list[_BoundOption] = []
2669 self._current_positional: int = 0
2671 self._current_flag: tuple[_BoundOption, Flag] | None = None
2672 self._current_flag_args: list[Argument] = []
2673 self._current_positional_args: list[Argument] = []
2675 self._post_process: dict[
2676 _BoundOption, tuple[list[Argument], list[Flag | None]]
2677 ] = {}
2679 root_ns = self._root_command.ns_ctor()
2680 self._load_command(self._root_command, root_ns)
2682 allow_flags = True
2684 for i, arg in enumerate(args):
2685 self._current_index = i
2687 # Handle `--`.
2688 if arg == "--" and allow_flags:
2689 self._flush_flag()
2690 allow_flags = False
2691 continue
2693 # Check what we have here.
2694 if allow_flags:
2695 result = self._detect_flag(arg)
2696 else:
2697 result = None
2699 if result is None:
2700 # This not a flag. Can be an argument to a positional/flag option.
2701 self._handle_positional(arg)
2702 else:
2703 # This is a flag.
2704 options, inline_arg = result
2705 self._handle_flags(options, inline_arg)
2707 self._finalize()
2709 return root_ns
2711 def _finalize(self):
2712 self._flush_flag()
2714 for flag, option in self._known_long_flags.items():
2715 self._finalize_unused_flag(flag, option)
2716 for flag, option in self._known_short_flags.items():
2717 self._finalize_unused_flag(flag, option)
2718 while self._current_positional < len(self._positionals):
2719 self._flush_positional()
2720 for group, options in self._mutex_groups.items():
2721 if group.required and group not in self._seen_mutex_groups:
2722 raise ArgumentError(
2723 "%s %s must be provided",
2724 "Either" if len(options) > 1 else "Flag",
2725 yuio.string.Or(
2726 (option.flags[0] for option in options if option.flags),
2727 color="msg/text:code/sh-usage hl/flag:sh-usage",
2728 ),
2729 )
2730 for option, (arguments, flags) in self._post_process.items():
2731 try:
2732 option.wrapped.post_process(
2733 _t.cast(CliParser[Namespace], self), arguments, option.ns
2734 )
2735 except ArgumentError as e:
2736 if e.arguments is None:
2737 e.arguments = arguments
2738 if e.flag is None and e.n_arg is not None and 0 <= e.n_arg < len(flags):
2739 e.flag = flags[e.n_arg]
2740 if e.option is None:
2741 e.option = option.wrapped
2742 raise
2743 except yuio.parse.ParsingError as e:
2744 flag = None
2745 if e.n_arg is not None and 0 <= e.n_arg < len(flags):
2746 flag = flags[e.n_arg]
2747 raise ArgumentError.from_parsing_error(
2748 e, flag=flag, arguments=arguments, option=option.wrapped
2749 )
2751 def _finalize_unused_flag(self, flag: str, option: _BoundOption):
2752 if option.required and not option.seen:
2753 raise ArgumentError(
2754 "Missing required flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2755 flag,
2756 )
2758 def _detect_flag(
2759 self, arg: str
2760 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None:
2761 if not arg.startswith("-") or len(arg) <= 1:
2762 # This is a positional.
2763 return None
2765 if arg.startswith("--"):
2766 # This is a long flag.
2767 return self._parse_long_flag(arg)
2768 else:
2769 return self._detect_short_flag(arg)
2771 def _parse_long_flag(
2772 self, arg: str
2773 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None:
2774 if "=" in arg:
2775 flag, inline_arg = arg.split("=", maxsplit=1)
2776 else:
2777 flag, inline_arg = arg, None
2778 flag = self._make_flag(flag)
2779 if long_opt := self._known_long_flags.get(flag.value):
2780 if inline_arg is not None:
2781 inline_arg = self._make_arg(
2782 long_opt, inline_arg, len(flag.value) + 1, flag
2783 )
2784 return [(long_opt, flag)], inline_arg
2786 # Try as abbreviated long flags.
2787 candidates: list[str] = []
2788 if self._allow_abbrev:
2789 for candidate in self._known_long_flags:
2790 if candidate.startswith(flag.value):
2791 candidates.append(candidate)
2792 if len(candidates) == 1:
2793 candidate = candidates[0]
2794 opt = self._known_long_flags[candidate]
2795 if not opt.allow_abbrev:
2796 raise ArgumentError(
2797 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, did you mean %s?",
2798 flag,
2799 candidate,
2800 flag=self._make_flag(""),
2801 )
2802 flag = self._make_flag(candidate)
2803 if inline_arg is not None:
2804 inline_arg = self._make_arg(
2805 opt, inline_arg, len(flag.value) + 1, flag
2806 )
2807 return [(opt, flag)], inline_arg
2809 if candidates:
2810 raise ArgumentError(
2811 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>, can be %s",
2812 flag,
2813 yuio.string.Or(
2814 candidates, color="msg/text:code/sh-usage hl/flag:sh-usage"
2815 ),
2816 flag=self._make_flag(""),
2817 )
2818 else:
2819 raise ArgumentError(
2820 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2821 flag,
2822 flag=self._make_flag(""),
2823 )
2825 def _detect_short_flag(
2826 self, arg: str
2827 ) -> tuple[list[tuple[_BoundOption, Flag]], Argument | None] | None:
2828 # Try detecting short flags first.
2829 short_opts: list[tuple[_BoundOption, Flag]] = []
2830 inline_arg = None
2831 inline_arg_pos = 0
2832 unknown_ch = None
2833 for i, ch in enumerate(arg[1:]):
2834 if ch == "=":
2835 # Short flag with explicit argument.
2836 inline_arg_pos = i + 2
2837 inline_arg = arg[inline_arg_pos:]
2838 break
2839 elif short_opts and (
2840 short_opts[-1][0].allow_implicit_inline_arg
2841 or short_opts[-1][0].nargs != 0
2842 ):
2843 # Short flag with implicit argument.
2844 inline_arg_pos = i + 1
2845 inline_arg = arg[inline_arg_pos:]
2846 break
2847 elif short_opt := self._known_short_flags.get("-" + ch):
2848 # Short flag, arguments may follow.
2849 short_opts.append((short_opt, self._make_flag("-" + ch)))
2850 else:
2851 # Unknown short flag. Will try parsing as abbreviated long flag next.
2852 unknown_ch = ch
2853 break
2854 if short_opts and not unknown_ch:
2855 if inline_arg is not None:
2856 inline_arg = self._make_arg(
2857 short_opts[-1][0], inline_arg, inline_arg_pos, short_opts[-1][1]
2858 )
2859 return short_opts, inline_arg
2861 # Try as signed int.
2862 if re.match(_NUM_RE, arg):
2863 # This is a positional.
2864 return None
2866 if unknown_ch and len(arg) > 2:
2867 raise ArgumentError(
2868 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>-%s</c> in argument <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2869 unknown_ch,
2870 arg,
2871 flag=self._make_flag(""),
2872 )
2873 else:
2874 raise ArgumentError(
2875 "Unknown flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2876 arg,
2877 flag=self._make_flag(""),
2878 )
2880 def _make_arg(
2881 self, opt: _BoundOption, arg: str, pos: int, flag: Flag | None = None
2882 ):
2883 return Argument(
2884 arg,
2885 index=self._current_index,
2886 pos=pos,
2887 metavar=opt.nth_metavar(0),
2888 flag=flag,
2889 )
2891 def _make_flag(self, arg: str):
2892 return Flag(arg, self._current_index)
2894 def _handle_positional(self, arg: str):
2895 if self._current_flag is not None:
2896 opt, flag = self._current_flag
2897 # This is an argument for a flag option.
2898 self._current_flag_args.append(
2899 Argument(
2900 arg,
2901 index=self._current_index,
2902 pos=0,
2903 metavar=opt.nth_metavar(len(self._current_flag_args)),
2904 flag=flag,
2905 )
2906 )
2907 nargs = opt.nargs
2908 if isinstance(nargs, int) and len(self._current_flag_args) == nargs:
2909 self._flush_flag() # This flag is full.
2910 else:
2911 # This is an argument for a positional option.
2912 if self._current_positional >= len(self._positionals):
2913 raise ArgumentError(
2914 "Unexpected positional argument <c msg/text:code/sh-usage hl/flag:sh-usage>%r</c>",
2915 arg,
2916 arguments=Argument(
2917 arg, index=self._current_index, pos=0, metavar="", flag=None
2918 ),
2919 )
2920 current_positional = self._positionals[self._current_positional]
2921 self._current_positional_args.append(
2922 Argument(
2923 arg,
2924 index=self._current_index,
2925 pos=0,
2926 metavar=current_positional.nth_metavar(
2927 len(self._current_positional_args)
2928 ),
2929 flag=None,
2930 )
2931 )
2932 nargs = current_positional.nargs
2933 if isinstance(nargs, int) and len(self._current_positional_args) == nargs:
2934 self._flush_positional() # This positional is full.
2936 def _handle_flags(
2937 self, options: list[tuple[_BoundOption, Flag]], inline_arg: Argument | None
2938 ):
2939 # If we've seen another flag before this one, and we were waiting
2940 # for that flag's arguments, flush them now.
2941 self._flush_flag()
2943 # Handle short flags in multi-arg sequence, i.e. `-li` -> `-l -i`
2944 for opt, name in options[:-1]:
2945 self._eval_option(opt, name, [])
2947 # Handle the last short flag in multi-arg sequence.
2948 opt, name = options[-1]
2949 if inline_arg is not None:
2950 # Flag with an inline argument, i.e. `-Xfoo`/`-X=foo` -> `-X foo`
2951 self._eval_option(opt, name, inline_arg)
2952 else:
2953 self._push_flag(opt, name)
2955 def _flush_positional(self):
2956 if self._current_positional >= len(self._positionals):
2957 return
2958 opt, args = (
2959 self._positionals[self._current_positional],
2960 self._current_positional_args,
2961 )
2963 self._current_positional += 1
2964 self._current_positional_args = []
2966 self._eval_option(opt, None, args)
2968 def _flush_flag(self):
2969 if self._current_flag is None:
2970 return
2972 (opt, name), args = (self._current_flag, self._current_flag_args)
2974 self._current_flag = None
2975 self._current_flag_args = []
2977 self._eval_option(opt, name, args)
2979 def _push_flag(self, opt: _BoundOption, flag: Flag):
2980 assert self._current_flag is None
2982 if opt.nargs == 0:
2983 # Flag without arguments, handle it right now.
2984 self._eval_option(opt, flag, [])
2985 else:
2986 # Flag with possible arguments, save it. If we see a non-flag later,
2987 # it will be added to this flag's arguments.
2988 self._current_flag = (opt, flag)
2989 self._current_flag_args = []
2991 def _eval_option(
2992 self, opt: _BoundOption, flag: Flag | None, arguments: Argument | list[Argument]
2993 ):
2994 if opt.mutex_group is not None:
2995 if seen := self._seen_mutex_groups.get(opt.mutex_group):
2996 raise ArgumentError(
2997 "Flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c> can't be given together with flag <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
2998 flag or self._make_flag(opt.nth_metavar(0)),
2999 seen[1],
3000 )
3001 self._seen_mutex_groups[opt.mutex_group] = (
3002 opt,
3003 flag or self._make_flag(opt.nth_metavar(0)),
3004 )
3006 if isinstance(arguments, list):
3007 _check_nargs(opt, flag, arguments)
3008 elif not opt.allow_inline_arg:
3009 raise ArgumentError(
3010 "This flag can't have arguments",
3011 flag=flag,
3012 arguments=arguments,
3013 option=opt.wrapped,
3014 )
3016 opt.seen = True
3017 try:
3018 opt.wrapped.process(
3019 _t.cast(CliParser[Namespace], self), flag, arguments, opt.ns
3020 )
3021 except ArgumentError as e:
3022 if e.flag is None:
3023 e.flag = flag
3024 if e.arguments is None:
3025 e.arguments = arguments
3026 if e.option is None:
3027 e.option = opt.wrapped
3028 raise
3029 except yuio.parse.ParsingError as e:
3030 raise ArgumentError.from_parsing_error(
3031 e, flag=flag, arguments=arguments, option=opt.wrapped
3032 )
3034 if not isinstance(arguments, list):
3035 arguments = [arguments]
3036 if opt not in self._post_process:
3037 self._post_process[opt] = ([], [])
3038 self._post_process[opt][0].extend(arguments)
3039 self._post_process[opt][1].extend([flag] * len(arguments))
3042def _check_flag(flag: str):
3043 if not flag.startswith("-"):
3044 raise TypeError(f"flag {flag!r} should start with `-`")
3045 if len(flag) == 2:
3046 if not re.match(_SHORT_FLAG_RE, flag):
3047 raise TypeError(f"invalid short flag {flag!r}")
3048 elif len(flag) == 1:
3049 raise TypeError(f"flag {flag!r} is too short")
3050 else:
3051 if not re.match(_LONG_FLAG_RE, flag):
3052 raise TypeError(f"invalid long flag {flag!r}")
3055def _is_short(flag: str):
3056 return flag.startswith("-") and len(flag) == 2 and flag != "--"
3059def _check_nargs(opt: _BoundOption, flag: Flag | None, args: list[Argument]):
3060 if not args and opt.allow_no_args:
3061 return
3062 match opt.nargs:
3063 case "+":
3064 if not args:
3065 if opt.flags is yuio.POSITIONAL:
3066 raise ArgumentError(
3067 "Missing required positional <c msg/text:code/sh-usage hl/flag:sh-usage>%s</c>",
3068 opt.nth_metavar(0),
3069 flag=flag,
3070 option=opt.wrapped,
3071 )
3072 else:
3073 raise ArgumentError(
3074 "Expected at least `1` argument, got `0`",
3075 flag=flag,
3076 option=opt.wrapped,
3077 )
3078 case n:
3079 if len(args) < n and (opt.flags is yuio.POSITIONAL):
3080 s = "" if n - len(args) == 1 else "s"
3081 raise ArgumentError(
3082 "Missing required positional%s %s",
3083 s,
3084 yuio.string.JoinStr(
3085 [opt.nth_metavar(i) for i in range(len(args), n)],
3086 color="msg/text:code/sh-usage hl/flag:sh-usage",
3087 ),
3088 flag=flag,
3089 option=opt.wrapped,
3090 )
3091 elif len(args) != n:
3092 s = "" if n == 1 else "s"
3093 raise ArgumentError(
3094 "Expected `%s` argument%s, got `%s`",
3095 n,
3096 s,
3097 len(args),
3098 flag=flag,
3099 option=opt.wrapped,
3100 )
3103def _quote_and_adjust_pos(s: str, pos: tuple[int, int]):
3104 s = s.translate(_UNPRINTABLE_TRANS)
3106 if not s:
3107 return "''", (1, 1)
3108 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII):
3109 return s, pos
3111 start, end = pos
3113 start_shift = 1 + s[:start].count("'") * 4
3114 end_shift = start_shift + s[start:end].count("'") * 4
3116 return "'" + s.replace("'", "'\"'\"'") + "'", (start + start_shift, end + end_shift)
3119def _quote(s: str):
3120 s = s.translate(_UNPRINTABLE_TRANS)
3122 if not s:
3123 return "''"
3124 elif not re.search(r"[^\w@%+=:,./-]", s, re.ASCII):
3125 return s
3126 else:
3127 return "'" + s.replace("'", "'\"'\"'") + "'"
3130class _HelpFormatter:
3131 def __init__(self, parser: yuio.doc.DocParser, all: bool = False) -> None:
3132 self.nodes: list[yuio.doc.AstBase] = []
3133 self.parser = parser
3134 self.all = all
3136 def add_command(
3137 self, prog: str, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /
3138 ):
3139 self._add_usage(prog, cmd, inherited)
3140 if cmd.desc:
3141 self.nodes.extend(self.parser.parse(cmd.desc).items)
3142 self._add_options(cmd)
3143 self._add_subcommands(cmd)
3144 self._add_flags(cmd, inherited)
3145 if cmd.epilog:
3146 self.nodes.append(_SetIndentation())
3147 self.nodes.extend(self.parser.parse(cmd.epilog).items)
3149 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
3150 return self.format(ctx)
3152 def format(self, ctx: yuio.string.ReprContext):
3153 res = _ColorizedString()
3154 lines = _CliFormatter(self.parser, ctx, all=self.all).format(
3155 yuio.doc.Document(items=self.nodes)
3156 )
3157 sep = False
3158 for line in lines:
3159 if sep:
3160 res.append_str("\n")
3161 res.append_colorized_str(line)
3162 sep = True
3163 return res
3165 def _add_usage(
3166 self, prog: str, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /
3167 ):
3168 self.nodes.append(_Usage(prog=prog, cmd=cmd, inherited=inherited))
3170 def _add_options(self, cmd: Command[Namespace], /):
3171 groups: dict[HelpGroup, list[Option[_t.Any]]] = {}
3172 for opt in cmd.options:
3173 if opt.flags is not yuio.POSITIONAL:
3174 continue
3175 if opt.help is yuio.DISABLED:
3176 continue
3177 group = opt.help_group or ARGS_GROUP
3178 if group.help is yuio.DISABLED:
3179 continue
3180 if group not in groups:
3181 groups[group] = []
3182 groups[group].append(opt)
3183 for group, options in groups.items():
3184 assert group.help is not yuio.DISABLED
3185 self.nodes.append(
3186 yuio.doc.Heading(
3187 items=self.parser.parse_paragraph(group.title), level=1
3188 )
3189 )
3190 if group.help:
3191 self.nodes.append(
3192 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3193 )
3194 arg_group = _HelpArgGroup(items=[])
3195 for opt in options:
3196 assert opt.help is not yuio.DISABLED
3197 arg_group.items.append(_HelpArg(opt))
3198 self.nodes.append(arg_group)
3200 def _add_subcommands(self, cmd: Command[Namespace], /):
3201 subcommands: dict[Command[Namespace] | LazyCommand[Namespace], list[str]] = {}
3202 for name, subcommand in cmd.subcommands.items():
3203 if subcommand.get_help() is yuio.DISABLED:
3204 continue
3205 if subcommand not in subcommands:
3206 subcommands[subcommand] = [name]
3207 else:
3208 subcommands[subcommand].append(name)
3209 if not subcommands:
3210 return
3211 group = SUBCOMMANDS_GROUP
3212 self.nodes.append(
3213 yuio.doc.Heading(items=self.parser.parse_paragraph(group.title), level=1)
3214 )
3215 if group.help:
3216 self.nodes.append(
3217 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3218 )
3219 arg_group = _HelpArgGroup(items=[])
3220 for subcommand, names in subcommands.items():
3221 help = subcommand.get_help()
3222 assert help is not yuio.DISABLED
3223 arg_group.items.append(_HelpSubCommand(names, help))
3224 self.nodes.append(arg_group)
3226 def _add_flags(self, cmd: Command[Namespace], inherited: list[Option[_t.Any]], /):
3227 groups: dict[
3228 HelpGroup, tuple[list[Option[_t.Any]], list[Option[_t.Any]], int]
3229 ] = {}
3230 for i, opt in enumerate(cmd.options + inherited):
3231 if not opt.flags:
3232 continue
3233 if opt.help is yuio.DISABLED:
3234 continue
3235 group = opt.help_group or OPTS_GROUP
3236 if group.help is yuio.DISABLED:
3237 continue
3238 is_inherited = i >= len(cmd.options)
3239 if group not in groups:
3240 groups[group] = ([], [], 0)
3241 if opt.required or (opt.mutex_group and opt.mutex_group.required):
3242 groups[group][0].append(opt)
3243 elif is_inherited and not opt.show_if_inherited and not self.all:
3244 required, optional, n_inherited = groups[group]
3245 groups[group] = required, optional, n_inherited + 1
3246 else:
3247 groups[group][1].append(opt)
3248 for group, (required, optional, n_inherited) in groups.items():
3249 assert group.help is not yuio.DISABLED
3251 if group.collapse and not self.all and not (required or optional):
3252 continue
3254 self.nodes.append(
3255 yuio.doc.Heading(
3256 items=self.parser.parse_paragraph(group.title), level=1
3257 )
3258 )
3260 if group.collapse and not self.all:
3261 all_flags: set[str] = set()
3262 for opt in required or optional:
3263 all_flags.update(opt.primary_long_flags or [])
3264 if len(all_flags) == 1:
3265 prefix = all_flags.pop()
3266 else:
3267 prefix = _commonprefix(all_flags)
3268 if not prefix:
3269 prefix = "--*"
3270 elif prefix.endswith("-"):
3271 prefix += "*"
3272 else:
3273 prefix += "-*"
3274 help = yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3275 self.nodes.append(
3276 _CollapsedOpt(
3277 flags=[prefix],
3278 items=[help],
3279 )
3280 )
3281 continue
3283 if group.help and (required or optional):
3284 self.nodes.append(
3285 yuio.doc.NoHeadings(items=self.parser.parse(group.help).items)
3286 )
3287 arg_group = _HelpArgGroup(items=[])
3288 for opt in required:
3289 assert opt.help is not yuio.DISABLED
3290 arg_group.items.append(_HelpOpt(opt))
3291 for opt in optional:
3292 assert opt.help is not yuio.DISABLED
3293 arg_group.items.append(_HelpOpt(opt))
3294 if n_inherited > 0:
3295 arg_group.items.append(_InheritedOpts(n_inherited=n_inherited))
3296 self.nodes.append(arg_group)
3299def _format_metavar(metavar: str, ctx: yuio.string.ReprContext):
3300 punct_color = ctx.get_color("hl/punct:sh-usage")
3301 metavar_color = ctx.get_color("hl/metavar:sh-usage")
3303 res = _ColorizedString()
3304 is_punctuation = False
3305 for part in re.split(r"((?:[{}()[\]\\;!&|]|\s)+)", metavar):
3306 if is_punctuation:
3307 res.append_color(punct_color)
3308 else:
3309 res.append_color(metavar_color)
3310 res.append_str(part)
3311 is_punctuation = not is_punctuation
3313 return res
3316_ARGS_COLUMN_WIDTH = 26
3317_ARGS_COLUMN_WIDTH_NARROW = 8
3320class _CliFormatter(yuio.doc.Formatter): # type: ignore
3321 def __init__(
3322 self,
3323 parser: yuio.doc.DocParser,
3324 ctx: yuio.string.ReprContext,
3325 /,
3326 *,
3327 all: bool = False,
3328 ):
3329 self.parser = parser
3330 self.all = all
3332 self._heading_indent = contextlib.ExitStack()
3333 self._args_column_width = (
3334 _ARGS_COLUMN_WIDTH if ctx.width >= 50 else _ARGS_COLUMN_WIDTH_NARROW
3335 )
3336 ctx.width = min(ctx.width, 80)
3338 super().__init__(ctx, allow_headings=True)
3340 self.base_color = self.ctx.get_color("msg/text:code/sh-usage")
3341 self.prog_color = self.base_color | self.ctx.get_color("hl/prog:sh-usage")
3342 self.punct_color = self.base_color | self.ctx.get_color("hl/punct:sh-usage")
3343 self.metavar_color = self.base_color | self.ctx.get_color("hl/metavar:sh-usage")
3344 self.flag_color = self.base_color | self.ctx.get_color("hl/flag:sh-usage")
3346 def _format_Heading(self, node: yuio.doc.Heading):
3347 if not self._allow_headings:
3348 with self._with_color("msg/text:paragraph"):
3349 self._format_Text(node)
3350 return
3352 if node.level == 1:
3353 self._heading_indent.close()
3355 raw_heading = "".join(map(str, node.items))
3356 if raw_heading and raw_heading[-1].isalnum():
3357 node.items.append(":")
3359 decoration = self.ctx.get_msg_decoration("heading/section")
3360 with (
3361 self._with_indent("msg/decoration:heading/section", decoration),
3362 self._with_color("msg/text:heading/section"),
3363 ):
3364 self._format_Text(node)
3366 if node.level == 1:
3367 self._heading_indent.enter_context(self._with_indent(None, " "))
3368 elif self._separate_paragraphs:
3369 self._line(self._indent)
3371 self._is_first_line = True
3373 def _format_SetIndentation(self, node: _SetIndentation):
3374 self._heading_indent.close()
3375 self._is_first_line = True
3376 if node.indent:
3377 self._heading_indent.enter_context(self._with_indent(None, node.indent))
3379 def _format_Usage(self, node: _Usage):
3380 if node.prefix:
3381 prefix = _ColorizedString(
3382 self.ctx.get_color("msg/text:heading/section"),
3383 node.prefix,
3384 self.base_color,
3385 " ",
3386 )
3387 else:
3388 prefix = _ColorizedString()
3390 usage = _ColorizedString()
3391 if node.cmd.usage:
3392 sh_usage_highlighter, sh_usage_syntax_name = yuio.hl.get_highlighter(
3393 "sh-usage"
3394 )
3396 usage = sh_usage_highlighter.highlight(
3397 node.cmd.usage.rstrip(),
3398 theme=self.ctx.theme,
3399 syntax=sh_usage_syntax_name,
3400 ).percent_format({"prog": node.prog}, self.ctx)
3401 else:
3402 usage = self._build_usage(node)
3404 with self._with_indent(None, prefix):
3405 self._line(
3406 usage.indent(
3407 indent=self._indent,
3408 continuation_indent=self._continuation_indent,
3409 )
3410 )
3412 def _build_usage(self, node: _Usage):
3413 flags_and_groups: list[
3414 Option[_t.Any] | tuple[MutuallyExclusiveGroup, list[Option[_t.Any]]]
3415 ] = []
3416 positionals: list[Option[_t.Any]] = []
3417 groups: dict[MutuallyExclusiveGroup, list[Option[_t.Any]]] = {}
3418 has_grouped_flags = False
3420 for i, opt in enumerate(node.cmd.options + node.inherited):
3421 is_inherited = i >= len(node.cmd.options)
3422 if is_inherited and (
3423 not opt.show_if_inherited or opt.flags is yuio.POSITIONAL
3424 ):
3425 continue
3426 if opt.help is yuio.DISABLED:
3427 continue
3428 if opt.help_group is not None and opt.help_group.help is yuio.DISABLED:
3429 continue
3430 if opt.flags is yuio.POSITIONAL:
3431 positionals.append(opt)
3432 elif opt.usage is yuio.COLLAPSE:
3433 has_grouped_flags = True
3434 elif not opt.usage:
3435 pass
3436 elif opt.mutex_group:
3437 if opt.mutex_group not in groups:
3438 group_items = []
3439 groups[opt.mutex_group] = group_items
3440 flags_and_groups.append((opt.mutex_group, group_items))
3441 else:
3442 group_items = groups[opt.mutex_group]
3443 group_items.append(opt)
3444 else:
3445 flags_and_groups.append(opt)
3447 res = _ColorizedString()
3448 res.append_color(self.prog_color)
3449 res.append_str(node.prog)
3451 if has_grouped_flags:
3452 res.append_color(self.base_color)
3453 res.append_str(" ")
3454 res.append_color(self.flag_color)
3455 res.append_str("<options>")
3457 res.append_color(self.base_color)
3459 in_opt_short_group = False
3460 for flag_or_group in flags_and_groups:
3461 match flag_or_group:
3462 case (group, flags):
3463 res.append_color(self.base_color)
3464 res.append_str(" ")
3465 res.append_color(self.punct_color)
3466 res.append_str("(" if group.required else "[")
3467 sep = False
3468 for flag in flags:
3469 if sep:
3470 res.append_str("|")
3471 usage, _ = flag.format_usage(self.ctx)
3472 res.append_colorized_str(usage.with_base_color(self.base_color))
3473 sep = True
3474 res.append_str(")" if group.required else "]")
3475 case flag:
3476 usage, can_group = flag.format_usage(self.ctx)
3477 if not flag.primary_short_flag or flag.nargs != 0 or flag.required:
3478 can_group = False
3480 if can_group:
3481 if not in_opt_short_group:
3482 res.append_color(self.base_color)
3483 res.append_str(" ")
3484 res.append_color(self.punct_color)
3485 res.append_str("[")
3486 res.append_color(self.flag_color)
3487 res.append_str("-")
3488 in_opt_short_group = True
3489 letter = (flag.primary_short_flag or "")[1:]
3490 res.append_str(letter)
3491 continue
3493 if in_opt_short_group:
3494 res.append_color(self.punct_color)
3495 res.append_str("]")
3496 in_opt_short_group = False
3498 res.append_color(self.base_color)
3499 res.append_str(" ")
3501 if not flag.required:
3502 res.append_color(self.punct_color)
3503 res.append_str("[")
3504 res.append_colorized_str(usage.with_base_color(self.base_color))
3505 if not flag.required:
3506 res.append_color(self.punct_color)
3507 res.append_str("]")
3509 if in_opt_short_group:
3510 res.append_color(self.punct_color)
3511 res.append_str("]")
3512 in_opt_short_group = False
3514 for positional in positionals:
3515 res.append_color(self.base_color)
3516 res.append_str(" ")
3517 res.append_colorized_str(
3518 positional.format_usage(self.ctx)[0].with_base_color(self.base_color)
3519 )
3521 if node.cmd.subcommands:
3522 res.append_str(" ")
3523 if not node.cmd.subcommand_required:
3524 res.append_color(self.punct_color)
3525 res.append_str("[")
3526 res.append_colorized_str(
3527 _format_metavar(node.cmd.metavar, self.ctx).with_base_color(
3528 self.base_color
3529 )
3530 )
3531 res.append_color(self.base_color)
3532 res.append_str(" ")
3533 res.append_color(self.metavar_color)
3534 res.append_str("...")
3535 if not node.cmd.subcommand_required:
3536 res.append_color(self.punct_color)
3537 res.append_str("]")
3539 return res
3541 def _format_HelpOpt(self, node: _HelpOpt):
3542 lead = _ColorizedString()
3543 if node.arg.primary_short_flag:
3544 lead.append_color(self.flag_color)
3545 lead.append_str(node.arg.primary_short_flag)
3546 sep = True
3547 else:
3548 lead.append_color(self.base_color)
3549 lead.append_str(" ")
3550 sep = False
3551 for flag in node.arg.primary_long_flags or []:
3552 if sep:
3553 lead.append_color(self.punct_color)
3554 lead.append_str(", ")
3555 lead.append_color(self.flag_color)
3556 lead.append_str(flag)
3557 sep = True
3559 lead.append_colorized_str(
3560 node.arg.format_metavar(self.ctx).with_base_color(self.base_color)
3561 )
3563 help = _parse_option_help(node.arg, self.parser, self.ctx, all=self.all)
3565 if help is None:
3566 self._line(self._indent + lead)
3567 return
3569 if lead.width + 2 > self._args_column_width:
3570 self._line(self._indent + lead)
3571 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3572 else:
3573 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3575 with indent_ctx:
3576 self._format(help)
3578 def _format_HelpArg(self, node: _HelpArg):
3579 lead = _format_metavar(node.arg.nth_metavar(0), self.ctx).with_base_color(
3580 self.base_color
3581 )
3583 help = _parse_option_help(node.arg, self.parser, self.ctx, all=self.all)
3585 if help is None:
3586 self._line(self._indent + lead)
3587 return
3589 if lead.width + 2 > self._args_column_width:
3590 self._line(self._indent + lead)
3591 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3592 else:
3593 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3595 with indent_ctx:
3596 self._format(help)
3598 def _format_HelpSubCommand(self, node: _HelpSubCommand):
3599 lead = _ColorizedString()
3600 sep = False
3601 for name in node.names:
3602 if sep:
3603 lead.append_color(self.punct_color)
3604 lead.append_str(", ")
3605 lead.append_color(self.flag_color)
3606 lead.append_str(name)
3607 sep = True
3609 help = node.help
3611 if not help:
3612 self._line(self._indent + lead)
3613 return
3615 if lead.width + 2 > self._args_column_width:
3616 self._line(self._indent + lead)
3617 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3618 else:
3619 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3621 with indent_ctx:
3622 self._format(self.parser.parse(help))
3624 def _format_CollapsedOpt(self, node: _CollapsedOpt):
3625 if not node.flags:
3626 self._format_Container(node)
3627 return
3629 lead = _ColorizedString()
3630 sep = False
3631 for flag in node.flags:
3632 if sep:
3633 lead.append_color(self.punct_color)
3634 lead.append_str(", ")
3635 lead.append_color(self.flag_color)
3636 lead.append_str(flag)
3637 sep = True
3639 if lead.width + 2 > self._args_column_width:
3640 self._line(self._indent + lead)
3641 indent_ctx = self._with_indent(None, " " * self._args_column_width)
3642 else:
3643 indent_ctx = self._with_indent(None, self._make_lead_padding(lead))
3645 with indent_ctx:
3646 self._separate_paragraphs = False
3647 self._allow_headings = False
3648 self._format_Container(node)
3649 self._separate_paragraphs = True
3650 self._allow_headings = True
3652 def _format_InheritedOpts(self, node: _InheritedOpts):
3653 raw = _ColorizedString()
3654 s = "" if node.n_inherited == 1 else "s"
3655 raw.append_color(self.ctx.get_color("secondary_color"))
3656 raw.append_str(f" +{node.n_inherited} global option{s}, --help=all to show")
3657 self._line(raw)
3659 def _format_HelpArgGroup(self, node: _HelpArgGroup):
3660 self._separate_paragraphs = False
3661 self._allow_headings = False
3662 self._format_Container(node)
3663 self._separate_paragraphs = True
3664 self._allow_headings = True
3666 def _make_lead_padding(self, lead: _ColorizedString):
3667 color = self.base_color
3668 return lead + color + " " * (self._args_column_width - lead.width)
3671@dataclass(eq=False, match_args=False, slots=True)
3672class _SetIndentation(yuio.doc.AstBase):
3673 indent: str = ""
3676@dataclass(eq=False, match_args=False, slots=True)
3677class _Usage(yuio.doc.AstBase):
3678 prog: str
3679 cmd: Command[Namespace]
3680 inherited: list[Option[_t.Any]]
3681 prefix: str = "Usage:"
3684@dataclass(eq=False, match_args=False, slots=True)
3685class _HelpOpt(yuio.doc.AstBase):
3686 arg: Option[_t.Any]
3689@dataclass(eq=False, match_args=False, slots=True)
3690class _CollapsedOpt(yuio.doc.Container[yuio.doc.AstBase]):
3691 flags: list[str]
3694@dataclass(eq=False, match_args=False, slots=True)
3695class _HelpArg(yuio.doc.AstBase):
3696 arg: Option[_t.Any]
3699@dataclass(eq=False, match_args=False, slots=True)
3700class _InheritedOpts(yuio.doc.AstBase):
3701 n_inherited: int
3704@dataclass(eq=False, match_args=False, slots=True)
3705class _HelpSubCommand(yuio.doc.AstBase):
3706 names: list[str]
3707 help: str | None
3710@dataclass(eq=False, match_args=False, slots=True)
3711class _HelpArgGroup(yuio.doc.Container[yuio.doc.AstBase]):
3712 pass
3715class _ShortUsageFormatter:
3716 def __init__(
3717 self,
3718 parser: yuio.doc.DocParser,
3719 subcommands: list[str] | None,
3720 option: Option[_t.Any],
3721 ):
3722 self.parser = parser
3723 self.subcommands = subcommands
3724 self.option = option
3726 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
3727 note_color = ctx.get_color("msg/text:error/note")
3728 heading_color = ctx.get_color("msg/text:heading/note")
3729 code_color = ctx.get_color("msg/text:code/sh-usage")
3730 punct_color = code_color | ctx.get_color("hl/punct:sh-usage")
3731 flag_color = code_color | ctx.get_color("hl/flag:sh-usage")
3733 res = _ColorizedString()
3734 res.append_color(heading_color)
3735 res.append_str("Help: ")
3737 if self.option.flags is not yuio.POSITIONAL:
3738 sep = False
3739 if self.option.primary_short_flag:
3740 res.append_color(flag_color)
3741 res.append_str(self.option.primary_short_flag)
3742 sep = True
3743 for flag in self.option.primary_long_flags or []:
3744 if sep:
3745 res.append_color(punct_color)
3746 res.append_str(", ")
3747 res.append_color(flag_color)
3748 res.append_str(flag)
3749 sep = True
3751 res.append_colorized_str(
3752 self.option.format_metavar(ctx).with_base_color(code_color)
3753 )
3755 res.append_color(heading_color)
3756 res.append_str("\n")
3757 res.append_color(note_color)
3759 if help := _parse_option_help(self.option, self.parser, ctx):
3760 with ctx.with_settings(width=ctx.width - 2):
3761 formatter = _CliFormatter(self.parser, ctx)
3762 sep = False
3763 for line in formatter.format(
3764 _HelpArgGroup(items=[_SetIndentation(" "), help])
3765 ):
3766 if sep:
3767 res.append_str("\n")
3768 res.append_colorized_str(line.with_base_color(note_color))
3769 sep = True
3771 return res
3774def _parse_option_help(
3775 option: Option[_t.Any],
3776 parser: yuio.doc.DocParser,
3777 ctx: yuio.string.ReprContext,
3778 /,
3779 *,
3780 all: bool = False,
3781) -> yuio.doc.AstBase | None:
3782 help = parser.parse(option.help or "")
3783 if help_tail := option.format_help_tail(ctx, all=all):
3784 help.items.append(yuio.doc.Raw(raw=help_tail))
3786 return help if help.items else None