mininterface 1.0.2__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {mininterface-1.0.2 → mininterface-1.0.3}/PKG-INFO +1 -1
  2. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/__init__.py +41 -22
  3. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/cli_parser.py +194 -75
  4. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/showcase.py +5 -0
  5. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_mininterface/adaptor.py +33 -0
  6. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_text_interface/adaptor.py +15 -2
  7. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/textual_app.py +1 -0
  8. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/widgets.py +13 -0
  9. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/adaptor.py +8 -1
  10. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/select_input.py +9 -0
  11. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/utils.py +102 -11
  12. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/settings.py +63 -5
  13. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/flag.py +1 -1
  14. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/path_tag.py +11 -0
  15. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/tag.py +12 -0
  16. {mininterface-1.0.2 → mininterface-1.0.3}/pyproject.toml +2 -1
  17. {mininterface-1.0.2 → mininterface-1.0.3}/LICENSE +0 -0
  18. {mininterface-1.0.2 → mininterface-1.0.3}/README.md +0 -0
  19. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/__main__.py +0 -0
  20. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/__init__.py +0 -0
  21. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/auxiliary.py +0 -0
  22. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/form_dict.py +0 -0
  23. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/redirectable.py +0 -0
  24. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_lib/start.py +0 -0
  25. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_mininterface/__init__.py +0 -0
  26. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_mininterface/mixin.py +0 -0
  27. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_text_interface/__init__.py +0 -0
  28. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_text_interface/facet.py +0 -0
  29. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/__init__.py +0 -0
  30. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/adaptor.py +0 -0
  31. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/button_contents.py +0 -0
  32. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/facet.py +0 -0
  33. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/file_picker_input.py +0 -0
  34. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/form_contents.py +0 -0
  35. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/secret_input.py +0 -0
  36. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_textual_interface/style.tcss +0 -0
  37. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/__init__.py +0 -0
  38. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/date_entry.py +0 -0
  39. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/external_fix.py +0 -0
  40. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/facet.py +0 -0
  41. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/redirect_text_tkinter.py +0 -0
  42. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_tk_interface/secret_entry.py +0 -0
  43. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_web_interface/__init__.py +0 -0
  44. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_web_interface/app.py +0 -0
  45. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_web_interface/child_adaptor.py +0 -0
  46. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/_web_interface/parent_adaptor.py +0 -0
  47. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/cli.py +0 -0
  48. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/exceptions.py +0 -0
  49. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/experimental.py +0 -0
  50. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/facet/__init__.py +0 -0
  51. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/interfaces.py +0 -0
  52. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/__init__.py +0 -0
  53. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/alias.py +0 -0
  54. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/callback_tag.py +0 -0
  55. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/datetime_tag.py +0 -0
  56. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/internal.py +0 -0
  57. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/secret_tag.py +0 -0
  58. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/select_tag.py +0 -0
  59. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/tag_factory.py +0 -0
  60. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/tag/type_stubs.py +0 -0
  61. {mininterface-1.0.2 → mininterface-1.0.3}/mininterface/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mininterface
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: A minimal access to GUI, TUI, CLI and config
5
5
  License: LGPL-3.0-or-later
6
6
  Author: Edvard Rejthar
@@ -16,7 +16,12 @@ from .tag.alias import Options, Validation
16
16
  try:
17
17
  from ._lib.start import ChooseSubcommandOverview, Start
18
18
  from .cli import Command, SubcommandPlaceholder
19
- from ._lib.cli_parser import assure_args, parse_cli, parse_config_file, parser_to_dataclass
19
+ from ._lib.cli_parser import (
20
+ assure_args,
21
+ parse_cli,
22
+ parse_config_file,
23
+ parser_to_dataclass,
24
+ )
20
25
  except DependencyRequired as e:
21
26
  assure_args, parse_cli, parse_config_file, parser_to_dataclass = (e,) * 4
22
27
  ChooseSubcommandOverview, Start, SubcommandPlaceholder = (e,) * 3
@@ -27,18 +32,27 @@ class _Empty:
27
32
  pass
28
33
 
29
34
 
