mininterface 1.1.0__tar.gz → 1.1.2__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 (73) hide show
  1. {mininterface-1.1.0 → mininterface-1.1.2}/PKG-INFO +5 -3
  2. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/__main__.py +1 -0
  3. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/auxiliary.py +6 -3
  4. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/cli_utils.py +19 -0
  5. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/dataclass_creation.py +28 -11
  6. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_mininterface/__init__.py +19 -3
  7. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_mininterface/adaptor.py +15 -0
  8. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_mininterface/mixin.py +6 -6
  9. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_text_interface/__init__.py +10 -6
  10. mininterface-1.1.2/mininterface/_text_interface/timeout.py +126 -0
  11. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/adaptor.py +6 -5
  12. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/button_contents.py +3 -3
  13. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/textual_app.py +13 -10
  14. mininterface-1.1.2/mininterface/_textual_interface/timeout.py +35 -0
  15. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/widgets.py +8 -0
  16. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/adaptor.py +15 -6
  17. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/select_input.py +1 -1
  18. mininterface-1.1.2/mininterface/_tk_interface/timeout.py +38 -0
  19. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/child_adaptor.py +6 -1
  20. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/flag.py +12 -8
  21. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/select_tag.py +11 -1
  22. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/tag.py +28 -1
  23. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/tag_factory.py +9 -1
  24. {mininterface-1.1.0 → mininterface-1.1.2}/pyproject.toml +2 -2
  25. {mininterface-1.1.0 → mininterface-1.1.2}/LICENSE +0 -0
  26. {mininterface-1.1.0 → mininterface-1.1.2}/README.md +0 -0
  27. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/__init__.py +0 -0
  28. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/__init__.py +0 -0
  29. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/argparse_support.py +0 -0
  30. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/cli_flags.py +0 -0
  31. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/cli_parser.py +0 -0
  32. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/config_file.py +0 -0
  33. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/form_dict.py +0 -0
  34. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/future_compatibility.py +0 -0
  35. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/redirectable.py +0 -0
  36. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/run.py +0 -0
  37. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/shortcuts.py +0 -0
  38. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/showcase.py +0 -0
  39. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/start.py +0 -0
  40. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/tyro_patches.py +0 -0
  41. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_text_interface/adaptor.py +0 -0
  42. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_text_interface/facet.py +0 -0
  43. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/__init__.py +0 -0
  44. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/facet.py +0 -0
  45. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/file_picker_input.py +0 -0
  46. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/form_contents.py +0 -0
  47. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/secret_input.py +0 -0
  48. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/style.tcss +0 -0
  49. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/__init__.py +0 -0
  50. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/date_entry.py +0 -0
  51. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/external_fix.py +0 -0
  52. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/facet.py +0 -0
  53. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/redirect_text_tkinter.py +0 -0
  54. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/secret_entry.py +0 -0
  55. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/utils.py +0 -0
  56. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/__init__.py +0 -0
  57. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/app.py +0 -0
  58. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/parent_adaptor.py +0 -0
  59. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/cli.py +0 -0
  60. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/exceptions.py +0 -0
  61. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/experimental.py +0 -0
  62. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/facet/__init__.py +0 -0
  63. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/interfaces.py +0 -0
  64. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/settings.py +0 -0
  65. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/__init__.py +0 -0
  66. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/alias.py +0 -0
  67. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/callback_tag.py +0 -0
  68. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/datetime_tag.py +0 -0
  69. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/internal.py +0 -0
  70. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/path_tag.py +0 -0
  71. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/secret_tag.py +0 -0
  72. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/type_stubs.py +0 -0
  73. {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/validators.py +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: mininterface
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: A minimal access to GUI, TUI, CLI and config
5
5
  License: LGPL-3.0-or-later
6
+ License-File: LICENSE
6
7
  Author: Edvard Rejthar
7
8
  Author-email: edvard.rejthar@nic.cz
8
9
  Requires-Python: >=3.10,<4.0
@@ -12,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.10
12
13
  Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
15
17
  Provides-Extra: all
16
18
  Provides-Extra: basic
17
19
  Provides-Extra: gui
@@ -32,7 +34,7 @@ Requires-Dist: tkcalendar ; extra == "gui" or extra == "ui" or extra == "all"
32
34
  Requires-Dist: tkinter-tooltip ; extra == "basic" or extra == "img" or extra == "tui" or extra == "gui" or extra == "web" or extra == "ui" or extra == "all"
33
35
  Requires-Dist: tkinter_form (==0.2.1) ; extra == "basic" or extra == "img" or extra == "tui" or extra == "gui" or extra == "web" or extra == "ui" or extra == "all"
34
36
  Requires-Dist: tkscrollableframe ; extra == "basic" or extra == "img" or extra == "tui" or extra == "gui" or extra == "web" or extra == "ui" or extra == "all"
35
- Requires-Dist: tyro (>=0.9,<0.10) ; extra == "basic" or extra == "img" or extra == "tui" or extra == "gui" or extra == "web" or extra == "ui" or extra == "all"
37
+ Requires-Dist: tyro (>=0.9.31,<0.10.0) ; extra == "basic" or extra == "img" or extra == "tui" or extra == "gui" or extra == "web" or extra == "ui" or extra == "all"
36
38
  Project-URL: Homepage, https://github.com/CZ-NIC/mininterface
37
39
  Description-Content-Type: text/markdown
38
40
 
@@ -33,6 +33,7 @@ Showcase_Type = Literal[1, 2]
33
33
  # @dataclass
34
34
  # class SharedLabel(Command):
35
35
  # text: Positional[str]
36
+ # NOTE implement `timeout`
36
37
 
37
38
 
38
39
  @dataclass
@@ -5,7 +5,7 @@ from argparse import ArgumentParser
5
5
  from dataclasses import fields, is_dataclass
6
6
  from functools import lru_cache
7
7
  from types import UnionType
8
- from typing import (Any, Callable, Iterable, Optional, TypeVar, Union,
8
+ from typing import (Any, Callable, Iterable, Optional, TypeVar, Union, Literal,
9
9
  get_args, get_origin, get_type_hints)
10
10
 
11
11
  from annotated_types import Ge, Gt, Le, Len, Lt, MultipleOf
@@ -90,8 +90,7 @@ def get_description(obj, param: str) -> str:
90
90
  if p := _get_parser(obj):
91
91
  try:
92
92
  d = get_descriptions(p)[param].strip()
93
- except KeyError:
94
- logger.warning("Cannot fetch description for '%s'", param)
93
+ except KeyError: # either fetching failed or user added no description
95
94
  return ""
96
95
  else:
97
96
  if d.replace("-", "_") == param:
@@ -192,6 +191,8 @@ def matches_annotation(value, annotation) -> bool:
192
191
 
193
192
  # generics, ex. list, tuple
194
193
  origin = get_origin(annotation)
194
+ if origin is Literal:
195
+ return value in get_args(annotation)
195
196
  if origin:
196
197
  if not isinstance(value, origin):
197
198
  return False
@@ -200,6 +201,8 @@ def matches_annotation(value, annotation) -> bool:
200
201
  if origin is list:
201
202
  return all(matches_annotation(item, subtypes[0]) for item in value)
202
203
  elif origin is tuple:
204
+ if len(subtypes) == 2 and subtypes[1] is Ellipsis: # ex. tuple[int, ...]
205
+ return all(matches_annotation(v, subtypes[0]) for v in value)
203
206
  if len(subtypes) != len(value):
204
207
  return False
205
208
  return all(matches_annotation(v, t) for v, t in zip(value, subtypes))
@@ -150,3 +150,22 @@ class SubcommandPlaceholder(Command):
150
150
 
151
151
 
152
152
  SubcommandPlaceholder.__name__ = "subcommand" # show just the shortcut in the CLI
153
+
154
+ # NOTE I'd like the method run to actually run here
155
+ # @dataclass
156
+ # class Console(Command):
157
+ # foo: str = "bar"
158
+ # def run(self):
159
+ # raise ValueError("DVA")
160
+ # self.interface.alert("Console!")
161
+ # @dataclass
162
+ # class Message(Command):
163
+ # text: str
164
+ # def run(self):
165
+ # raise ValueError("RAZ")
166
+ # @dataclass
167
+ # class Env:
168
+ # val: Message | Console
169
+ # m = run(Env) # here
170
+ # m = run([Message, Console]) # and here too
171
+ # Then, add is as a tip to Supported-types.md.
@@ -2,7 +2,7 @@ import re
2
2
  import warnings
3
3
  from dataclasses import MISSING, dataclass, fields, is_dataclass
4
4
  from types import UnionType
5
- from typing import Annotated, Optional, Type, Union, get_args, get_origin
5
+ from typing import Annotated, Optional, Type, Union, get_args, get_origin, TypeVar
6
6
 
7
7
  try:
8
8
  from tyro._singleton import MISSING_NONPROP
@@ -33,6 +33,7 @@ try: # Attrs is not a dependency but integration
33
33
  except ImportError:
34
34
  attr = None
35
35
 
36
+ T = TypeVar("T")
36
37
 
37
38
  @dataclass(slots=True)
38
39
  class ChosenSubcommand:
@@ -73,7 +74,10 @@ def coerce_type_to_annotation(value, annotation):
73
74
  }
74
75
 
75
76
  # For nested dataclass or BaseModel etc.
76
- return value
77
+ try: # ex. `Path(value)`
78
+ return annotation(value)
79
+ except Exception:
80
+ return value
77
81
 
78
82
 
79
83
  def _get_wrong_field(
@@ -104,7 +108,7 @@ def _unwrap_annotated(tp):
104
108
  return tp
105
109
 
106
110
 
107
- def create_with_missing(env, disk: dict, wf: Optional[dict] = None, mint: Optional["Mininterface"] = None):
111
+ def create_with_missing(env: T, disk: dict, wf: Optional[dict] = None, m: Optional["Mininterface"] = None)->T:
108
112
  """
109
113
  Create a default instance of an Env object. This is due to provent tyro to spawn warnings about missing fields.
110
114
  Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
@@ -112,6 +116,8 @@ def create_with_missing(env, disk: dict, wf: Optional[dict] = None, mint: Option
112
116
  The result contains MISSING_NONPROP on the places the original Env object must have a value.
113
117
 
114
118
  And such fields are put into the wf (wrong_fields) dict.
119
+
120
+ Having the `m` defined means we build the dataclass. None means we are still in the config file parsing context.
115
121
  """
116
122
  # NOTE a test is missing
117
123
  # @dataclass
@@ -142,7 +148,7 @@ def create_with_missing(env, disk: dict, wf: Optional[dict] = None, mint: Option
142
148
  # as the default value takes the precedence over the hard coded one, even if missing.
143
149
  out = {}
144
150
  missings: list[Tag] = []
145
- for name, v in proc_method(env, disk, wf, mint):
151
+ for name, v in proc_method(env, disk, wf, m):
146
152
  out[name] = v
147
153
  if v == MISSING_NONPROP and wf is not None:
148
154
  # For building config file, the MISSING_NONPROP is alright as we expect tyro to fail
@@ -170,7 +176,7 @@ def _process_pydantic(env, disk, wf: Optional[dict], m: Optional["Mininterface"]
170
176
  elif f.default is not None:
171
177
  v = f.default
172
178
  else:
173
- v = _process_field(name, f.annotation, MISSING, wf, m, default_value=MISSING)
179
+ v = _process_field(name, f.annotation, MISSING_NONPROP, wf, m, default_value=MISSING)
174
180
  yield name, v
175
181
 
176
182
 
@@ -183,7 +189,7 @@ def _process_attr(env, disk, wf: Optional[dict], m: Optional["Mininterface"] = N
183
189
  elif has_default:
184
190
  v = f.default
185
191
  else:
186
- v = _process_field(f.name, f.type, MISSING, wf, m, default_val)
192
+ v = _process_field(f.name, f.type, MISSING_NONPROP, wf, m, default_val)
187
193
  yield f.name, v
188
194
 
189
195
 
@@ -239,16 +245,27 @@ def _process_field(fname, ftype, disk_value, wf, m, default_value=MISSING):
239
245
  else:
240
246
  raise ValueError(f"Type {disk_value} not found in {ftype}")
241
247
 
242
- if disk_value is not MISSING:
248
+ if disk_value is not MISSING_NONPROP:
243
249
  if _is_struct_type(ftype):
244
250
  return _init_struct_value(ftype, disk_value, wf, fname, m)
251
+ # NOTE for handling subcommands in config file, I'll need something like this,
252
+ # combined with a structure like ChosenSubcommand, possibly inheriting from MISSING_NONPROP
253
+ # to be ignored by tyro, yet fetchable by cli_parser
254
+ # elif _is_subcommands:
255
+ # return {cl.__name__: _init_struct_value(cl, {}, wf, fname, m) for cl in get_args(ftype)}
245
256
  return coerce_type_to_annotation(disk_value, ftype)
246
257
 
258
+ if _is_struct_type(ftype):
259
+ # Ex. `foo: Subcommand`
260
+ return _init_struct_value(ftype, {}, wf, fname, m)
261
+
247
262
  # We must handle the case when there are multiple subcommands possible.
248
263
  # The user decides now which way to go (choose a subcommand).
249
- if (origin is Union or origin is UnionType) and all(_is_struct_type(cl) for cl in get_args(ftype)):
250
- if not m: # we should not come here
251
- raise RuntimeError("Missing interface, report the issue please.")
264
+ # Ex. `foo: Subcommand1 | Subcommand2`
265
+ _is_subcommands = (origin is Union or origin is UnionType) and all(_is_struct_type(cl) for cl in get_args(ftype))
266
+ if _is_subcommands:
267
+ if not m: # we are parsing the config file. The fields are not defined in the config file
268
+ return MISSING_NONPROP
252
269
  ftype = choose_subcommand(get_args(ftype), m)
253
270
  return _init_struct_value(ftype, {}, wf, fname, m)
254
271
 
@@ -267,7 +284,7 @@ def _process_dataclass(env, disk, wf: Optional[dict], m: Optional["Mininterface"
267
284
  elif f.default is not MISSING:
268
285
  v = f.default
269
286
  else:
270
- v = _process_field(f.name, f.type, MISSING, wf, m)
287
+ v = _process_field(f.name, f.type, MISSING_NONPROP, wf, m)
271
288
  yield f.name, v
272
289
 
273
290
 
@@ -164,8 +164,13 @@ class Mininterface(Generic[EnvClass]):
164
164
  def __exit__(self, *_):
165
165
  pass
166
166
 
167
- def alert(self, text: str) -> None:
168
- """Prompt the user to confirm the text."""
167
+ def alert(self, text: str, *, timeout: int = 0) -> None:
168
+ """Prompt the user to confirm the text.
169
+
170
+ Args:
171
+ text: Displayed text
172
+ timeout: Auto-confirm after N seconds (0 = disabled).
173
+ """
169
174
  print("Alert text", text)
170
175
  return
171
176
 
@@ -240,6 +245,7 @@ class Mininterface(Generic[EnvClass]):
240
245
  # So that we can have `ask("My number", int, Gt(0))`
241
246
  # NOTE Missing tests.
242
247
  # NOTE Missing default= param. (Should be same as passing `ask("My number", Tag(5))`)
248
+ # NOTE add annotation=Tag, ex. `m.ask('Repo password', SecretTag)`
243
249
 
244
250
  if annotation is int:
245
251
  print("Asking number:", text)
@@ -254,7 +260,7 @@ class Mininterface(Generic[EnvClass]):
254
260
  # Output:
255
261
  return assure_tag(annotation, validation)._make_default_value()
256
262
 
257
- def confirm(self, text: str, default: bool = True) -> bool:
263
+ def confirm(self, text: str, default: bool = True, *, timeout: int = 0) -> bool:
258
264
  """Display confirm box and returns bool.
259
265
 
260
266
  ```python
@@ -264,9 +270,19 @@ class Mininterface(Generic[EnvClass]):
264
270
 
265
271
  ![Is yes window](asset/is_yes.avif "A prompted dialog")
266
272
 
273
+ Automatically submit after 10 seconds with the `timeout` parameter.
274
+
275
+ ```python
276
+ m = run()
277
+ print(m.confirm("Is that alright?"), timeout=10) # True/False
278
+ ```
279
+
280
+ ![Is yes window](asset/confirm_countdown.avif "A prompted dialog with timeout")
281
+
267
282
  Args:
268
283
  text: Displayed text.
269
284
  default: Focus the button with this value.
285
+ timeout: Auto-confirm after N seconds (0 = disabled).
270
286
 
271
287
  Returns:
272
288
  bool: Whether the user has chosen the Yes button.
@@ -140,3 +140,18 @@ class MinAdaptor(BackendAdaptor):
140
140
  raise SystemExit(validation_fails)
141
141
 
142
142
  return form
143
+
144
+ class Timeout(ABC):
145
+ """ Auto-submit dialogs """
146
+
147
+ def __init__(self, timeout: int, adaptor: "BackendAdaptor"):
148
+ self.timeout = timeout
149
+ self.adaptor = adaptor
150
+
151
+ @abstractmethod
152
+ def countdown(self):
153
+ ...
154
+
155
+ @abstractmethod
156
+ def cancel(self):
157
+ ...
@@ -13,12 +13,12 @@ from . import Mininterface
13
13
  class RichUiMixin(Mininterface):
14
14
  _adaptor: "RichUiAdaptor"
15
15
 
16
- def alert(self, text: str) -> None:
16
+ def alert(self, text: str, *, timeout: int = 0) -> None:
17
17
  """Display the OK dialog with text."""
18
- self._adaptor.buttons(text, [("Ok", None)])
18
+ self._adaptor.buttons(text, [("Ok", None)], timeout=timeout)
19
19
 
20
- def confirm(self, text, default: bool = True) -> bool:
21
- return self._adaptor.yes_no(text, not default)
20
+ def confirm(self, text, default: bool = True, *, timeout: int = 0) -> bool:
21
+ return self._adaptor.yes_no(text, not default, timeout=timeout)
22
22
 
23
23
  def ask(
24
24
  self,
@@ -36,7 +36,7 @@ class RichUiMixin(Mininterface):
36
36
 
37
37
  class RichUiAdaptor(ABC):
38
38
  @abstractmethod
39
- def yes_no(self, text: str, focus_no=True): ...
39
+ def yes_no(self, text: str, focus_no=True, *, timeout: int = 0)->bool: ...
40
40
 
41
41
  @abstractmethod
42
- def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1): ...
42
+ def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1, *, timeout: int = 0): ...
@@ -2,12 +2,14 @@ import sys
2
2
  from pprint import pprint
3
3
  from typing import TYPE_CHECKING, Iterable, Type, TypeVar
4
4
 
5
+
5
6
  from ..tag.tag_factory import assure_tag
6
7
 
7
8
  from ..exceptions import Cancelled, InterfaceNotAvailable
8
9
  from .._lib.form_dict import DataClass, EnvClass, FormDict, tag_assure_type
9
10
  from .._mininterface import Mininterface
10
11
  from ..tag.tag import Tag, TagValue, ValidationCallback
12
+ from .timeout import input_timeout
11
13
  from .adaptor import TextAdaptor
12
14
 
13
15
  if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self`
@@ -75,23 +77,25 @@ class TextInterface(AssureInteractiveTerminal, Mininterface):
75
77
 
76
78
  _adaptor: TextAdaptor
77
79
 
78
- def alert(self, text: str):
80
+ def alert(self, text: str, *, timeout: int = 0):
79
81
  """Display text and let the user hit any key."""
80
82
  with StdinTTYWrapper():
81
- input(text + " Hit any key.")
83
+ input_timeout(text + " Hit any key.", timeout, True)
82
84
 
83
85
  def ask(
84
86
  self,
85
87
  text: str,
86
88
  annotation: Type[TagValue] | Tag = str,
87
89
  validation: Iterable[ValidationCallback] | ValidationCallback | None = None,
90
+ *,
91
+ _timeout: int = 0
88
92
  ) -> TagValue:
89
93
  with StdinTTYWrapper():
90
94
  if not self.interactive:
91
95
  return super().ask(text, annotation=annotation, validation=validation)
92
96
  while True:
93
97
  try:
94
- txt = input(text + ": ") if text else input()
98
+ txt = input_timeout(text + ":" if text else "", _timeout)
95
99
  except EOFError:
96
100
  raise Cancelled(".. cancelled")
97
101
  t = assure_tag(annotation, validation)
@@ -133,20 +137,20 @@ class TextInterface(AssureInteractiveTerminal, Mininterface):
133
137
  pdb.set_trace()
134
138
  return form
135
139
 
136
- def confirm(self, text: str, default: bool = True):
140
+ def confirm(self, text: str, default: bool = True, *, timeout: int = 0):
137
141
  with StdinTTYWrapper():
138
142
  if default:
139
143
  t = text + " [y]/n"
140
144
  else:
141
145
  t = text + " y/[n]"
142
- val = self.ask(text=t).lower()
146
+ val = self.ask(text=t, _timeout=timeout).lower()
143
147
  if not val:
144
148
  return bool(default)
145
149
  if val in ("y", "yes", ""):
146
150
  return True
147
151
  if val in ("n", "no", ""):
148
152
  return False
149
- return self.confirm(text, default)
153
+ return self.confirm(text, default, timeout=timeout)
150
154
 
151
155
 
152
156
  class ReplInterface(TextInterface):
@@ -0,0 +1,126 @@
1
+ # if not timeout:
2
+ # return input(prompt)
3
+
4
+ # print(f"{prompt} (countdown {int(timeout)} sec)", end='', flush=True)
5
+
6
+
7
+ import sys
8
+ import threading
9
+ import time
10
+ from io import UnsupportedOperation
11
+ from ..exceptions import Cancelled
12
+
13
+ if sys.platform == "win32":
14
+ import msvcrt
15
+ else:
16
+ import select
17
+ import tty
18
+ import termios
19
+
20
+ def input_timeout(prompt: str, timeout: int = 0, exit_on_keypress: bool = False) -> str:
21
+ """
22
+ Cross-platform input with timeout and visual dots.
23
+
24
+ - Shows dots every second until user starts typing.
25
+ - exit_on_keypress=True: returns immediately after any key (prints newline immediately).
26
+ - exit_on_keypress=False: disables timeout after first key, waits for Enter (newline printed at Enter).
27
+ - Returns user input string or empty string if timeout expires.
28
+ - Ctrl+C raises Cancelled.
29
+ """
30
+ input_started = threading.Event()
31
+ inp = []
32
+
33
+ # Thread to print dots
34
+ def dots_thread():
35
+ for _ in range(timeout):
36
+ if input_started.is_set():
37
+ break
38
+ time.sleep(1)
39
+ if not input_started.is_set():
40
+ print(".", end='', flush=True)
41
+
42
+ if timeout:
43
+ print(f"{prompt} (countdown {int(timeout)} sec)", end='', flush=True)
44
+ t_dots = threading.Thread(target=dots_thread, daemon=True)
45
+ t_dots.start()
46
+ timeout_running = True
47
+ else:
48
+ print(prompt + " ", end='', flush=True)
49
+ timeout_running = False
50
+
51
+ start_time = time.time()
52
+
53
+ try:
54
+ if sys.platform == "win32":
55
+ while True:
56
+ if msvcrt.kbhit():
57
+ char = msvcrt.getwch()
58
+ if char == '\r':
59
+ print() # newline at Enter
60
+ return "".join(inp)
61
+ elif char == '\x03': # Ctrl+C
62
+ raise Cancelled
63
+ elif char == '\x08': # Backspace
64
+ if inp:
65
+ inp.pop()
66
+ print("\b \b", end='', flush=True)
67
+ continue
68
+ else:
69
+ inp.append(char)
70
+ print(char, end='', flush=True)
71
+
72
+ input_started.set()
73
+ if exit_on_keypress:
74
+ print() # newline immediately
75
+ return "".join(inp)
76
+
77
+ # Stop if timeout exceeded and input not started
78
+ if timeout_running and (time.time() - start_time >= timeout) and not input_started.is_set():
79
+ input_started.set()
80
+ print()
81
+ return ""
82
+ time.sleep(0.01)
83
+
84
+ else:
85
+ fd = sys.stdin.fileno()
86
+ old_settings = termios.tcgetattr(fd)
87
+ tty.setcbreak(fd)
88
+ try:
89
+ while True:
90
+ rlist, _, _ = select.select([sys.stdin], [], [], 0.1)
91
+ if rlist:
92
+ char = sys.stdin.read(1)
93
+ if char == '\n':
94
+ print() # newline at Enter
95
+ return "".join(inp)
96
+ elif char == '\x03':
97
+ raise Cancelled
98
+ elif char == '\x7f': # Backspace
99
+ if inp:
100
+ inp.pop()
101
+ print("\b \b", end='', flush=True)
102
+ continue
103
+ else:
104
+ inp.append(char)
105
+ print(char, end='', flush=True)
106
+
107
+ input_started.set()
108
+ if exit_on_keypress:
109
+ print() # newline immediately
110
+ return "".join(inp)
111
+
112
+ # Stop if timeout exceeded and input not started
113
+ if timeout_running and (time.time() - start_time >= timeout) and not input_started.is_set():
114
+ input_started.set()
115
+ print()
116
+ return ""
117
+ finally:
118
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
119
+ except Exception:
120
+ # Fallback to class input.
121
+ # 'Press any key' will not work.
122
+ # Github actions raises `termios.error: (25, 'Inappropriate ioctl for device')`
123
+ # pytest test_interface.py::TestInterface::test_ask raises `io.UnsupportedOperation: redirected stdin is pseudofile, has no fileno()`
124
+ return input(prompt + " ")
125
+ finally:
126
+ input_started.set() # ensure dots thread stops
@@ -57,7 +57,7 @@ class TextualAdaptor(BackendAdaptor):
57
57
  # NOTE: DatetimeTag not implemented
58
58
  case SelectTag():
59
59
  if tag.multiple:
60
- selected = set(tag.val)
60
+ selected = set(tag._get_ui_val())
61
61
  o = MySelectionList(tag, *((label, val, val in selected) for label, val, *_ in tag._get_options()))
62
62
  else:
63
63
  radio_buttons = [
@@ -100,12 +100,13 @@ class TextualAdaptor(BackendAdaptor):
100
100
  else:
101
101
  return []
102
102
 
103
- def yes_no(self, text: str, focus_no=True):
104
- return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no) + 1)
103
+ def yes_no(self, text: str, focus_no=True, *, timeout: int = 0):
104
+ return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no) + 1, timeout=timeout)
105
105
 
106
- def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1):
106
+ def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1, *, timeout: int = 0):
107
107
  self._build_buttons(text, buttons, focused)
108
- self.app = app = TextualApp(self, False)
108
+ self.app = app = TextualApp(self, False, timeout=timeout)
109
+
109
110
  if not app.run():
110
111
  raise Cancelled
111
112
  return self._get_buttons_val()
@@ -1,9 +1,9 @@
1
- from typing import TYPE_CHECKING, Any, Optional
1
+ from typing import TYPE_CHECKING, Optional
2
2
 
3
3
  from textual import events
4
- from textual.app import App, ComposeResult
4
+ from textual.app import ComposeResult
5
5
  from textual.containers import Center, Container
6
- from textual.widgets import Footer, Label, Static, Button
6
+ from textual.widgets import Footer, Label
7
7
 
8
8
 
9
9
  from .widgets import MySubmitButton
@@ -1,18 +1,15 @@
1
1
  import asyncio
2
2
  from typing import TYPE_CHECKING
3
- from textual import events
3
+
4
4
  from textual.app import App
5
5
  from textual.containers import Container
6
6
  from textual.widget import Widget
7
7
 
8
- from .form_contents import FormContents
9
-
10
8
  from .button_contents import ButtonContents
11
-
12
-
9
+ from .form_contents import FormContents
10
+ from .timeout import TextualTimeout
13
11
  from .widgets import TagWidget
14
12
 
15
-
16
13
  if TYPE_CHECKING:
17
14
  from .adaptor import TextualAdaptor
18
15
 
@@ -28,12 +25,13 @@ class TextualApp(App[bool | None]):
28
25
  # We need to jump out of dir to allow children inherits (WebInterface)
29
26
  CSS_PATH = "../_textual_interface/style.tcss"
30
27
 
31
- def __init__(self, adaptor: "TextualAdaptor", submit: str | bool = True):
28
+ def __init__(self, adaptor: "TextualAdaptor", submit: str | bool = True, timeout=None):
32
29
  super().__init__()
33
30
  self.widgets: WidgetList = []
34
31
  self.focusable_: WidgetList = []
35
32
  self.adaptor = adaptor
36
33
  self.submit = submit
34
+ self.timeout = timeout
37
35
 
38
36
  # Form confirmation
39
37
  if submit:
@@ -50,15 +48,20 @@ class TextualApp(App[bool | None]):
50
48
  self.contents = Container()
51
49
  yield self.contents
52
50
 
53
- def on_mount(self) -> None:
51
+ async def on_mount(self) -> None:
54
52
  self.has_been_confirmed = False
55
- # def on_mount(self, event: events.Mount) -> None:
56
53
  self.contents.remove_children()
57
54
  if self.adaptor.button_app:
58
55
  c = ButtonContents(self.adaptor, self.adaptor.button_app)
59
56
  else:
60
57
  c = FormContents(self.adaptor, self.widgets, self.focusable_)
61
- self.contents.mount(c)
58
+ await self.contents.mount(c)
59
+
60
+ # timeout
61
+ if self.adaptor.button_app and self.timeout:
62
+ if not c.to_focus:
63
+ raise ValueError("Don't know what to timeout")
64
+ TextualTimeout(timeout=self.timeout, adaptor=self.adaptor, button=c.to_focus)
62
65
 
63
66
  def action_confirm(self):
64
67
  # next time, start on the same widget
@@ -0,0 +1,35 @@
1
+ import asyncio
2
+ from .widgets import MyButton as Button
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .._mininterface.adaptor import Timeout
6
+
7
+ if TYPE_CHECKING:
8
+ from . import TextualAdaptor
9
+
10
+
11
+ class TextualTimeout(Timeout):
12
+ button: Button
13
+
14
+ def __init__(self, timeout: int, adaptor: "TextualAdaptor", button: Button):
15
+ super().__init__(timeout, adaptor)
16
+ self.button = button
17
+ self.orig = self.button.label
18
+ self._task = asyncio.create_task(self.countdown(timeout))
19
+ self.button.set_blur_callback(lambda event=None: self.cancel())
20
+
21
+ async def countdown(self, count: int):
22
+ self.button.label = f"{self.orig} ({count})"
23
+
24
+ while count > 0:
25
+ await asyncio.sleep(1)
26
+ count -= 1
27
+ self.button.label = f"{self.orig} ({count})"
28
+
29
+ self.button.press()
30
+
31
+ def cancel(self):
32
+ if self._task and not self._task.done():
33
+ self._task.cancel()
34
+ self._task = None
35
+ self.button.label = self.orig
@@ -117,10 +117,18 @@ class MyButton(TagWidget, Button):
117
117
 
118
118
  def __init__(self, tag, *args, **kwargs):
119
119
  super().__init__(tag, tag.label, *args, **kwargs)
120
+ self.blur_callback = None
120
121
 
121
122
  def on_button_pressed(self, event):
122
123
  self.tag._facet.submit(_post_submit=self.tag._run_callable)
123
124
 
125
+ def on_blur(self):
126
+ if clb:=self.blur_callback:
127
+ clb()
128
+
129
+ def set_blur_callback(self, clb):
130
+ self.blur_callback = clb
131
+
124
132
  def get_ui_value(self):
125
133
  return self.tag.val
126
134
 
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Callable
5
5
 
6
6
  from textual.widgets import Checkbox
7
7
 
8
+
8
9
  try:
9
10
  from tkscrollableframe import ScrolledFrame
10
11
  from tktooltip import ToolTip
@@ -22,6 +23,7 @@ from ..settings import GuiSettings
22
23
  from ..tag import Tag
23
24
  from .facet import TkFacet
24
25
  from .utils import recursive_set_focus, replace_widgets
26
+ from .timeout import TkTimeout
25
27
 
26
28
 
27
29
  class TkAdaptor(Tk, RichUiAdaptor, BackendAdaptor):
@@ -139,20 +141,27 @@ class TkAdaptor(Tk, RichUiAdaptor, BackendAdaptor):
139
141
  return self.run_dialog(form, title, submit)
140
142
  return form
141
143
 
142
- def yes_no(self, text: str, focus_no=True):
143
- return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no) + 1)
144
+ def yes_no(self, text: str, focus_no=True, *, timeout: int = 0):
145
+ return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no) + 1, timeout=timeout)
144
146
 
145
- def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1):
147
+ def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1, *, timeout: int = 0):
146
148
  label = Label(self.frame, text=text)
147
149
  label.pack(pady=10)
150
+ b_focused = None
148
151
 
149
152
  for i, (text, value) in enumerate(buttons):
150
153
  button = Button(self.frame, text=text, command=lambda v=value: self._ok(v))
151
154
  button.pack(side=LEFT, padx=10)
152
155
  if i == focused - 1:
153
156
  button.focus_set()
154
- b = button
155
- button.bind("<Return>", lambda _: b.invoke())
157
+ b_focused = button
158
+ button.bind("<Return>", lambda _, b=b_focused: b.invoke())
159
+
160
+ if timeout:
161
+ if not b_focused:
162
+ raise ValueError("Don't know what to timeout")
163
+ TkTimeout(timeout, self, b_focused)
164
+
156
165
  return self.mainloop()
157
166
 
158
167
  def _bind_event(self, event, handler):
@@ -215,7 +224,7 @@ class TkAdaptor(Tk, RichUiAdaptor, BackendAdaptor):
215
224
  def _destroy(self):
216
225
  self.destroy()
217
226
 
218
- def bind_shortcut(self, shortcut: str, widget: Widget|Callable[[], Widget]):
227
+ def bind_shortcut(self, shortcut: str, widget: Widget | Callable[[], Widget]):
219
228
  def _(event=None):
220
229
  nonlocal widget
221
230
  if isinstance(widget, FunctionType):
@@ -104,7 +104,7 @@ class SelectInputWrapper:
104
104
  taken = False
105
105
 
106
106
  for i, (choice_label, choice_val, tip, tupled_key) in enumerate(options):
107
- var = BooleanVar(value=choice_val in tag.val)
107
+ var = BooleanVar(value=choice_val in tag._get_ui_val())
108
108
 
109
109
  def on_toggle(val=choice_val, var=var):
110
110
  if var.get():
@@ -0,0 +1,38 @@
1
+ from tkinter import Button
2
+ from typing import TYPE_CHECKING
3
+ from .._mininterface.adaptor import Timeout
4
+
5
+ if TYPE_CHECKING:
6
+ from . import TkAdaptor
7
+
8
+
9
+
10
+ class TkTimeout(Timeout):
11
+ adaptor: "TkAdaptor"
12
+
13
+ def __init__(self, timeout: int, adaptor: "TkAdaptor", button: Button):
14
+ super().__init__(timeout, adaptor)
15
+ self.button = button
16
+ self.after_id = None
17
+ self.orig:str = self.button.cget("text")
18
+
19
+ self.countdown(timeout)
20
+
21
+ self.button.bind("<FocusOut>", self.cancel)
22
+
23
+ def countdown(self, count):
24
+ try:
25
+ self.button.config(text=f"{self.orig} ({count})")
26
+ except: # The form has been submitted and the button is not valid anymore
27
+ return
28
+
29
+ if count > 0:
30
+ self.after_id = self.adaptor.after(1000, self.countdown, count - 1)
31
+ else:
32
+ self.button.invoke()
33
+
34
+ def cancel(self, event=None):
35
+ if self.after_id is not None:
36
+ self.adaptor.after_cancel(self.after_id)
37
+ self.after_id = None
38
+ self.button.config(text=self.orig)
@@ -71,7 +71,12 @@ class SerializedChildAdaptor(TextualAdaptor):
71
71
  self._original_stdout.buffer.flush()
72
72
  return self.receive()
73
73
 
74
- def buttons(self, text: str, buttons: ButtonAppType, focused: int = 1):
74
+ def buttons(self, text: str, buttons: ButtonAppType, focused: int = 1, *, timeout: int = 0):
75
+ if timeout:
76
+ # NOTE timeout not implemented
77
+ import warnings
78
+ warnings.warn("Timeout not implemented for web interface")
79
+ print("Warning: Timeout not implemented for web interface")
75
80
  return self.send(SerCommand.BUTTONS, text, buttons, focused)
76
81
 
77
82
  def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True) -> TagDict:
@@ -73,9 +73,9 @@ def _assure_blank_or_bool(args):
73
73
 
74
74
 
75
75
  BlankTrue = Annotated[
76
- list[str] | bool | None,
76
+ bool | None,
77
77
  PrimitiveConstructorSpec(
78
- nargs="*", # NOTE should be probably = (0,1)
78
+ nargs=(0,1),
79
79
  metavar="blank=True|BOOL",
80
80
  instance_from_str=_assure_blank_or_bool,
81
81
  is_instance=lambda instance: True, # NOTE not sure
@@ -105,7 +105,7 @@ T = TypeVar("T")
105
105
 
106
106
  _custom_registry = ConstructorRegistry()
107
107
 
108
- Blank = Annotated[T | None, None]
108
+ Blank = Annotated[T | bool | None, None]
109
109
  """
110
110
  This marker specifies:
111
111
 
@@ -147,7 +147,7 @@ $ program.py --test # True
147
147
  $ program.py --test 5 # 5
148
148
  ```
149
149
 
150
- The default blank value might be specified by a `Literal` in the `Annotated` statement.
150
+ The default blank value might be specified by a `Literal` present in the `Annotated` statement.
151
151
 
152
152
  ```python
153
153
  @dataclass
@@ -188,7 +188,7 @@ $ program.py --test False # False
188
188
 
189
189
 
190
190
  !!! Warning
191
- Experimental.
191
+ Experimental. It adds `bool` as a valid type too.
192
192
 
193
193
  ??? Discussion
194
194
  The design is working but syntax `Annotated[str, Blank(True)]` might be a cleaner design. Do you have an opinion? Let us know.
@@ -196,6 +196,7 @@ $ program.py --test False # False
196
196
 
197
197
  # NOTE untested
198
198
  # NOTE Should we move rather to mininterface.cli?
199
+ # NOTE Now it adds `bool` too. Otherwise we could not set the value to True.
199
200
 
200
201
  # NOTE Python 3.13 would allow
201
202
  # type Blank[T, U = None] = Optional[T]
@@ -211,7 +212,7 @@ if not TYPE_CHECKING:
211
212
 
212
213
  class _Blank(_Marker):
213
214
  def __getitem__(self, key):
214
- return Annotated[(key | None, self)]
215
+ return Annotated[(key | bool | None, self)]
215
216
 
216
217
  def __init__(self, description: str):
217
218
  self.description = description
@@ -238,7 +239,8 @@ def _(
238
239
  except:
239
240
  annotation = frame.f_back.f_back.f_locals["arg"].field.type
240
241
  except:
241
- raise ValueError("Cannot determine the default blank value.")
242
+ # ex. `threads: Blank[int] | Literal["auto"] = "auto"
243
+ raise ValueError("Cannot determine the default blank value. Check the mininterface.tag.flag.Blank annotation or raise a project issue please.")
242
244
 
243
245
  type_, *metadata = get_args(annotation)
244
246
  for m in metadata:
@@ -255,12 +257,14 @@ def _(
255
257
  if len(types) == 1:
256
258
  metavar = getattr(type_, "__name__", repr(type_))
257
259
  else:
258
- metavar = "|".join(getattr(s, "__name__", repr(s)) for s in types if s is not NoneType)
260
+ # NOTE we now suppress bool even if user `Blank[str|bool]` explicitely set it
261
+ metavar = "|".join(getattr(s, "__name__", repr(s)) for s in types if s not in (NoneType, bool))
259
262
 
260
263
  def instance_from_str(args):
261
264
  if not args:
262
265
  return default_val
263
266
  val = args[0]
267
+ # NOTE bool is now always in types
264
268
  if bool in types:
265
269
  if val == "True":
266
270
  return True
@@ -100,6 +100,14 @@ m.select([Color.GREEN, Color.BLUE])
100
100
 
101
101
  ![Options from enum list](asset/choice_enum_list.avif)
102
102
 
103
+ !!! Note
104
+ Enum keys are seen from the CLI (unless modified). Enum values are seen from the UI. Should you need to bear an additional information, not displayed in CLI nor UI, tackle the Enum class.
105
+ For the examples, see [Supported types / Enum](Supported-types.md/#enums).
106
+
107
+ ## Literal
108
+
109
+ `typing.Literal` allows you to do a one-liner. Their values are seen from the CLI. See the [Supported types / Literal](Supported-types.md/#literal) examples.
110
+
103
111
  ## Further examples
104
112
 
105
113
  See [mininterface.select][mininterface.Mininterface.select] or [`SelectTag.options`][mininterface.tag.SelectTag.options] for further usage.
@@ -254,7 +262,7 @@ class SelectTag(Tag[TagValue]):
254
262
  def _get_selected_keys(self):
255
263
  if not self.multiple:
256
264
  raise AttributeError
257
- return [k for k, val, *_ in self._get_options() if val in self.val]
265
+ return [k for k, val, *_ in self._get_options() if val in self._get_ui_val()]
258
266
 
259
267
  @classmethod
260
268
  def _repr_val(cls, v) -> str:
@@ -357,6 +365,8 @@ class SelectTag(Tag[TagValue]):
357
365
  return [(delim.join(key), key) for key in keys]
358
366
 
359
367
  def _make_default_value(self):
368
+ if self.multiple:
369
+ return []
360
370
  if d := self._build_options():
361
371
  return next(iter(d.values()))
362
372
 
@@ -669,6 +669,8 @@ class Tag(Generic[TagValue]):
669
669
  return [_(subt) for subt in subtype]
670
670
  if origin is tuple:
671
671
  return origin, list(subtype)
672
+ elif origin is dict:
673
+ return origin, subtype
672
674
  elif origin is Literal:
673
675
  return origin, subtype
674
676
  elif len(subtype) == 1:
@@ -695,7 +697,7 @@ class Tag(Generic[TagValue]):
695
697
  def set_error_text(self, s=""):
696
698
  """ Mark the field as required and possibly adds an error text to the description. """
697
699
  if s:
698
- self.description = f"{s} {self._original_desc}"
700
+ self.description = f"{s} {self._original_desc}".strip()
699
701
  if n := self._original_label:
700
702
  # Why checking self._original_label?
701
703
  # If for any reason (I do not know the use case) is not set, we would end up with '* None'
@@ -911,8 +913,33 @@ class Tag(Generic[TagValue]):
911
913
  candidate = origin(
912
914
  cast_to_(v) for cast_to_, v in zip(cast_to, literal_eval(ui_value))
913
915
  )
916
+ elif get_origin(cast_to) is tuple:
917
+ cast_to = get_args(cast_to)
918
+ if len(cast_to) == 2 and cast_to[1] is Ellipsis:
919
+ # ex. `list[tuple[int, ...]]`
920
+ cast_to = cast_to[0]
921
+ candidate = origin(tuple(cast_to(v) for v in tuple_) for tuple_ in literal_eval(ui_value))
922
+ else: # ex. `list[tuple[int, Path, str]]`
923
+ tuples = []
924
+ for str_tuple in literal_eval(ui_value):
925
+ try:
926
+ typed_tuple = tuple(
927
+ cast_to_(v) for v, cast_to_ in zip(str_tuple, cast_to, strict=True)
928
+ )
929
+ except ValueError as e:
930
+ # this was the right way, there is no reason to attempt further
931
+ self.set_error_text(f"Bad tuple length for {str_tuple!r}")
932
+ return False
933
+ tuples.append(typed_tuple)
934
+
935
+ candidate = origin(tuples)
936
+ elif origin is dict: # ex. `dict[str, str]`
937
+ # we ignore cast_to as `[str, str]` part will be checked later
938
+ candidate = literal_eval(ui_value)
914
939
  else:
915
940
  candidate = origin(cast_to(v) for v in literal_eval(ui_value))
941
+ elif cast_to is dict: # `annotation=dict`
942
+ candidate = literal_eval(ui_value) #
916
943
  else:
917
944
  candidate = cast_to(ui_value)
918
945
  except (TypeError, ValueError, SyntaxError):
@@ -2,7 +2,7 @@ from copy import copy
2
2
  from datetime import date, time
3
3
  from enum import Enum
4
4
  from pathlib import Path
5
- from typing import Any, Iterable, Literal, Type, get_origin, get_type_hints
5
+ from typing import Any, Iterable, Literal, Type, get_args, get_origin, get_type_hints
6
6
 
7
7
  from annotated_types import BaseMetadata, GroupedMetadata, Len
8
8
 
@@ -108,6 +108,14 @@ def tag_factory(
108
108
  elif isinstance(metadata, (BaseMetadata, Len)):
109
109
  # Why not checking `GroupedMetadata` instead of `Len`? See below. You won't believe.
110
110
  validators.append(metadata)
111
+ elif get_origin(metadata) is Literal:
112
+ if "<class 'mininterface.tag.flag._Blank'>" in (repr(type(f)) for f in field_type.__metadata__):
113
+ # a special case, this is a default CLI value and will be processed by flag.Blank
114
+ # `foo: Annotated[Blank[int], Literal[2]] = None`
115
+ # Using repr and not importing due to (vague) performance reasons.
116
+ continue
117
+ # `variable = 2, 3; foo: Annotated[int, Literal[variable]] = None`
118
+ annotation = metadata
111
119
  if not tag:
112
120
  tag = tag_assure_type(Tag(val, description, annotation, *args, **kwargs))
113
121
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "mininterface"
7
- version = "1.1.0"
7
+ version = "1.1.2"
8
8
  description = "A minimal access to GUI, TUI, CLI and config"
9
9
  authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
10
10
  license = "LGPL-3.0-or-later"
@@ -17,7 +17,7 @@ python = "^3.10"
17
17
  simple_term_menu = "*"
18
18
  annotated-types = "*"
19
19
  # Basic requirements (CLI and full .form support (m.form(dataclass_class))
20
- tyro = { version = "^0.9", optional = true }
20
+ tyro = { version = "^0.9.31", optional = true }
21
21
  pyyaml = { version = "*", optional = true }
22
22
  humanize = { version = "*", optional = true }
23
23
  textual = { version = "<2.0.0", optional = true }
File without changes
File without changes