Coverage for yuio / app.py: 94%
365 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
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:: allow_abbrev
150 .. autoattribute:: subcommand_required
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
198.. _sub-commands-more:
200Controlling how sub-commands are invoked
201----------------------------------------
203By default, if a command has sub-commands, the user is required to provide
204a sub-command. This behavior can be disabled by setting :attr:`App.subcommand_required`
205to :data:`False`.
207When this happens, we need to understand whether a subcommand was invoked or not.
208To determine this, you can accept a special parameter called `_command_info`
209of type :class:`CommandInfo`. It will contain info about the current function,
210including its name and subcommand:
212.. code-block:: python
214 @app
215 def main(_command_info: CommandInfo):
216 if _command_info.subcommand is not None:
217 # A subcommand was invoked.
218 ...
220You can call the subcommand on your own by using ``_command_info.subcommand``
221as a callable:
223.. code-block:: python
225 @app
226 def main(_command_info: CommandInfo):
227 if _command_info.subcommand is not None and ...:
228 _command_info.subcommand() # manually invoking a subcommand
230If you wish to disable calling the subcommand, you can return :data:`False`
231from the main function:
233.. code-block:: python
235 @app
236 def main(_command_info: CommandInfo):
237 ...
238 # Subcommand will not be invoked.
239 return False
241.. autoclass:: CommandInfo
242 :members:
245.. _flags-with-multiple-values:
247Handling options with multiple values
248-------------------------------------
250When you create an option with a container type, Yuio enables passing its values
251by specifying multiple arguments. For example:
253.. code-block:: python
255 @yuio.app.app
256 def main(list: list[int]):
257 print(list)
259Here, you can pass values to :flag:`--list` as separate arguments:
261.. code-block:: console
263 $ app --list 1 2 3
264 [1, 2, 3]
266If you specify value for :flag:`--list` inline, it will be handled as
267a delimiter-separated list:
269.. code-block:: console
271 $ app --list='1 2 3'
272 [1, 2, 3]
274This allows resolving ambiguities between flags and positional arguments:
276.. code-block:: console
278 $ app --list='1 2 3' subcommand
280Technically, :flag:`--list 1 2 3` causes Yuio to invoke
281``list_parser.parse_many(["1", "2", "3"])``, while :flag:`--list='1 2 3'` causes Yuio
282to invoke ``list_parser.parse("1 2 3")``.
285.. _flags-with-optional-values:
287Handling flags with optional values
288-----------------------------------
290When designing a CLI, one important question is how to handle flags with optional
291values, if at all. There are several things to consider:
2931. Does a flag have clear and predictable behavior when its value is not specified?
295 For boolean flags the default behavior is obvious: :flag:`--use-gpu` will enable
296 GPU, i.e. it is equivalent to :flag:`--use-gpu=true`.
298 For flags that accept non-boolean values, though, things get messier. What will
299 a flag like :flag:`--n-threads` do? Will it calculate number of threads based on
300 available CPU cores? Will it use some default value?
302 In these cases, it is usually better to require a sentinel value:
303 :flag:`--n-threads=auto`.
3052. Where should flag's value go, it it's provided?
307 We can only allow passing value inline, i.e. :flag:`--use-gpu=true`. Or we can
308 greedily take the following argument as flag's value, i.e. :flag:`--use-gpu true`.
310 The later approach has a significant downside: we don't know
311 whether the next argument was intended for the flag or for a free-standing option.
313 For example:
315 .. code-block:: console
317 $ my-renderer --color true # is `true` meant for `--color`,
318 $ # or is it a subcommand for `my-renderer`?
320Here's how Yuio handles this dilemma:
3221. High level API does not allow creating flags with optional values.
324 To create one, you have to make a custom implementation of :class:`yuio.cli.Option`
325 and set its :attr:`~yuio.cli.Option.allow_no_args` to :data:`True`. This will
326 correspond to the greedy approach.
328 .. note::
330 Positionals with defaults are treated as optional because they don't
331 create ambiguities.
3332. Boolean flags allow specifying value inline, but not as a separate argument.
3353. Yuio does not allow passing inline values to short boolean flags
336 without adding an equals sign. For example, :flag:`-ftrue` will not work,
337 while :flag:`-f=true` will.
339 This is done to enable grouping short flags: :flag:`ls -laH` should be parsed
340 as :flag:`ls -l -a -H`, not as :flag:`ls -l=aH`.
3424. On lower levels of API, Yuio allows precise control over this behavior
343 by setting :attr:`Option.nargs <yuio.cli.Option.nargs>`,
344 :attr:`Option.allow_no_args <yuio.cli.Option.allow_no_args>`,
345 :attr:`Option.allow_inline_arg <yuio.cli.Option.allow_inline_arg>`,
346 and :attr:`Option.allow_implicit_inline_arg <yuio.cli.Option.allow_implicit_inline_arg>`.
349.. _custom-cli-options:
351Creating custom CLI options
352---------------------------
354You can override default behavior and presentation of a CLI option by passing
355custom `option_ctor` to :func:`field`. Furthermore, you can create your own
356implementation of :class:`yuio.cli.Option` to further fine-tune how an option
357is parsed, presented in CLI help, etc.
359.. autofunction:: bool_option
361.. autofunction:: parse_one_option
363.. autofunction:: parse_many_option
365.. autofunction:: store_const_option
367.. autofunction:: count_option
369.. autofunction:: store_true_option
371.. autofunction:: store_false_option
373.. type:: OptionCtor
374 :canonical: typing.Callable[[OptionSettings], yuio.cli.Option[T]]
376 CLI option constructor. Takes a single positional argument
377 of type :class:`OptionSettings`, and returns an instance
378 of :class:`yuio.cli.Option`.
380.. autoclass:: OptionSettings
381 :members:
384Re-imports
385----------
387.. type:: HelpGroup
388 :no-index:
390 Alias of :obj:`yuio.cli.HelpGroup`.
392.. type:: MutuallyExclusiveGroup
393 :no-index:
395 Alias of :obj:`yuio.cli.MutuallyExclusiveGroup`.
397.. data:: MISC_GROUP
398 :no-index:
400 Alias of :obj:`yuio.cli.MISC_GROUP`.
402.. data:: OPTS_GROUP
403 :no-index:
405 Alias of :obj:`yuio.cli.OPTS_GROUP`.
407.. data:: SUBCOMMANDS_GROUP
408 :no-index:
410 Alias of :obj:`yuio.cli.SUBCOMMANDS_GROUP`.
412"""
414from __future__ import annotations
416import dataclasses
417import functools
418import inspect
419import json
420import logging
421import pathlib
422import sys
423import types
424from dataclasses import dataclass
426import yuio
427import yuio.cli
428import yuio.complete
429import yuio.config
430import yuio.dbg
431import yuio.doc
432import yuio.io
433import yuio.parse
434import yuio.string
435import yuio.term
436import yuio.theme
437import yuio.util
438from yuio.cli import (
439 MISC_GROUP,
440 OPTS_GROUP,
441 SUBCOMMANDS_GROUP,
442 HelpGroup,
443 MutuallyExclusiveGroup,
444)
445from yuio.config import (
446 OptionCtor,
447 OptionSettings,
448 bool_option,
449 collect_option,
450 count_option,
451 field,
452 inline,
453 parse_many_option,
454 parse_one_option,
455 positional,
456 store_const_option,
457 store_false_option,
458 store_true_option,
459)
460from yuio.util import find_docs as _find_docs
461from yuio.util import to_dash_case as _to_dash_case
463from typing import TYPE_CHECKING
465if TYPE_CHECKING:
466 import typing_extensions as _t
467else:
468 from yuio import _typing as _t
470__all__ = [
471 "MISC_GROUP",
472 "OPTS_GROUP",
473 "SUBCOMMANDS_GROUP",
474 "App",
475 "AppError",
476 "CommandInfo",
477 "HelpGroup",
478 "MutuallyExclusiveGroup",
479 "OptionCtor",
480 "OptionSettings",
481 "app",
482 "bool_option",
483 "collect_option",
484 "count_option",
485 "field",
486 "inline",
487 "parse_many_option",
488 "parse_one_option",
489 "positional",
490 "store_const_option",
491 "store_false_option",
492 "store_true_option",
493]
495C = _t.TypeVar("C", bound=_t.Callable[..., None | bool])
496C2 = _t.TypeVar("C2", bound=_t.Callable[..., None | bool])
499class AppError(yuio.PrettyException, Exception):
500 """
501 An error that you can throw from an app to finish its execution without printing
502 a traceback.
504 """
507@_t.overload
508def app(
509 *,
510 prog: str | None = None,
511 usage: str | None = None,
512 description: str | None = None,
513 epilog: str | None = None,
514 version: str | None = None,
515 bug_report: yuio.dbg.ReportSettings | bool = False,
516 is_dev_mode: bool | None = None,
517 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
518) -> _t.Callable[[C], App[C]]: ...
519@_t.overload
520def app(
521 command: C,
522 /,
523 *,
524 prog: str | None = None,
525 usage: str | None = None,
526 description: str | None = None,
527 epilog: str | None = None,
528 version: str | None = None,
529 bug_report: yuio.dbg.ReportSettings | bool = False,
530 is_dev_mode: bool | None = None,
531 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
532) -> App[C]: ...
533def app(
534 command: _t.Callable[..., None | bool] | None = None,
535 /,
536 *,
537 prog: str | None = None,
538 usage: str | None = None,
539 description: str | None = None,
540 epilog: str | None = None,
541 allow_abbrev: bool = False,
542 subcommand_required: bool = True,
543 setup_logging: bool = True,
544 theme: (
545 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
546 ) = None,
547 version: str | None = None,
548 bug_report: yuio.dbg.ReportSettings | bool = False,
549 is_dev_mode: bool | None = None,
550 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
551) -> _t.Any:
552 """
553 Create an application.
555 This is a decorator that's supposed to be used on the main method
556 of the application. This decorator returns an :class:`App` object.
558 :param command:
559 the main function of the application.
560 :param prog:
561 overrides program's name, see :attr:`App.prog`.
562 :param usage:
563 overrides program's usage description, see :attr:`App.usage`.
564 :param description:
565 overrides program's description, see :attr:`App.description`.
566 :param epilog:
567 overrides program's epilog, see :attr:`App.epilog`.
568 :param allow_abbrev:
569 whether to allow abbreviating unambiguous flags, see :attr:`App.allow_abbrev`.
570 :param subcommand_required:
571 whether this app requires a subcommand,
572 see :attr:`App.subcommand_required`.
573 :param setup_logging:
574 whether to perform basic logging setup on startup,
575 see :attr:`App.setup_logging`.
576 :param theme:
577 overrides theme that will be used when setting up :mod:`yuio.io`,
578 see :attr:`App.theme`.
579 :param version:
580 program's version, will be displayed using the :flag:`--version` flag.
581 :param bug_report:
582 settings for automated bug report generation. If present,
583 adds the :flag:`--bug-report` flag.
584 :param is_dev_mode:
585 enables additional logging, see :attr:`App.is_dev_mode`.
586 :param doc_format:
587 overrides program's documentation format, see :attr:`App.doc_format`.
588 :returns:
589 an :class:`App` object that wraps the original function.
591 """
593 def registrar(command: C, /) -> App[C]:
594 return App(
595 command,
596 prog=prog,
597 usage=usage,
598 description=description,
599 epilog=epilog,
600 allow_abbrev=allow_abbrev,
601 subcommand_required=subcommand_required,
602 setup_logging=setup_logging,
603 theme=theme,
604 version=version,
605 bug_report=bug_report,
606 is_dev_mode=is_dev_mode,
607 doc_format=doc_format,
608 )
610 if command is None:
611 return registrar
612 else:
613 return registrar(command)
616@_t.final
617@dataclass(frozen=True, eq=False, match_args=False, slots=True)
618class CommandInfo:
619 """
620 Data about the invoked command.
622 """
624 name: str
625 """
626 Name of the current command.
628 If it was invoked by alias,
629 this will contains the primary command name.
631 For the main function, the name will be set to ``"__main__"``.
633 """
635 # Internal, do not use.
636 _config: _t.Any = dataclasses.field(repr=False)
637 _executed: bool = dataclasses.field(default=False, repr=False)
638 _subcommand: CommandInfo | None | yuio.Missing = dataclasses.field(
639 default=yuio.MISSING, repr=False
640 )
642 @property
643 def subcommand(self) -> CommandInfo | None:
644 """
645 Subcommand of this command, if one was given.
647 """
649 if self._subcommand is yuio.MISSING:
650 if self._config._subcommand is None:
651 subcommand = None
652 else:
653 subcommand = CommandInfo(
654 self._config._subcommand, self._config._subcommand_ns.config
655 )
656 object.__setattr__(self, "_subcommand", subcommand)
657 return self._subcommand # pyright: ignore[reportReturnType]
659 def __call__(self) -> _t.Literal[False]:
660 """
661 Execute this command.
663 """
665 if self._executed:
666 return False
667 object.__setattr__(self, "_executed", True)
669 should_invoke_subcommand = self._config._run(self)
670 if should_invoke_subcommand is None:
671 should_invoke_subcommand = True
673 if should_invoke_subcommand and self.subcommand is not None:
674 self.subcommand()
676 return False
679class App(_t.Generic[C]):
680 """
681 A class that encapsulates app settings and logic for running it.
683 It is better to create instances of this class using the :func:`app` decorator,
684 as it provides means to decorate the main function and specify all of the app's
685 parameters.
687 """
689 def __init__(
690 self,
691 command: C,
692 /,
693 *,
694 prog: str | None = None,
695 usage: str | None = None,
696 help: str | yuio.Disabled | None = None,
697 description: str | None = None,
698 epilog: str | None = None,
699 allow_abbrev: bool = False,
700 subcommand_required: bool = True,
701 setup_logging: bool = True,
702 theme: (
703 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
704 ) = None,
705 version: str | None = None,
706 bug_report: yuio.dbg.ReportSettings | bool = False,
707 is_dev_mode: bool | None = None,
708 doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser | None = None,
709 ):
710 self.prog: str | None = prog
711 """
712 Program or subcommand's primary name.
714 For main app, this controls its display name and generation of shell completion
715 scripts.
717 For subcommands, this is always equal to subcommand's main name.
719 By default, inferred from :data:`sys.argv` and subcommand name.
721 """
723 self.usage: str | None = usage
724 """
725 Program or subcommand synapsis.
727 This string will be processed using the to ``bash`` syntax,
728 and then it will be ``%``-formatted with a single keyword argument ``prog``.
729 If command supports multiple signatures, each of them should be listed
730 on a separate string. For example::
732 @app
733 def main(): ...
735 main.usage = \"""
736 %(prog)s [-q] [-f] [-m] [<branch>]
737 %(prog)s [-q] [-f] [-m] --detach [<branch>]
738 %(prog)s [-q] [-f] [-m] [--detach] <commit>
739 ...
740 \"""
742 By default, usage is generated from CLI flags.
744 """
746 if description is None and command.__doc__:
747 description = yuio.util.dedent(command.__doc__).removesuffix("\n")
748 if description is None:
749 description = ""
751 self.description: str = description
752 """
753 Text that is shown before CLI flags help, usually contains
754 short description of the program or subcommand.
756 The text should be formatted using Markdown or RST,
757 depending on :attr:`~App.doc_format`. For example:
759 .. code-block:: python
761 @yuio.app.app(doc_format="md")
762 def main(): ...
764 main.description = \"""
765 This command does a thing.
767 # Different ways to do a thing
769 This command can apply multiple algorithms to achieve
770 a necessary state in which a thing can be done. This includes:
772 - randomly turning the screen on and off;
774 - banging a head on a table;
776 - fiddling with your PCs power cord.
778 By default, the best algorithm is determined automatically.
779 However, you can hint a preferred algorithm via the `--hint-algo` flag.
781 \"""
783 By default, inferred from command's docstring.
785 """
787 if help is None and description:
788 help = description
789 if (index := help.find("\n\n")) != -1:
790 help = help[:index]
791 elif help is None:
792 help = ""
794 self.help: str | yuio.Disabled = help
795 """
796 Short help message that is shown when listing subcommands.
798 By default, uses first paragraph of description.
800 """
802 self.epilog: str | None = epilog
803 """
804 Text that is shown after the main portion of the help message.
806 The text should be formatted using Markdown or RST,
807 depending on :attr:`~App.doc_format`.
809 """
811 self.allow_abbrev: bool = allow_abbrev
812 """
813 Allow abbreviating CLI flags if that doesn't create ambiguity.
815 Disabled by default.
817 """
819 self.subcommand_required: bool = subcommand_required
820 """
821 Require the user to provide a subcommand for this command.
823 If this command doesn't have any subcommands, this option is ignored.
825 Enabled by default.
827 """
829 self.setup_logging: bool = setup_logging
830 """
831 If :data:`True`, the app will call :func:`logging.basicConfig` during
832 its initialization. Disable this if you want to customize
833 logging initialization.
835 Disabling this option also removes the :flag:`--verbose` flag form the CLI.
837 """
839 self.theme: (
840 yuio.theme.Theme | _t.Callable[[yuio.term.Term], yuio.theme.Theme] | None
841 ) = theme
842 """
843 A custom theme that will be passed to :func:`yuio.io.setup`
844 on application startup.
846 """
848 self.version: str | None = version
849 """
850 If not :data:`None`, add :flag:`--version` flag to the CLI.
852 """
854 self.bug_report: yuio.dbg.ReportSettings | bool = bug_report
855 """
856 If not :data:`False`, add :flag:`--bug-report` flag to the CLI.
858 This flag automatically collects data about environment and prints it
859 in a format suitable for adding to a bug report.
861 """
863 self.is_dev_mode: bool | None = is_dev_mode
864 """
865 If :data:`True`, this will enable :func:`logging.captureWarnings`
866 and configure internal Yuio logging to show warnings.
868 By default, dev mode is detected by checking if :attr:`~App.version`
869 contains substring ``"dev"``.
871 .. note::
873 You can always enable full debug logging by setting environment
874 variable ``YUIO_DEBUG``.
876 If enabled, full log will be saved to ``YUIO_DEBUG_FILE``.
878 """
880 self.doc_format: _t.Literal["md", "rst"] | yuio.doc.DocParser = (
881 doc_format or "rst"
882 )
883 """
884 Format or parser that will be used to interpret documentation.
886 """
888 self._ordered_subcommands: list[App[_t.Any]] = []
889 self._subcommands: dict[str, App[_t.Any]] = {}
890 self._parent: App[_t.Any] | None = None
891 self._aliases: list[str] | None = None
893 if callable(command):
894 self._config_type = _command_from_callable(command)
895 else:
896 raise TypeError(f"expected a function, got {command}")
898 functools.update_wrapper(
899 self, # type: ignore
900 command,
901 assigned=("__module__", "__name__", "__qualname__", "__doc__"),
902 updated=(),
903 )
905 self._command = command
907 @functools.wraps(command)
908 def wrapped_command(*args, **kwargs):
909 a_params: list[str] = getattr(self._config_type, "_a_params")
910 a_kw_params: list[str] = getattr(self._config_type, "_a_kw_params")
911 var_a_param: str | None = getattr(self._config_type, "_var_a_param")
912 kw_params: list[str] = getattr(self._config_type, "_kw_params")
914 i = 0
916 for name in a_params:
917 if name in kwargs:
918 raise TypeError(
919 f"positional-only argument {name} was given as keyword argument"
920 )
921 if i < len(args):
922 kwargs[name] = args[i]
923 i += 1
925 for name in a_kw_params:
926 if i >= len(args):
927 break
928 if name in kwargs:
929 raise TypeError(f"argument {name} was given twice")
930 kwargs[name] = args[i]
931 i += 1
933 if var_a_param:
934 if var_a_param in kwargs:
935 raise TypeError(f"unexpected argument {var_a_param}")
936 kwargs[var_a_param] = args[i:]
937 i = len(args)
938 elif i < len(args):
939 s = "" if i == 1 else "s"
940 raise TypeError(
941 f"expected at most {i} positional argument{s}, got {len(args)}"
942 )
944 kwargs.pop("_command_info", None)
946 config = self._config_type(**kwargs)
948 for name in a_params + a_kw_params + kw_params:
949 if not hasattr(config, name) and name != "_command_info":
950 raise TypeError(f"missing required argument {name}")
952 return CommandInfo("__raw__", config, False)()
954 self.wrapped: C = wrapped_command # type: ignore
955 """
956 The original callable what was wrapped by :func:`app`.
958 """
960 @_t.overload
961 def subcommand(
962 self,
963 /,
964 *,
965 name: str | None = None,
966 aliases: list[str] | None = None,
967 usage: str | None = None,
968 help: str | yuio.Disabled | None = None,
969 description: str | None = None,
970 epilog: str | None = None,
971 ) -> _t.Callable[[C2], App[C2]]: ...
973 @_t.overload
974 def subcommand(
975 self,
976 cb: C2,
977 /,
978 *,
979 name: str | None = None,
980 aliases: list[str] | None = None,
981 usage: str | None = None,
982 help: str | yuio.Disabled | None = None,
983 description: str | None = None,
984 epilog: str | None = None,
985 ) -> App[C2]: ...
987 def subcommand(
988 self,
989 cb: _t.Callable[..., None | bool] | None = None,
990 /,
991 *,
992 name: str | None = None,
993 aliases: list[str] | None = None,
994 usage: str | None = None,
995 help: str | yuio.Disabled | None = None,
996 description: str | None = None,
997 epilog: str | None = None,
998 subcommand_required: bool = True,
999 ) -> _t.Any:
1000 """
1001 Register a subcommand for the given app.
1003 This method can be used as a decorator, similar to the :func:`app` function.
1005 :param name:
1006 allows overriding subcommand's name.
1007 :param aliases:
1008 allows adding alias names for subcommand.
1009 :param usage:
1010 overrides subcommand's usage description, see :attr:`App.usage`.
1011 :param help:
1012 overrides subcommand's short help, see :attr:`App.help`.
1013 pass :data:`~yuio.DISABLED` to hide this subcommand in CLI help message.
1014 :param description:
1015 overrides subcommand's description, see :attr:`App.description`.
1016 :param epilog:
1017 overrides subcommand's epilog, see :attr:`App.epilog`.
1018 :param subcommand_required:
1019 whether this subcommand requires another subcommand,
1020 see :attr:`App.subcommand_required`.
1021 :returns:
1022 a new :class:`App` object for a subcommand.
1024 """
1026 def registrar(cb: C2, /) -> App[C2]:
1027 main_name = name or _to_dash_case(cb.__name__)
1028 app = App(
1029 cb,
1030 prog=main_name,
1031 usage=usage,
1032 help=help,
1033 description=description,
1034 epilog=epilog,
1035 subcommand_required=subcommand_required,
1036 )
1037 app._parent = self
1038 app._aliases = aliases
1040 self._ordered_subcommands.append(app)
1041 self._subcommands[main_name] = app
1042 if aliases:
1043 self._subcommands.update({alias: app for alias in aliases})
1045 return app
1047 if cb is None:
1048 return registrar
1049 else:
1050 return registrar(cb)
1052 def run(self, args: list[str] | None = None) -> _t.NoReturn:
1053 """
1054 Parse arguments, set up :mod:`yuio.io` and :mod:`logging`,
1055 and run the application.
1057 :param args:
1058 command line arguments. If none are given,
1059 use arguments from :data:`sys.argv`.
1060 :returns:
1061 this method does not return, it exits the program instead.
1063 """
1065 if args is None:
1066 args = sys.argv[1:]
1068 if "--yuio-custom-completer--" in args:
1069 index = args.index("--yuio-custom-completer--")
1070 _run_custom_completer(
1071 self._make_cli_command(root=True), args[index + 1], args[index + 2]
1072 )
1073 sys.exit(0)
1075 if "--yuio-bug-report--" in args:
1076 from yuio.dbg import print_report
1078 print_report(settings=self.bug_report, app=self)
1079 sys.exit(0)
1081 yuio.io.setup(theme=self.theme, wrap_stdio=True)
1083 try:
1084 if self.is_dev_mode is None:
1085 self.is_dev_mode = (
1086 self.version is not None and "dev" in self.version.casefold()
1087 )
1088 if self.is_dev_mode:
1089 yuio.enable_internal_logging(add_handler=True)
1091 help_parser = self._make_help_parser()
1093 cli_command = self._make_cli_command(root=True)
1094 namespace = yuio.cli.CliParser(
1095 cli_command, help_parser=help_parser, allow_abbrev=self.allow_abbrev
1096 ).parse(args)
1098 if self.setup_logging:
1099 logging_level = {
1100 0: logging.WARNING,
1101 1: logging.INFO,
1102 2: logging.DEBUG,
1103 }.get(namespace["_verbose"], logging.DEBUG)
1104 logging.basicConfig(handlers=[yuio.io.Handler()], level=logging_level)
1106 command = CommandInfo("__main__", _config=namespace.config)
1107 command()
1108 sys.exit(0)
1109 except yuio.cli.ArgumentError as e:
1110 yuio.io.raw(e, add_newline=True, wrap=True)
1111 sys.exit(1)
1112 except (AppError, yuio.cli.ArgumentError, yuio.parse.ParsingError) as e:
1113 yuio.io.failure(e)
1114 sys.exit(1)
1115 except KeyboardInterrupt:
1116 yuio.io.failure("Received Keyboard Interrupt, stopping now")
1117 sys.exit(130)
1118 except Exception as e:
1119 yuio.io.failure_with_tb("Error: %s", e)
1120 sys.exit(3)
1121 finally:
1122 yuio.io.restore_streams()
1124 def _make_help_parser(self):
1125 if self.doc_format == "md":
1126 from yuio.md import MdParser
1128 return MdParser()
1129 elif self.doc_format == "rst":
1130 from yuio.rst import RstParser
1132 return RstParser()
1133 else:
1134 return self.doc_format
1136 def _make_cli_command(self, root: bool = False):
1137 options = self._config_type._build_options()
1139 if root:
1140 options.append(yuio.cli.HelpOption())
1141 if self.version:
1142 options.append(yuio.cli.VersionOption(version=self.version))
1143 if self.setup_logging:
1144 options.append(
1145 yuio.cli.CountOption(
1146 flags=["-v", "--verbose"],
1147 usage=yuio.COLLAPSE,
1148 help="Increase output verbosity.",
1149 help_group=yuio.cli.MISC_GROUP,
1150 show_if_inherited=False,
1151 dest="_verbose",
1152 )
1153 )
1154 if self.bug_report:
1155 options.append(yuio.cli.BugReportOption(app=self))
1156 options.append(yuio.cli.CompletionOption())
1157 options.append(_ColorOption())
1159 subcommands = {}
1160 subcommand_for_app = {}
1161 for name, sub_app in self._subcommands.items():
1162 if sub_app not in subcommand_for_app:
1163 subcommand_for_app[sub_app] = sub_app._make_cli_command()
1164 subcommands[name] = subcommand_for_app[sub_app]
1166 return yuio.cli.Command(
1167 name=self.prog or pathlib.Path(sys.argv[0]).stem,
1168 desc=self.description or "",
1169 help=self.help,
1170 epilog=self.epilog or "",
1171 usage=yuio.util.dedent(self.usage or ""),
1172 options=options,
1173 subcommands=subcommands,
1174 subcommand_required=self.subcommand_required,
1175 ns_ctor=lambda: yuio.cli.ConfigNamespace(self._config_type()),
1176 dest="_subcommand",
1177 ns_dest="_subcommand_ns",
1178 )
1181def _command_from_callable(
1182 cb: _t.Callable[..., None | bool],
1183) -> type[yuio.config.Config]:
1184 sig = inspect.signature(cb)
1186 dct = {}
1187 annotations = {}
1189 try:
1190 docs = _find_docs(cb)
1191 except Exception:
1192 yuio._logger.warning(
1193 "unable to get documentation for %s.%s",
1194 cb.__module__,
1195 cb.__qualname__,
1196 )
1197 docs = {}
1199 dct["_a_params"] = a_params = []
1200 dct["_var_a_param"] = var_a_param = None
1201 dct["_a_kw_params"] = a_kw_params = []
1202 dct["_kw_params"] = kw_params = []
1204 for name, param in sig.parameters.items():
1205 if param.kind is param.VAR_KEYWORD:
1206 raise TypeError("variadic keyword parameters are not supported")
1208 is_special = False
1209 if name.startswith("_"):
1210 is_special = True
1211 if name != "_command_info":
1212 raise TypeError(f"unknown special parameter {name}")
1213 if param.kind is param.VAR_POSITIONAL:
1214 raise TypeError(f"special parameter {name} can't be variadic")
1216 if param.default is not param.empty:
1217 field = param.default
1218 else:
1219 field = yuio.MISSING
1220 if not isinstance(field, yuio.config._FieldSettings):
1221 field = _t.cast(
1222 yuio.config._FieldSettings, yuio.config.field(default=field)
1223 )
1225 annotation = param.annotation
1226 if annotation is param.empty and not is_special:
1227 raise TypeError(f"parameter {name} requires type annotation")
1229 match param.kind:
1230 case param.POSITIONAL_ONLY:
1231 if field.flags is None:
1232 field = dataclasses.replace(field, flags=yuio.POSITIONAL)
1233 a_params.append(name)
1234 case param.VAR_POSITIONAL:
1235 if field.flags is None:
1236 field = dataclasses.replace(field, flags=yuio.POSITIONAL)
1237 annotation = list[annotation]
1238 dct["_var_a_param"] = var_a_param = name
1239 case param.POSITIONAL_OR_KEYWORD:
1240 a_kw_params.append(name)
1241 case param.KEYWORD_ONLY:
1242 kw_params.append(name)
1244 if not is_special:
1245 dct[name] = field
1246 annotations[name] = annotation
1248 dct["_run"] = _command_from_callable_run_impl(
1249 cb, a_params + a_kw_params, var_a_param, kw_params
1250 )
1251 dct["_color"] = None
1252 dct["_verbose"] = 0
1253 dct["_subcommand"] = None
1254 dct["_subcommand_ns"] = None
1255 dct["__annotations__"] = annotations
1256 dct["__module__"] = getattr(cb, "__module__", None)
1257 dct["__doc__"] = getattr(cb, "__doc__", None)
1258 dct["__yuio_pre_parsed_docs__"] = docs
1260 return types.new_class(
1261 cb.__name__,
1262 (yuio.config.Config,),
1263 {"_allow_positionals": True},
1264 exec_body=lambda ns: ns.update(dct),
1265 )
1268def _command_from_callable_run_impl(
1269 cb: _t.Callable[..., None | bool],
1270 a_params: list[str],
1271 var_a_param: str | None,
1272 kw_params: list[str],
1273):
1274 def run(self, command_info):
1275 get = lambda name: (
1276 command_info if name == "_command_info" else getattr(self, name)
1277 )
1278 args = [get(name) for name in a_params]
1279 if var_a_param is not None:
1280 args.extend(get(var_a_param))
1281 kwargs = {name: get(name) for name in kw_params}
1282 return cb(*args, **kwargs)
1284 return run
1287def _run_custom_completer(command: yuio.cli.Command[_t.Any], raw_data: str, word: str):
1288 data = json.loads(raw_data)
1289 path: str = data["path"]
1290 flags: set[str] = set(data["flags"])
1291 index: int = data["index"]
1293 root = command
1294 for name in path.split("/"):
1295 if not name:
1296 continue
1297 if name not in command.subcommands:
1298 return
1299 root = command.subcommands[name]
1301 positional_index = 0
1302 for option in root.options:
1303 option_flags = option.flags
1304 if option_flags is yuio.POSITIONAL:
1305 option_flags = [str(positional_index)]
1306 positional_index += 1
1307 if flags.intersection(option_flags):
1308 completer, is_many = option.get_completer()
1309 break
1310 else:
1311 completer, is_many = None, False
1313 if completer:
1314 yuio.complete._run_completer_at_index(completer, is_many, index, word)
1317@dataclass(eq=False, kw_only=True)
1318class _ColorOption(yuio.cli.Option[_t.Never]):
1319 # `yuio.term` will scan `sys.argv` on its own, this option just checks format
1320 # and adds help entry.
1322 _ALLOWED_VALUES = (
1323 "y",
1324 "yes",
1325 "true",
1326 "1",
1327 "n",
1328 "no",
1329 "false",
1330 "0",
1331 "ansi",
1332 "ansi-256",
1333 "ansi-true",
1334 )
1336 _PUBLIC_VALUES = (
1337 ("true", "3-bit colors or higher"),
1338 ("false", "disable colors"),
1339 ("ansi", "force 3-bit colors"),
1340 ("ansi-256", "force 8-bit colors"),
1341 ("ansi-true", "force 24-bit colors"),
1342 )
1344 def __init__(self):
1345 super().__init__(
1346 flags=["--color", "--no-color"],
1347 allow_inline_arg=True,
1348 allow_implicit_inline_arg=True,
1349 nargs=0,
1350 allow_no_args=True,
1351 required=False,
1352 metavar=(),
1353 mutex_group=None,
1354 usage=yuio.COLLAPSE,
1355 help="Enable or disable ANSI colors.",
1356 help_group=yuio.cli.MISC_GROUP,
1357 show_if_inherited=False,
1358 allow_abbrev=False,
1359 dest="_color",
1360 default_desc=None,
1361 )
1363 def process(
1364 self,
1365 cli_parser: yuio.cli.CliParser[yuio.cli.Namespace],
1366 flag: yuio.cli.Flag | None,
1367 arguments: yuio.cli.Argument | list[yuio.cli.Argument],
1368 ns: yuio.cli.Namespace,
1369 ):
1370 if isinstance(arguments, yuio.cli.Argument):
1371 if flag and flag.value == "--no-color":
1372 raise yuio.cli.ArgumentError(
1373 "This flag can't have arguments", flag=flag, arguments=arguments
1374 )
1375 if arguments.value.casefold() not in self._ALLOWED_VALUES:
1376 raise yuio.cli.ArgumentError(
1377 "Can't parse `%r` as color, should be %s",
1378 arguments.value,
1379 yuio.string.Or(value for value, _ in self._PUBLIC_VALUES),
1380 flag=flag,
1381 arguments=arguments,
1382 )
1384 @functools.cached_property
1385 def primary_short_flag(self):
1386 return None
1388 @functools.cached_property
1389 def primary_long_flags(self):
1390 return ["--color", "--no-color"]
1392 def format_alias_flags(
1393 self,
1394 ctx: yuio.string.ReprContext,
1395 /,
1396 *,
1397 all: bool = False,
1398 ) -> (
1399 list[yuio.string.ColorizedString | tuple[yuio.string.ColorizedString, str]]
1400 | None
1401 ):
1402 if self.flags is yuio.POSITIONAL:
1403 return None
1405 primary_flags = set(self.primary_long_flags or [])
1406 if self.primary_short_flag:
1407 primary_flags.add(self.primary_short_flag)
1409 aliases: list[
1410 yuio.string.ColorizedString | tuple[yuio.string.ColorizedString, str]
1411 ] = []
1412 flag_color = ctx.get_color("hl/flag:sh-usage")
1413 punct_color = ctx.get_color("hl/punct:sh-usage")
1414 metavar_color = ctx.get_color("hl/metavar:sh-usage")
1415 res = yuio.string.ColorizedString()
1416 res.start_no_wrap()
1417 res.append_color(flag_color)
1418 res.append_str("--color")
1419 res.end_no_wrap()
1420 res.append_color(punct_color)
1421 res.append_str("={")
1422 sep = False
1423 for value, _ in self._PUBLIC_VALUES:
1424 if sep:
1425 res.append_color(punct_color)
1426 res.append_str("|")
1427 res.append_color(metavar_color)
1428 res.append_str(value)
1429 sep = True
1430 res.append_color(punct_color)
1431 res.append_str("}")
1432 aliases.append(res)
1433 return aliases
1435 def get_completer(self) -> tuple[yuio.complete.Completer | None, bool]:
1436 return yuio.complete.Choice(
1437 [
1438 yuio.complete.Option(value, comment)
1439 for value, comment in self._PUBLIC_VALUES
1440 ]
1441 ), False