30
- def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | None = None,
31
- ask_on_empty_cli: bool = False,
32
- title: str = "",
33
- config_file: Path | str | bool = True,
34
- add_verbose: bool = True,
35
- ask_for_missing: bool = True,
36
- # We do not use InterfaceType as a type here because we want the documentation to show full alias:
37
- interface: Type[Mininterface] | Literal["gui"] | Literal["tui"] | Literal["text"] | Literal["web"] | None = None,
38
- args: Optional[Sequence[str]] = None,
39
- settings: Optional[MininterfaceSettings] = None,
40
- **kwargs) -> Mininterface[EnvClass]:
41
- """ The main access, start here.
35
+ def run(
36
+ env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | None = None,
37
+ ask_on_empty_cli: bool = False,
38
+ title: str = "",
39
+ config_file: Path | str | bool = True,
40
+ add_verbose: bool = True,
41
+ ask_for_missing: bool = True,
42
+ # We do not use InterfaceType as a type here because we want the documentation to show full alias:
43
+ interface: (
44
+ Type[Mininterface]
45
+ | Literal["gui"]
46
+ | Literal["tui"]
47
+ | Literal["text"]
48
+ | Literal["web"]
49
+ | None
50
+ ) = None,
51
+ args: Optional[Sequence[str]] = None,
52
+ settings: Optional[MininterfaceSettings] = None,
53
+ **kwargs
54
+ ) -> Mininterface[EnvClass]:
55
+ """The main access, start here.
42
56
  Wrap your configuration dataclass into `run` to access the interface. An interface is chosen automatically,
43
57
  with the preference of the graphical one, regressed to a text interface for machines without display.
44
58
  Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly
@@ -59,9 +73,9 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
59
73
  ```python
60
74
  @dataclass
61
75
  class Env:
62
- number: int = 3
63
- text: str = ""
64
- m = run(Env, ask_on_empty=True)
76
+ number: int = 3
77
+ text: str = ""
78
+ m = run(Env, ask_on_empty=True)
65
79
  ```
66
80
 
67
81
  ```bash
@@ -77,6 +91,7 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
77
91
  whose name stem is the same as the program's.
78
92
  Ex: `program.py` will search for `program.yaml`.
79
93
  If False, no config file is used.
94
+ See the [Config file](Config-file.md) section.
80
95
  add_verbose: Adds the verbose flag that automatically sets the level to `logging.INFO` (*-v*) or `logging.DEBUG` (*-vv*).
81
96
 
82
97
  ```python
@@ -203,18 +218,24 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
203
218
  if superform_args is not None:
204
219
  # Run Superform as multiple subcommands exist and we have to decide which one to run.
205
220
  m = get_interface(interface, title, settings, None)
206
- ChooseSubcommandOverview(env_or_list, m, args=superform_args, ask_for_missing=ask_for_missing)
221
+ ChooseSubcommandOverview(
222
+ env_or_list, m, args=superform_args, ask_for_missing=ask_for_missing
223
+ )
207
224
  return m # m with added `m.env`
208
225
 
209
226
  # B) A single Env object, or a list of such objects (with one is being selected via args)
210
227
  # C) No Env object
211
228
 
212
229
  # Parse CLI arguments, possibly merged from a config file.
213
- kwargs, settings = parse_config_file(env_or_list or _Empty, config_file, settings, **kwargs)
230
+ kwargs, settings = parse_config_file(
231
+ env_or_list or _Empty, config_file, settings, **kwargs
232
+ )
214
233
  if env_or_list:
215
234
  # B) single Env object
216
235
  # Load configuration from CLI and a config file
217
- env, wrong_fields = parse_cli(env_or_list, kwargs, add_verbose, ask_for_missing, args)
236
+ env, wrong_fields = parse_cli(
237
+ env_or_list, kwargs, add_verbose, ask_for_missing, args
238
+ )
218
239
  m = get_interface(interface, title, settings, env)
219
240
 
220
241
  # Empty CLI → GUI edit
@@ -243,6 +264,4 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
243
264
  return m
244
265
 
245
266
 
246
- __all__ = ["run", "Mininterface", "Tag",
247
- "Cancelled",
248
- "Validation", "Options"]
267
+ __all__ = ["run", "Mininterface", "Tag", "Cancelled", "Validation", "Options"]
@@ -8,16 +8,32 @@ import sys
8
8
  import warnings
