Coverage for yuio / app.py: 59%
620 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +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"""
9This module provides base functionality to build CLI interfaces.
11Creating and running an app
12---------------------------
14Yuio's CLI applications have functional interface. Decorate main function
15with the :func:`app` decorator, and use :meth:`App.run` method to start it:
17.. code-block:: python
19 # Let's define an app with one flag and one positional argument.
20 @app
21 def main(
22 #: help message for `arg`
23 arg: str = positional(),
24 #: help message for `--flag`
25 flag: int = 0
26 ):
27 \"""this command does a thing\"""
28 yuio.io.info("flag=%r, arg=%r", flag, arg)
30 if __name__ == "__main__":
31 # We can now use `main.run` to parse arguments and invoke `main`.
32 # Notice that `run` does not return anything. Instead, it terminates
33 # python process with an appropriate exit code.
34 main.run("--flag 10 foobar!".split())
36Function's arguments will become program's flags and positionals, and function's
37docstring will become app's :attr:`~App.description`.
39Help messages for the flags are parsed from line comments
40right above the field definition (comments must start with ``#:``).
41They are all formatted using Markdown (see :mod:`yuio.md`).
43Parsers for CLI argument values are derived from type hints.
44Use the ``parser`` parameter of the :func:`field` function to override them.
46Arguments with bool parsers and parsers that support
47:meth:`parsing collections <yuio.parse.Parser.supports_parse_many>`
48are handled to provide better CLI experience:
50.. invisible-code-block: python
52 import pathlib
54.. code-block:: python
56 @app
57 def main(
58 # Will create flags `--verbose` and `--no-verbose`.
59 # Since default is `False`, `--no-verbose` will be hidden from help
60 # to reduce clutter.
61 verbose: bool = False,
62 # Will create a flag with `nargs=*`: `--inputs path1 path2 ...`
63 inputs: list[pathlib.Path] = [],
64 ): ...
66.. autofunction:: app
68.. autoclass:: App
70 .. automethod:: run
72 .. method:: wrapped(...)
74 The original callable what was wrapped by :func:`app`.
77Configuring CLI arguments
78-------------------------
80Names and types of arguments are determined by names and types of the app function's
81arguments. You can use the :func:`field` function to override them:
83.. autofunction:: field
85.. autodata:: yuio.DISABLED
87.. autodata:: yuio.MISSING
89.. autodata:: yuio.POSITIONAL
91.. autodata:: yuio.GROUP
93.. autofunction:: inline
95.. autofunction:: positional
97.. autoclass:: MutuallyExclusiveGroup
100Creating argument groups
101------------------------
103You can use :class:`~yuio.config.Config` as a type of an app function's parameter.
104This will make all of config fields into flags as well. By default, Yuio will use
105parameter name as a prefix for all fields in the config; you can override it
106with :func:`field` or :func:`inline`:
108.. code-block:: python
110 class KillCmdConfig(yuio.config.Config):
111 # Will be loaded from `--signal`.
112 signal: int
114 # Will be loaded from `-p` or `--pid`.
115 pid: int = field(flags=["-p", "--pid"])
118 @app
119 def main(
120 # `kill_cmd.signal` will be loaded from `--kill-cmd-signal`.
121 kill_cmd: KillCmdConfig,
122 # `copy_cmd_2.signal` will be loaded from `--kill-signal`.
123 kill_cmd_2: KillCmdConfig = field(flags="--kill"),
124 # `kill_cmd_3.signal` will be loaded from `--signal`.
125 kill_cmd_3: KillCmdConfig = field(flags=""),
126 ): ...
128.. note::
130 Positional arguments are not allowed in configs,
131 only in apps.
134App settings
135------------
137You can override default usage and help messages as well as control some of the app's
138help formatting using its arguments:
140.. class:: App
141 :noindex:
143 .. autoattribute:: prog
145 .. autoattribute:: usage
147 .. autoattribute:: description
149 .. autoattribute:: help
151 .. autoattribute:: epilog
153 .. autoattribute:: allow_abbrev
155 .. autoattribute:: subcommand_required
157 .. autoattribute:: setup_logging
159 .. autoattribute:: theme
161 .. autoattribute:: version
163 .. autoattribute:: bug_report
165 .. autoattribute:: is_dev_mode
168Creating sub-commands
169---------------------
171You can create multiple sub-commands for the main function
172using the :meth:`App.subcommand` method:
174.. code-block:: python
176 @app
177 def main(): ...
180 @main.subcommand
181 def do_stuff(): ...
183There is no limit to how deep you can nest subcommands, but for usability reasons
184we suggest not exceeding level of sub-sub-commands (``git stash push``, anyone?)
186When user invokes a subcommand, the ``main()`` function is called first,
187then subcommand. In the above example, invoking our app with subcommand ``push``
188will cause ``main()`` to be called first, then ``push()``.
190This behavior is useful when you have some global configuration flags
191attached to the ``main()`` command. See the `example app`_ for details.
193.. _example app: https://github.com/taminomara/yuio/blob/main/examples/app
195.. class:: App
196 :noindex:
198 .. automethod:: subcommand
201Controlling how sub-commands are invoked
202----------------------------------------
204By default, if a command has sub-commands, the user is required to provide
205a sub-command. This behavior can be disabled by setting :attr:`App.subcommand_required`
206to :data:`False`.
208When this happens, we need to understand whether a subcommand was invoked or not.
209To determine this, you can accept a special parameter called ``_command_info``
210of type :class:`CommandInfo`. It will contain info about the current function,
211including its name and subcommand:
213.. code-block:: python
215 @app
216 def main(_command_info: CommandInfo):
217 if _command_info.subcommand is not None:
218 # A subcommand was invoked.
219 ...
221You can call the subcommand on your own by using ``_command_info.subcommand``
222as a callable:
224.. code-block:: python
226 @app
227 def main(_command_info: CommandInfo):
228 if _command_info.subcommand is not None and ...:
229 _command_info.subcommand() # manually invoking a subcommand
231If you wish to disable calling the subcommand, you can return :data:`False`
232from the main function:
234.. code-block:: python
236 @app
237 def main(_command_info: CommandInfo):
238 ...
239 # Subcommand will not be invoked.
240 return False
242.. autoclass:: CommandInfo
243 :members:
245"""
247from __future__ import annotations
249import argparse
250import contextlib
251import dataclasses
252import functools
253import inspect
254import logging
255import os
256import re
257import sys
258import types
259from dataclasses import dataclass
261import yuio
262import yuio.color
263import yuio.complete
264import yuio.config
265import yuio.dbg
266import yuio.io
267import yuio.md
268import yuio.parse
269import yuio.string
270import yuio.term
271import yuio.theme
272from yuio import _typing as _t
273from yuio.config import MutuallyExclusiveGroup, field, inline, positional
274from yuio.util import _find_docs
275from yuio.util import dedent as _dedent
276from yuio.util import to_dash_case as _to_dash_case
278__all__ = [
279 "App",
280 "AppError",
281 "CommandInfo",
282 "MutuallyExclusiveGroup",
283 "app",
284 "field",
285 "inline",
286 "positional",
287]
289C = _t.TypeVar("C", bound=_t.Callable[..., None])
290C2 = _t.TypeVar("C2", bound=_t.Callable[..., None])
293class AppError(yuio.PrettyException, Exception):
294 """
295 An error that you can throw from an app to finish its execution without printing
296 a traceback.
298 """
301@_t.overload
302def app(
303 *,
304 prog: str | None = None,
305 usage: str | None = None,
306 description: str | None = None,
307 epilog: str | None = None,
308 version: str | None = None,
309 bug_report: yuio.dbg.ReportSettings | bool = False,
310 is_dev_mode: bool | None = None,
311) -> _t.Callable[[C], App[C]]: ...
312@_t.overload
313def app(
314 command: C,
315 /,
316 *,
317 prog: str | None = None,
318 usage: str | None = None,
319 description: str | None = None,
320 epilog: str | None = None,
321 version: str | None = None,
322 bug_report: yuio.dbg.ReportSettings | bool = False,
323 is_dev_mode: bool | None = None,
324) -> App[C]: ...
325def app(
326 command: _t.Callable[..., None] | None = None,
327 /,
328 *,
329 prog: str | None = None,
330 usage: str | None = None,
331 description: str | None = None,
332 epilog: str | None = None,
333 version: str | None = None,
334 bug_report: yuio.dbg.ReportSettings | bool = False,
335 is_dev_mode: bool | None = None,
336) -> _t.Any:
337 """
338 Create an application.
340 This is a decorator that's supposed to be used on the main method
341 of the application. This decorator returns an :class:`App` object.
343 :param command:
344 the main function of the application.
345 :param prog:
346 overrides program's name, see :attr:`App.prog`.
347 :param usage:
348 overrides program's usage description, see :attr:`App.usage`.
349 :param description:
350 overrides program's description, see :attr:`App.description`.
351 :param epilog:
352 overrides program's epilog, see :attr:`App.epilog`.
353 :param version:
354 program's version, will be displayed using the :flag:`--version` flag.
355 :param bug_report:
356 settings for automated bug report generation. If present,
357 adds the :flag:`--bug-report` flag.
358 :param is_dev_mode:
359 enables additional logging, see :attr:`App.is_dev_mode`.
360 :returns:
361 an :class:`App` object that wraps the original function.
363 """
365 def registrar(command: C, /) -> App[C]:
366 return App(
367 command,
368 prog=prog,
369 usage=usage,
370 description=description,
371 epilog=epilog,
372 version=version,
373 bug_report=bug_report,
374 is_dev_mode=is_dev_mode,
375 )
377 if command is None:
378 return registrar
379 else:
380 return registrar(command)
383@_t.final
384@dataclass(frozen=True, eq=False, match_args=False, slots=True)
385class CommandInfo:
386 """
387 Data about the invoked command.
389 """
391 name: str
392 """
393 Name of the current command.
395 If it was invoked by alias,
396 this will contains the primary command name.
398 For the main function, the name will be set to ``"__main__"``.
400 """
402 subcommand: CommandInfo | None
403 """
404 Subcommand of this command, if one was given.
406 """
408 # Internal, do not use.
409 _config: _t.Any = dataclasses.field(repr=False)
410 _executed: bool = dataclasses.field(default=False, repr=False)
412 def __call__(self) -> _t.Literal[False]:
413 """
414 Execute this command.
416 """
418 if self._executed:
419 return False
420 object.__setattr__(self, "_executed", True)
422 if self._config is not None:
423 should_invoke_subcommand = self._config._run(self)
424 if should_invoke_subcommand is None:
425 should_invoke_subcommand = True
426 else:
427 should_invoke_subcommand = True
429 if should_invoke_subcommand and self.subcommand is not None:
430 self.subcommand()
432 return False
435class App(_t.Generic[C]):
436 """
437 A class that encapsulates app settings and logic for running it.
439 It is better to create instances of this class using the :func:`app` decorator,
440 as it provides means to decorate the main function and specify all of the app's
441 parameters.
443 """
445 @dataclass(frozen=True, eq=False, match_args=False, slots=True)
446 class _SubApp:
447 app: App[_t.Any]
448 name: str
449 aliases: list[str] | None = None
450 is_primary: bool = False
452 def __init__(
453 self,
454 command: C,
455 /,
456 *,
457 prog: str | None = None,
458 usage: str | None = None,
459 help: str | yuio.Disabled | None = None,
460 description: str | None = None,
461 epilog: str | None = None,
462 version: str | None = None,
463 bug_report: yuio.dbg.ReportSettings | bool = False,
464 is_dev_mode: bool | None = None,
465 ):
466 self.prog: str | None = prog
467 """
468 Program or subcommand display name.
470 By default, inferred from :data:`sys.argv` and subcommand names.
472 See `prog <https://docs.python.org/3/library/argparse.html#prog>`_
473 in :mod:`argparse`.
475 """
477 self.usage: str | None = usage
478 """
479 Program or subcommand synapsis.
481 This string will be processed using the to ``bash`` syntax,
482 and then it will be ``%``-formatted with a single keyword argument ``prog``.
483 If command supports multiple signatures, each of them should be listed
484 on a separate string. For example::
486 @app
487 def main(): ...
489 main.usage = \"""
490 %(prog)s [-q] [-f] [-m] [<branch>]
491 %(prog)s [-q] [-f] [-m] --detach [<branch>]
492 %(prog)s [-q] [-f] [-m] [--detach] <commit>
493 ...
494 \"""
496 By default, usage is generated from CLI flags.
498 See `usage <https://docs.python.org/3/library/argparse.html#usage>`_
499 in :mod:`argparse`.
501 """
503 if not description and command.__doc__:
504 description = command.__doc__
506 self.description: str | None = description
507 """
508 Text that is shown before CLI flags help, usually contains
509 short description of the program or subcommand.
511 The text should be formatted using markdown. For example:
513 .. code-block:: python
515 @app
516 def main(): ...
518 main.description = \"""
519 this command does a thing.
521 # different ways to do a thing
523 this command can apply multiple algorithms to achieve
524 a necessary state in which a thing can be done. This includes:
526 - randomly turning the screen on and off;
528 - banging a head on a table;
530 - fiddling with your PCs power cord.
532 By default, the best algorithm is determined automatically.
533 However, you can hint a preferred algorithm via the `--hint-algo` flag.
535 \"""
537 By default, inferred from command's docstring.
539 See `description <https://docs.python.org/3/library/argparse.html#description>`_
540 in :mod:`argparse`.
542 """
544 if help is yuio.DISABLED:
545 help = argparse.SUPPRESS
546 elif help is None and description:
547 lines = description.split("\n\n", 1)
548 help = lines[0].rstrip(".")
550 self.help: str | None = help
551 """
552 Short help message that is shown when listing subcommands.
554 By default, inferred from command's docstring.
556 See `help <https://docs.python.org/3/library/argparse.html#help>`_
557 in :mod:`argparse`.
559 """
561 self.epilog: str | None = epilog
562 """
563 Text that is shown after the main portion of the help message.
565 Text format is identical to the one for :attr:`~App.description`.
567 See `epilog <https://docs.python.org/3/library/argparse.html#epilog>`_
568 in :mod:`argparse`.
570 """
572 self.allow_abbrev: bool = False
573 """
574 Allow abbreviating CLI flags if that doesn't create ambiguity.
576 Disabled by default.
578 See `allow_abbrev <https://docs.python.org/3/library/argparse.html#allow-abbrev>`_
579 in :mod:`argparse`.
581 """
583 self.subcommand_required: bool = True
584 """
585 Require the user to provide a subcommand for this command.
587 If this command doesn't have any subcommands, this option is ignored.
589 Enabled by default.
591 """
593 self.setup_logging: bool = True
594 """
595 If :data:`True`, the app will call :func:`logging.basicConfig` during
596 its initialization. Disable this if you want to customize
597 logging initialization.
599 Disabling this option also removes the ``--verbose`` flag form the CLI.
601 """
603 self.theme: (
604 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
605 ) = None
606 """
607 A custom theme that will be passed to :func:`yuio.io.setup`
608 on application startup.
610 """
612 self.version: str | None = version
613 """
614 If not :data:`None`, add :flag:`--version` flag to the CLI.
616 """
618 self.bug_report: yuio.dbg.ReportSettings | bool = bug_report
619 """
620 If not :data:`False`, add :flag:`--bug-report` flag to the CLI.
622 This flag automatically collects data about environment and prints it
623 in a format suitable for adding to a bug report.
625 """
627 self.is_dev_mode: bool | None = is_dev_mode
628 """
629 If :data:`True`, this will enable :func:`logging.captureWarnings`
630 and configure internal Yuio logging to show warnings.
632 By default, dev mode is detected by checking if :attr:`~App.version`
633 contains substring ``"dev"``.
635 .. note::
637 You can always enable full debug logging by setting environment
638 variable ``YUIO_DEBUG``.
640 If enabled, full log will be saved to ``YUIO_DEBUG_FILE``
641 (default is ``./yuio.log``).
643 """
645 self.__sub_apps: dict[str, App._SubApp] = {}
647 if callable(command):
648 self.__config_type = _command_from_callable(command)
649 else:
650 raise TypeError(f"expected a function, got {command}")
652 functools.update_wrapper(
653 self, # type: ignore
654 command,
655 assigned=("__module__", "__name__", "__qualname__", "__doc__"),
656 updated=(),
657 )
659 self._command = command
661 @functools.wraps(command)
662 def wrapped_command(*args, **kwargs):
663 if args:
664 names = self.__config_type.__annotations__
665 if len(args) > len(names):
666 s = "" if len(names) == 1 else "s"
667 raise TypeError(
668 f"expected at most {len(names)} positional argument{s}, got {len(args)}"
669 )
670 for arg, name in zip(args, names):
671 if name in kwargs:
672 raise TypeError(f"argument {name} was given twice")
673 kwargs[name] = arg
674 return CommandInfo("__raw__", None, self.__config_type(**kwargs), False)()
676 self.wrapped: C = wrapped_command # type: ignore
677 """
678 The original callable what was wrapped by :func:`app`.
680 """
682 @_t.overload
683 def subcommand(
684 self,
685 /,
686 *,
687 name: str | None = None,
688 aliases: list[str] | None = None,
689 usage: str | None = None,
690 help: str | yuio.Disabled | None = None,
691 description: str | None = None,
692 epilog: str | None = None,
693 ) -> _t.Callable[[C2], App[C2]]: ...
695 @_t.overload
696 def subcommand(
697 self,
698 cb: C2,
699 /,
700 *,
701 name: str | None = None,
702 aliases: list[str] | None = None,
703 usage: str | None = None,
704 help: str | yuio.Disabled | None = None,
705 description: str | None = None,
706 epilog: str | None = None,
707 ) -> App[C2]: ...
709 def subcommand(
710 self,
711 cb: _t.Callable[..., None] | None = None,
712 /,
713 *,
714 name: str | None = None,
715 aliases: list[str] | None = None,
716 usage: str | None = None,
717 help: str | yuio.Disabled | None = None,
718 description: str | None = None,
719 epilog: str | None = None,
720 ) -> _t.Any:
721 """
722 Register a subcommand for the given app.
724 This method can be used as a decorator, similar to the :func:`app` function.
726 :param name:
727 allows overriding subcommand's name.
728 :param aliases:
729 allows adding alias names for subcommand.
730 :param usage:
731 overrides subcommand's usage description, see :attr:`App.usage`.
732 :param help:
733 overrides subcommand's short help, see :attr:`App.help`.
734 pass :data:`~yuio.DISABLED` to hide this subcommand in CLI help message.
735 :param description:
736 overrides subcommand's description, see :attr:`App.description`.
737 :param epilog:
738 overrides subcommand's epilog, see :attr:`App.epilog`.
739 :returns:
740 a new :class:`App` object for a subcommand.
742 """
744 def registrar(cb: C2, /) -> App[C2]:
745 app = App(
746 cb,
747 usage=usage,
748 help=help,
749 description=description,
750 epilog=epilog,
751 )
753 app.allow_abbrev = self.allow_abbrev
755 main_name = name or _to_dash_case(cb.__name__)
756 self.__sub_apps[main_name] = App._SubApp(
757 app, main_name, aliases, is_primary=True
758 )
759 if aliases:
760 alias_app = App._SubApp(app, main_name)
761 self.__sub_apps.update({alias: alias_app for alias in aliases})
763 return app
765 if cb is None:
766 return registrar
767 else:
768 return registrar(cb)
770 def run(self, args: _t.Sequence[str] | None = None) -> _t.NoReturn:
771 """
772 Parse arguments, set up :mod:`yuio.io` and :mod:`logging`,
773 and run the application.
775 :param args:
776 command line arguments. If none are given,
777 use arguments from :data:`sys.argv`.
778 :returns:
779 this method does not return, it exits the program instead.
781 """
783 if args is None:
784 args = sys.argv[1:]
786 if "--yuio-custom-completer--" in args:
787 index = args.index("--yuio-custom-completer--")
788 yuio.complete._run_custom_completer(
789 self.__get_completions(), args[index + 1], args[index + 2]
790 )
791 sys.exit(0)
793 if "--yuio-bug-report--" in args:
794 from yuio.dbg import print_report
796 print_report(settings=self.bug_report, app=self)
797 sys.exit(0)
799 yuio.io.setup(theme=self.theme, wrap_stdio=True)
801 parser, subparsers_map = self.__setup_arg_parser()
802 namespace = parser.parse_args(args)
804 if self.is_dev_mode is None:
805 self.is_dev_mode = (
806 self.version is not None and "dev" in self.version.lower()
807 )
808 if self.is_dev_mode:
809 yuio.enable_internal_logging(propagate=True)
811 if self.setup_logging:
812 logging_level = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}.get(
813 namespace.verbosity_level, logging.DEBUG
814 )
815 logging.basicConfig(handlers=[yuio.io.Handler()], level=logging_level)
817 try:
818 command = self.__load_from_namespace(namespace)
819 command()
820 sys.exit(0)
821 except AppError as e:
822 yuio.io.failure(e)
823 sys.exit(1)
824 except (argparse.ArgumentTypeError, argparse.ArgumentError) as e:
825 # Make sure we print subcommand's usage, not the main one.
826 subcommand_path = self.__get_subcommand_path(namespace)
827 subparser = subparsers_map[subcommand_path]
828 subparser.error(str(e))
829 except KeyboardInterrupt:
830 yuio.io.failure("Received Keyboard Interrupt, stopping now")
831 sys.exit(130)
832 except Exception as e:
833 msg = str(e)
834 if "Original traceback:" in msg:
835 msg = re.sub(
836 r"\n*?^Original traceback:.*",
837 "",
838 msg,
839 flags=re.MULTILINE | re.DOTALL,
840 )
841 yuio.io.failure_with_tb("Error: %s", msg)
842 sys.exit(3)
843 finally:
844 yuio.io.restore_streams()
846 def __load_from_namespace(self, namespace: argparse.Namespace) -> CommandInfo:
847 return self.__load_from_namespace_impl(namespace, "app")
849 def __load_from_namespace_impl(
850 self, namespace: argparse.Namespace, ns_prefix: str
851 ) -> CommandInfo:
852 config = self.__config_type._load_from_namespace(namespace, ns_prefix=ns_prefix)
853 subcommand = None
855 if name := getattr(namespace, ns_prefix + "@subcommand", None):
856 sub_app = self.__sub_apps[name]
857 subcommand = dataclasses.replace(
858 sub_app.app.__load_from_namespace_impl(
859 namespace, f"{ns_prefix}/{sub_app.name}"
860 ),
861 name=sub_app.name,
862 )
864 return CommandInfo("__main__", subcommand, _config=config)
866 def __get_subcommand_path(self, namespace: argparse.Namespace) -> str:
867 return self.__get_subcommand_path_impl(namespace, "app")
869 def __get_subcommand_path_impl(self, namespace: argparse.Namespace, ns_prefix: str):
870 if name := getattr(namespace, ns_prefix + "@subcommand", None):
871 sub_app = self.__sub_apps[name]
872 return sub_app.app.__get_subcommand_path_impl(
873 namespace, f"{ns_prefix}/{sub_app.name}"
874 )
875 else:
876 return ns_prefix
878 def __setup_arg_parser(
879 self, parser: argparse.ArgumentParser | None = None
880 ) -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentParser]]:
881 prog = self.prog
882 if not prog:
883 prog = os.path.basename(sys.argv[0])
885 parser = parser or _ArgumentParser(
886 prog=self.prog,
887 usage=self.usage,
888 description=self.description,
889 epilog=self.epilog,
890 allow_abbrev=self.allow_abbrev,
891 add_help=False,
892 formatter_class=_HelpFormatter, # type: ignore
893 )
895 subparsers_map = {}
897 self.__setup_arg_parser_impl(self, parser, "app", prog, subparsers_map)
899 return parser, subparsers_map
901 def __setup_arg_parser_impl(
902 self,
903 main_app: App[_t.Any],
904 parser: argparse.ArgumentParser,
905 ns_prefix: str,
906 prog: str,
907 subparsers_map: dict[str, argparse.ArgumentParser],
908 ):
909 subparsers_map[ns_prefix] = parser
911 self.__config_type._setup_arg_parser(parser, ns_prefix=ns_prefix)
913 if self.__sub_apps:
914 subparsers = parser.add_subparsers(
915 required=self.subcommand_required,
916 dest=ns_prefix + "@subcommand",
917 metavar="<subcommand>",
918 parser_class=_ArgumentParser,
919 )
921 for name, sub_app in self.__sub_apps.items():
922 if not sub_app.is_primary:
923 continue
925 sub_prog = f"{prog} {name}"
927 subparser = subparsers.add_parser(
928 name,
929 aliases=sub_app.aliases or [],
930 prog=sub_prog,
931 help=sub_app.app.help,
932 usage=sub_app.app.usage,
933 description=sub_app.app.description,
934 epilog=sub_app.app.epilog,
935 allow_abbrev=self.allow_abbrev,
936 add_help=False,
937 formatter_class=_HelpFormatter, # type: ignore
938 )
940 sub_app.app.__setup_arg_parser_impl(
941 main_app,
942 subparser,
943 ns_prefix=f"{ns_prefix}/{name}",
944 prog=sub_prog,
945 subparsers_map=subparsers_map,
946 )
948 if main_app.__config_type is not self.__config_type:
949 main_app.__config_type._setup_arg_parser(
950 parser,
951 group=parser.add_argument_group("global options"), # pyright: ignore[reportArgumentType]
952 ns_prefix="app",
953 )
955 aux = parser.add_argument_group("auxiliary options")
956 color = aux.add_mutually_exclusive_group()
957 color.add_argument(
958 "--force-color",
959 help="force-enable colored output",
960 action=_NoOpAction, # Note: `yuio.term` inspects `sys.argv` on its own
961 nargs=0,
962 )
963 color.add_argument(
964 "--force-no-color",
965 help="force-disable colored output",
966 action=_NoOpAction, # Note: `yuio.term` inspects `sys.argv` on its own
967 nargs=0,
968 )
970 aux.add_argument(
971 "-h",
972 "--help",
973 help="show this help message and exit",
974 action=_HelpAction,
975 nargs=0,
976 )
978 if main_app.setup_logging:
979 aux.add_argument(
980 "-v",
981 "--verbose",
982 help="increase output verbosity",
983 # note the merge function in `_Namespace` for this dest.
984 action=_StoreConstAction,
985 const=1,
986 default=0,
987 nargs=0,
988 dest="verbosity_level",
989 )
991 if main_app.version is not None:
992 aux.add_argument(
993 "-V",
994 "--version",
995 action=_VersionAction,
996 nargs=0,
997 version=main_app.version,
998 help="show program's version number and exit",
999 )
1001 if main_app.bug_report:
1002 aux.add_argument(
1003 "--bug-report",
1004 action=_BugReportAction,
1005 nargs=0,
1006 app=main_app,
1007 help="show environment data for bug report and exit",
1008 )
1010 aux.add_argument(
1011 "--completions",
1012 help="generate autocompletion scripts and exit",
1013 nargs="?",
1014 action=_make_completions_action(main_app),
1015 )
1017 def __get_completions(self) -> yuio.complete._CompleterSerializer:
1018 serializer = yuio.complete._CompleterSerializer(
1019 add_help=True,
1020 add_version=self.version is not None,
1021 add_bug_report=bool(self.bug_report),
1022 )
1023 self.__setup_arg_parser(serializer.as_parser())
1024 return serializer
1026 def __write_completions(self, shell: str):
1027 yuio.complete._write_completions(self.__get_completions(), self.prog, shell)
1030class _NoReprConfig(yuio.config.Config):
1031 def __repr__(self):
1032 return "<move along, nothing to see here>"
1035def _command_from_callable(cb: _t.Callable[..., None]) -> type[yuio.config.Config]:
1036 sig = inspect.signature(cb)
1038 dct = {}
1039 annotations = {}
1041 accepts_command_info = False
1043 try:
1044 docs = _find_docs(cb)
1045 except Exception:
1046 yuio._logger.warning(
1047 "unable to get documentation for %s.%s",
1048 cb.__module__,
1049 cb.__qualname__,
1050 )
1051 docs = {}
1053 for name, param in sig.parameters.items():
1054 if param.kind not in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY):
1055 raise TypeError("positional-only and variadic arguments are not supported")
1057 if name.startswith("_"):
1058 if name == "_command_info":
1059 accepts_command_info = True
1060 continue
1061 else:
1062 raise TypeError(f"unknown special param {name}")
1064 if param.default is not param.empty:
1065 field = param.default
1066 else:
1067 field = yuio.MISSING
1068 if not isinstance(field, yuio.config._FieldSettings):
1069 field = _t.cast(
1070 yuio.config._FieldSettings, yuio.config.field(default=field)
1071 )
1072 if field.default is yuio.MISSING:
1073 field = dataclasses.replace(field, required=True)
1074 if name in docs:
1075 field = dataclasses.replace(field, help=docs[name])
1077 if param.annotation is param.empty:
1078 raise TypeError(f"param {name} requires type annotation")
1080 dct[name] = field
1081 annotations[name] = param.annotation
1083 dct["_run"] = _command_from_callable_run_impl(
1084 cb, list(annotations.keys()), accepts_command_info
1085 )
1086 dct["__annotations__"] = annotations
1087 dct["__module__"] = getattr(cb, "__module__", None)
1088 dct["__doc__"] = getattr(cb, "__doc__", None)
1090 return types.new_class(
1091 cb.__name__,
1092 (_NoReprConfig,),
1093 {"_allow_positionals": True},
1094 exec_body=lambda ns: ns.update(dct),
1095 )
1098def _command_from_callable_run_impl(
1099 cb: _t.Callable[..., None], params: list[str], accepts_command_info
1100):
1101 def run(self, command_info):
1102 kw = {name: getattr(self, name) for name in params}
1103 if accepts_command_info:
1104 kw["_command_info"] = command_info
1105 return cb(**kw)
1107 return run
1110class _ArgumentParser(argparse.ArgumentParser):
1111 def parse_known_args(self, args=None, namespace=None): # type: ignore
1112 self._merge_by_dest: dict[str, _t.Callable[[_t.Any, _t.Any], _t.Any]] = {
1113 action.dest: merge
1114 for action in self._actions
1115 if (get_merge := getattr(action, "get_merge", None))
1116 and (merge := get_merge())
1117 }
1118 self._merge_by_dest["verbosity_level"] = lambda l, r: l + r
1119 if namespace is None:
1120 namespace = _Namespace(self)
1121 return super().parse_known_args(args=args, namespace=namespace)
1123 def error(self, message: str) -> _t.NoReturn:
1124 self.print_usage()
1125 yuio.io.failure("Error: %s", message)
1126 sys.exit(2)
1129class _Namespace(argparse.Namespace):
1130 # Since we add flags from main function to all of the subparsers,
1131 # we need to merge them properly. Otherwise, values from subcommands
1132 # will override values from the main command: `app --foo=x subcommand --foo=y`
1133 # will result in `--foo` being just `y`, not `merge(x, y)`. In fact, argparse
1134 # will override every absent flag with its default: `app --foo x subcommand`
1135 # will result in `--foo` having a default value.
1136 def __init__(self, parser: _ArgumentParser):
1137 self.__parser = parser
1139 def __setattr__(self, name: str, value: _t.Any):
1140 if value is yuio.MISSING and hasattr(self, name):
1141 # Flag was not specified in a subcommand, don't override it.
1142 return
1143 if (prev := getattr(self, name, yuio.MISSING)) is not yuio.MISSING and (
1144 merge := self.__parser._merge_by_dest.get(name)
1145 ) is not None:
1146 # Flag was specified in main command and in a subcommand, merge the values.
1147 value = merge(prev, value)
1148 super().__setattr__(name, value)
1151def _make_completions_action(app: App[_t.Any]):
1152 class _CompletionsAction(argparse.Action):
1153 @staticmethod
1154 def get_usage():
1155 return False
1157 @staticmethod
1158 def get_parser():
1159 return yuio.parse.OneOf(
1160 yuio.parse.Lower(yuio.parse.Str()),
1161 ["all", "bash", "zsh", "fish", "pwsh", "uninstall"],
1162 )
1164 def __init__(self, **kwargs):
1165 kwargs["metavar"] = self.get_parser().describe_or_def()
1166 super().__init__(**kwargs)
1168 def __call__(self, parser, namespace, value, *args):
1169 try:
1170 app._App__write_completions(self.get_parser().parse(value or "all")) # type: ignore
1171 except argparse.ArgumentTypeError as e:
1172 raise argparse.ArgumentError(self, str(e))
1173 parser.exit()
1175 return _CompletionsAction
1178class _NoOpAction(argparse.Action):
1179 @staticmethod
1180 def get_usage():
1181 return False
1183 def __call__(self, parser, namespace, value, *args):
1184 pass
1187class _StoreConstAction(argparse.Action):
1188 @staticmethod
1189 def get_usage():
1190 return False
1192 def __call__(self, parser, namespace, values, option_string=None):
1193 setattr(namespace, self.dest, self.const)
1196class _HelpAction(argparse.Action):
1197 @staticmethod
1198 def get_usage():
1199 return False
1201 def __call__(self, parser, namespace, values, option_string=None):
1202 parser.print_help()
1203 parser.exit()
1206class _VersionAction(argparse.Action):
1207 @staticmethod
1208 def get_usage():
1209 return False
1211 def __init__(self, version=None, **kwargs):
1212 super().__init__(**kwargs)
1213 self.version = version
1215 def __call__(self, parser, namespace, values, option_string=None):
1216 print(self.version) # noqa: T201
1217 parser.exit()
1220class _BugReportAction(argparse.Action):
1221 @staticmethod
1222 def get_usage():
1223 return False
1225 def __init__(self, app: App[_t.Any], **kwargs):
1226 super().__init__(**kwargs)
1227 self.app = app
1229 def __call__(self, parser, namespace, values, option_string=None):
1230 yuio.dbg.print_report(settings=self.app.bug_report, app=self.app)
1231 parser.exit()
1234_MAX_ARGS_COLUMN_WIDTH = 24
1237class _CliMdFormatter(yuio.md.MdFormatter): # type: ignore
1238 def __init__(
1239 self,
1240 theme: yuio.theme.Theme,
1241 *,
1242 width: int | None = None,
1243 ):
1244 self._heading_indent = contextlib.ExitStack()
1245 self._args_column_width = _MAX_ARGS_COLUMN_WIDTH
1247 super().__init__(
1248 theme,
1249 width=width,
1250 allow_headings=True,
1251 )
1253 self.width = min(self.width, 80)
1255 def colorize(
1256 self,
1257 s: str,
1258 /,
1259 *,
1260 default_color: yuio.color.Color | str = yuio.color.Color.NONE,
1261 ):
1262 return yuio.string.colorize(
1263 s,
1264 default_color=default_color,
1265 parse_cli_flags_in_backticks=True,
1266 ctx=self.theme,
1267 )
1269 def _format_Heading(self, node: yuio.md.Heading):
1270 if node.level == 1:
1271 self._heading_indent.close()
1273 decoration = self.theme.msg_decorations.get("heading/section", "")
1274 with self._with_indent("msg/decoration:heading/section", decoration):
1275 self._format_Text(
1276 node,
1277 default_color=self.theme.get_color("msg/text:heading/section"),
1278 )
1280 if node.level == 1:
1281 self._heading_indent.enter_context(self._with_indent(None, " "))
1282 else:
1283 self._line(self._indent)
1285 self._is_first_line = True
1287 def _format_Usage(self, node: "_Usage"):
1288 with self._with_indent(None, node.prefix):
1289 self._line(
1290 node.usage.indent(
1291 indent=self._indent,
1292 continuation_indent=self._continuation_indent,
1293 )
1294 )
1296 def _format_HelpArg(self, node: _HelpArg):
1297 if node.help is None:
1298 self._line(self._indent + node.args)
1299 return
1301 if node.args.width + 2 > self._args_column_width:
1302 self._line(self._indent + node.indent + node.args)
1303 indent_ctx = self._with_indent(None, " " * self._args_column_width)
1304 else:
1305 indent_ctx = self._with_indent(
1306 None,
1307 node.indent
1308 + node.args
1309 + " " * (self._args_column_width - len(node.indent) - node.args.width),
1310 )
1312 with indent_ctx:
1313 if node.help:
1314 self._format(node.help)
1316 def _format_HelpArgGroup(self, node: _HelpArgGroup):
1317 for item in node.items:
1318 self._format(item)
1321@dataclass(eq=False, match_args=False, slots=True)
1322class _Usage(yuio.md.AstBase):
1323 prefix: yuio.string.ColorizedString
1324 usage: yuio.string.ColorizedString
1327@dataclass(eq=False, match_args=False, slots=True)
1328class _HelpArg(yuio.md.AstBase):
1329 indent: str
1330 args: yuio.string.ColorizedString
1331 help: yuio.md.AstBase | None
1334@dataclass(eq=False, match_args=False, slots=True)
1335class _HelpArgGroup(yuio.md.Container[_HelpArg]):
1336 pass
1339class _HelpFormatter:
1340 def __init__(self, prog: str):
1341 self._prog = prog
1342 self._term = yuio.io.get_term()
1343 self._theme = yuio.io.get_theme()
1344 self._usage_main_color = self._theme.get_color("msg/text:code/sh-usage")
1345 self._usage_prog_color = self._usage_main_color | self._theme.get_color(
1346 "hl/prog:sh-usage"
1347 )
1348 self._usage_punct_color = self._usage_main_color | self._theme.get_color(
1349 "hl/punct:sh-usage"
1350 )
1351 self._usage_metavar_color = self._usage_main_color | self._theme.get_color(
1352 "hl/metavar:sh-usage"
1353 )
1354 self._usage_flag_color = self._usage_main_color | self._theme.get_color(
1355 "hl/flag:sh-usage"
1356 )
1357 self._formatter = _CliMdFormatter(self._theme)
1358 self._nodes: list[yuio.md.AstBase] = []
1359 self._args_column_width = 0
1361 def start_section(self, heading: str | None):
1362 if heading:
1363 if not heading.endswith(":"):
1364 heading += ":"
1365 self._nodes.append(yuio.md.Heading(lines=[heading], level=1))
1367 def end_section(self):
1368 if self._nodes and isinstance(self._nodes[-1], yuio.md.Heading):
1369 self._nodes.pop()
1371 def add_text(self, text):
1372 if text != argparse.SUPPRESS and text:
1373 self._nodes.append(self._formatter.parse(text))
1375 def add_usage(
1376 self, usage, actions: _t.Iterable[argparse.Action], groups, prefix=None
1377 ):
1378 if usage == argparse.SUPPRESS:
1379 return
1381 if prefix is not None:
1382 c_prefix = self._formatter.colorize(
1383 prefix,
1384 default_color="msg/text:heading/section",
1385 )
1386 else:
1387 c_prefix = yuio.string.ColorizedString(
1388 [self._theme.get_color("msg/text:heading/section"), "usage: "]
1389 )
1391 if usage is not None:
1392 usage = _dedent(usage.strip())
1393 sh_usage_highlighter = yuio.md.SyntaxHighlighter.get_highlighter("sh-usage")
1395 c_usage = sh_usage_highlighter.highlight(
1396 self._theme,
1397 usage,
1398 ).percent_format({"prog": self._prog}, self._theme)
1399 else:
1400 c_usage = yuio.string.ColorizedString(
1401 [self._usage_prog_color, str(self._prog)]
1402 )
1403 c_usage_elems = yuio.string.ColorizedString()
1405 optionals: list[argparse.Action | argparse._MutuallyExclusiveGroup] = []
1406 positionals: list[argparse.Action | argparse._MutuallyExclusiveGroup] = []
1407 for action in actions:
1408 if action.option_strings:
1409 optionals.append(action)
1410 else:
1411 positionals.append(action)
1412 for group in groups:
1413 if len(group._group_actions) <= 1:
1414 continue
1415 for arr in [optionals, positionals]:
1416 try:
1417 start = arr.index(group._group_actions[0])
1418 except (ValueError, IndexError):
1419 continue
1420 else:
1421 end = start + len(group._group_actions)
1422 if arr[start:end] == group._group_actions:
1423 arr[start:end] = [group]
1425 has_omitted_usages = False
1426 sep = False
1427 for arr in optionals, positionals:
1428 for elem in arr:
1429 if isinstance(elem, argparse.Action):
1430 usage_settings = getattr(elem, "get_usage", lambda: True)()
1431 if usage_settings is yuio.GROUP:
1432 has_omitted_usages = True
1433 continue
1434 if (
1435 not usage_settings
1436 or elem.help == argparse.SUPPRESS
1437 or elem.metavar == argparse.SUPPRESS
1438 ):
1439 continue
1440 if sep:
1441 c_usage_elems += self._usage_main_color
1442 c_usage_elems += " "
1443 self._format_action_short(elem, c_usage_elems)
1444 sep = True
1445 elif elem._group_actions:
1446 group_actions = []
1447 for action in elem._group_actions:
1448 usage_settings = getattr(
1449 action, "get_usage", lambda: True
1450 )()
1451 if usage_settings is yuio.GROUP:
1452 has_omitted_usages = True
1453 elif (
1454 usage_settings
1455 and action.help != argparse.SUPPRESS
1456 and action.metavar != argparse.SUPPRESS
1457 ):
1458 group_actions.append(action)
1459 if not group_actions:
1460 continue
1461 if sep:
1462 c_usage_elems += self._usage_main_color
1463 c_usage_elems += " "
1464 if len(group_actions) == 1:
1465 self._format_action_short(group_actions[0], c_usage_elems)
1466 sep = True
1467 else:
1468 for i, action in enumerate(group_actions):
1469 if i == 0:
1470 c_usage_elems += self._usage_punct_color
1471 c_usage_elems += "(" if elem.required else "["
1472 self._format_action_short(
1473 action, c_usage_elems, in_group=True
1474 )
1475 if i + 1 < len(group_actions):
1476 c_usage_elems += self._usage_punct_color
1477 c_usage_elems += "|"
1478 else:
1479 c_usage_elems += self._usage_punct_color
1480 c_usage_elems += ")" if elem.required else "]"
1481 sep = True
1483 if has_omitted_usages:
1484 c_usage_elems_prev = c_usage_elems
1485 c_usage_elems = yuio.string.ColorizedString(
1486 [
1487 self._usage_punct_color,
1488 "[",
1489 self._usage_flag_color,
1490 "<options>",
1491 self._usage_punct_color,
1492 "]",
1493 ]
1494 )
1495 if c_usage_elems_prev:
1496 c_usage_elems += self._usage_main_color
1497 c_usage_elems += " "
1498 c_usage_elems += c_usage_elems_prev
1500 if c_usage_elems:
1501 c_usage += self._usage_main_color
1502 c_usage += " "
1503 c_usage += c_usage_elems
1505 self._nodes.append(
1506 _Usage(
1507 prefix=c_prefix,
1508 usage=c_usage,
1509 )
1510 )
1512 def add_argument(self, action: argparse.Action, indent: str = ""):
1513 if action.help != argparse.SUPPRESS:
1514 c_usage = yuio.string.ColorizedString()
1515 sep = False
1516 if not action.option_strings:
1517 self._format_action_metavar(action, 0, c_usage)
1518 for option_string in action.option_strings:
1519 if sep:
1520 c_usage += self._usage_punct_color
1521 c_usage += " "
1522 c_usage += self._usage_flag_color
1523 c_usage += option_string
1524 if action.nargs != 0:
1525 c_usage += self._usage_punct_color
1526 c_usage += " "
1527 self._format_action_metavar_expl(action, c_usage)
1528 sep = True
1530 if self._nodes and isinstance(self._nodes[-1], _HelpArgGroup):
1531 group = self._nodes[-1]
1532 else:
1533 group = _HelpArgGroup(items=[])
1534 self._nodes.append(group)
1535 group.items.append(
1536 _HelpArg(
1537 indent=indent,
1538 args=c_usage,
1539 help=self._formatter.parse(action.help) if action.help else None,
1540 )
1541 )
1543 arg_width = len(indent) + c_usage.width + 2
1544 if arg_width <= _MAX_ARGS_COLUMN_WIDTH:
1545 self._args_column_width = max(self._args_column_width, arg_width)
1547 try:
1548 get_subactions = action._get_subactions # type: ignore
1549 except AttributeError:
1550 pass
1551 else:
1552 self.add_arguments(get_subactions(), indent + " ")
1554 def add_arguments(self, actions, indent: str = ""):
1555 for action in actions:
1556 self.add_argument(action, indent)
1558 def format_help(self) -> str:
1559 self._formatter._args_column_width = self._args_column_width
1560 res = yuio.string.ColorizedString()
1561 for line in self._formatter.format_node(yuio.md.Document(items=self._nodes)):
1562 res += line
1563 res += "\n"
1564 res += yuio.color.Color()
1565 return "".join(res.process_colors(self._term.color_support))
1567 def _format_action_short(
1568 self,
1569 action: argparse.Action,
1570 out: yuio.string.ColorizedString,
1571 in_group: bool = False,
1572 ):
1573 if not in_group and not action.required:
1574 out += self._usage_punct_color
1575 out += "["
1577 if action.option_strings:
1578 out += self._usage_flag_color
1579 out += action.option_strings[0]
1580 if action.nargs != 0:
1581 out += self._usage_punct_color
1582 out += " "
1584 self._format_action_metavar_expl(action, out)
1586 if not in_group and not action.required:
1587 out += self._usage_punct_color
1588 out += "]"
1590 def _format_action_metavar_expl(
1591 self, action: argparse.Action, out: yuio.string.ColorizedString
1592 ):
1593 nargs = action.nargs if action.nargs is not None else 1
1595 if nargs == argparse.OPTIONAL:
1596 out += self._usage_punct_color
1597 out += "["
1598 self._format_action_metavar(action, 0, out)
1599 out += self._usage_punct_color
1600 out += "]"
1601 elif nargs == argparse.ZERO_OR_MORE:
1602 out += self._usage_punct_color
1603 out += "["
1604 self._format_action_metavar(action, 0, out)
1605 out += self._usage_punct_color
1606 out += " ...]"
1607 elif nargs == argparse.ONE_OR_MORE:
1608 self._format_action_metavar(action, 0, out)
1609 out += self._usage_punct_color
1610 out += " ["
1611 self._format_action_metavar(action, 1, out)
1612 out += self._usage_punct_color
1613 out += " ...]"
1614 elif nargs == argparse.REMAINDER:
1615 out += self._usage_main_color
1616 out += "..."
1617 elif nargs == argparse.PARSER:
1618 self._format_action_metavar(action, 1, out)
1619 out += self._usage_main_color
1620 out += " ..."
1621 elif isinstance(nargs, int):
1622 sep = False
1623 for i in range(nargs):
1624 if sep:
1625 out += self._usage_punct_color
1626 out += " "
1627 self._format_action_metavar(action, i, out)
1628 sep = True
1630 def _format_action_metavar(
1631 self, action: argparse.Action, n: int, out: yuio.string.ColorizedString
1632 ):
1633 metavar_t = action.metavar
1634 if not metavar_t and action.option_strings:
1635 metavar_t = f"<{action.option_strings[0]}>"
1636 if not metavar_t:
1637 metavar_t = "<value>"
1638 if isinstance(metavar_t, tuple):
1639 metavar = metavar_t[n] if n < len(metavar_t) else metavar_t[-1]
1640 else:
1641 metavar = metavar_t
1643 cur_color = None
1644 is_punctuation = False
1645 for part in re.split(r"((?:[{}()[\]\\;!&]|\s)+)", metavar):
1646 if is_punctuation and cur_color is not self._usage_punct_color:
1647 cur_color = self._usage_punct_color
1648 out += self._usage_punct_color
1649 elif not is_punctuation and cur_color is not self._usage_metavar_color:
1650 cur_color = self._usage_metavar_color
1651 out += self._usage_metavar_color
1652 out += part
1653 is_punctuation = not is_punctuation
1655 def _format_args(self, *_):
1656 # argparse calls this method sometimes
1657 # to check if given metavar is valid or not (TODO!)
1658 pass
1660 def _set_color(self, *_):
1661 pass
1663 def _expand_help(self, *_):
1664 pass