Coverage for yuio / app.py: 93%
308 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
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.. autofunction:: inline
87.. autofunction:: positional
90Creating argument groups
91------------------------
93You can use :class:`~yuio.config.Config` as a type of an app function's parameter.
94This will make all of config fields into flags as well. By default, Yuio will use
95parameter name as a prefix for all fields in the config; you can override it
96with :func:`field` or :func:`inline`:
98.. code-block:: python
100 class KillCmdConfig(yuio.config.Config):
101 # Will be loaded from `--signal`.
102 signal: int
104 # Will be loaded from `-p` or `--pid`.
105 pid: int = field(flags=["-p", "--pid"])
108 @app
109 def main(
110 # `kill_cmd.signal` will be loaded from `--kill-cmd-signal`.
111 kill_cmd: KillCmdConfig,
112 # `copy_cmd_2.signal` will be loaded from `--kill-signal`.
113 kill_cmd_2: KillCmdConfig = field(flags="--kill"),
114 # `kill_cmd_3.signal` will be loaded from `--signal`.
115 kill_cmd_3: KillCmdConfig = field(flags=""),
116 ): ...
118.. note::
120 Positional arguments are not allowed in configs,
121 only in apps.
124App settings
125------------
127You can override default usage and help messages as well as control some of the app's
128help formatting using its arguments:
130.. class:: App
131 :noindex:
133 .. autoattribute:: prog
135 .. autoattribute:: usage
137 .. autoattribute:: description
139 .. autoattribute:: help
141 .. autoattribute:: epilog
143 .. autoattribute:: allow_abbrev
145 .. autoattribute:: subcommand_required
147 .. autoattribute:: setup_logging
149 .. autoattribute:: theme
151 .. autoattribute:: version
153 .. autoattribute:: bug_report
155 .. autoattribute:: is_dev_mode
158Creating sub-commands
159---------------------
161You can create multiple sub-commands for the main function
162using the :meth:`App.subcommand` method:
164.. code-block:: python
166 @app
167 def main(): ...
170 @main.subcommand
171 def do_stuff(): ...
173There is no limit to how deep you can nest subcommands, but for usability reasons
174we suggest not exceeding level of sub-sub-commands (:flag:`git stash push`, anyone?)
176When user invokes a subcommand, the ``main()`` function is called first,
177then subcommand. In the above example, invoking our app with subcommand ``push``
178will cause ``main()`` to be called first, then ``push()``.
180This behavior is useful when you have some global configuration flags
181attached to the ``main()`` command. See the `example app`_ for details.
183.. _example app: https://github.com/taminomara/yuio/blob/main/examples/app
185.. class:: App
186 :noindex:
188 .. automethod:: subcommand
191.. _sub-commands-more:
193Controlling how sub-commands are invoked
194----------------------------------------
196By default, if a command has sub-commands, the user is required to provide
197a sub-command. This behavior can be disabled by setting :attr:`App.subcommand_required`
198to :data:`False`.
200When this happens, we need to understand whether a subcommand was invoked or not.
201To determine this, you can accept a special parameter called `_command_info`
202of type :class:`CommandInfo`. It will contain info about the current function,
203including its name and subcommand:
205.. code-block:: python
207 @app
208 def main(_command_info: CommandInfo):
209 if _command_info.subcommand is not None:
210 # A subcommand was invoked.
211 ...
213You can call the subcommand on your own by using ``_command_info.subcommand``
214as a callable:
216.. code-block:: python
218 @app
219 def main(_command_info: CommandInfo):
220 if _command_info.subcommand is not None and ...:
221 _command_info.subcommand() # manually invoking a subcommand
223If you wish to disable calling the subcommand, you can return :data:`False`
224from the main function:
226.. code-block:: python
228 @app
229 def main(_command_info: CommandInfo):
230 ...
231 # Subcommand will not be invoked.
232 return False
234.. autoclass:: CommandInfo
235 :members:
238.. _flags-with-multiple-values:
240Handling options with multiple values
241-------------------------------------
243When you create an option with a container type, Yuio enables passing its values
244by specifying multiple arguments. For example:
246.. code-block:: python
248 @yuio.app.app
249 def main(list: list[int]):
250 print(list)
252Here, you can pass values to :flag:`--list` as separate arguments:
254.. code-block:: console
256 $ app --list 1 2 3
257 [1, 2, 3]
259If you specify value for :flag:`--list` inline, it will be handled as
260a delimiter-separated list:
262.. code-block:: console
264 $ app --list='1 2 3'
265 [1, 2, 3]
267This allows resolving ambiguities between flags and positional arguments:
269.. code-block:: console
271 $ app --list='1 2 3' subcommand
273Technically, :flag:`--list 1 2 3` causes Yuio to invoke
274``list_parser.parse_many(["1", "2", "3"])``, while :flag:`--list='1 2 3'` causes Yuio
275to invoke ``list_parser.parse("1 2 3")``.
278.. _flags-with-optional-values:
280Handling flags with optional values
281-----------------------------------
283When designing a CLI, one important question is how to handle flags with optional
284values, if at all. There are several things to consider:
2861. Does a flag have clear and predictable behavior when its value is not specified?
288 For boolean flags the default behavior is obvious: :flag:`--use-gpu` will enable
289 GPU, i.e. it is equivalent to :flag:`--use-gpu=true`.
291 For flags that accept non-boolean values, though, things get messier. What will
292 a flag like :flag:`--n-threads` do? Will it calculate number of threads based on
293 available CPU cores? Will it use some default value?
295 In these cases, it is usually better to require a sentinel value:
296 :flag:`--n-threads=auto`.
2982. Where should flag's value go, it it's provided?
300 We can only allow passing value inline, i.e. :flag:`--use-gpu=true`. Or we can
301 greedily take the following argument as flag's value, i.e. :flag:`--use-gpu true`.
303 The later approach has a significant downside: we don't know
304 whether the next argument was intended for the flag or for a free-standing option.
306 For example:
308 .. code-block:: console
310 $ my-renderer --color true # is `true` meant for `--color`,
311 $ # or is it a subcommand for `my-renderer`?
313Here's how Yuio handles this dilemma:
3151. High level API does not allow creating flags with optional values.
317 To create one, you have to make a custom implementation of :class:`yuio.cli.Option`
318 and set its :attr:`~yuio.cli.Option.allow_no_args` to :data:`True`. This will
319 correspond to the greedy approach.
321 .. note::
323 Positionals with defaults are treated as optional because they don't
324 create ambiguities.
3262. Boolean flags allow specifying value inline, but not as a separate argument.
3283. Yuio does not allow passing inline values to short boolean flags
329 without adding an equals sign. For example, :flag:`-ftrue` will not work,
330 while :flag:`-f=true` will.
332 This is done to enable grouping short flags: :flag:`ls -laH` should be parsed
333 as :flag:`ls -l -a -H`, not as :flag:`ls -l=aH`.
3354. On lower levels of API, Yuio allows precise control over these behavior
336 by setting :attr:`Option.nargs <yuio.cli.Option.nargs>`,
337 :attr:`Option.allow_no_args <yuio.cli.Option.allow_no_args>`,
338 :attr:`Option.allow_inline_arg <yuio.cli.Option.allow_inline_arg>`,
339 and :attr:`Option.allow_implicit_inline_arg <yuio.cli.Option.allow_implicit_inline_arg>`.
342.. _custom-cli-options:
344Creating custom CLI options
345---------------------------
347You can override default behavior and presentation of a CLI option by passing
348custom `option_ctor` to :func:`field`. Furthermore, you can create your own
349implementation of :class:`yuio.cli.Option` to further fine-tune how an option
350is parsed, presented in CLI help, etc.
352.. autofunction:: bool_option
354.. autofunction:: parse_one_option
356.. autofunction:: parse_many_option
358.. autofunction:: store_const_option
360.. autofunction:: count_option
362.. autofunction:: store_true_option
364.. autofunction:: store_false_option
366.. type:: OptionCtor
367 :canonical: typing.Callable[[OptionSettings], yuio.cli.Option[T]]
369 CLI option constructor. Takes a single positional argument
370 of type :class:`OptionSettings`, and returns an instance
371 of :class:`yuio.cli.Option`.
373.. autoclass:: OptionSettings
374 :members:
377Re-imports
378----------
380.. type:: HelpGroup
381 :no-index:
383 Alias of :obj:`yuio.cli.HelpGroup`.
385.. type:: MutuallyExclusiveGroup
386 :no-index:
388 Alias of :obj:`yuio.cli.MutuallyExclusiveGroup`.
390.. data:: MISC_GROUP
391 :no-index:
393 Alias of :obj:`yuio.cli.MISC_GROUP`.
395.. data:: OPTS_GROUP
396 :no-index:
398 Alias of :obj:`yuio.cli.OPTS_GROUP`.
400.. data:: SUBCOMMANDS_GROUP
401 :no-index:
403 Alias of :obj:`yuio.cli.SUBCOMMANDS_GROUP`.
405"""
407from __future__ import annotations
409import dataclasses
410import functools
411import inspect
412import json
413import logging
414import pathlib
415import sys
416import types
417from dataclasses import dataclass
419import yuio
420import yuio.cli
421import yuio.complete
422import yuio.config
423import yuio.dbg
424import yuio.io
425import yuio.parse
426import yuio.string
427import yuio.term
428import yuio.theme
429import yuio.util
430from yuio.cli import (
431 MISC_GROUP,
432 OPTS_GROUP,
433 SUBCOMMANDS_GROUP,
434 HelpGroup,
435 MutuallyExclusiveGroup,
436)
437from yuio.config import (
438 OptionCtor,
439 OptionSettings,
440 bool_option,
441 count_option,
442 field,
443 inline,
444 parse_many_option,
445 parse_one_option,
446 positional,
447 store_const_option,
448 store_false_option,
449 store_true_option,
450)
451from yuio.util import _find_docs
452from yuio.util import to_dash_case as _to_dash_case
454from typing import TYPE_CHECKING
456if TYPE_CHECKING:
457 import typing_extensions as _t
458else:
459 from yuio import _typing as _t
461__all__ = [
462 "MISC_GROUP",
463 "OPTS_GROUP",
464 "SUBCOMMANDS_GROUP",
465 "App",
466 "AppError",
467 "CommandInfo",
468 "HelpGroup",
469 "MutuallyExclusiveGroup",
470 "OptionCtor",
471 "OptionSettings",
472 "app",
473 "bool_option",
474 "count_option",
475 "field",
476 "inline",
477 "parse_many_option",
478 "parse_one_option",
479 "positional",
480 "store_const_option",
481 "store_false_option",
482 "store_true_option",
483]
485C = _t.TypeVar("C", bound=_t.Callable[..., None | bool])
486C2 = _t.TypeVar("C2", bound=_t.Callable[..., None | bool])
489class AppError(yuio.PrettyException, Exception):
490 """
491 An error that you can throw from an app to finish its execution without printing
492 a traceback.
494 """
497@_t.overload
498def app(
499 *,
500 prog: str | None = None,
501 usage: str | None = None,
502 description: str | None = None,
503 epilog: str | None = None,
504 version: str | None = None,
505 bug_report: yuio.dbg.ReportSettings | bool = False,
506 is_dev_mode: bool | None = None,
507) -> _t.Callable[[C], App[C]]: ...
508@_t.overload
509def app(
510 command: C,
511 /,
512 *,
513 prog: str | None = None,
514 usage: str | None = None,
515 description: str | None = None,
516 epilog: str | None = None,
517 version: str | None = None,
518 bug_report: yuio.dbg.ReportSettings | bool = False,
519 is_dev_mode: bool | None = None,
520) -> App[C]: ...
521def app(
522 command: _t.Callable[..., None | bool] | None = None,
523 /,
524 *,
525 prog: str | None = None,
526 usage: str | None = None,
527 description: str | None = None,
528 epilog: str | None = None,
529 allow_abbrev: bool = False,
530 subcommand_required: bool = True,
531 setup_logging: bool = True,
532 theme: (
533 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
534 ) = None,
535 version: str | None = None,
536 bug_report: yuio.dbg.ReportSettings | bool = False,
537 is_dev_mode: bool | None = None,
538) -> _t.Any:
539 """
540 Create an application.
542 This is a decorator that's supposed to be used on the main method
543 of the application. This decorator returns an :class:`App` object.
545 :param command:
546 the main function of the application.
547 :param prog:
548 overrides program's name, see :attr:`App.prog`.
549 :param usage:
550 overrides program's usage description, see :attr:`App.usage`.
551 :param description:
552 overrides program's description, see :attr:`App.description`.
553 :param epilog:
554 overrides program's epilog, see :attr:`App.epilog`.
555 :param allow_abbrev:
556 whether to allow abbreviating unambiguous flags, see :attr:`App.allow_abbrev`.
557 :param subcommand_required:
558 whether this app requires a subcommand,
559 see :attr:`App.subcommand_required`.
560 :param setup_logging:
561 whether to perform basic logging setup on startup,
562 see :attr:`App.setup_logging`.
563 :param theme:
564 overrides theme that will be used when setting up :mod:`yuio.io`,
565 see :attr:`App.theme`.
566 :param version:
567 program's version, will be displayed using the :flag:`--version` flag.
568 :param bug_report:
569 settings for automated bug report generation. If present,
570 adds the :flag:`--bug-report` flag.
571 :param is_dev_mode:
572 enables additional logging, see :attr:`App.is_dev_mode`.
573 :returns:
574 an :class:`App` object that wraps the original function.
576 """
578 def registrar(command: C, /) -> App[C]:
579 return App(
580 command,
581 prog=prog,
582 usage=usage,
583 description=description,
584 epilog=epilog,
585 allow_abbrev=allow_abbrev,
586 subcommand_required=subcommand_required,
587 setup_logging=setup_logging,
588 theme=theme,
589 version=version,
590 bug_report=bug_report,
591 is_dev_mode=is_dev_mode,
592 )
594 if command is None:
595 return registrar
596 else:
597 return registrar(command)
600@_t.final
601@dataclass(frozen=True, eq=False, match_args=False, slots=True)
602class CommandInfo:
603 """
604 Data about the invoked command.
606 """
608 name: str
609 """
610 Name of the current command.
612 If it was invoked by alias,
613 this will contains the primary command name.
615 For the main function, the name will be set to ``"__main__"``.
617 """
619 # Internal, do not use.
620 _config: _t.Any = dataclasses.field(repr=False)
621 _executed: bool = dataclasses.field(default=False, repr=False)
622 _subcommand: CommandInfo | None | yuio.Missing = dataclasses.field(
623 default=yuio.MISSING, repr=False
624 )
626 @property
627 def subcommand(self) -> CommandInfo | None:
628 """
629 Subcommand of this command, if one was given.
631 """
633 if self._subcommand is yuio.MISSING:
634 if self._config._subcommand is None:
635 subcommand = None
636 else:
637 subcommand = CommandInfo(
638 self._config._subcommand, self._config._subcommand_ns.config
639 )
640 object.__setattr__(self, "_subcommand", subcommand)
641 return self._subcommand # pyright: ignore[reportReturnType]
643 def __call__(self) -> _t.Literal[False]:
644 """
645 Execute this command.
647 """
649 if self._executed:
650 return False
651 object.__setattr__(self, "_executed", True)
653 should_invoke_subcommand = self._config._run(self)
654 if should_invoke_subcommand is None:
655 should_invoke_subcommand = True
657 if should_invoke_subcommand and self.subcommand is not None:
658 self.subcommand()
660 return False
663class App(_t.Generic[C]):
664 """
665 A class that encapsulates app settings and logic for running it.
667 It is better to create instances of this class using the :func:`app` decorator,
668 as it provides means to decorate the main function and specify all of the app's
669 parameters.
671 """
673 @dataclass(frozen=True, eq=False, match_args=False, slots=True)
674 class _SubApp:
675 app: App[_t.Any]
676 name: str
677 aliases: list[str] | None = None
678 is_primary: bool = False
680 def __init__(
681 self,
682 command: C,
683 /,
684 *,
685 prog: str | None = None,
686 usage: str | None = None,
687 help: str | yuio.Disabled | None = None,
688 description: str | None = None,
689 epilog: str | None = None,
690 allow_abbrev: bool = False,
691 subcommand_required: bool = True,
692 setup_logging: bool = True,
693 theme: (
694 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
695 ) = None,
696 version: str | None = None,
697 bug_report: yuio.dbg.ReportSettings | bool = False,
698 is_dev_mode: bool | None = None,
699 ):
700 self.prog: str | None = prog
701 """
702 Program or subcommand's primary name.
704 For main app, this controls its display name and generation of shell completion
705 scripts.
707 For subcommands, this is always equal to subcommand's main name.
709 By default, inferred from :data:`sys.argv` and subcommand name.
711 """
713 self.usage: str | None = usage
714 """
715 Program or subcommand synapsis.
717 This string will be processed using the to ``bash`` syntax,
718 and then it will be ``%``-formatted with a single keyword argument ``prog``.
719 If command supports multiple signatures, each of them should be listed
720 on a separate string. For example::
722 @app
723 def main(): ...
725 main.usage = \"""
726 %(prog)s [-q] [-f] [-m] [<branch>]
727 %(prog)s [-q] [-f] [-m] --detach [<branch>]
728 %(prog)s [-q] [-f] [-m] [--detach] <commit>
729 ...
730 \"""
732 By default, usage is generated from CLI flags.
734 See `usage <https://docs.python.org/3/library/argparse.html#usage>`_
735 in :mod:`argparse`.
737 """
739 if not description and command.__doc__:
740 description = yuio.util._process_docstring(
741 command.__doc__, only_first_paragraph=False
742 )
744 self.description: str | None = description
745 """
746 Text that is shown before CLI flags help, usually contains
747 short description of the program or subcommand.
749 The text should be formatted using markdown. For example:
751 .. code-block:: python
753 @app
754 def main(): ...
756 main.description = \"""
757 This command does a thing.
759 # Different ways to do a thing:
761 This command can apply multiple algorithms to achieve
762 a necessary state in which a thing can be done. This includes:
764 - randomly turning the screen on and off;
766 - banging a head on a table;
768 - fiddling with your PCs power cord.
770 By default, the best algorithm is determined automatically.
771 However, you can hint a preferred algorithm via the `--hint-algo` flag.
773 \"""
775 By default, inferred from command's docstring.
777 """
779 if help is None and description:
780 help = description
781 if (index := help.find("\n\n")) != -1:
782 help = help[:index]
784 self.help: str | yuio.Disabled | None = help
785 """
786 Short help message that is shown when listing subcommands.
788 By default, uses first paragraph of description.
790 """
792 self.epilog: str | None = epilog
793 """
794 Text that is shown after the main portion of the help message.
796 Text format is identical to the one for :attr:`~App.description`.
798 """
800 self.allow_abbrev: bool = allow_abbrev
801 """
802 Allow abbreviating CLI flags if that doesn't create ambiguity.
804 Disabled by default.
806 """
808 self.subcommand_required: bool = subcommand_required
809 """
810 Require the user to provide a subcommand for this command.
812 If this command doesn't have any subcommands, this option is ignored.
814 Enabled by default.
816 """
818 self.setup_logging: bool = setup_logging
819 """
820 If :data:`True`, the app will call :func:`logging.basicConfig` during
821 its initialization. Disable this if you want to customize
822 logging initialization.
824 Disabling this option also removes the :flag:`--verbose` flag form the CLI.
826 """
828 self.theme: (
829 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
830 ) = theme
831 """
832 A custom theme that will be passed to :func:`yuio.io.setup`
833 on application startup.
835 """
837 self.version: str | None = version
838 """
839 If not :data:`None`, add :flag:`--version` flag to the CLI.
841 """
843 self.bug_report: yuio.dbg.ReportSettings | bool = bug_report
844 """
845 If not :data:`False`, add :flag:`--bug-report` flag to the CLI.
847 This flag automatically collects data about environment and prints it
848 in a format suitable for adding to a bug report.
850 """
852 self.is_dev_mode: bool | None = is_dev_mode
853 """
854 If :data:`True`, this will enable :func:`logging.captureWarnings`
855 and configure internal Yuio logging to show warnings.
857 By default, dev mode is detected by checking if :attr:`~App.version`
858 contains substring ``"dev"``.
860 .. note::
862 You can always enable full debug logging by setting environment
863 variable ``YUIO_DEBUG``.
865 If enabled, full log will be saved to ``YUIO_DEBUG_FILE``.
867 """
869 self.__sub_apps: dict[str, App._SubApp] = {}
871 if callable(command):
872 self.__config_type = _command_from_callable(command)
873 else:
874 raise TypeError(f"expected a function, got {command}")
876 functools.update_wrapper(
877 self, # type: ignore
878 command,
879 assigned=("__module__", "__name__", "__qualname__", "__doc__"),
880 updated=(),
881 )
883 self._command = command
885 @functools.wraps(command)
886 def wrapped_command(*args, **kwargs):
887 if args:
888 names = self.__config_type.__annotations__
889 if len(args) > len(names):
890 s = "" if len(names) == 1 else "s"
891 raise TypeError(
892 f"expected at most {len(names)} positional argument{s}, got {len(args)}"
893 )
894 for arg, name in zip(args, names):
895 if name in kwargs:
896 raise TypeError(f"argument {name} was given twice")
897 kwargs[name] = arg
898 return CommandInfo("__raw__", self.__config_type(**kwargs), False)()
900 self.wrapped: C = wrapped_command # type: ignore
901 """
902 The original callable what was wrapped by :func:`app`.
904 """
906 @_t.overload
907 def subcommand(
908 self,
909 /,
910 *,
911 name: str | None = None,
912 aliases: list[str] | None = None,
913 usage: str | None = None,
914 help: str | yuio.Disabled | None = None,
915 description: str | None = None,
916 epilog: str | None = None,
917 ) -> _t.Callable[[C2], App[C2]]: ...
919 @_t.overload
920 def subcommand(
921 self,
922 cb: C2,
923 /,
924 *,
925 name: str | None = None,
926 aliases: list[str] | None = None,
927 usage: str | None = None,
928 help: str | yuio.Disabled | None = None,
929 description: str | None = None,
930 epilog: str | None = None,
931 ) -> App[C2]: ...
933 def subcommand(
934 self,
935 cb: _t.Callable[..., None | bool] | None = None,
936 /,
937 *,
938 name: str | None = None,
939 aliases: list[str] | None = None,
940 usage: str | None = None,
941 help: str | yuio.Disabled | None = None,
942 description: str | None = None,
943 epilog: str | None = None,
944 subcommand_required: bool = True,
945 ) -> _t.Any:
946 """
947 Register a subcommand for the given app.
949 This method can be used as a decorator, similar to the :func:`app` function.
951 :param name:
952 allows overriding subcommand's name.
953 :param aliases:
954 allows adding alias names for subcommand.
955 :param usage:
956 overrides subcommand's usage description, see :attr:`App.usage`.
957 :param help:
958 overrides subcommand's short help, see :attr:`App.help`.
959 pass :data:`~yuio.DISABLED` to hide this subcommand in CLI help message.
960 :param description:
961 overrides subcommand's description, see :attr:`App.description`.
962 :param epilog:
963 overrides subcommand's epilog, see :attr:`App.epilog`.
964 :param subcommand_required:
965 whether this subcommand requires another subcommand,
966 see :attr:`App.subcommand_required`.
967 :returns:
968 a new :class:`App` object for a subcommand.
970 """
972 def registrar(cb: C2, /) -> App[C2]:
973 main_name = name or _to_dash_case(cb.__name__)
974 app = App(
975 cb,
976 prog=main_name,
977 usage=usage,
978 help=help,
979 description=description,
980 epilog=epilog,
981 subcommand_required=subcommand_required,
982 )
984 self.__sub_apps[main_name] = App._SubApp(
985 app, main_name, aliases, is_primary=True
986 )
987 if aliases:
988 alias_app = App._SubApp(app, main_name)
989 self.__sub_apps.update({alias: alias_app for alias in aliases})
991 return app
993 if cb is None:
994 return registrar
995 else:
996 return registrar(cb)
998 def run(self, args: list[str] | None = None) -> _t.NoReturn:
999 """
1000 Parse arguments, set up :mod:`yuio.io` and :mod:`logging`,
1001 and run the application.
1003 :param args:
1004 command line arguments. If none are given,
1005 use arguments from :data:`sys.argv`.
1006 :returns:
1007 this method does not return, it exits the program instead.
1009 """
1011 if args is None:
1012 args = sys.argv[1:]
1014 if "--yuio-custom-completer--" in args:
1015 index = args.index("--yuio-custom-completer--")
1016 _run_custom_completer(
1017 self.__make_cli_command(root=True), args[index + 1], args[index + 2]
1018 )
1019 sys.exit(0)
1021 if "--yuio-bug-report--" in args:
1022 from yuio.dbg import print_report
1024 print_report(settings=self.bug_report, app=self)
1025 sys.exit(0)
1027 yuio.io.setup(theme=self.theme, wrap_stdio=True)
1029 try:
1030 if self.is_dev_mode is None:
1031 self.is_dev_mode = (
1032 self.version is not None and "dev" in self.version.casefold()
1033 )
1034 if self.is_dev_mode:
1035 yuio.enable_internal_logging(add_handler=True)
1037 cli_command = self.__make_cli_command(root=True)
1038 namespace = yuio.cli.CliParser(cli_command).parse(args)
1040 if self.setup_logging:
1041 logging_level = {
1042 0: logging.WARNING,
1043 1: logging.INFO,
1044 2: logging.DEBUG,
1045 }.get(namespace["_verbosity"], logging.DEBUG)
1046 logging.basicConfig(handlers=[yuio.io.Handler()], level=logging_level)
1048 command = CommandInfo("__main__", _config=namespace.config)
1049 command()
1050 sys.exit(0)
1051 except yuio.cli.ArgumentError as e:
1052 yuio.io.raw(e, add_newline=True)
1053 sys.exit(1)
1054 except (AppError, yuio.cli.ArgumentError, yuio.parse.ParsingError) as e:
1055 yuio.io.failure(e)
1056 sys.exit(1)
1057 except KeyboardInterrupt:
1058 yuio.io.failure("Received Keyboard Interrupt, stopping now")
1059 sys.exit(130)
1060 except Exception as e:
1061 yuio.io.failure_with_tb("Error: %s", e)
1062 sys.exit(3)
1063 finally:
1064 yuio.io.restore_streams()
1066 def __make_cli_command(self, root: bool = False):
1067 options = self.__config_type._build_options()
1069 if root:
1070 options.append(yuio.cli.HelpOption())
1071 if self.version:
1072 options.append(yuio.cli.VersionOption(version=self.version))
1073 if self.setup_logging:
1074 options.append(
1075 yuio.cli.CountOption(
1076 flags=["-v", "--verbose"],
1077 usage=yuio.GROUP,
1078 help="Increase output verbosity.",
1079 help_group=yuio.cli.MISC_GROUP,
1080 show_if_inherited=False,
1081 dest="_verbosity",
1082 )
1083 )
1084 if self.bug_report:
1085 options.append(yuio.cli.BugReportOption(app=self))
1086 options.append(yuio.cli.CompletionOption())
1087 options.append(_ColorOption())
1089 subcommands = {}
1090 for sub_app in self.__sub_apps.values():
1091 if not sub_app.is_primary:
1092 continue
1093 aubcommand = sub_app.app.__make_cli_command()
1094 subcommands[sub_app.name] = aubcommand
1095 for alias in sub_app.aliases or []:
1096 subcommands[alias] = aubcommand
1098 return yuio.cli.Command(
1099 name=self.prog or pathlib.Path(sys.argv[0]).stem,
1100 desc=self.description or "",
1101 help=self.help if self.help is not None else "",
1102 epilog=self.epilog or "",
1103 usage=self.usage,
1104 options=options,
1105 subcommands=subcommands,
1106 subcommand_required=self.subcommand_required,
1107 ns_ctor=lambda: yuio.cli.ConfigNamespace(self.__config_type()),
1108 dest="_subcommand",
1109 ns_dest="_subcommand_ns",
1110 )
1113def _command_from_callable(
1114 cb: _t.Callable[..., None | bool],
1115) -> type[yuio.config.Config]:
1116 sig = inspect.signature(cb)
1118 dct = {}
1119 annotations = {}
1121 accepts_command_info = False
1123 try:
1124 docs = _find_docs(cb)
1125 except Exception:
1126 yuio._logger.warning(
1127 "unable to get documentation for %s.%s",
1128 cb.__module__,
1129 cb.__qualname__,
1130 )
1131 docs = {}
1133 for name, param in sig.parameters.items():
1134 if param.kind not in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY):
1135 raise TypeError("positional-only and variadic arguments are not supported")
1137 if name.startswith("_"):
1138 if name == "_command_info":
1139 accepts_command_info = True
1140 continue
1141 else:
1142 raise TypeError(f"unknown special param {name}")
1144 if param.default is not param.empty:
1145 field = param.default
1146 else:
1147 field = yuio.MISSING
1148 if not isinstance(field, yuio.config._FieldSettings):
1149 field = _t.cast(
1150 yuio.config._FieldSettings, yuio.config.field(default=field)
1151 )
1152 if name in docs:
1153 field = dataclasses.replace(field, help=docs[name])
1155 if param.annotation is param.empty:
1156 raise TypeError(f"param {name} requires type annotation")
1158 dct[name] = field
1159 annotations[name] = param.annotation
1161 dct["_run"] = _command_from_callable_run_impl(
1162 cb, list(annotations.keys()), accepts_command_info
1163 )
1164 dct["_color"] = None
1165 dct["_verbosity"] = 0
1166 dct["_subcommand"] = None
1167 dct["_subcommand_ns"] = None
1168 dct["__annotations__"] = annotations
1169 dct["__module__"] = getattr(cb, "__module__", None)
1170 dct["__doc__"] = getattr(cb, "__doc__", None)
1172 return types.new_class(
1173 cb.__name__,
1174 (yuio.config.Config,),
1175 {"_allow_positionals": True},
1176 exec_body=lambda ns: ns.update(dct),
1177 )
1180def _command_from_callable_run_impl(
1181 cb: _t.Callable[..., None | bool], params: list[str], accepts_command_info
1182):
1183 def run(self, command_info):
1184 kw = {name: getattr(self, name) for name in params}
1185 if accepts_command_info:
1186 kw["_command_info"] = command_info
1187 return cb(**kw)
1189 return run
1192def _run_custom_completer(command: yuio.cli.Command[_t.Any], raw_data: str, word: str):
1193 data = json.loads(raw_data)
1194 path: str = data["path"]
1195 flags: set[str] = set(data["flags"])
1196 index: int = data["index"]
1198 root = command
1199 for name in path.split("/"):
1200 if not name:
1201 continue
1202 if name not in command.subcommands:
1203 return
1204 root = command.subcommands[name]
1206 positional_index = 0
1207 for option in root.options:
1208 option_flags = option.flags
1209 if option_flags is yuio.POSITIONAL:
1210 option_flags = [str(positional_index)]
1211 positional_index += 1
1212 if flags.intersection(option_flags):
1213 completer, is_many = option.get_completer()
1214 break
1215 else:
1216 completer, is_many = None, False
1218 if completer:
1219 yuio.complete._run_completer_at_index(completer, is_many, index, word)
1222@dataclass(eq=False, kw_only=True)
1223class _ColorOption(yuio.cli.Option[_t.Never]):
1224 # `yuio.term` will scan `sys.argv` on its own, this option just checks format
1225 # and adds help entry.
1227 _ALLOWED_VALUES = (
1228 "y",
1229 "yes",
1230 "true",
1231 "1",
1232 "n",
1233 "no",
1234 "false",
1235 "0",
1236 "ansi",
1237 "ansi-256",
1238 "ansi-true",
1239 )
1241 _PUBLIC_VALUES = (
1242 ("true", "3-bit colors or higher"),
1243 ("false", "disable colors"),
1244 ("ansi", "force 3-bit colors"),
1245 ("ansi-256", "force 8-bit colors"),
1246 ("ansi-true", "force 24-bit colors"),
1247 )
1249 def __init__(self):
1250 super().__init__(
1251 flags=["--color", "--no-color"],
1252 allow_inline_arg=True,
1253 allow_implicit_inline_arg=True,
1254 nargs=0,
1255 allow_no_args=True,
1256 required=False,
1257 metavar=(),
1258 mutex_group=None,
1259 usage=yuio.GROUP,
1260 help="Enable or disable ANSI colors.",
1261 help_group=yuio.cli.MISC_GROUP,
1262 show_if_inherited=False,
1263 allow_abbrev=False,
1264 )
1266 def process(
1267 self,
1268 cli_parser: yuio.cli.CliParser[yuio.cli.Namespace],
1269 flag: yuio.cli.Flag | None,
1270 arguments: yuio.cli.Argument | list[yuio.cli.Argument],
1271 ns: yuio.cli.Namespace,
1272 ):
1273 if isinstance(arguments, yuio.cli.Argument):
1274 if flag and flag.value == "--no-color":
1275 raise yuio.cli.ArgumentError(
1276 "This flag can't have arguments", flag=flag, arguments=arguments
1277 )
1278 if arguments.value.casefold() not in self._ALLOWED_VALUES:
1279 raise yuio.cli.ArgumentError(
1280 "Can't parse `%r` as color, should be %s",
1281 arguments.value,
1282 yuio.string.Or(value for value, _ in self._PUBLIC_VALUES),
1283 flag=flag,
1284 arguments=arguments,
1285 )
1287 @functools.cached_property
1288 def primary_short_flag(self):
1289 return None
1291 @functools.cached_property
1292 def primary_long_flags(self):
1293 return ["--color", "--no-color"]
1295 def format_alias_flags(
1296 self,
1297 ctx: yuio.string.ReprContext,
1298 /,
1299 *,
1300 all: bool = False,
1301 ) -> list[yuio.string.ColorizedString] | None:
1302 if self.flags is yuio.POSITIONAL:
1303 return None
1305 primary_flags = set(self.primary_long_flags or [])
1306 if self.primary_short_flag:
1307 primary_flags.add(self.primary_short_flag)
1309 aliases: list[yuio.string.ColorizedString] = []
1310 flag_color = ctx.get_color("hl/flag:sh-usage")
1311 punct_color = ctx.get_color("hl/punct:sh-usage")
1312 metavar_color = ctx.get_color("hl/metavar:sh-usage")
1313 res = yuio.string.ColorizedString()
1314 res.start_no_wrap()
1315 res.append_color(flag_color)
1316 res.append_str("--color")
1317 res.end_no_wrap()
1318 res.append_color(punct_color)
1319 res.append_str("={")
1320 sep = False
1321 for value, _ in self._PUBLIC_VALUES:
1322 if sep:
1323 res.append_color(punct_color)
1324 res.append_str("|")
1325 res.append_color(metavar_color)
1326 res.append_str(value)
1327 sep = True
1328 res.append_color(punct_color)
1329 res.append_str("}")
1330 aliases.append(res)
1331 return aliases
1333 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1334 return yuio.complete.Choice(
1335 [
1336 yuio.complete.Option(value, comment)
1337 for value, comment in self._PUBLIC_VALUES
1338 ]
1339 ), False