9
9
  from argparse import Action, ArgumentParser
10
10
  from contextlib import ExitStack
11
- from dataclasses import (MISSING, Field, asdict, dataclass, field, fields,
12
- is_dataclass, make_dataclass)
11
+ from dataclasses import (
12
+ MISSING,
13
+ Field,
14
+ asdict,
15
+ dataclass,
16
+ field,
17
+ fields,
18
+ is_dataclass,
19
+ make_dataclass,
20
+ )
13
21
  from pathlib import Path
14
22
  from types import SimpleNamespace
15
- from typing import (Annotated, Any, Callable, Optional, Sequence, Type, Union, get_args,
16
- get_origin)
23
+ from typing import (
24
+ Annotated,
25
+ Any,
26
+ Callable,
27
+ Optional,
28
+ Sequence,
29
+ Type,
30
+ Union,
31
+ get_args,
32
+ get_origin,
33
+ )
17
34
  from unittest.mock import patch
18
35
 
19
- from .auxiliary import (dataclass_asdict_no_defaults, merge_dicts,
20
- yield_annotations)
36
+ from .auxiliary import dataclass_asdict_no_defaults, merge_dicts, yield_annotations
21
37
  from .form_dict import DataClass, EnvClass, MissingTagValue
22
38
  from ..settings import MininterfaceSettings
23
39
  from ..tag import Tag
@@ -33,17 +49,19 @@ try:
33
49
  from tyro.extras import get_parser
34
50
  except ImportError:
35
51
  from ..exceptions import DependencyRequired
52
+
36
53
  raise DependencyRequired("basic")
37
54
 
38
55
 
39
56
  # Pydantic is not a project dependency, that is just an optional integration
40
57
  try: # Pydantic is not a dependency but integration
41
58
  from pydantic import BaseModel
59
+
42
60
  pydantic = True
43
61
  except ImportError:
44
62
  pydantic = False
45
63
  BaseModel = False
46
- try: # Attrs is not a dependency but integration
64
+ try: # Attrs is not a dependency but integration
47
65
  import attr
48
66
  except ImportError:
49
67
  attr = None
@@ -58,11 +76,11 @@ reraise: Optional[Callable] = None
58
76
 
59
77
 
60
78
  class Patches:
61
- """ Various mocking patches. """
79
+ """Various mocking patches."""
62
80
 
63
81
  @staticmethod
64
82
  def custom_error(self: TyroArgumentParser, message: str):
65
- """ Fetch missing required options in GUI.
83
+ """Fetch missing required options in GUI.
66
84
  On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting
67
85
  the error message function. Then, we reconstruct the missing options.
68
86
  Thanks to this we will be able to invoke a UI dialog with the missing options only.
@@ -71,28 +89,38 @@ class Patches:
71
89
  if not message.startswith("the following arguments are required:"):
72
90
  return super(TyroArgumentParser, self).error(message)
73
91
  eavesdrop = message
74
- def reraise(): return super(TyroArgumentParser, self).error(message)
92
+
93
+ def reraise():
94
+ return super(TyroArgumentParser, self).error(message)
95
+
75
96
  raise SystemExit(2) # will be catched
76
97
 
77
98
  @staticmethod
78
99
  def custom_init(self: TyroArgumentParser, *args, **kwargs):
79
100
  super(TyroArgumentParser, self).__init__(*args, **kwargs)
80
- default_prefix = '-' if '-' in self.prefix_chars else self.prefix_chars[0]
81
- self.add_argument(default_prefix+'v', default_prefix*2+'verbose', action='count', default=0,
82
- help="Verbosity level. Can be used twice to increase.")
101
+ default_prefix = "-" if "-" in self.prefix_chars else self.prefix_chars[0]
102
+ self.add_argument(
103
+ default_prefix + "v",
104
+ default_prefix * 2 + "verbose",
105
+ action="count",
106
+ default=0,
107
+ help="Verbosity level. Can be used twice to increase.",
108
+ )
83
109
 
84
110
  @staticmethod
85
111
  def custom_parse_known_args(self: TyroArgumentParser, args=None, namespace=None):
