Coverage for yuio / config.py: 95%
476 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 a base class for configs that can be loaded from
10files, environment variables or command line arguments (via :mod:`yuio.app`).
12Derive your config from the :class:`Config` base class. Inside of its body,
13define config fields using type annotations, just like :mod:`dataclasses`:
15.. code-block:: python
17 class AppConfig(Config):
18 #: trained model to execute
19 model: pathlib.Path
21 #: input data for the model
22 data: pathlib.Path
24 #: enable or disable gpu
25 use_gpu: bool = True
27Then use config's constructors and the :meth:`~Config.update` method
28to load it from various sources::
30 # Load config from a file.
31 config = AppConfig.load_from_json_file("~/.my_app_cfg.json")
33 # Update config with values from env.
34 config.update(AppConfig.load_from_env())
37Config base class
38-----------------
40.. autoclass:: Config
43Advanced field configuration
44----------------------------
46By default, :class:`Config` infers names for env variables and flags,
47appropriate parsers, and other things from field's name, type hint, and comments.
49If you need to override them, you can use :func:`yuio.app.field`
50and :func:`yuio.app.inline` functions (also available from :mod:`yuio.config`):
52.. code-block:: python
54 class AppConfig(Config):
55 model: pathlib.Path | None = field(
56 default=None,
57 help="trained model to execute",
58 )
61Nesting configs
62---------------
64You can nest configs to achieve modularity:
66.. code-block:: python
68 class ExecutorConfig(Config):
69 #: number of threads to use
70 threads: int
72 #: enable or disable gpu
73 use_gpu: bool = True
76 class AppConfig(Config):
77 #: executor parameters
78 executor: ExecutorConfig
80 #: trained model to execute
81 model: pathlib.Path
83To initialise a nested config, pass either an instance of if
84or a dict with its variables to the config's constructor:
86.. code-block:: python
88 # The following lines are equivalent:
89 config = AppConfig(executor=ExecutorConfig(threads=16))
90 config = AppConfig(executor={"threads": 16})
91 # ...although type checkers will complain about dict =(
94Parsing environment variables
95-----------------------------
97You can load config from environment through :meth:`~Config.load_from_env`.
99Names of environment variables are just capitalized field names.
100Use the :func:`yuio.app.field` function to override them:
102.. code-block:: python
104 class KillCmdConfig(Config):
105 # Will be loaded from `SIGNAL`.
106 signal: int
108 # Will be loaded from `PROCESS_ID`.
109 pid: int = field(env="PROCESS_ID")
111In nested configs, environment variable names are prefixed with name
112of a field that contains the nested config:
114.. code-block:: python
116 class BigConfig(Config):
117 # `kill_cmd.signal` will be loaded from `KILL_CMD_SIGNAL`.
118 kill_cmd: KillCmdConfig
120 # `kill_cmd_2.signal` will be loaded from `KILL_SIGNAL`.
121 kill_cmd_2: KillCmdConfig = field(env="KILL")
123 # `kill_cmd_3.signal` will be loaded from `SIGNAL`.
124 kill_cmd_3: KillCmdConfig = field(env="")
126You can also disable loading a field from an environment altogether:
128.. code-block:: python
130 class KillCmdConfig(Config):
131 # Will not be loaded from env.
132 pid: int = field(env=yuio.DISABLED)
134To prefix all variable names with some string, pass the `prefix` parameter
135to the :meth:`~Config.load_from_env` function:
137.. code-block:: python
139 # config.kill_cmd.field will be loaded
140 # from `MY_APP_KILL_CMD_SIGNAL`
141 config = BigConfig.load_from_env("MY_APP")
144Parsing config files
145--------------------
147You can load config from structured config files,
148such as `json`, `yaml` or `toml`:
150.. skip: next
152.. code-block:: python
154 class ExecutorConfig(Config):
155 threads: int
156 use_gpu: bool = True
159 class AppConfig(Config):
160 executor: ExecutorConfig
161 model: pathlib.Path
164 config = AppConfig.load_from_json_file("~/.my_app_cfg.json")
166In this example, contents of the above config would be:
168.. code-block:: json
170 {
171 "executor": {
172 "threads": 16,
173 "use_gpu": true
174 },
175 "model": "/path/to/model"
176 }
178Note that, unlike with environment variables,
179there is no way to inline nested configs.
182Additional config validation
183----------------------------
185If you have invariants that can't be captured with type system,
186you can override :meth:`~Config.validate_config`. This method will be called
187every time you load a config from file, arguments or environment:
189.. code-block:: python
191 class DocGenConfig(Config):
192 categories: list[str] = ["quickstart", "api_reference"]
193 category_names: dict[str, str] = {"deep_dive": "Deep Dive"}
195 def validate_config(self):
196 for category in self.category_names:
197 if category not in self.categories:
198 raise yuio.parse.ParsingError(f"unknown category {category}")
201Merging configs
202---------------
204Configs are specially designed to be merge-able. The basic pattern is to create
205an empty config instance, then :meth:`~Config.update` it with every config source:
207.. skip: next
209.. code-block:: python
211 config = AppConfig()
212 config.update(AppConfig.load_from_json_file("~/.my_app_cfg.json"))
213 config.update(AppConfig.load_from_env())
214 # ...and so on.
216The :meth:`~Config.update` function ignores default values, and only overrides
217keys that were actually configured.
219If you need a more complex update behavior, you can add a merge function for a field:
221.. code-block:: python
223 class AppConfig(Config):
224 plugins: list[str] = field(
225 default=[],
226 merge=lambda left, right: [*left, *right],
227 )
229Here, whenever we :meth:`~Config.update` ``AppConfig``, ``plugins`` from both instances
230will be concatenated.
232.. warning::
234 Merge function shouldn't mutate its arguments.
235 It should produce a new value instead.
237.. warning::
239 Merge function will not be called for default value. It's advisable to keep the
240 default value empty, and add the actual default to the initial empty config:
242 .. skip: next
244 .. code-block:: python
246 config = AppConfig(plugins=["markdown", "rst"])
247 config.update(...)
250Re-imports
251----------
254.. function:: field
255 :no-index:
257 Alias of :obj:`yuio.app.field`
259.. function:: inline
260 :no-index:
262 Alias of :obj:`yuio.app.inline`
264.. function:: positional
265 :no-index:
267 Alias of :obj:`yuio.app.positional`
269.. function:: bool_option
270 :no-index:
272 Alias of :obj:`yuio.app.bool_option`
274.. function:: count_option
275 :no-index:
277 Alias of :obj:`yuio.app.count_option`
279.. function:: parse_many_option
280 :no-index:
282 Alias of :obj:`yuio.app.parse_many_option`
284.. function:: parse_one_option
285 :no-index:
287 Alias of :obj:`yuio.app.parse_one_option`
289.. function:: store_const_option
290 :no-index:
292 Alias of :obj:`yuio.app.store_const_option`
294.. function:: store_false_option
295 :no-index:
297 Alias of :obj:`yuio.app.store_false_option`
299.. function:: store_true_option
300 :no-index:
302 Alias of :obj:`yuio.app.store_true_option`
304.. type:: HelpGroup
305 :no-index:
307 Alias of :obj:`yuio.cli.HelpGroup`.
309.. type:: MutuallyExclusiveGroup
310 :no-index:
312 Alias of :obj:`yuio.cli.MutuallyExclusiveGroup`.
314.. type:: OptionCtor
315 :no-index:
317 Alias of :obj:`yuio.app.OptionCtor`.
319.. type:: OptionSettings
320 :no-index:
322 Alias of :obj:`yuio.app.OptionSettings`.
324.. data:: MISC_GROUP
325 :no-index:
327 Alias of :obj:`yuio.cli.MISC_GROUP`.
329.. data:: OPTS_GROUP
330 :no-index:
332 Alias of :obj:`yuio.cli.OPTS_GROUP`.
334.. data:: SUBCOMMANDS_GROUP
335 :no-index:
337 Alias of :obj:`yuio.cli.SUBCOMMANDS_GROUP`.
339"""
341from __future__ import annotations
343import json
344import os
345import pathlib
346import textwrap
347import types
348from dataclasses import dataclass
350import yuio
351import yuio.cli
352import yuio.complete
353import yuio.json_schema
354import yuio.parse
355import yuio.string
356from yuio.cli import (
357 MISC_GROUP,
358 OPTS_GROUP,
359 SUBCOMMANDS_GROUP,
360 HelpGroup,
361 MutuallyExclusiveGroup,
362)
363from yuio.util import _find_docs
365import yuio._typing_ext as _tx
366from typing import TYPE_CHECKING
368if TYPE_CHECKING:
369 import typing_extensions as _t
370else:
371 from yuio import _typing as _t
373__all__ = [
374 "MISC_GROUP",
375 "OPTS_GROUP",
376 "SUBCOMMANDS_GROUP",
377 "Config",
378 "HelpGroup",
379 "MutuallyExclusiveGroup",
380 "OptionCtor",
381 "OptionSettings",
382 "bool_option",
383 "count_option",
384 "field",
385 "inline",
386 "parse_many_option",
387 "parse_one_option",
388 "positional",
389 "store_const_option",
390 "store_false_option",
391 "store_true_option",
392]
394T = _t.TypeVar("T")
395Cfg = _t.TypeVar("Cfg", bound="Config")
398@dataclass(frozen=True, slots=True)
399class _FieldSettings:
400 default: _t.Any
401 parser: yuio.parse.Parser[_t.Any] | None = None
402 help: str | yuio.Disabled | None = None
403 env: str | yuio.Disabled | None = None
404 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None
405 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING
406 metavar: str | None = None
407 required: bool | None = None
408 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None = None
409 mutex_group: MutuallyExclusiveGroup | None = None
410 help_group: HelpGroup | None = None
411 usage: yuio.Group | bool | None = None
412 show_if_inherited: bool | None = None
413 option_ctor: _t.Callable[[OptionSettings], _t.Any] | None = None
415 def _update_defaults(
416 self,
417 qualname: str,
418 name: str,
419 ty_with_extras: _t.Any,
420 parsed_help: str | None,
421 allow_positionals: bool,
422 ) -> _Field:
423 ty = ty_with_extras
424 while _t.get_origin(ty) is _t.Annotated:
425 ty = _t.get_args(ty)[0]
426 is_subconfig = isinstance(ty, type) and issubclass(ty, Config)
428 help: str | yuio.Disabled
429 if self.help is not None:
430 help = self.help
431 elif parsed_help is not None:
432 help = parsed_help
433 elif is_subconfig and ty.__doc__:
434 help = ty.__doc__
435 else:
436 help = ""
438 env: str | yuio.Disabled
439 if self.env is not None:
440 env = self.env
441 else:
442 env = name.upper()
443 if env == "" and not is_subconfig:
444 raise TypeError(f"{qualname} got an empty env variable name")
446 flags: list[str] | yuio.Positional | yuio.Disabled
447 if self.flags is yuio.DISABLED:
448 flags = self.flags
449 elif self.flags is yuio.POSITIONAL:
450 if not allow_positionals:
451 raise TypeError(
452 f"{qualname}: positional arguments are not allowed in configs"
453 )
454 if is_subconfig:
455 raise TypeError(
456 f"error in {qualname}: nested configs can't be positional"
457 )
458 flags = self.flags
459 elif self.flags is None:
460 flags = ["--" + name.replace("_", "-")]
461 else:
462 if isinstance(self.flags, str):
463 flags = self.flags.split() or [""]
464 else:
465 flags = self.flags
467 if is_subconfig:
468 if not flags:
469 raise TypeError(
470 f"error in {qualname}: nested configs should have exactly one flag; "
471 "to disable prefixing, pass an empty string as a flag"
472 )
473 if len(flags) > 1:
474 raise TypeError(
475 f"error in {qualname}: nested configs can't have multiple flags"
476 )
477 if flags[0]:
478 if not flags[0].startswith("--"):
479 raise TypeError(
480 f"error in {qualname}: nested configs can't have a short flag"
481 )
482 try:
483 yuio.cli._check_flag(flags[0])
484 except TypeError as e:
485 raise TypeError(f"error in {qualname}: {e}") from None
486 else:
487 if not flags:
488 raise TypeError(f"{qualname} should have at least one flag")
489 for flag in flags:
490 try:
491 yuio.cli._check_flag(flag)
492 except TypeError as e:
493 raise TypeError(f"error in {qualname}: {e}") from None
495 default = self.default
496 if is_subconfig and default is not yuio.MISSING:
497 raise TypeError(f"error in {qualname}: nested configs can't have defaults")
499 parser = self.parser
500 if is_subconfig and parser is not None:
501 raise TypeError(f"error in {qualname}: nested configs can't have parsers")
502 elif not is_subconfig and parser is None:
503 try:
504 parser = yuio.parse.from_type_hint(ty_with_extras)
505 except TypeError as e:
506 raise TypeError(
507 f"can't derive parser for {qualname}:\n"
508 + textwrap.indent(str(e), " ")
509 ) from None
510 if parser is not None:
511 origin = _t.get_origin(ty)
512 args = _t.get_args(ty)
513 is_optional = (
514 default is None or _tx.is_union(origin) and types.NoneType in args
515 )
516 if is_optional and not yuio.parse._is_optional_parser(parser):
517 parser = yuio.parse.Optional(parser)
518 completer = self.completer
519 metavar = self.metavar
520 if not metavar and flags is yuio.POSITIONAL:
521 metavar = f"<{name.replace('_', '-')}>"
522 if completer is not None or metavar is not None:
523 parser = yuio.parse.WithMeta(parser, desc=metavar, completer=completer)
525 required = self.required
526 if is_subconfig and required:
527 raise TypeError(f"error in {qualname}: nested configs can't be required")
528 if required is None:
529 if is_subconfig:
530 required = False
531 elif allow_positionals:
532 required = default is yuio.MISSING
533 else:
534 required = False
536 merge = self.merge
537 if is_subconfig and merge is not None:
538 raise TypeError(
539 f"error in {qualname}: nested configs can't have merge function"
540 )
542 mutex_group = self.mutex_group
543 if is_subconfig and mutex_group is not None:
544 raise TypeError(
545 f"error in {qualname}: nested configs can't be a part "
546 "of a mutually exclusive group"
547 )
548 if flags is yuio.POSITIONAL and mutex_group is not None:
549 raise TypeError(
550 f"error in {qualname}: positional arguments can't appear in mutually exclusive groups"
551 )
553 usage = self.usage
555 show_if_inherited = self.show_if_inherited
557 help_group = self.help_group
559 option_ctor = self.option_ctor
560 if option_ctor is not None and is_subconfig:
561 raise TypeError(
562 f"error in {qualname}: nested configs can't have option constructors"
563 )
565 return _Field(
566 default,
567 parser,
568 help,
569 env,
570 flags,
571 is_subconfig,
572 ty,
573 required,
574 merge,
575 mutex_group,
576 help_group,
577 usage,
578 show_if_inherited,
579 option_ctor,
580 )
583@dataclass(frozen=True, slots=True)
584class _Field:
585 default: _t.Any
586 parser: yuio.parse.Parser[_t.Any] | None
587 help: str | yuio.Disabled
588 env: str | yuio.Disabled
589 flags: list[str] | yuio.Positional | yuio.Disabled
590 is_subconfig: bool
591 ty: type
592 required: bool
593 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None
594 mutex_group: MutuallyExclusiveGroup | None
595 help_group: HelpGroup | None
596 usage: yuio.Group | bool | None
597 show_if_inherited: bool | None = None
598 option_ctor: _t.Callable[[OptionSettings], _t.Any] | None = None
601@_t.overload
602def field(
603 *,
604 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
605 metavar: str | None = None,
606 help: str | yuio.Disabled | None = None,
607 env: str | yuio.Disabled | None = None,
608 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None,
609 required: bool | None = None,
610 mutex_group: MutuallyExclusiveGroup | None = None,
611 help_group: HelpGroup | None = None,
612 show_if_inherited: bool | None = None,
613 usage: yuio.Group | bool | None = None,
614) -> _t.Any: ...
615@_t.overload
616def field(
617 *,
618 default: None,
619 parser: yuio.parse.Parser[T] | None = None,
620 help: str | yuio.Disabled | None = None,
621 env: str | yuio.Disabled | None = None,
622 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None,
623 required: bool | None = None,
624 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
625 metavar: str | None = None,
626 merge: _t.Callable[[T, T], T] | None = None,
627 mutex_group: MutuallyExclusiveGroup | None = None,
628 help_group: HelpGroup | None = None,
629 usage: yuio.Group | bool | None = None,
630 show_if_inherited: bool | None = None,
631 option_ctor: OptionCtor[T] | None = None,
632) -> T | None: ...
633@_t.overload
634def field(
635 *,
636 default: T | yuio.Missing = yuio.MISSING,
637 parser: yuio.parse.Parser[T] | None = None,
638 help: str | yuio.Disabled | None = None,
639 env: str | yuio.Disabled | None = None,
640 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None,
641 required: bool | None = None,
642 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
643 metavar: str | None = None,
644 merge: _t.Callable[[T, T], T] | None = None,
645 mutex_group: MutuallyExclusiveGroup | None = None,
646 help_group: HelpGroup | None = None,
647 usage: yuio.Group | bool | None = None,
648 show_if_inherited: bool | None = None,
649 option_ctor: OptionCtor[T] | None = None,
650) -> T: ...
651def field(
652 *,
653 default: _t.Any = yuio.MISSING,
654 parser: yuio.parse.Parser[_t.Any] | None = None,
655 help: str | yuio.Disabled | None = None,
656 env: str | yuio.Disabled | None = None,
657 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None,
658 required: bool | None = None,
659 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
660 metavar: str | None = None,
661 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None = None,
662 mutex_group: MutuallyExclusiveGroup | None = None,
663 help_group: HelpGroup | None = None,
664 usage: yuio.Group | bool | None = None,
665 show_if_inherited: bool | None = None,
666 option_ctor: _t.Callable[..., _t.Any] | None = None,
667) -> _t.Any:
668 """
669 Field descriptor, used for additional configuration of CLI options
670 and config fields.
672 :param default:
673 default value for the field or CLI option.
674 :param parser:
675 parser that will be used to parse config values and CLI options.
676 :param help:
677 help message that will be used in CLI option description,
678 formatted using Markdown (see :mod:`yuio.md`).
680 Pass :data:`yuio.DISABLED` to remove this field from CLI help.
682 In sub-config fields, controls grouping of fields; pass an empty string
683 to disable grouping.
684 :param env:
685 specifies name of environment variable that will be used if loading config
686 from environment.
688 Pass :data:`~yuio.DISABLED` to disable loading this field form environment.
690 In sub-config fields, controls prefix for all environment variables within
691 this sub-config; pass an empty string to disable prefixing.
692 :param flags:
693 list of names (or a single name) of CLI flags that will be used for this field.
695 In configs, pass :data:`~yuio.DISABLED` to disable loading this field form CLI arguments.
697 In apps, pass :data:`~yuio.POSITIONAL` to make this argument positional.
699 In sub-config fields, controls prefix for all flags withing this sub-config;
700 pass an empty string to disable prefixing.
701 :param completer:
702 completer that will be used for autocompletion in CLI. Using this option
703 is equivalent to overriding `completer` with :class:`yuio.parse.WithMeta`.
704 :param metavar:
705 value description that will be used for CLI help messages. Using this option
706 is equivalent to overriding `desc` with :class:`yuio.parse.WithMeta`.
707 :param merge:
708 defines how values of this field are merged when configs are updated.
709 :param mutex_group:
710 defines mutually exclusive group for this field.
711 :param help_group:
712 overrides group in which this field will be placed when generating CLI help
713 message.
714 :param usage:
715 controls how this field renders in CLI usage section.
717 Pass :data:`False` to remove this field from usage.
719 Pass :class:`yuio.GROUP` to omit this field and add a single string
720 ``<options>`` instead.
722 Setting `usage` on sub-config fields overrides default `usage` for all
723 fields within this sub-config.
724 :param option_ctor:
725 this parameter is similar to :mod:`argparse`\\ 's ``action``: it allows
726 overriding logic for handling CLI arguments by providing a custom
727 :class:`~yuio.cli.Option` implementation.
729 `option_ctor` should be a callable which takes a single positional argument
730 of type :class:`~yuio.app.OptionSettings`, and returns an instance
731 of :class:`yuio.cli.Option`.
732 :returns:
733 a magic object that will be replaced with field's default value once a new
734 config class is created.
735 :example:
736 In apps:
738 .. invisible-code-block: python
740 import yuio.app
742 .. code-block:: python
744 @yuio.app.app
745 def main(
746 # Will be loaded from `--input`.
747 input: pathlib.Path | None = None,
748 # Will be loaded from `-o` or `--output`.
749 output: pathlib.Path | None = field(default=None, flags="-o --output"),
750 ): ...
752 In configs:
754 .. code-block:: python
756 class AppConfig(Config):
757 model: pathlib.Path | None = field(
758 default=None,
759 help="trained model to execute",
760 )
762 """
764 return _FieldSettings(
765 default=default,
766 parser=parser,
767 help=help,
768 env=env,
769 flags=flags,
770 completer=completer,
771 metavar=metavar,
772 required=required,
773 merge=merge,
774 mutex_group=mutex_group,
775 help_group=help_group,
776 usage=usage,
777 show_if_inherited=show_if_inherited,
778 option_ctor=option_ctor,
779 )
782def inline(
783 help: str | yuio.Disabled | None = None,
784 usage: yuio.Group | bool | None = None,
785 show_if_inherited: bool | None = None,
786 help_group: HelpGroup | None = None,
787) -> _t.Any:
788 """
789 A shortcut for inlining nested configs.
791 Equivalent to calling :func:`~yuio.app.field` with `env` and `flags`
792 set to an empty string.
794 """
796 return field(
797 help=help,
798 env="",
799 flags="",
800 usage=usage,
801 show_if_inherited=show_if_inherited,
802 help_group=help_group,
803 )
806@_t.overload
807def positional(
808 *,
809 help: str | yuio.Disabled | None = None,
810 env: str | yuio.Disabled | None = None,
811 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
812 metavar: str | None = None,
813 usage: yuio.Group | bool | None = None,
814) -> _t.Any: ...
815@_t.overload
816def positional(
817 *,
818 default: None,
819 parser: yuio.parse.Parser[T] | None = None,
820 help: str | yuio.Disabled | None = None,
821 env: str | yuio.Disabled | None = None,
822 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
823 metavar: str | None = None,
824 usage: yuio.Group | bool | None = None,
825) -> T | None: ...
826@_t.overload
827def positional(
828 *,
829 default: T | yuio.Missing = yuio.MISSING,
830 parser: yuio.parse.Parser[T] | None = None,
831 help: str | yuio.Disabled | None = None,
832 env: str | yuio.Disabled | None = None,
833 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
834 metavar: str | None = None,
835 usage: yuio.Group | bool | None = None,
836) -> T: ...
837def positional(
838 *,
839 default: _t.Any = yuio.MISSING,
840 parser: yuio.parse.Parser[_t.Any] | None = None,
841 help: str | yuio.Disabled | None = None,
842 env: str | yuio.Disabled | None = None,
843 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING,
844 metavar: str | None = None,
845 usage: yuio.Group | bool | None = None,
846) -> _t.Any:
847 """
848 A shortcut for adding a positional argument.
850 Equivalent to calling :func:`field` with `flags` set to :data:`~yuio.POSITIONAL`.
852 """
854 return field(
855 default=default,
856 parser=parser,
857 help=help,
858 env=env,
859 flags=yuio.POSITIONAL,
860 completer=completer,
861 metavar=metavar,
862 usage=usage,
863 )
866@_t.dataclass_transform(
867 eq_default=False,
868 order_default=False,
869 kw_only_default=True,
870 frozen_default=False,
871 field_specifiers=(field, inline, positional),
872)
873class Config:
874 """
875 Base class for configs.
877 Pass keyword args to set fields, or pass another config to copy it::
879 Config(config1, config2, ..., field1=value1, ...)
881 Upon creation, all fields that aren't explicitly initialized
882 and don't have defaults are considered missing.
883 Accessing them will raise :class:`AttributeError`.
885 .. automethod:: update
887 .. automethod:: load_from_env
889 .. automethod:: load_from_json_file
891 .. automethod:: load_from_yaml_file
893 .. automethod:: load_from_toml_file
895 .. automethod:: load_from_parsed_file
897 .. automethod:: to_json_schema
899 .. automethod:: to_json_value
901 .. automethod:: validate_config
903 """
905 @classmethod
906 def __get_fields(cls) -> dict[str, _Field]:
907 if cls.__fields is not None:
908 return cls.__fields
910 if cls.__allow_positionals:
911 docs = {}
912 else:
913 try:
914 docs = _find_docs(cls)
915 except Exception:
916 yuio._logger.warning(
917 "unable to get documentation for class %s.%s",
918 cls.__module__,
919 cls.__qualname__,
920 )
921 docs = {}
923 fields = {}
925 for base in reversed(cls.__mro__):
926 if base is not cls and hasattr(base, "_Config__get_fields"):
927 fields.update(getattr(base, "_Config__get_fields")())
929 try:
930 types = _t.get_type_hints(cls, include_extras=True)
931 except NameError as e:
932 if "<locals>" in cls.__qualname__:
933 raise NameError(
934 f"{e}. "
935 f"Note: forward references do not work inside functions "
936 f"(see https://github.com/python/typing/issues/797)"
937 ) from None
938 raise # pragma: no cover
940 for name, field in cls.__gathered_fields.items():
941 if not isinstance(field, _FieldSettings):
942 field = _FieldSettings(default=field)
944 fields[name] = field._update_defaults(
945 f"{cls.__qualname__}.{name}",
946 name,
947 types[name],
948 docs.get(name),
949 cls.__allow_positionals,
950 )
951 cls.__fields = fields
953 return fields
955 def __init_subclass__(cls, _allow_positionals=None, **kwargs):
956 super().__init_subclass__(**kwargs)
958 if _allow_positionals is not None:
959 cls.__allow_positionals: bool = _allow_positionals
960 cls.__fields: dict[str, _Field] | None = None
962 cls.__gathered_fields: dict[str, _FieldSettings | _t.Any] = {}
963 for name in cls.__annotations__:
964 if not name.startswith("_"):
965 cls.__gathered_fields[name] = cls.__dict__.get(name, yuio.MISSING)
966 for name, value in cls.__dict__.items():
967 if isinstance(value, _FieldSettings) and name not in cls.__gathered_fields:
968 qualname = f"{cls.__qualname__}.{name}"
969 raise TypeError(
970 f"error in {qualname}: field without annotations is not allowed"
971 )
972 for name, value in cls.__gathered_fields.items():
973 if isinstance(value, _FieldSettings):
974 value = value.default
975 setattr(cls, name, value)
977 def __init__(self, *args: _t.Self | dict[str, _t.Any], **kwargs):
978 for name, field in self.__get_fields().items():
979 if field.is_subconfig:
980 setattr(self, name, field.ty())
982 for arg in args:
983 self.update(arg)
985 self.update(kwargs)
987 def update(self, other: _t.Self | dict[str, _t.Any], /):
988 """
989 Update fields in this config with fields from another config.
991 This function is similar to :meth:`dict.update`.
993 Nested configs are updated recursively.
995 :param other:
996 data for update.
998 """
1000 if not other:
1001 return
1003 if isinstance(other, Config):
1004 if (
1005 self.__class__ not in other.__class__.__mro__
1006 and other.__class__ not in self.__class__.__mro__
1007 ):
1008 raise TypeError("updating from an incompatible config")
1009 ns = other.__dict__
1010 elif isinstance(other, dict):
1011 ns = other
1012 for name in ns:
1013 if name not in self.__get_fields():
1014 raise TypeError(f"unknown field: {name}")
1015 else:
1016 raise TypeError("expected a dict or a config class")
1018 for name, field in self.__get_fields().items():
1019 if name in ns:
1020 if field.is_subconfig:
1021 getattr(self, name).update(ns[name])
1022 elif ns[name] is not yuio.MISSING:
1023 if field.merge is not None and name in self.__dict__:
1024 setattr(self, name, field.merge(getattr(self, name), ns[name]))
1025 else:
1026 setattr(self, name, ns[name])
1028 @classmethod
1029 def load_from_env(cls, prefix: str = "") -> _t.Self:
1030 """
1031 Load config from environment variables.
1033 :param prefix:
1034 if given, names of all environment variables will be prefixed with
1035 this string and an underscore.
1036 :returns:
1037 a parsed config.
1038 :raises:
1039 :class:`~yuio.parse.ParsingError`.
1041 """
1043 result = cls.__load_from_env(prefix)
1044 result.validate_config()
1045 return result
1047 @classmethod
1048 def __load_from_env(cls, prefix: str = "") -> _t.Self:
1049 fields = {}
1051 for name, field in cls.__get_fields().items():
1052 if field.env is yuio.DISABLED:
1053 continue
1055 if prefix and field.env:
1056 env = f"{prefix}_{field.env}"
1057 else:
1058 env = f"{prefix}{field.env}"
1060 if field.is_subconfig:
1061 fields[name] = field.ty.load_from_env(prefix=env)
1062 elif env in os.environ:
1063 assert field.parser is not None
1064 try:
1065 fields[name] = field.parser.parse(os.environ[env])
1066 except yuio.parse.ParsingError as e:
1067 raise yuio.parse.ParsingError(
1068 "Can't parse environment variable `%s`:\n%s",
1069 env,
1070 yuio.string.Indent(e),
1071 ) from None
1073 return cls(**fields)
1075 @classmethod
1076 def _build_options(cls):
1077 return cls.__build_options("", "", None, True, False)
1079 @classmethod
1080 def __build_options(
1081 cls,
1082 prefix: str,
1083 dest_prefix: str,
1084 help_group: yuio.cli.HelpGroup | None,
1085 usage: yuio.Group | bool,
1086 show_if_inherited: bool,
1087 ) -> list[yuio.cli.Option[_t.Any]]:
1088 options: list[yuio.cli.Option[_t.Any]] = []
1090 if prefix:
1091 prefix += "-"
1093 for name, field in cls.__get_fields().items():
1094 if field.flags is yuio.DISABLED:
1095 continue
1097 dest = dest_prefix + name
1099 flags: list[str] | yuio.Positional
1100 if prefix and field.flags is not yuio.POSITIONAL:
1101 flags = [prefix + flag.lstrip("-") for flag in field.flags]
1102 else:
1103 flags = field.flags
1105 field_usage = field.usage
1106 if field_usage is None:
1107 field_usage = usage
1109 field_show_if_inherited = field.show_if_inherited
1110 if field_show_if_inherited is None:
1111 field_show_if_inherited = show_if_inherited
1113 if field.is_subconfig:
1114 assert flags is not yuio.POSITIONAL
1115 assert issubclass(field.ty, Config)
1116 if field.help_group is None:
1117 if field.help is yuio.DISABLED:
1118 subgroup = yuio.cli.HelpGroup("", help=yuio.DISABLED)
1119 elif field.help:
1120 lines = field.help.split("\n\n", 1)
1121 title = lines[0].replace("\n", " ").rstrip(".") or name
1122 help = textwrap.dedent(lines[1]) if len(lines) > 1 else ""
1123 subgroup = yuio.cli.HelpGroup(title=title, help=help)
1124 else:
1125 subgroup = help_group
1126 else:
1127 subgroup = field.help_group
1128 options.extend(
1129 field.ty.__build_options(
1130 flags[0],
1131 dest + ".",
1132 subgroup,
1133 field_usage,
1134 field_show_if_inherited,
1135 )
1136 )
1137 continue
1139 assert field.parser is not None
1141 option_ctor = field.option_ctor or _default_option
1142 option = option_ctor(
1143 OptionSettings(
1144 name=name,
1145 parser=field.parser,
1146 flags=flags,
1147 required=field.required,
1148 mutex_group=field.mutex_group,
1149 usage=field_usage,
1150 help=field.help,
1151 help_group=field.help_group or help_group,
1152 show_if_inherited=field_show_if_inherited,
1153 merge=field.merge,
1154 dest=dest,
1155 default=field.default,
1156 long_flag_prefix=prefix or "--",
1157 )
1158 )
1159 options.append(option)
1161 return options
1163 @classmethod
1164 def load_from_json_file(
1165 cls,
1166 path: str | pathlib.Path,
1167 /,
1168 *,
1169 ignore_unknown_fields: bool = False,
1170 ignore_missing_file: bool = False,
1171 ) -> _t.Self:
1172 """
1173 Load config from a ``.json`` file.
1175 :param path:
1176 path of the config file.
1177 :param ignore_unknown_fields:
1178 if :data:`True`, this method will ignore fields that aren't listed
1179 in config class.
1180 :param ignore_missing_file:
1181 if :data:`True`, silently ignore a missing file error. This is useful
1182 when loading a config from a home directory.
1183 :returns:
1184 a parsed config.
1185 :raises:
1186 :class:`~yuio.parse.ParsingError` if config parsing has failed
1187 or if config file doesn't exist.
1189 """
1191 return cls.__load_from_file(
1192 path, json.loads, ignore_unknown_fields, ignore_missing_file
1193 )
1195 @classmethod
1196 def load_from_yaml_file(
1197 cls,
1198 path: str | pathlib.Path,
1199 /,
1200 *,
1201 ignore_unknown_fields: bool = False,
1202 ignore_missing_file: bool = False,
1203 ) -> _t.Self:
1204 """
1205 Load config from a ``.yaml`` file.
1207 This requires `PyYaml <https://pypi.org/project/PyYAML/>`__ package
1208 to be installed.
1210 :param path:
1211 path of the config file.
1212 :param ignore_unknown_fields:
1213 if :data:`True`, this method will ignore fields that aren't listed
1214 in config class.
1215 :param ignore_missing_file:
1216 if :data:`True`, silently ignore a missing file error. This is useful
1217 when loading a config from a home directory.
1218 :returns:
1219 a parsed config.
1220 :raises:
1221 :class:`~yuio.parse.ParsingError` if config parsing has failed
1222 or if config file doesn't exist. Can raise :class:`ImportError`
1223 if ``PyYaml`` is not available.
1225 """
1227 try:
1228 import yaml
1229 except ImportError:
1230 raise ImportError("PyYaml is not available")
1232 return cls.__load_from_file(
1233 path, yaml.safe_load, ignore_unknown_fields, ignore_missing_file
1234 )
1236 @classmethod
1237 def load_from_toml_file(
1238 cls,
1239 path: str | pathlib.Path,
1240 /,
1241 *,
1242 ignore_unknown_fields: bool = False,
1243 ignore_missing_file: bool = False,
1244 ) -> _t.Self:
1245 """
1246 Load config from a ``.toml`` file.
1248 This requires
1249 `tomllib <https://docs.python.org/3/library/tomllib.html>`_ or
1250 `toml <https://pypi.org/project/toml/>`_ package
1251 to be installed.
1253 :param path:
1254 path of the config file.
1255 :param ignore_unknown_fields:
1256 if :data:`True`, this method will ignore fields that aren't listed
1257 in config class.
1258 :param ignore_missing_file:
1259 if :data:`True`, silently ignore a missing file error. This is useful
1260 when loading a config from a home directory.
1261 :returns:
1262 a parsed config.
1263 :raises:
1264 :class:`~yuio.parse.ParsingError` if config parsing has failed
1265 or if config file doesn't exist. Can raise :class:`ImportError`
1266 if ``toml`` is not available.
1268 """
1270 try:
1271 import toml
1272 except ImportError:
1273 try:
1274 import tomllib as toml
1275 except ImportError:
1276 raise ImportError("toml is not available")
1278 return cls.__load_from_file(
1279 path, toml.loads, ignore_unknown_fields, ignore_missing_file
1280 )
1282 @classmethod
1283 def __load_from_file(
1284 cls,
1285 path: str | pathlib.Path,
1286 file_parser: _t.Callable[[str], _t.Any],
1287 ignore_unknown_fields: bool = False,
1288 ignore_missing_file: bool = False,
1289 ) -> _t.Self:
1290 path = pathlib.Path(path)
1292 if ignore_missing_file and (not path.exists() or not path.is_file()):
1293 return cls()
1295 try:
1296 loaded = file_parser(path.read_text())
1297 except Exception as e:
1298 raise yuio.parse.ParsingError(
1299 "Invalid config <c path>%s</c>:\n%s",
1300 path,
1301 yuio.string.Indent(e),
1302 ) from None
1304 return cls.load_from_parsed_file(
1305 loaded, ignore_unknown_fields=ignore_unknown_fields, path=path
1306 )
1308 @classmethod
1309 def load_from_parsed_file(
1310 cls,
1311 parsed: dict[str, object],
1312 /,
1313 *,
1314 ignore_unknown_fields: bool = False,
1315 path: str | pathlib.Path | None = None,
1316 ) -> _t.Self:
1317 """
1318 Load config from parsed config file.
1320 This method takes a dict with arbitrary values that you'd get from
1321 parsing type-rich configs such as ``yaml`` or ``json``.
1323 For example::
1325 with open("conf.yaml") as file:
1326 config = Config.load_from_parsed_file(yaml.load(file))
1328 :param parsed:
1329 data from parsed file.
1330 :param ignore_unknown_fields:
1331 if :data:`True`, this method will ignore fields that aren't listed
1332 in config class.
1333 :param path:
1334 path of the original file, used for error reporting.
1335 :returns:
1336 a parsed config.
1337 :raises:
1338 :class:`~yuio.parse.ParsingError`.
1340 """
1342 try:
1343 result = cls.__load_from_parsed_file(
1344 yuio.parse.ConfigParsingContext(parsed), ignore_unknown_fields, ""
1345 )
1346 result.validate_config()
1347 return result
1348 except yuio.parse.ParsingError as e:
1349 if path is None:
1350 raise
1351 else:
1352 raise yuio.parse.ParsingError(
1353 "Invalid config <c path>%s</c>:\n%s",
1354 path,
1355 yuio.string.Indent(e),
1356 ) from None
1358 @classmethod
1359 def __load_from_parsed_file(
1360 cls,
1361 ctx: yuio.parse.ConfigParsingContext,
1362 ignore_unknown_fields: bool = False,
1363 field_prefix: str = "",
1364 ) -> _t.Self:
1365 value = ctx.value
1367 if not isinstance(value, dict):
1368 raise yuio.parse.ParsingError.type_mismatch(value, dict, ctx=ctx)
1370 fields = {}
1372 if not ignore_unknown_fields:
1373 for name in value:
1374 if name not in cls.__get_fields() and name != "$schema":
1375 raise yuio.parse.ParsingError(
1376 "Unknown field `%s`", f"{field_prefix}{name}"
1377 )
1379 for name, field in cls.__get_fields().items():
1380 if name in value:
1381 if field.is_subconfig:
1382 fields[name] = field.ty.__load_from_parsed_file(
1383 ctx.descend(value[name], name),
1384 ignore_unknown_fields,
1385 field_prefix=name + ".",
1386 )
1387 else:
1388 assert field.parser is not None
1389 fields[name] = field.parser.parse_config_with_ctx(
1390 ctx.descend(value[name], name)
1391 )
1393 return cls(**fields)
1395 def __getattribute(self, item):
1396 value = super().__getattribute__(item)
1397 if value is yuio.MISSING:
1398 raise AttributeError(f"{item} is not configured")
1399 else:
1400 return value
1402 # A dirty hack to hide `__getattribute__` from type checkers.
1403 locals()["__getattribute__"] = __getattribute
1405 def __repr__(self):
1406 field_reprs = ", ".join(
1407 f"{name}={getattr(self, name, yuio.MISSING)!r}"
1408 for name in self.__get_fields()
1409 )
1410 return f"{self.__class__.__name__}({field_reprs})"
1412 def __rich_repr__(self):
1413 for name in self.__get_fields():
1414 yield name, getattr(self, name, yuio.MISSING)
1416 def validate_config(self):
1417 """
1418 Perform config validation.
1420 This function is called every time a config is loaded from CLI arguments,
1421 file, or environment variables. It should check that config is correct,
1422 and raise :class:`yuio.parse.ParsingError` if it's not.
1424 :raises:
1425 :class:`~yuio.parse.ParsingError`.
1427 """
1429 @classmethod
1430 def to_json_schema(
1431 cls, ctx: yuio.json_schema.JsonSchemaContext
1432 ) -> yuio.json_schema.JsonSchemaType:
1433 """
1434 Create a JSON schema object based on this config.
1436 The purpose of this method is to make schemas for use in IDEs, i.e. to provide
1437 autocompletion or simple error checking. The returned schema is not guaranteed
1438 to reflect all constraints added to the parser.
1440 :param ctx:
1441 context for building a schema.
1442 :returns:
1443 a JSON schema that describes structure of this config.
1445 """
1447 return ctx.add_type(cls, _tx.type_repr(cls), lambda: cls.__to_json_schema(ctx))
1449 def to_json_value(
1450 self, *, include_defaults: bool = True
1451 ) -> yuio.json_schema.JsonValue:
1452 """
1453 Convert this config to a representation suitable for JSON serialization.
1455 :param include_defaults:
1456 if :data:`False`, default values will be skipped.
1457 :returns:
1458 a config converted to JSON-serializable representation.
1459 :raises:
1460 :class:`TypeError` if any of the config fields contain values that can't
1461 be converted to JSON by their respective parsers.
1463 """
1465 data = {}
1466 for name, field in self.__get_fields().items():
1467 if not include_defaults and name not in self.__dict__:
1468 continue
1469 if field.is_subconfig:
1470 value = getattr(self, name).to_json_value(
1471 include_defaults=include_defaults
1472 )
1473 if value:
1474 data[name] = value
1475 else:
1476 assert field.parser
1477 try:
1478 value = getattr(self, name)
1479 except AttributeError:
1480 pass
1481 else:
1482 data[name] = field.parser.to_json_value(value)
1483 return data
1485 @classmethod
1486 def __to_json_schema(
1487 cls, ctx: yuio.json_schema.JsonSchemaContext
1488 ) -> yuio.json_schema.JsonSchemaType:
1489 properties: dict[str, yuio.json_schema.JsonSchemaType] = {}
1490 defaults = {}
1492 properties["$schema"] = yuio.json_schema.String()
1494 for name, field in cls.__get_fields().items():
1495 if field.is_subconfig:
1496 properties[name] = field.ty.to_json_schema(ctx)
1497 else:
1498 assert field.parser
1499 field_schema = field.parser.to_json_schema(ctx)
1500 if field.help and field.help is not yuio.DISABLED:
1501 field_schema = yuio.json_schema.Meta(
1502 field_schema, description=field.help
1503 )
1504 properties[name] = field_schema
1505 if field.default is not yuio.MISSING:
1506 try:
1507 defaults[name] = field.parser.to_json_value(field.default)
1508 except TypeError:
1509 pass
1511 return yuio.json_schema.Meta(
1512 yuio.json_schema.Object(properties),
1513 title=cls.__name__,
1514 description=cls.__doc__,
1515 default=defaults,
1516 )
1519Config.__init_subclass__(_allow_positionals=False)
1522@dataclass(eq=False, kw_only=True)
1523class OptionSettings:
1524 """
1525 Settings for creating an :class:`~yuio.cli.Option` derived from field's type
1526 and configuration.
1528 """
1530 name: str | None
1531 """
1532 Name of config field or app parameter that caused creation of this option.
1534 """
1536 parser: yuio.parse.Parser[_t.Any]
1537 """
1538 Parser associated with this option.
1540 """
1542 flags: list[str] | yuio.Positional
1543 """
1544 See :attr:`yuio.cli.Option.flags`.
1546 """
1548 required: bool
1549 """
1550 See :attr:`yuio.cli.Option.required`.
1552 """
1554 mutex_group: None | MutuallyExclusiveGroup
1555 """
1556 See :attr:`yuio.cli.Option.mutex_group`.
1558 """
1560 usage: yuio.Group | bool
1561 """
1562 See :attr:`yuio.cli.Option.usage`.
1564 """
1566 help: str | yuio.Disabled
1567 """
1568 See :attr:`yuio.cli.Option.help`.
1570 """
1572 help_group: HelpGroup | None
1573 """
1574 See :attr:`yuio.cli.Option.help_group`.
1576 """
1578 show_if_inherited: bool
1579 """
1580 See :attr:`yuio.cli.Option.show_if_inherited`.
1582 """
1584 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None
1585 """
1586 See :attr:`yuio.cli.ValueOption.merge`.
1588 """
1590 dest: str
1591 """
1592 See :attr:`yuio.cli.ValueOption.dest`. We don't provide any guarantees about `dest`\\ 's
1593 contents and recommend treating it as an opaque value.
1595 """
1597 default: _t.Any | yuio.Missing
1598 """
1599 See :attr:`yuio.cli.ValueOption.default`.
1601 """
1603 long_flag_prefix: str
1604 """
1605 This argument will contain prefix that was added to all :attr:`~OptionSettings.flags`.
1606 For apps and top level configs if will be ``"--"``, for nested configs it will
1607 include additional prefixes, for example ``"--nested-"``.
1609 """
1612OptionCtor: _t.TypeAlias = _t.Callable[[OptionSettings], yuio.cli.Option[T]]
1615def _default_option(s: OptionSettings):
1616 if s.flags is not yuio.POSITIONAL and yuio.parse._is_bool_parser(s.parser):
1617 return bool_option()(s)
1618 elif s.parser.supports_parse_many():
1619 return parse_many_option()(s)
1620 else:
1621 return parse_one_option()(s)
1624def bool_option(*, neg_flags: list[str] | None = None) -> OptionCtor[bool]:
1625 """
1626 Factory for :class:`yuio.cli.BoolOption`.
1628 :param neg_flags:
1629 additional set of flags that will set option's value to :data:`False`. If not
1630 given, a negative flag will be created by adding prefix ``no-`` to the first
1631 long flag of the option.
1632 :example:
1633 Boolean flag :flag:`--json` implicitly creates flag :flag:`--no-json`:
1635 .. code-block:: python
1636 :emphasize-lines: 5
1638 @yuio.app.app
1639 def main(
1640 json: bool = yuio.app.field(
1641 default=False,
1642 option_ctor=yuio.app.bool_option(),
1643 ),
1644 ): ...
1646 Boolean flag :flag:`--json` with explicitly provided flag
1647 :flag:`--disable-json`:
1649 .. code-block:: python
1650 :emphasize-lines: 5-7
1652 @yuio.app.app
1653 def main(
1654 json: bool = yuio.app.field(
1655 default=False,
1656 option_ctor=yuio.app.bool_option(
1657 neg_flags=["--disable-json"],
1658 ),
1659 ),
1660 ): ...
1662 """
1664 def ctor(s: OptionSettings, /):
1665 if s.flags is yuio.POSITIONAL:
1666 raise TypeError(f"error in {s.name}: BoolOption can't be positional")
1667 if neg_flags is None:
1668 _neg_flags = []
1669 for flag in s.flags:
1670 if not yuio.cli._is_short(flag) and flag.startswith(s.long_flag_prefix):
1671 prefix = s.long_flag_prefix.strip("-")
1672 if prefix:
1673 prefix += "-"
1674 suffix = flag[len(s.long_flag_prefix) :].removeprefix("-")
1675 _neg_flags.append(f"--{prefix}no-{suffix}")
1676 break
1677 elif s.long_flag_prefix == "--":
1678 _neg_flags = neg_flags
1679 else:
1680 _neg_flags = []
1681 for flag in neg_flags:
1682 _neg_flags.append(s.long_flag_prefix + flag.lstrip("-"))
1683 return yuio.cli.BoolOption(
1684 pos_flags=s.flags,
1685 neg_flags=_neg_flags,
1686 required=s.required,
1687 mutex_group=s.mutex_group,
1688 usage=s.usage,
1689 help=s.help,
1690 help_group=s.help_group,
1691 show_if_inherited=s.show_if_inherited,
1692 dest=s.dest,
1693 parser=s.parser,
1694 merge=s.merge,
1695 default=s.default,
1696 )
1698 return ctor
1701def parse_one_option() -> OptionCtor[_t.Any]:
1702 """
1703 Factory for :class:`yuio.cli.ParseOneOption`.
1705 This option takes one argument and passes it
1706 to :meth:`Parser.parse() <yuio.parse.Parser.parse>`.
1708 :example:
1709 Forcing a field which can use :func:`parse_many_option`
1710 to use :func:`parse_one_option` instead.
1712 .. code-block:: python
1713 :emphasize-lines: 6
1715 @yuio.app.app
1716 def main(
1717 files: list[str] = yuio.app.field(
1718 default=[],
1719 parser=yuio.parse.List(yuio.parse.Int(), delimiter=","),
1720 option_ctor=yuio.app.parse_one_option(),
1721 ),
1722 ): ...
1724 This will disable multi-argument syntax:
1726 .. code-block:: console
1728 $ prog --files a.txt,b.txt # Ok
1729 $ prog --files a.txt b.txt # Error: `--files` takes one argument.
1731 """
1733 def ctor(s: OptionSettings, /):
1734 return yuio.cli.ParseOneOption(
1735 flags=s.flags,
1736 required=s.required,
1737 mutex_group=s.mutex_group,
1738 usage=s.usage,
1739 help=s.help,
1740 help_group=s.help_group,
1741 show_if_inherited=s.show_if_inherited,
1742 dest=s.dest,
1743 parser=s.parser,
1744 merge=s.merge,
1745 default=s.default,
1746 )
1748 return ctor
1751def parse_many_option() -> OptionCtor[_t.Any]:
1752 """
1753 Factory for :class:`yuio.cli.ParseManyOption`.
1755 This option takes multiple arguments and passes them
1756 to :meth:`Parser.parse_many() <yuio.parse.Parser.parse_many>`.
1758 """
1760 def ctor(s: OptionSettings, /):
1761 return yuio.cli.ParseManyOption(
1762 flags=s.flags,
1763 required=s.required,
1764 mutex_group=s.mutex_group,
1765 usage=s.usage,
1766 help=s.help,
1767 help_group=s.help_group,
1768 show_if_inherited=s.show_if_inherited,
1769 dest=s.dest,
1770 parser=s.parser,
1771 merge=s.merge,
1772 default=s.default,
1773 )
1775 return ctor
1778def store_const_option(const: T) -> OptionCtor[T]:
1779 """
1780 Factory for :class:`yuio.cli.StoreConstOption`.
1782 This options takes no arguments. When it's encountered amongst CLI arguments,
1783 it writes `const` to the resulting config.
1785 """
1787 def ctor(s: OptionSettings, /):
1788 if s.flags is yuio.POSITIONAL:
1789 raise TypeError(f"error in {s.name}: StoreConstOption can't be positional")
1791 return yuio.cli.StoreConstOption(
1792 flags=s.flags,
1793 required=s.required,
1794 mutex_group=s.mutex_group,
1795 usage=s.usage,
1796 help=s.help,
1797 help_group=s.help_group,
1798 show_if_inherited=s.show_if_inherited,
1799 dest=s.dest,
1800 merge=s.merge,
1801 default=s.default,
1802 const=const,
1803 )
1805 return ctor
1808def count_option() -> OptionCtor[int]:
1809 """
1810 Factory for :class:`yuio.cli.CountOption`.
1812 This option counts number of times it's encountered amongst CLI arguments.
1814 Equivalent to using :func:`store_const_option` with ``const=1``
1815 and ``merge=lambda a, b: a + b``.
1817 :example:
1819 .. code-block:: python
1821 @yuio.app.app
1822 def main(
1823 quiet: int = yuio.app.field(
1824 default=0,
1825 flags=["-q", "--quiet"],
1826 option_ctor=yuio.app.count_option(),
1827 ),
1828 ): ...
1830 .. code-block:: console
1832 prog -qq # quiet=2
1834 """
1836 def ctor(s: OptionSettings, /):
1837 if s.flags is yuio.POSITIONAL:
1838 raise TypeError(f"error in {s.name}: CountOption can't be positional")
1840 return yuio.cli.CountOption(
1841 flags=s.flags,
1842 required=s.required,
1843 mutex_group=s.mutex_group,
1844 usage=s.usage,
1845 help=s.help,
1846 help_group=s.help_group,
1847 show_if_inherited=s.show_if_inherited,
1848 dest=s.dest,
1849 default=s.default,
1850 )
1852 return ctor
1855def store_true_option() -> OptionCtor[bool]:
1856 """
1857 Factory for :class:`yuio.cli.StoreTrueOption`.
1859 Equivalent to using :func:`store_const_option` with ``const=True``.
1861 """
1863 def ctor(s: OptionSettings, /):
1864 if s.flags is yuio.POSITIONAL:
1865 raise TypeError(f"error in {s.name}: StoreTrueOption can't be positional")
1867 return yuio.cli.StoreTrueOption(
1868 flags=s.flags,
1869 required=s.required,
1870 mutex_group=s.mutex_group,
1871 usage=s.usage,
1872 help=s.help,
1873 help_group=s.help_group,
1874 show_if_inherited=s.show_if_inherited,
1875 dest=s.dest,
1876 default=s.default,
1877 )
1879 return ctor
1882def store_false_option() -> OptionCtor[bool]:
1883 """
1884 Factory for :class:`yuio.cli.StoreFalseOption`.
1886 Equivalent to using :func:`store_const_option` with ``const=False``.
1888 """
1890 def ctor(s: OptionSettings, /):
1891 if s.flags is yuio.POSITIONAL:
1892 raise TypeError(f"error in {s.name}: StoreFalseOption can't be positional")
1894 return yuio.cli.StoreFalseOption(
1895 flags=s.flags,
1896 required=s.required,
1897 mutex_group=s.mutex_group,
1898 usage=s.usage,
1899 help=s.help,
1900 help_group=s.help_group,
1901 show_if_inherited=s.show_if_inherited,
1902 dest=s.dest,
1903 default=s.default,
1904 )
1906 return ctor