Coverage for yuio / app.py: 88%
438 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"""
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, # [1]_
24 /,
25 *,
26 #: help message for `--flag`
27 flag: int = 0 # [2]_
28 ):
29 \"""this command does a thing\"""
30 yuio.io.info("flag=%r, arg=%r", flag, arg)
32 if __name__ == "__main__":
33 # We can now use `main.run` to parse arguments and invoke `main`.
34 # Notice that `run` does not return anything. Instead, it terminates
35 # python process with an appropriate exit code.
36 main.run("--flag 10 foobar!".split())
38.. code-annotations::
40 1. Positional-only arguments become positional CLI options.
41 2. Other arguments become CLI flags.
43Function's arguments will become program's flags and positionals, and function's
44docstring will become app's :attr:`~App.description`.
46Help messages for the flags are parsed from line comments
47right above the field definition (comments must start with ``#:``).
48They are all formatted using Markdown or RST depending on :attr:`App.doc_format`.
50Parsers for CLI argument values are derived from type hints.
51Use the `parser` parameter of the :func:`field` function to override them.
53Arguments with bool parsers and parsers that support
54:meth:`parsing collections <yuio.parse.Parser.supports_parse_many>`
55are handled to provide better CLI experience:
57.. invisible-code-block: python
59 import pathlib
61.. code-block:: python
63 @app
64 def main(
65 # Will create flags `--verbose` and `--no-verbose`.
66 # Since default is `False`, `--no-verbose` will be hidden from help
67 # to reduce clutter.
68 verbose: bool = False,
69 # Will create a flag with `nargs=*`: `--inputs path1 path2 ...`
70 inputs: list[pathlib.Path] = [],
71 ): ...
73.. autofunction:: app
75.. autoclass:: App
77 .. automethod:: run
79 .. method:: wrapped(...)
81 The original callable what was wrapped by :func:`app`.
84Configuring CLI arguments
85-------------------------
87Names and types of arguments are determined by names and types of the app function's
88arguments. You can use the :func:`field` function to override them:
90.. autofunction:: field
92.. autofunction:: inline
95Using configs in CLI
96--------------------
98You can use :class:`~yuio.config.Config` as a type of an app function's parameter.
99This will make all of config fields into flags as well. By default, Yuio will use
100parameter name as a prefix for all fields in the config; you can override it
101with :func:`field` or :func:`inline`:
103.. code-block:: python
105 class KillCmdConfig(yuio.config.Config):
106 signal: int
107 pid: int = field(flags=["-p", "--pid"])
110 @app
111 def main(
112 kill_cmd: KillCmdConfig, # [1]_
113 kill_cmd_2: KillCmdConfig = field(flags="--kill"), # [2]_
114 kill_cmd_3: KillCmdConfig = field(flags=""), # [3]_
115 ): ...
117.. code-annotations::
119 1. ``kill_cmd.signal`` will be loaded from :flag:`--kill-cmd-signal`.
120 2. ``copy_cmd_2.signal`` will be loaded from :flag:`--kill-signal`.
121 3. ``kill_cmd_3.signal`` will be loaded from :flag:`--signal`.
123.. note::
125 Positional arguments are not allowed in configs,
126 only in apps.
129App settings
130------------
132You can override default usage and help messages as well as control some of the app's
133help formatting using its arguments:
135.. class:: App
136 :noindex:
138 .. autoattribute:: prog
140 .. autoattribute:: usage
142 .. autoattribute:: description
144 .. autoattribute:: help
146 .. autoattribute:: epilog
148 .. autoattribute:: subcommand_required
150 .. autoattribute:: allow_abbrev
152 .. autoattribute:: setup_logging
154 .. autoattribute:: theme
156 .. autoattribute:: version
158 .. autoattribute:: bug_report
160 .. autoattribute:: is_dev_mode
162 .. autoattribute:: doc_format
165Creating sub-commands
166---------------------
168You can create multiple sub-commands for the main function
169using the :meth:`App.subcommand` method:
171.. code-block:: python
173 @app
174 def main(): ...
177 @main.subcommand
178 def do_stuff(): ...
180There is no limit to how deep you can nest subcommands, but for usability reasons
181we suggest not exceeding level of sub-sub-commands (:flag:`git stash push`, anyone?)
183When user invokes a subcommand, the ``main()`` function is called first,
184then subcommand. In the above example, invoking our app with subcommand ``push``
185will cause ``main()`` to be called first, then ``push()``.
187This behavior is useful when you have some global configuration flags
188attached to the ``main()`` command. See the `example app`_ for details.
190.. _example app: https://github.com/taminomara/yuio/blob/main/examples/app
192.. class:: App
193 :noindex:
195 .. automethod:: subcommand
197 .. automethod:: lazy_subcommand
200.. _sub-commands-more:
202Controlling how sub-commands are invoked
203----------------------------------------
205By default, if a command has sub-commands, the user is required to provide
206a sub-command. This behavior can be disabled by setting :attr:`App.subcommand_required`
207to :data:`False`.
209When this happens, we need to understand whether a subcommand was invoked or not.
210To determine this, you can accept a special parameter called `_command_info`
211of type :class:`CommandInfo`. It will contain info about the current function,
212including its name and subcommand:
214.. code-block:: python
216 @app
217 def main(_command_info: CommandInfo):
218 if _command_info.subcommand is not None:
219 # A subcommand was invoked.
220 ...
222You can call the subcommand on your own by using ``_command_info.subcommand``
223as a callable:
225.. code-block:: python
227 @app
228 def main(_command_info: CommandInfo):
229 if _command_info.subcommand is not None and ...:
230 _command_info.subcommand() # manually invoking a subcommand
232If you wish to disable calling the subcommand, you can return :data:`False`
233from the main function:
235.. code-block:: python
237 @app
238 def main(_command_info: CommandInfo):
239 ...
240 # Subcommand will not be invoked.
241 return False
243.. autoclass:: CommandInfo
244 :members:
247.. _flags-with-multiple-values:
249Handling options with multiple values
250-------------------------------------
252When you create an option with a container type, Yuio enables passing its values
253by specifying multiple arguments. For example:
255.. code-block:: python
257 @yuio.app.app
258 def main(list: list[int]):
259 print(list)
261Here, you can pass values to :flag:`--list` as separate arguments:
263.. code-block:: console
265 $ app --list 1 2 3
266 [1, 2, 3]
268If you specify value for :flag:`--list` inline, it will be handled as
269a delimiter-separated list:
271.. code-block:: console
273 $ app --list='1 2 3'
274 [1, 2, 3]
276This allows resolving ambiguities between flags and positional arguments:
278.. code-block:: console
280 $ app --list='1 2 3' subcommand
282Technically, :flag:`--list 1 2 3` causes Yuio to invoke
283``list_parser.parse_many(["1", "2", "3"])``, while :flag:`--list='1 2 3'` causes Yuio
284to invoke ``list_parser.parse("1 2 3")``.
287.. _flags-with-optional-values:
289Handling flags with optional values
290-----------------------------------
292When designing a CLI, one important question is how to handle flags with optional
293values, if at all. There are several things to consider:
2951. Does a flag have clear and predictable behavior when its value is not specified?
297 For boolean flags the default behavior is obvious: :flag:`--use-gpu` will enable
298 GPU, i.e. it is equivalent to :flag:`--use-gpu=true`.
300 For flags that accept non-boolean values, though, things get messier. What will
301 a flag like :flag:`--n-threads` do? Will it calculate number of threads based on
302 available CPU cores? Will it use some default value?
304 In these cases, it is usually better to require a sentinel value:
305 :flag:`--n-threads=auto`.
3072. Where should flag's value go, it it's provided?
309 We can only allow passing value inline, i.e. :flag:`--use-gpu=true`. Or we can
310 greedily take the following argument as flag's value, i.e. :flag:`--use-gpu true`.
312 The later approach has a significant downside: we don't know
313 whether the next argument was intended for the flag or for a free-standing option.
315 For example:
317 .. code-block:: console
319 $ my-renderer --color true # is `true` meant for `--color`,
320 $ # or is it a subcommand for `my-renderer`?
322Here's how Yuio handles this dilemma:
3241. High level API does not allow creating flags with optional values.
326 To create one, you have to make a custom implementation of :class:`yuio.cli.Option`
327 and set its :attr:`~yuio.cli.Option.allow_no_args` to :data:`True`. This will
328 correspond to the greedy approach.
330 .. note::
332 Positionals with defaults are treated as optional because they don't
333 create ambiguities.
3352. Boolean flags allow specifying value inline, but not as a separate argument.
3373. Yuio does not allow passing inline values to short boolean flags
338 without adding an equals sign. For example, :flag:`-ftrue` will not work,
339 while :flag:`-f=true` will.
341 This is done to enable grouping short flags: :flag:`ls -laH` should be parsed
342 as :flag:`ls -l -a -H`, not as :flag:`ls -l=aH`.
3444. On lower levels of API, Yuio allows precise control over this behavior
345 by setting :attr:`Option.nargs <yuio.cli.Option.nargs>`,
346 :attr:`Option.allow_no_args <yuio.cli.Option.allow_no_args>`,
347 :attr:`Option.allow_inline_arg <yuio.cli.Option.allow_inline_arg>`,
348 and :attr:`Option.allow_implicit_inline_arg <yuio.cli.Option.allow_implicit_inline_arg>`.
351.. _custom-cli-options:
353Creating custom CLI options
354---------------------------
356You can override default behavior and presentation of a CLI option by passing
357custom `option_ctor` to :func:`field`. Furthermore, you can create your own
358implementation of :class:`yuio.cli.Option` to further fine-tune how an option
359is parsed, presented in CLI help, etc.
361.. autofunction:: bool_option
363.. autofunction:: parse_one_option
365.. autofunction:: parse_many_option
367.. autofunction:: store_const_option
369.. autofunction:: count_option
371.. autofunction:: store_true_option
373.. autofunction:: store_false_option
375.. type:: OptionCtor
376 :canonical: typing.Callable[[OptionSettings], yuio.cli.Option[T]]
378 CLI option constructor. Takes a single positional argument
379 of type :class:`OptionSettings`, and returns an instance
380 of :class:`yuio.cli.Option`.
382.. autoclass:: OptionSettings
383 :members:
386Re-imports
387----------
389.. type:: HelpGroup
390 :no-index:
392 Alias of :obj:`yuio.cli.HelpGroup`.
394.. type:: MutuallyExclusiveGroup
395 :no-index:
397 Alias of :obj:`yuio.cli.MutuallyExclusiveGroup`.
399.. data:: MISC_GROUP
400 :no-index:
402 Alias of :obj:`yuio.cli.MISC_GROUP`.
404.. data:: OPTS_GROUP
405 :no-index:
407 Alias of :obj:`yuio.cli.OPTS_GROUP`.
409.. data:: SUBCOMMANDS_GROUP
410 :no-index:
412 Alias of :obj:`yuio.cli.SUBCOMMANDS_GROUP`.
414"""
416from __future__ import annotations
418import dataclasses
419import functools
420import inspect
421import json
422import logging
423import pathlib
424import sys
425import types
426from dataclasses import dataclass
428import yuio
429import yuio.cli
430import yuio.complete
431import yuio.config
432import yuio.dbg
433import yuio.doc
434import yuio.io
435import yuio.parse
436import yuio.string
437import yuio.term
438import yuio.theme
439from yuio.cli import (
440 MISC_GROUP,
441 OPTS_GROUP,
442 SUBCOMMANDS_GROUP,
443 HelpGroup,
444 MutuallyExclusiveGroup,
445)
446from yuio.config import (
447 OptionCtor,
448 OptionSettings,
449 bool_option,
450 collect_option,
451 count_option,
452 field,
453 inline,
454 parse_many_option,
455 parse_one_option,
456 positional,
457 store_const_option,
458 store_false_option,
459 store_true_option,
460)
461from yuio.util import dedent as _dedent
462from yuio.util import find_docs as _find_docs
463from yuio.util import to_dash_case as _to_dash_case
465from typing import TYPE_CHECKING
466from typing import ClassVar as _ClassVar
468if TYPE_CHECKING:
469 import typing_extensions as _t
470else:
471 from yuio import _typing as _t
473__all__ = [
474 "MISC_GROUP",
475 "OPTS_GROUP",
476 "SUBCOMMANDS_GROUP",
477 "App",
478 "AppError",
479 "CommandInfo",
480 "HelpGroup",
481 "MutuallyExclusiveGroup",
482 "OptionCtor",
483 "OptionSettings",
484 "SubcommandRegistrar",
485 "app",
486 "bool_option",
487 "collect_option",
488 "count_option",
489 "field",
490 "inline",
491 "parse_many_option",
492 "parse_one_option",
493 "positional",
494 "store_const_option",
495 "store_false_option",
496 "store_true_option",
497]
499C = _t.TypeVar("C", bound=_t.Callable[..., None | bool])
500C2 = _t.TypeVar("C2", bound=_t.Callable[..., None | bool])
501CB = _t.TypeVar("CB", bound="App[_t.Any]")
504class AppError(yuio.PrettyException, Exception):
505 """
506 An error that you can throw from an app to finish its execution without printing
507 a traceback.
509 """
512@_t.overload
513def app(
514 *,
515 prog: str | None = None,
516 usage: str | None = None,
517 description: str | None = None,
518 epilog: str | None = None,
519 version: str | None = None,
520 bug_report: yuio.dbg.ReportSettings | bool = False,
521 is_dev_mode: bool | None = None,
522 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
523) -> _t.Callable[[C], App[C]]: ...
524@_t.overload
525def app(
526 command: C,
527 /,
528 *,
529 prog: str | None = None,
530 usage: str | None = None,
531 description: str | None = None,
532 epilog: str | None = None,
533 version: str | None = None,
534 bug_report: yuio.dbg.ReportSettings | bool = False,
535 is_dev_mode: bool | None = None,
536 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
537) -> App[C]: ...
538def app(
539 command: _t.Callable[..., None | bool] | None = None,
540 /,
541 *,
542 prog: str | None = None,
543 usage: str | None = None,
544 description: str | None = None,
545 epilog: str | None = None,
546 allow_abbrev: bool = False,
547 subcommand_required: bool = True,
548 setup_logging: bool = True,
549 theme: (
550 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
551 ) = None,
552 version: str | None = None,
553 bug_report: yuio.dbg.ReportSettings | bool = False,
554 is_dev_mode: bool | None = None,
555 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
556) -> _t.Any:
557 """
558 Create an application.
560 This is a decorator that's supposed to be used on the main method
561 of the application. This decorator returns an :class:`App` object.
563 :param command:
564 the main function of the application.
565 :param prog:
566 overrides program's name, see :attr:`App.prog`.
567 :param usage:
568 overrides program's usage description, see :attr:`App.usage`.
569 :param description:
570 overrides program's description, see :attr:`App.description`.
571 :param epilog:
572 overrides program's epilog, see :attr:`App.epilog`.
573 :param allow_abbrev:
574 whether to allow abbreviating unambiguous flags, see :attr:`App.allow_abbrev`.
575 :param subcommand_required:
576 whether this app requires a subcommand,
577 see :attr:`App.subcommand_required`.
578 :param setup_logging:
579 whether to perform basic logging setup on startup,
580 see :attr:`App.setup_logging`.
581 :param theme:
582 overrides theme that will be used when setting up :mod:`yuio.io`,
583 see :attr:`App.theme`.
584 :param version:
585 program's version, will be displayed using the :flag:`--version` flag.
586 :param bug_report:
587 settings for automated bug report generation. If present,
588 adds the :flag:`--bug-report` flag.
589 :param is_dev_mode:
590 enables additional logging, see :attr:`App.is_dev_mode`.
591 :param doc_format:
592 overrides program's documentation format, see :attr:`App.doc_format`.
593 :returns:
594 an :class:`App` object that wraps the original function.
596 """
598 def registrar(command: C, /) -> App[C]:
599 return App(
600 command,
601 prog=prog,
602 usage=usage,
603 description=description,
604 epilog=epilog,
605 subcommand_required=subcommand_required,
606 allow_abbrev=allow_abbrev,
607 setup_logging=setup_logging,
608 theme=theme,
609 version=version,
610 bug_report=bug_report,
611 is_dev_mode=is_dev_mode,
612 doc_format=doc_format,
613 )
615 if command is None:
616 return registrar
617 else:
618 return registrar(command)
621@_t.final
622class CommandInfo:
623 """
624 Data about the invoked command.
626 """
628 def __init__(
629 self,
630 name: str,
631 command: App[_t.Any],
632 namespace: yuio.cli.ConfigNamespace["_CommandConfig"],
633 ):
634 self.name = name
635 """
636 Name of the current command.
638 If it was invoked by alias,
639 this will contains the primary command name.
641 For the main function, the name will be set to ``"__main__"``.
643 """
645 self.__namespace = namespace
646 self.__command = command
647 self.__subcommand: CommandInfo | None | yuio.Missing = yuio.MISSING
648 self.__executed: bool = False
650 @property
651 def subcommand(self) -> CommandInfo | None:
652 """
653 Subcommand of this command, if one was given.
655 """
657 if self.__subcommand is yuio.MISSING:
658 self.__subcommand = self.__command._get_subcommand(self.__namespace)
659 return self.__subcommand
661 def __call__(self) -> _t.Literal[False]:
662 """
663 Execute this command.
665 """
667 if self.__executed:
668 return False
669 self.__executed = True
671 should_invoke_subcommand = self.__command._invoke(self.__namespace, self)
672 if should_invoke_subcommand is None:
673 should_invoke_subcommand = True
675 if should_invoke_subcommand and self.subcommand is not None:
676 self.subcommand()
678 return False
681@dataclass(eq=False, match_args=False, slots=True)
682class _SubcommandData:
683 names: list[str]
684 help: str | yuio.Disabled | None
685 command: App[_t.Any] | _Lazy
687 def load(self) -> App[_t.Any]:
688 if isinstance(self.command, _Lazy):
689 self.command = self.command.load()
690 return self.command
692 def make_cli_command(self):
693 return self.load()._make_cli_command(self.name, self.help)
695 @property
696 def name(self):
697 return self.names[0]
700class SubcommandRegistrar(_t.Protocol):
701 """
702 Type for a callback returned from :meth:`App.subcommand`.
704 """
706 @_t.overload
707 def __call__(self, cb: C, /) -> App[C]: ...
708 @_t.overload
709 def __call__(self, cb: CB, /) -> CB: ...
710 def __call__(self, cb, /) -> _t.Any: ...
713@dataclass(frozen=True, eq=False, match_args=False, slots=True)
714class _Lazy:
715 path: str
717 def load(self) -> App[_t.Any]:
718 import importlib
720 path = self.path
721 if ":" in path:
722 mod, _, path = path.partition(":")
723 path_parts = path.split(".")
725 try:
726 root = importlib.import_module(mod)
727 except ImportError as e:
728 raise ImportError(f"failed to import lazy subcommand {self.path}: {e}")
729 else:
730 path_parts = path.split(".")
732 i = len(path_parts)
733 while i > 0:
734 try:
735 root = importlib.import_module(".".join(path_parts[:i]))
736 path_parts = path_parts[i:]
737 except ImportError:
738 pass
739 else:
740 break
741 i -= 1
742 else:
743 raise ImportError(f"failed to import lazy subcommand {self.path}")
745 for name in path_parts:
746 try:
747 root = getattr(root, name)
748 except AttributeError as e:
749 raise AttributeError(
750 f"failed to import lazy subcommand {self.path}: {e}"
751 )
753 if not isinstance(root, App):
754 root = App(root) # type: ignore
756 return root
759@_t.final
760class App(_t.Generic[C]):
761 """
762 A class that encapsulates app settings and logic for running it.
764 It is better to create instances of this class using the :func:`app` decorator,
765 as it provides means to decorate the main function and specify all of the app's
766 parameters.
768 """
770 def __init__(
771 self,
772 command: C,
773 /,
774 *,
775 prog: str | None = None,
776 usage: str | None = None,
777 help: str | yuio.Disabled | None = None,
778 description: str | None = None,
779 epilog: str | None = None,
780 subcommand_required: bool = True,
781 allow_abbrev: bool = False,
782 setup_logging: bool = True,
783 theme: (
784 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
785 ) = None,
786 version: str | None = None,
787 bug_report: yuio.dbg.ReportSettings | bool = False,
788 is_dev_mode: bool | None = None,
789 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
790 ):
791 self.prog: str | None = prog
792 """
793 Program's primary name.
795 For main app, this attribute controls its display name and generation
796 of shell completion scripts.
798 For subcommands, this attribute is ignored.
800 By default, inferred from :data:`sys.argv`.
802 """
804 self.usage: str = usage or ""
805 """
806 Program or subcommand synapsis.
808 This string will be processed using the to ``bash`` syntax,
809 and then it will be ``%``-formatted with a single keyword argument ``prog``.
810 If command supports multiple signatures, each of them should be listed
811 on a separate string. For example::
813 @app
814 def main(): ...
816 main.usage = \"""
817 %(prog)s [-q] [-f] [-m] [<branch>]
818 %(prog)s [-q] [-f] [-m] --detach [<branch>]
819 %(prog)s [-q] [-f] [-m] [--detach] <commit>
820 ...
821 \"""
823 By default, usage is generated from CLI flags.
825 """
827 if description is None and command.__doc__:
828 description = command.__doc__
829 if description is None:
830 description = ""
832 self.description: str = description
833 """
834 Text that is shown before CLI flags help, usually contains
835 short description of the program or subcommand.
837 The text should be formatted using Markdown or RST,
838 depending on :attr:`~App.doc_format`. For example:
840 .. code-block:: python
842 @yuio.app.app(doc_format="md")
843 def main(): ...
845 main.description = \"""
846 This command does a thing.
848 # Different ways to do a thing
850 This command can apply multiple algorithms to achieve
851 a necessary state in which a thing can be done. This includes:
853 - randomly turning the screen on and off;
855 - banging a head on a table;
857 - fiddling with your PCs power cord.
859 By default, the best algorithm is determined automatically.
860 However, you can hint a preferred algorithm via the `--hint-algo` flag.
862 \"""
864 By default, inferred from command's docstring.
866 """
868 if help is None and description:
869 help = description
870 if (index := help.find("\n\n")) != -1:
871 help = help[:index]
872 elif help is None:
873 help = ""
875 self.help: str | yuio.Disabled = help
876 """
877 Short help message that is shown when listing subcommands.
879 By default, uses first paragraph of description.
881 """
883 self.epilog: str = epilog or ""
884 """
885 Text that is shown after the main portion of the help message.
887 The text should be formatted using Markdown or RST,
888 depending on :attr:`~App.doc_format`.
890 """
892 self.subcommand_required: bool = subcommand_required
893 """
894 Require the user to provide a subcommand for this command.
896 If this command doesn't have any subcommands, this option is ignored.
898 Enabled by default.
900 """
902 self.allow_abbrev: bool = allow_abbrev
903 """
904 Allow abbreviating CLI flags if that doesn't create ambiguity.
906 Disabled by default.
908 .. note::
910 This attribute should be set in the root app; it is ignored in subcommands.
912 """
914 self.setup_logging: bool = setup_logging
915 """
916 If :data:`True`, the app will call :func:`logging.basicConfig` during
917 its initialization. Disable this if you want to customize
918 logging initialization.
920 Disabling this option also removes the :flag:`--verbose` flag form the CLI.
922 .. note::
924 This attribute should be set in the root app; it is ignored in subcommands.
926 """
928 self.theme: (
929 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
930 ) = theme
931 """
932 A custom theme that will be passed to :func:`yuio.io.setup`
933 on application startup.
935 .. note::
937 This attribute should be set in the root app; it is ignored in subcommands.
939 """
941 self.version: str | None = version
942 """
943 If not :data:`None`, add :flag:`--version` flag to the CLI.
945 .. note::
947 This attribute should be set in the root app; it is ignored in subcommands.
949 """
951 self.bug_report: yuio.dbg.ReportSettings | bool = bug_report
952 """
953 If not :data:`False`, add :flag:`--bug-report` flag to the CLI.
955 This flag automatically collects data about environment and prints it
956 in a format suitable for adding to a bug report.
958 .. note::
960 This attribute should be set in the root app; it is ignored in subcommands.
962 """
964 self.is_dev_mode: bool | None = is_dev_mode
965 """
966 If :data:`True`, this will enable :func:`logging.captureWarnings`
967 and configure internal Yuio logging to show warnings.
969 By default, dev mode is detected by checking if :attr:`~App.version`
970 contains substring ``"dev"``.
972 .. tip::
974 You can always enable full debug logging by setting environment
975 variable ``YUIO_DEBUG``.
977 If enabled, full log will be saved to ``YUIO_DEBUG_FILE``.
979 .. note::
981 This attribute should be set in the root app; it is ignored in subcommands.
983 """
985 self.doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser = (
986 doc_format or "rst"
987 )
988 """
989 Format or parser that will be used to interpret documentation.
991 .. note::
993 This attribute should be set in the root app; it is ignored in subcommands.
995 """
997 self._subcommands: dict[str, _SubcommandData] = {}
999 if callable(command):
1000 self._config_type, self._callback = _command_from_callable(command)
1001 else:
1002 raise TypeError(f"expected a function, got {command}")
1004 functools.update_wrapper(
1005 self, # type: ignore
1006 command,
1007 assigned=("__module__", "__name__", "__qualname__", "__doc__"),
1008 updated=(),
1009 )
1011 @functools.wraps(command)
1012 def wrapped_command(*args, **kwargs):
1013 a_params: list[str] = getattr(self._config_type, "_a_params")
1014 a_kw_params: list[str] = getattr(self._config_type, "_a_kw_params")
1015 var_a_param: str | None = getattr(self._config_type, "_var_a_param")
1016 kw_params: list[str] = getattr(self._config_type, "_kw_params")
1018 i = 0
1020 for name in a_params:
1021 if name in kwargs:
1022 raise TypeError(
1023 f"positional-only argument {name} was given as keyword argument"
1024 )
1025 if i < len(args):
1026 kwargs[name] = args[i]
1027 i += 1
1029 for name in a_kw_params:
1030 if i >= len(args):
1031 break
1032 if name in kwargs:
1033 raise TypeError(f"argument {name} was given twice")
1034 kwargs[name] = args[i]
1035 i += 1
1037 if var_a_param:
1038 if var_a_param in kwargs:
1039 raise TypeError(f"unexpected argument {var_a_param}")
1040 kwargs[var_a_param] = args[i:]
1041 i = len(args)
1042 elif i < len(args):
1043 s = "" if i == 1 else "s"
1044 raise TypeError(
1045 f"expected at most {i} positional argument{s}, got {len(args)}"
1046 )
1048 kwargs.pop("_command_info", None)
1050 config = self._config_type(**kwargs)
1052 for name in a_params + a_kw_params + kw_params:
1053 if not hasattr(config, name) and name != "_command_info":
1054 raise TypeError(f"missing required argument {name}")
1056 namespace = yuio.cli.ConfigNamespace(config)
1058 return CommandInfo("__main__", self, namespace)()
1060 self.__wrapped__ = _t.cast(C, wrapped_command)
1062 @property
1063 def wrapped(self) -> C:
1064 """
1065 The original callable what was wrapped by :func:`app`.
1067 """
1069 return self.__wrapped__
1071 @_t.overload
1072 def subcommand(
1073 self,
1074 /,
1075 *,
1076 name: str | None = None,
1077 aliases: list[str] | None = None,
1078 usage: str | None = None,
1079 help: str | yuio.Disabled | None = None,
1080 description: str | None = None,
1081 epilog: str | None = None,
1082 ) -> SubcommandRegistrar: ...
1083 @_t.overload
1084 def subcommand(
1085 self,
1086 cb: C2,
1087 /,
1088 *,
1089 name: str | None = None,
1090 aliases: list[str] | None = None,
1091 usage: str | None = None,
1092 help: str | yuio.Disabled | None = None,
1093 description: str | None = None,
1094 epilog: str | None = None,
1095 ) -> App[C2]: ...
1096 @_t.overload
1097 def subcommand(
1098 self,
1099 cb: CB,
1100 /,
1101 *,
1102 name: str | None = None,
1103 aliases: list[str] | None = None,
1104 help: str | yuio.Disabled | None = None,
1105 ) -> CB: ...
1106 def subcommand(
1107 self,
1108 cb: _t.Callable[..., None | bool] | App[_t.Any] | None = None,
1109 /,
1110 *,
1111 name: str | None = None,
1112 aliases: list[str] | None = None,
1113 usage: str | None = None,
1114 help: str | yuio.Disabled | None = None,
1115 description: str | None = None,
1116 epilog: str | None = None,
1117 subcommand_required: bool = True,
1118 ) -> _t.Any:
1119 """
1120 Register a subcommand for the given app.
1122 This method can be used as a decorator, similar to the :func:`app` function.
1124 :param name:
1125 allows overriding subcommand's name.
1126 :param aliases:
1127 allows adding alias names for subcommand.
1128 :param usage:
1129 overrides subcommand's usage description, see :attr:`App.usage`.
1130 :param help:
1131 overrides subcommand's short help, see :attr:`App.help`.
1132 pass :data:`~yuio.DISABLED` to hide this subcommand in CLI help message.
1133 :param description:
1134 overrides subcommand's description, see :attr:`App.description`.
1135 :param epilog:
1136 overrides subcommand's epilog, see :attr:`App.epilog`.
1137 :param subcommand_required:
1138 whether this subcommand requires another subcommand,
1139 see :attr:`App.subcommand_required`.
1140 :returns:
1141 a new :class:`App` object for a subcommand.
1143 """
1145 def registrar(cb, /) -> App[_t.Any]:
1146 if not isinstance(cb, App):
1147 cb = App(
1148 cb,
1149 usage=usage,
1150 help=help,
1151 description=description,
1152 epilog=epilog,
1153 subcommand_required=subcommand_required,
1154 )
1156 names = [name or _to_dash_case(cb.wrapped.__name__), *(aliases or [])]
1157 subcommand_data = _SubcommandData(names, help, cb)
1158 self._add_subcommand(subcommand_data)
1160 return cb
1162 if cb is None:
1163 return registrar
1164 else:
1165 return registrar(cb)
1167 def lazy_subcommand(
1168 self,
1169 path: str,
1170 name: str,
1171 /,
1172 *,
1173 aliases: list[str] | None = None,
1174 help: str | yuio.Disabled | None = None,
1175 ):
1176 """
1177 Add a subcommand for this app that will be imported and loaded on demand.
1179 :param path:
1180 dot-separated path to a command or command's main function.
1182 As a hint, module can be separated from the rest of the path with
1183 a semicolon, i.e. ``"module.submodule:class.method"``.
1184 :param name:
1185 subcommand's primary name.
1186 :param aliases:
1187 allows adding alias names for subcommand.
1188 :param help:
1189 allows specifying subcommand's help. If given, generating CLI help for
1190 base command will not require importing subcommand.
1191 :example:
1192 In module ``my_app.commands.run``:
1194 .. code-block:: python
1196 import yuio.app
1199 @yuio.app.app
1200 def command(): ...
1202 In module ``my_app.main``:
1204 .. code-block:: python
1206 import yuio.app
1209 @yuio.app.app
1210 def main(): ...
1213 main.lazy_subcommand("my_app.commands.run:command", "run")
1215 """
1217 subcommand_data = _SubcommandData([name, *(aliases or [])], help, _Lazy(path))
1218 self._add_subcommand(subcommand_data)
1220 def _add_subcommand(self, subcommand_data: _SubcommandData):
1221 for nam in subcommand_data.names:
1222 if nam in self._subcommands:
1223 subcommand = self._subcommands[nam].load()
1224 raise ValueError(
1225 f"{self.__class__.__module__}.{self.__class__.__name__}: "
1226 f"subcommand {nam!r} already registered in "
1227 f"{subcommand.__class__.__module__}.{subcommand.__class__.__name__}"
1228 )
1229 self._subcommands.update(dict.fromkeys(subcommand_data.names, subcommand_data))
1231 def run(self, args: list[str] | None = None) -> _t.NoReturn:
1232 """
1233 Parse arguments, set up :mod:`yuio.io` and :mod:`logging`,
1234 and run the application.
1236 :param args:
1237 command line arguments. If none are given,
1238 use arguments from :data:`sys.argv`.
1239 :returns:
1240 this method does not return, it exits the program instead.
1242 """
1244 if args is None:
1245 args = sys.argv[1:]
1247 prog = self.prog or pathlib.Path(sys.argv[0]).stem
1249 if "--yuio-custom-completer--" in args:
1250 index = args.index("--yuio-custom-completer--")
1251 _run_custom_completer(
1252 self._make_cli_command(prog, is_root=True),
1253 args[index + 1],
1254 args[index + 2],
1255 )
1256 sys.exit(0)
1258 if "--yuio-bug-report--" in args:
1259 from yuio.dbg import print_report
1261 print_report(settings=self.bug_report, app=self)
1262 sys.exit(0)
1264 yuio.io.setup(theme=self.theme, wrap_stdio=True)
1266 try:
1267 if self.is_dev_mode is None:
1268 self.is_dev_mode = (
1269 self.version is not None and "dev" in self.version.casefold()
1270 )
1271 if self.is_dev_mode:
1272 yuio.enable_internal_logging(add_handler=True)
1274 help_parser = self._make_help_parser()
1276 cli_command = self._make_cli_command(prog, is_root=True)
1277 namespace = yuio.cli.CliParser(
1278 cli_command, help_parser=help_parser, allow_abbrev=self.allow_abbrev
1279 ).parse(args)
1281 if self.setup_logging:
1282 logging_level = {
1283 0: logging.WARNING,
1284 1: logging.INFO,
1285 2: logging.DEBUG,
1286 }.get(namespace["_verbose"], logging.DEBUG)
1287 logging.basicConfig(handlers=[yuio.io.Handler()], level=logging_level)
1289 CommandInfo("__main__", self, namespace)()
1290 sys.exit(0)
1291 except yuio.cli.ArgumentError as e:
1292 yuio.io.raw(e, add_newline=True, wrap=True)
1293 sys.exit(1)
1294 except (AppError, yuio.cli.ArgumentError, yuio.parse.ParsingError) as e:
1295 yuio.io.failure(e)
1296 sys.exit(1)
1297 except KeyboardInterrupt:
1298 yuio.io.failure("Received Keyboard Interrupt, stopping now")
1299 sys.exit(130)
1300 except Exception as e:
1301 yuio.io.failure_with_tb("Error: %s", e)
1302 sys.exit(3)
1303 finally:
1304 yuio.io.restore_streams()
1306 def _make_help_parser(self):
1307 if self.doc_format == "md":
1308 from yuio.md import MdParser
1310 return MdParser()
1311 elif self.doc_format == "rst":
1312 from yuio.rst import RstParser
1314 return RstParser()
1315 else:
1316 return self.doc_format
1318 def _make_cli_command(
1319 self, name: str, help: str | yuio.Disabled | None = None, is_root: bool = False
1320 ):
1321 options: list[yuio.cli.Option[_t.Any]] = self._config_type._build_options()
1323 if is_root:
1324 options.append(yuio.cli.HelpOption())
1325 if self.version:
1326 options.append(yuio.cli.VersionOption(version=self.version))
1327 if self.setup_logging:
1328 options.append(
1329 yuio.cli.CountOption(
1330 flags=["-v", "--verbose"],
1331 usage=yuio.COLLAPSE,
1332 help="Increase output verbosity.",
1333 help_group=yuio.cli.MISC_GROUP,
1334 show_if_inherited=False,
1335 dest="_verbose",
1336 )
1337 )
1338 if self.bug_report:
1339 options.append(yuio.cli.BugReportOption(app=self))
1340 options.append(yuio.cli.CompletionOption())
1341 options.append(_ColorOption())
1343 subcommands: dict[
1344 str, yuio.cli.Command[_t.Any] | yuio.cli.LazyCommand[_t.Any]
1345 ] = {}
1346 for subcommand_name, subcommand_data in self._subcommands.items():
1347 if subcommand_data.name not in subcommands:
1348 subcommands[subcommand_data.name] = yuio.cli.LazyCommand(
1349 help=subcommand_data.help,
1350 loader=subcommand_data.make_cli_command,
1351 )
1352 subcommands[subcommand_name] = subcommands[subcommand_data.name]
1354 if help is None:
1355 help = self.help
1357 return yuio.cli.Command(
1358 name=name,
1359 desc=_dedent(self.description),
1360 help=_dedent(help) if help is not yuio.DISABLED else help,
1361 epilog=_dedent(self.epilog),
1362 usage=_dedent(self.usage).strip(),
1363 options=options,
1364 subcommands=subcommands,
1365 subcommand_required=self.subcommand_required,
1366 ns_ctor=self._create_ns,
1367 dest="_subcommand",
1368 ns_dest="_subcommand_ns",
1369 )
1371 def _create_ns(self):
1372 return yuio.cli.ConfigNamespace(self._config_type())
1374 def _invoke(
1375 self,
1376 namespace: yuio.cli.ConfigNamespace["_CommandConfig"],
1377 command_info: CommandInfo,
1378 ) -> bool | None:
1379 return self._callback(namespace.config, command_info)
1381 def _get_subcommand(
1382 self, namespace: yuio.cli.ConfigNamespace["_CommandConfig"]
1383 ) -> CommandInfo | None:
1384 config = namespace.config
1385 if config._subcommand is None:
1386 return None
1387 else:
1388 subcommand_ns = config._subcommand_ns
1389 subcommand_data = self._subcommands[config._subcommand]
1391 assert subcommand_ns is not None
1393 return CommandInfo(
1394 subcommand_data.name, subcommand_data.load(), subcommand_ns
1395 )
1398class _CommandConfig(yuio.config.Config):
1399 _a_params: _ClassVar[list[str]]
1400 _var_a_param: _ClassVar[str | None]
1401 _a_kw_params: _ClassVar[list[str]]
1402 _kw_params: _ClassVar[list[str]]
1403 _subcommand: str | None = None
1404 _subcommand_ns: yuio.cli.ConfigNamespace[_CommandConfig] | None = None
1407def _command_from_callable(
1408 cb: _t.Callable[..., None | bool],
1409) -> tuple[
1410 type[_CommandConfig],
1411 _t.Callable[[_CommandConfig, CommandInfo], bool | None],
1412]:
1413 sig = inspect.signature(cb)
1415 dct = {}
1416 annotations = {}
1418 try:
1419 docs = _find_docs(cb)
1420 except Exception:
1421 yuio._logger.warning(
1422 "unable to get documentation for %s.%s",
1423 cb.__module__,
1424 cb.__qualname__,
1425 )
1426 docs = {}
1428 dct["_a_params"] = a_params = []
1429 dct["_var_a_param"] = var_a_param = None
1430 dct["_a_kw_params"] = a_kw_params = []
1431 dct["_kw_params"] = kw_params = []
1433 for name, param in sig.parameters.items():
1434 if param.kind is param.VAR_KEYWORD:
1435 raise TypeError("variadic keyword parameters are not supported")
1437 is_special = False
1438 if name.startswith("_"):
1439 is_special = True
1440 if name != "_command_info":
1441 raise TypeError(f"unknown special parameter {name}")
1442 if param.kind is param.VAR_POSITIONAL:
1443 raise TypeError(f"special parameter {name} can't be variadic")
1445 if param.default is not param.empty:
1446 field = param.default
1447 else:
1448 field = yuio.MISSING
1449 if not isinstance(field, yuio.config._FieldSettings):
1450 field = _t.cast(
1451 yuio.config._FieldSettings, yuio.config.field(default=field)
1452 )
1454 annotation = param.annotation
1455 if annotation is param.empty and not is_special:
1456 raise TypeError(f"parameter {name} requires type annotation")
1458 match param.kind:
1459 case param.POSITIONAL_ONLY:
1460 if field.flags is None:
1461 field = dataclasses.replace(field, flags=yuio.POSITIONAL)
1462 a_params.append(name)
1463 case param.VAR_POSITIONAL:
1464 if field.flags is None:
1465 field = dataclasses.replace(field, flags=yuio.POSITIONAL)
1466 annotation = list[annotation]
1467 dct["_var_a_param"] = var_a_param = name
1468 case param.POSITIONAL_OR_KEYWORD:
1469 a_kw_params.append(name)
1470 case param.KEYWORD_ONLY:
1471 kw_params.append(name)
1473 if not is_special:
1474 dct[name] = field
1475 annotations[name] = annotation
1477 dct["_color"] = None
1478 dct["_verbose"] = 0
1479 dct["_subcommand"] = None
1480 dct["_subcommand_ns"] = None
1481 dct["__annotations__"] = annotations
1482 dct["__module__"] = getattr(cb, "__module__", None)
1483 dct["__doc__"] = getattr(cb, "__doc__", None)
1484 dct["__yuio_pre_parsed_docs__"] = docs
1486 config = types.new_class(
1487 cb.__name__,
1488 (_CommandConfig,),
1489 {"_allow_positionals": True},
1490 exec_body=lambda ns: ns.update(dct),
1491 )
1492 callback = _command_from_callable_run_impl(
1493 cb, a_params + a_kw_params, var_a_param, kw_params
1494 )
1496 return config, callback
1499def _command_from_callable_run_impl(
1500 cb: _t.Callable[..., None | bool],
1501 a_params: list[str],
1502 var_a_param: str | None,
1503 kw_params: list[str],
1504):
1505 def run(config: _CommandConfig, command_info: CommandInfo):
1506 def get(name: str) -> _t.Any:
1507 return command_info if name == "_command_info" else getattr(config, name)
1509 args = [get(name) for name in a_params]
1510 if var_a_param is not None:
1511 args.extend(get(var_a_param))
1512 kwargs = {name: get(name) for name in kw_params}
1513 return cb(*args, **kwargs)
1515 return run
1518def _run_custom_completer(command: yuio.cli.Command[_t.Any], raw_data: str, word: str):
1519 data = json.loads(raw_data)
1520 path: str = data["path"]
1521 flags: set[str] = set(data["flags"])
1522 index: int = data["index"]
1524 root = command
1525 for name in path.split("/"):
1526 if not name:
1527 continue
1528 if name not in command.subcommands:
1529 return
1530 root = command.subcommands[name].load()
1532 positional_index = 0
1533 for option in root.options:
1534 option_flags = option.flags
1535 if option_flags is yuio.POSITIONAL:
1536 option_flags = [str(positional_index)]
1537 positional_index += 1
1538 if flags.intersection(option_flags):
1539 completer, is_many = option.get_completer()
1540 break
1541 else:
1542 completer, is_many = None, False
1544 if completer:
1545 yuio.complete._run_completer_at_index(completer, is_many, index, word)
1548@dataclass(eq=False, kw_only=True)
1549class _ColorOption(yuio.cli.Option[_t.Never]):
1550 # `yuio.term` will scan `sys.argv` on its own, this option just checks format
1551 # and adds help entry.
1553 _ALLOWED_VALUES = (
1554 "y",
1555 "yes",
1556 "true",
1557 "1",
1558 "n",
1559 "no",
1560 "false",
1561 "0",
1562 "ansi",
1563 "ansi-256",
1564 "ansi-true",
1565 )
1567 _PUBLIC_VALUES = (
1568 ("true", "3-bit colors or higher"),
1569 ("false", "disable colors"),
1570 ("ansi", "force 3-bit colors"),
1571 ("ansi-256", "force 8-bit colors"),
1572 ("ansi-true", "force 24-bit colors"),
1573 )
1575 def __init__(self):
1576 super().__init__(
1577 flags=["--color", "--no-color"],
1578 allow_inline_arg=True,
1579 allow_implicit_inline_arg=True,
1580 nargs=0,
1581 allow_no_args=True,
1582 required=False,
1583 metavar=(),
1584 mutex_group=None,
1585 usage=yuio.COLLAPSE,
1586 help="Enable or disable ANSI colors.",
1587 help_group=yuio.cli.MISC_GROUP,
1588 show_if_inherited=False,
1589 allow_abbrev=False,
1590 dest="_color",
1591 default_desc=None,
1592 )
1594 def process(
1595 self,
1596 cli_parser: yuio.cli.CliParser[yuio.cli.Namespace],
1597 flag: yuio.cli.Flag | None,
1598 arguments: yuio.cli.Argument | list[yuio.cli.Argument],
1599 ns: yuio.cli.Namespace,
1600 ):
1601 if isinstance(arguments, yuio.cli.Argument):
1602 if flag and flag.value == "--no-color":
1603 raise yuio.cli.ArgumentError(
1604 "This flag can't have arguments", flag=flag, arguments=arguments
1605 )
1606 if arguments.value.casefold() not in self._ALLOWED_VALUES:
1607 raise yuio.cli.ArgumentError(
1608 "Can't parse `%r` as color, should be %s",
1609 arguments.value,
1610 yuio.string.Or(value for value, _ in self._PUBLIC_VALUES),
1611 flag=flag,
1612 arguments=arguments,
1613 )
1615 @functools.cached_property
1616 def primary_short_flag(self):
1617 return None
1619 @functools.cached_property
1620 def primary_long_flags(self):
1621 return ["--color", "--no-color"]
1623 def format_alias_flags(
1624 self,
1625 ctx: yuio.string.ReprContext,
1626 /,
1627 *,
1628 all: bool = False,
1629 ) -> (
1630 list[yuio.string.ColorizedString | tuple[yuio.string.ColorizedString, str]]
1631 | None
1632 ):
1633 if self.flags is yuio.POSITIONAL:
1634 return None
1636 primary_flags = set(self.primary_long_flags or [])
1637 if self.primary_short_flag:
1638 primary_flags.add(self.primary_short_flag)
1640 aliases: list[
1641 yuio.string.ColorizedString | tuple[yuio.string.ColorizedString, str]
1642 ] = []
1643 flag_color = ctx.get_color("hl/flag:sh-usage")
1644 punct_color = ctx.get_color("hl/punct:sh-usage")
1645 metavar_color = ctx.get_color("hl/metavar:sh-usage")
1646 res = yuio.string.ColorizedString()
1647 res.start_no_wrap()
1648 res.append_color(flag_color)
1649 res.append_str("--color")
1650 res.end_no_wrap()
1651 res.append_color(punct_color)
1652 res.append_str("={")
1653 sep = False
1654 for value, _ in self._PUBLIC_VALUES:
1655 if sep:
1656 res.append_color(punct_color)
1657 res.append_str("|")
1658 res.append_color(metavar_color)
1659 res.append_str(value)
1660 sep = True
1661 res.append_color(punct_color)
1662 res.append_str("}")
1663 aliases.append(res)
1664 return aliases
1666 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1667 return yuio.complete.Choice(
1668 [
1669 yuio.complete.Option(value, comment)
1670 for value, comment in self._PUBLIC_VALUES
1671 ]
1672 ), False