86
- namespace, args = super(TyroArgumentParser, self).parse_known_args(args, namespace)
112
+ namespace, args = super(TyroArgumentParser, self).parse_known_args(
113
+ args, namespace
114
+ )
87
115
  # NOTE We may check that the Env does not have its own `verbose``
88
116
  if hasattr(namespace, "verbose"):
89
117
  if namespace.verbose > 0:
90
- log_level = {
91
- 1: logging.INFO,
92
- 2: logging.DEBUG,
93
- 3: logging.NOTSET
94
- }.get(namespace.verbose, logging.NOTSET)
95
- logging.basicConfig(level=log_level, format='%(levelname)s - %(message)s')
118
+ log_level = {1: logging.INFO, 2: logging.DEBUG, 3: logging.NOTSET}.get(
119
+ namespace.verbose, logging.NOTSET
120
+ )
121
+ logging.basicConfig(
122
+ level=log_level, format="%(levelname)s - %(message)s"
123
+ )
96
124
  delattr(namespace, "verbose")
97
125
  return namespace, args
98
126
 
@@ -114,19 +142,23 @@ def assure_args(args: Optional[Sequence[str]] = None):
114
142
  return args
115
143
 
116
144
 
117
- def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
118
- kwargs: dict,
119
- add_verbose: bool = True,
120
- ask_for_missing: bool = True,
121
- args: Optional[Sequence[str]] = None) -> tuple[EnvClass, WrongFields]:
122
- """ Run the tyro parser to fetch program configuration from CLI """
145
+ def parse_cli(
146
+ env_or_list: Type[EnvClass] | list[Type[EnvClass]],
147
+ kwargs: dict,
148
+ add_verbose: bool = True,
149
+ ask_for_missing: bool = True,
150
+ args: Optional[Sequence[str]] = None,
151
+ ) -> tuple[EnvClass, WrongFields]:
152
+ """Run the tyro parser to fetch program configuration from CLI"""
123
153
  if isinstance(env_or_list, list):
124
154
  # We have to convert the list of possible classes (subcommands) to union for tyro.
125
155
  # We have to accept the list and not an union directly because we are not able
126
156
  # to type hint a union type, only a union instance.
127
157
  # def sugg(a: UnionType[EnvClass]) -> EnvClass: ...
128
158
  # sugg(Subcommand1 | Subcommand2). -> IDE will not suggest anything
129
- type_form = Union[tuple(env_or_list)] # Union[*env_or_list] not supported in Python3.10
159
+ type_form = Union[
160
+ tuple(env_or_list)
161
+ ] # Union[*env_or_list] not supported in Python3.10
130
162
  env_classes = env_or_list
131
163
  else:
132
164
  type_form = env_or_list
@@ -142,14 +174,20 @@ def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
142
174
  # Mock parser, inject special options into
143
175
  patches = []
144
176
  if ask_for_missing: # Get the missing flags from the parser
145
- patches.append(patch.object(TyroArgumentParser, 'error', Patches.custom_error))
177
+ patches.append(patch.object(TyroArgumentParser, "error", Patches.custom_error))
146
178
  if add_verbose: # Mock parser to add verbosity
147
179
  # The verbose flag is added only if neither the env_class nor any of the subcommands have the verbose flag already
148
180
  if all("verbose" not in cl.__annotations__ for cl in env_classes):
149
- patches.extend((
150
- patch.object(TyroArgumentParser, '__init__', Patches.custom_init),
151
- patch.object(TyroArgumentParser, 'parse_known_args', Patches.custom_parse_known_args)
152
- ))
181
+ patches.extend(
182
+ (
183
+ patch.object(TyroArgumentParser, "__init__", Patches.custom_init),
184
+ patch.object(
185
+ TyroArgumentParser,
186
+ "parse_known_args",
187
+ Patches.custom_parse_known_args,
188
+ ),
189
+ )
190
+ )
153
191
 
154
192
  # Run the parser, with the mocks
155
193
  try:
@@ -178,12 +216,21 @@ def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
178
216
  parser: ArgumentParser = get_parser(type_form, **kwargs)
179
217
  subargs = args
180
218
  elif len(args):
181
- env = next((env for env in env_classes if to_kebab_case(env.__name__) == args[0]), None)
219
+ env = next(
220
+ (
221
+ env
222
+ for env in env_classes
223
+ if to_kebab_case(env.__name__) == args[0]
224
+ ),
225
+ None,
226
+ )
182
227
  if env:
183
228
  parser: ArgumentParser = get_parser(env)
184
229
  subargs = args[1:]
185
230
  if not env:
186
- raise NotImplemented("This case of nested dataclasses is not implemented. Raise an issue please.")
231
+ raise NotImplemented(
232
+ "This case of nested dataclasses is not implemented. Raise an issue please."
233
+ )
187
234
 
188
235
  # Determine missing argument of the given dataclass
189
236
  positionals = (p for p in parser._actions if p.default != argparse.SUPPRESS)
@@ -194,12 +241,16 @@ def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
194
241
  # Positional
195
242
  # Ex: `The following arguments are required: PATH, INT, STR`
196
243
  argument = next(positionals)
197
- register_wrong_field(env, kwargs, wf, argument, exception, eavesdrop)
244
+ register_wrong_field(
245
+ env, kwargs, wf, argument, exception, eavesdrop
246
+ )
198
247
  else:
199
248
  # required arguments
200
249
  # Ex: `the following arguments are required: --foo, --bar`
201
- if argument := identify_required(parser, arg):
202
- register_wrong_field(env, kwargs, wf, argument, exception, eavesdrop)
250
+ if argument := identify_required(parser, arg):
251
+ register_wrong_field(
252
+ env, kwargs, wf, argument, exception, eavesdrop
253
+ )
203
254
 
204
255
  # Second attempt to parse CLI.
205
256
  # We have just put a default values for missing fields so that tyro will not fail.
@@ -213,7 +264,7 @@ def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
213
264
  # (This is not true anymore; to support pydantic we put a default value of the type,
214
265
  # so there is probably no more warning to be caught.)
215
266
  with warnings.catch_warnings():
216
- warnings.simplefilter('ignore')
267
+ warnings.simplefilter("ignore")
217
268
  try:
218
269
  env = cli(env, args=subargs, **kwargs)
219
270
  except AssertionError:
@@ -243,7 +294,9 @@ def identify_required(parser: ArgumentParser, arg: str) -> None | Action:
243
294
  # we should never come here, as treating missing subcommand should be treated by run/start.choose_subcommand
244
295
  return
245
296
  try:
246
- argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
297
+ argument: Action = next(
298
+ iter(p for p in parser._actions if arg in p.option_strings)
299
+ )
247
300
  except:
248
301
  # missing subcommand flag not implemented (correction: might be implemented and we never come here anymore)
249
302
  return
@@ -268,8 +321,15 @@ def argument_to_field_name(env_class: EnvClass, argument: Action):
268
321
  return field_name
269
322
 
270
323
 
271
- def register_wrong_field(env_class: EnvClass, kwargs: dict, wf: dict, argument: Action, exception: BaseException, eavesdrop):
272
- """ The field is missing.
324
+ def register_wrong_field(
325
+ env_class: EnvClass,
326
+ kwargs: dict,
327
+ wf: dict,
328
+ argument: Action,
329
+ exception: BaseException,
330
+ eavesdrop,
331
+ ):
332
+ """The field is missing.
273
333
  We prepare it to the list of wrong fields to be filled up
274
334
  and make a temporary default value so that tyro will not fail.
275
335
  """
@@ -277,12 +337,13 @@ def register_wrong_field(env_class: EnvClass, kwargs: dict, wf: dict, argument:
277
337
  # NOTE: We put MissingTagValue to the UI to clearly state that the value is missing.
278
338
  # However, the UI then is not able to use ex. the number filtering capabilities.
279
339
  # Putting there None is not a good idea as dataclass_to_tagdict fails if None is not allowed by the annotation.
280
- tag = wf[field_name] = tag_factory(MissingTagValue(exception, eavesdrop),
281
- (argument.help or "").replace("(required)", ""),
282
- validation=not_empty,
283
- _src_class=env_class,
284
- _src_key=field_name
285
- )
340
+ tag = wf[field_name] = tag_factory(
341
+ MissingTagValue(exception, eavesdrop),
342
+ (argument.help or "").replace("(required)", ""),
343
+ validation=not_empty,
344
+ _src_class=env_class,
345
+ _src_key=field_name,
346
+ )
286
347
  # Why `_make_default_value`? We need to put a default value so that the parsing will not fail.
287
348
  # A None would be enough because Mininterface will ask for the missing values
288
349
  # promply, however, Pydantic model would fail.
@@ -300,11 +361,13 @@ def set_default(kwargs, field_name, val):
300
361
  setattr(kwargs["default"], field_name, val)
301
362
 
302
363
 
303
- def parse_config_file(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
304
- config_file: Path | None = None,
305
- settings: Optional[MininterfaceSettings] = None,
306
- **kwargs) -> tuple[dict, MininterfaceSettings | None]:
307
- """ Fetches the config file into the program defaults kwargs["default"] and UI settings.
364
+ def parse_config_file(
365
+ env_or_list: Type[EnvClass] | list[Type[EnvClass]],
366
+ config_file: Path | None = None,
367
+ settings: Optional[MininterfaceSettings] = None,
368
+ **kwargs,
369
+ ) -> tuple[dict, MininterfaceSettings | None]:
370
+ """Fetches the config file into the program defaults kwargs["default"] and UI settings.
308
371
 
309
372
  Args:
310
373
  env_class: Class with the configuration.
@@ -326,24 +389,31 @@ def parse_config_file(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
326
389
  if config_file and subcommands:
327
390
  # Reading config files when using subcommands is not implemented.
328
391
  kwargs.pop("default", None)
329
- warnings.warn(f"Config file {config_file} is ignored because subcommands are used."
330
- " It is not easy to set how this should work."
331
- " Describe the developer your usecase so that they might implement this.")
392
+ warnings.warn(
393
+ f"Config file {config_file} is ignored because subcommands are used."
394
+ " It is not easy to set how this should work."
395
+ " Describe the developer your usecase so that they might implement this."
396
+ )
332
397
 
333
398
  if "default" not in kwargs and not subcommands and config_file:
334
399
  # Undocumented feature. User put a namespace into kwargs["default"]
335
400
  # that already serves for defaults. We do not fetch defaults yet from a config file.
336
401
  disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
337
- if confopt := disk.pop("mininterface", None):
338
- # Section 'mininterface' in the config file.
339
- settings = _merge_settings(settings, confopt)
402
+ try:
403
+ if confopt := disk.pop("mininterface", None):
404
+ # Section 'mininterface' in the config file.
405
+ settings = _merge_settings(settings, confopt)
340
406
 
341
- kwargs["default"] = _create_with_missing(env, disk)
407
+ kwargs["default"] = _create_with_missing(env, disk)
408
+ except TypeError:
409
+ raise SyntaxError(f"Config file parsing failed for {config_file}")
342
410
 
343
411
  return kwargs, settings
344
412
 
345
413
 
346
- def _merge_settings(runopt: MininterfaceSettings | None, confopt: dict, _def_fact=MininterfaceSettings) -> MininterfaceSettings:
414
+ def _merge_settings(
415
+ runopt: MininterfaceSettings | None, confopt: dict, _def_fact=MininterfaceSettings
416
+ ) -> MininterfaceSettings:
347
417
  # Settings inheritance:
348
418
  # Config file > program-given through run(settings=) > the default settings (original dataclasses)
349
419
 
@@ -357,14 +427,18 @@ def _merge_settings(runopt: MininterfaceSettings | None, confopt: dict, _def_fac
357
427
 
358
428
  # Merge option sections.
359
429
  # Ex: TextSettings will derive from both Tui and Ui. You may specify a Tui default value, common for all Tui interfaces.
360
- for sources in [("ui", "gui"),
361
- ("ui", "tui"),
362
- ("ui", "tui", "textual"),
363
- ("ui", "tui", "text"),
364
- ("ui", "tui", "textual", "web"),
365
- ]:
430
+ for sources in [
431
+ ("ui", "gui"),
432
+ ("ui", "tui"),
433
+ ("ui", "tui", "textual"),
434
+ ("ui", "tui", "text"),
435
+ ("ui", "tui", "textual", "web"),
436
+ ]:
366
437
  target = sources[-1]
367
- confopt[target] = {**{k: v for s in sources for k, v in confopt.get(s, {}).items()}, **confopt.get(target, {})}
438
+ confopt[target] = {
439
+ **{k: v for s in sources for k, v in confopt.get(s, {}).items()},
440
+ **confopt.get(target, {}),
441
+ }
368
442
 
369
443
  for key, value in vars(_create_with_missing(_def_fact, confopt)).items():
370
444
  if value is not MISSING_NONPROP:
@@ -372,6 +446,47 @@ def _merge_settings(runopt: MininterfaceSettings | None, confopt: dict, _def_fac
372
446
  return runopt
373
447
 
374
448
 
449
+ def coerce_type_to_annotation(value, annotation):
450
+ """
451
+ Coerce value (e.g. list) to expected type (e.g. tuple[int, int]).
452
+ Only handles basic cases: tuple[...] from list, and recurses if needed.
453
+ """
454
+ if annotation is None:
455
+ return value
456
+
457
+ annotation = _unwrap_annotated(annotation)
458
+ origin = get_origin(annotation)
459
+
460
+ # Handle tuple[...] conversion
461
+ if origin is tuple and isinstance(value, list):
462
+ args = get_args(annotation)
463
+ if args and len(args) == len(value):
464
+ return tuple(
465
+ coerce_type_to_annotation(v, arg) for v, arg in zip(value, args)
466
+ )
467
+ return tuple(value)
468
+
469
+ # Handle list[...] conversion
470
+ if origin is list and isinstance(value, list):
471
+ args = get_args(annotation)
472
+ if args:
473
+ return [coerce_type_to_annotation(v, args[0]) for v in value]
474
+ return value
475
+
476
+ # Handle dict[...] conversion
477
+ if origin is dict and isinstance(value, dict):
478
+ key_type, val_type = get_args(annotation)
479
+ return {
480
+ coerce_type_to_annotation(k, key_type): coerce_type_to_annotation(
481
+ v, val_type
482
+ )
483
+ for k, v in value.items()
484
+ }
485
+
486
+ # For nested dataclass or BaseModel etc.
487
+ return value
488
+
489
+
375
490
  def _unwrap_annotated(tp):
376
491
  """
377
492
  Annotated[Inner, ...] -> `Inner`,
@@ -435,7 +550,7 @@ def _process_pydantic(env, disk):
435
550
  if isinstance(f.default, BaseModel):
436
551
  v = _create_with_missing(f.default.__class__, disk[name])
437
552
  else:
438
- v = disk[name]
553
+ v = coerce_type_to_annotation(disk[name], f.annotation)
439
554
  elif f.default is not None:
440
555
  v = f.default
441
556
  yield name, v
@@ -447,7 +562,7 @@ def _process_attr(env, disk):
447
562
  if attr.has(f.default):
448
563
  v = _create_with_missing(f.default.__class__, disk[f.name])
449
564
  else:
450
- v = disk[f.name]
565
+ v = coerce_type_to_annotation(disk[f.name], f.type)
451
566
  elif f.default is not attr.NOTHING:
452
567
  v = f.default
453
568
  else:
@@ -463,7 +578,7 @@ def _process_dataclass(env, disk):
463
578
  if is_dataclass(_unwrap_annotated(f.type)):
464
579
  v = _create_with_missing(f.type, disk[f.name])
465
580
  else:
466
- v = disk[f.name]
581
+ v = coerce_type_to_annotation(disk[f.name], f.type)
467
582
  elif f.default_factory is not MISSING:
468
583
  v = f.default_factory()
469
584
  elif f.default is not MISSING:
@@ -474,7 +589,7 @@ def _process_dataclass(env, disk):
474
589
 
475
590
 
476
591
  def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass:
477
- """ Note that in contrast to the argparse, we create default values.
592
+ """Note that in contrast to the argparse, we create default values.
478
593
  When an optional flag is not used, argparse put None, we have a default value.
479
594
 
480
595
  This does make sense for most values and should not pose problems for truthy-values.
@@ -502,7 +617,9 @@ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass
502
617
  arg_type = list[action.type or str]
503
618
  opt["default_factory"] = list
504
619
  else:
505
- if isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction)):
620
+ if isinstance(
621
+ action, (argparse._StoreTrueAction, argparse._StoreFalseAction)
622
+ ):
506
623
  arg_type = bool
507
624
  elif isinstance(action, argparse._StoreConstAction):
508
625
  arg_type = type(action.const)
@@ -520,7 +637,9 @@ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass
520
637
  # nevertheless.
521
638
  # Ex. parser.add_argument("--time", type=time) -> does work poorly in argparse.
522
639
  action.default = Tag(annotation=arg_type)._make_default_value()
523
- opt["default"] = action.default if action.default != argparse.SUPPRESS else None
640
+ opt["default"] = (
641
+ action.default if action.default != argparse.SUPPRESS else None
642
+ )
524
643
 
525
644
  # build a dataclass field, either optional, or positional
526
645
  met = {"metadata": {"help": action.help}}
@@ -533,6 +652,6 @@ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass
533
652
 
534
653
 
535
654
  def to_kebab_case(name: str) -> str:
536
- """ MyClass -> my-class """
655
+ """MyClass -> my-class"""
537
656
  # I did not find where tyro does it. If I find it, I might use its function instead.
538
- return re.sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
657
+ return re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
@@ -5,6 +5,8 @@ from typing import Annotated, Literal
5
5
 
6
6
  from tyro.conf import Positional
7
7
 
8
+ from ..tag.select_tag import SelectTag
9
+
8
10
  from ..exceptions import ValidationFail
9
11
  from ..cli import Command, SubcommandPlaceholder
10
12
  from ..tag.secret_tag import SecretTag
@@ -85,6 +87,9 @@ class Env:
85
87
  my_choice: Annotated[str, Options("one", "two", "three")] = "two"
86
88
  """ Choose between values """
87
89
 
90
+ my_multiple: Annotated[str, SelectTag(options=("one", "two", "three"), multiple=True)] = "two"
91
+ """ Choose values """
92
+
88
93
 
89
94
  def showcase(case: int):
90
95
  kw = {"args": []}
@@ -1,4 +1,6 @@
1
1
  from abc import ABC, abstractmethod
2
+ from itertools import chain
3
+ from string import ascii_lowercase
2
4
  from typing import TYPE_CHECKING, Callable, Optional
3
5
 
4
6
  from .._lib.auxiliary import flatten
@@ -38,6 +40,37 @@ class BackendAdaptor(ABC):
38
40
  Setups the facet._fetch_from_adaptor.
39
41
  """
40
42
  self.facet._fetch_from_adaptor(form)
43
+ if self.settings.mnemonic is not False:
44
+ self._determine_mnemonic(form, self.settings.mnemonic is True)
45
+
46
+ def _determine_mnemonic(self, form: TagDict, also_nones=False):
47
+ """ also_nones – Also determine those tags when Tag.mnemonic=None. """
48
+ # Determine mnemonic
49
+ used_mnemonic = set()
50
+ to_be_determined: list[Tag] = []
51
+ tags = list(flatten(form))
52
+ if len(tags) <= 1: # do not use mnemonic for single field which is focused by default
53
+ return
54
+ for tag in tags:
55
+ if tag.mnemonic is False:
56
+ continue
57
+ if isinstance(tag.mnemonic, str):
58
+ used_mnemonic.add(tag.mnemonic)
59
+ tag._mnemonic = tag.mnemonic
60
+ elif also_nones or tag.mnemonic:
61
+ # .settings.mnemonic=None + tag.mnemonic=True OR
62
+ # .settings.mnemonic=True + tag.mnemonic=None
63
+ to_be_determined.append(tag)
64
+
65
+ # Find free mnemonic for Tag
66
+ for tag in to_be_determined:
67
+ # try every char in label
68
+ # then, if no free letter, give a random letter
69
+ for c in chain((c.lower() for c in tag.label if c.isalpha()), ascii_lowercase):
70
+ if c not in used_mnemonic:
71
+ used_mnemonic.add(c)
72
+ tag._mnemonic = c
73
+ break
41
74
 
42
75
  def submit_done(self) -> bool:
43
76
  if action := self.post_submit_action: