mininterface 1.1.1__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.1 → mininterface-1.1.2}/PKG-INFO +2 -2
  2. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/__main__.py +1 -0
  3. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/cli_utils.py +19 -0
  4. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/dataclass_creation.py +22 -9
  5. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_mininterface/__init__.py +18 -3
  6. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_mininterface/adaptor.py +15 -0
  7. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_mininterface/mixin.py +6 -6
  8. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_text_interface/__init__.py +10 -6
  9. mininterface-1.1.2/mininterface/_text_interface/timeout.py +126 -0
  10. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/adaptor.py +6 -5
  11. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/button_contents.py +3 -3
  12. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/textual_app.py +13 -10
  13. mininterface-1.1.2/mininterface/_textual_interface/timeout.py +35 -0
  14. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/widgets.py +8 -0
  15. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/adaptor.py +15 -6
  16. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/select_input.py +1 -1
  17. mininterface-1.1.2/mininterface/_tk_interface/timeout.py +38 -0
  18. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_web_interface/child_adaptor.py +6 -1
  19. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/flag.py +1 -1
  20. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/select_tag.py +11 -1
  21. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/tag.py +23 -3
  22. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/tag_factory.py +9 -1
  23. {mininterface-1.1.1 → mininterface-1.1.2}/pyproject.toml +2 -2
  24. {mininterface-1.1.1 → mininterface-1.1.2}/LICENSE +0 -0
  25. {mininterface-1.1.1 → mininterface-1.1.2}/README.md +0 -0
  26. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/__init__.py +0 -0
  27. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/__init__.py +0 -0
  28. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/argparse_support.py +0 -0
  29. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/auxiliary.py +0 -0
  30. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/cli_flags.py +0 -0
  31. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/cli_parser.py +0 -0
  32. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/config_file.py +0 -0
  33. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/form_dict.py +0 -0
  34. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/future_compatibility.py +0 -0
  35. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/redirectable.py +0 -0
  36. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/run.py +0 -0
  37. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/shortcuts.py +0 -0
  38. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/showcase.py +0 -0
  39. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/start.py +0 -0
  40. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_lib/tyro_patches.py +0 -0
  41. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_text_interface/adaptor.py +0 -0
  42. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_text_interface/facet.py +0 -0
  43. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/__init__.py +0 -0
  44. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/facet.py +0 -0
  45. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/file_picker_input.py +0 -0
  46. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/form_contents.py +0 -0
  47. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/secret_input.py +0 -0
  48. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_textual_interface/style.tcss +0 -0
  49. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/__init__.py +0 -0
  50. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/date_entry.py +0 -0
  51. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/external_fix.py +0 -0
  52. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/facet.py +0 -0
  53. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/redirect_text_tkinter.py +0 -0
  54. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/secret_entry.py +0 -0
  55. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_tk_interface/utils.py +0 -0
  56. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_web_interface/__init__.py +0 -0
  57. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_web_interface/app.py +0 -0
  58. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/_web_interface/parent_adaptor.py +0 -0
  59. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/cli.py +0 -0
  60. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/exceptions.py +0 -0
  61. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/experimental.py +0 -0
  62. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/facet/__init__.py +0 -0
  63. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/interfaces.py +0 -0
  64. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/settings.py +0 -0
  65. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/__init__.py +0 -0
  66. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/alias.py +0 -0
  67. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/callback_tag.py +0 -0
  68. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/datetime_tag.py +0 -0
  69. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/internal.py +0 -0
  70. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/path_tag.py +0 -0
  71. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/secret_tag.py +0 -0
  72. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/tag/type_stubs.py +0 -0
  73. {mininterface-1.1.1 → mininterface-1.1.2}/mininterface/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mininterface
3
- Version: 1.1.1
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
6
  License-File: LICENSE
@@ -34,7 +34,7 @@ Requires-Dist: tkcalendar ; extra == "gui" or extra == "ui" or extra == "all"
34
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"
35
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"
36
36
  Requires-Dist: tkscrollableframe ; 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,<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"
38
38
  Project-URL: Homepage, https://github.com/CZ-NIC/mininterface
39
39
  Description-Content-Type: text/markdown
40
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
@@ -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.
@@ -108,7 +108,7 @@ def _unwrap_annotated(tp):
108
108
  return tp
109
109
 
110
110
 
111
- def create_with_missing(env: T, disk: dict, wf: Optional[dict] = None, mint: Optional["Mininterface"] = None)->T:
111
+ def create_with_missing(env: T, disk: dict, wf: Optional[dict] = None, m: Optional["Mininterface"] = None)->T:
112
112
  """
113
113
  Create a default instance of an Env object. This is due to provent tyro to spawn warnings about missing fields.
114
114
  Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
@@ -116,6 +116,8 @@ def create_with_missing(env: T, disk: dict, wf: Optional[dict] = None, mint: Opt
116
116
  The result contains MISSING_NONPROP on the places the original Env object must have a value.
117
117
 
118
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.
119
121
  """
120
122
  # NOTE a test is missing
121
123
  # @dataclass
@@ -146,7 +148,7 @@ def create_with_missing(env: T, disk: dict, wf: Optional[dict] = None, mint: Opt
146
148
  # as the default value takes the precedence over the hard coded one, even if missing.
147
149
  out = {}
148
150
  missings: list[Tag] = []
149
- for name, v in proc_method(env, disk, wf, mint):
151
+ for name, v in proc_method(env, disk, wf, m):
150
152
  out[name] = v
151
153
  if v == MISSING_NONPROP and wf is not None:
152
154
  # For building config file, the MISSING_NONPROP is alright as we expect tyro to fail
@@ -174,7 +176,7 @@ def _process_pydantic(env, disk, wf: Optional[dict], m: Optional["Mininterface"]
174
176
  elif f.default is not None:
175
177
  v = f.default
176
178
  else:
177
- 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)
178
180
  yield name, v
179
181
 
180
182
 
@@ -187,7 +189,7 @@ def _process_attr(env, disk, wf: Optional[dict], m: Optional["Mininterface"] = N
187
189
  elif has_default:
188
190
  v = f.default
189
191
  else:
190
- 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)
191
193
  yield f.name, v
192
194
 
193
195
 
@@ -243,16 +245,27 @@ def _process_field(fname, ftype, disk_value, wf, m, default_value=MISSING):
243
245
  else:
244
246
  raise ValueError(f"Type {disk_value} not found in {ftype}")
245
247
 
246
- if disk_value is not MISSING:
248
+ if disk_value is not MISSING_NONPROP:
247
249
  if _is_struct_type(ftype):
248
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)}
249
256
  return coerce_type_to_annotation(disk_value, ftype)
250
257
 
258
+ if _is_struct_type(ftype):
259
+ # Ex. `foo: Subcommand`
260
+ return _init_struct_value(ftype, {}, wf, fname, m)
261
+
251
262
  # We must handle the case when there are multiple subcommands possible.
252
263
  # The user decides now which way to go (choose a subcommand).
253
- if (origin is Union or origin is UnionType) and all(_is_struct_type(cl) for cl in get_args(ftype)):
254
- if not m: # we should not come here
255
- 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
256
269
  ftype = choose_subcommand(get_args(ftype), m)
257
270
  return _init_struct_value(ftype, {}, wf, fname, m)
258
271
 
@@ -271,7 +284,7 @@ def _process_dataclass(env, disk, wf: Optional[dict], m: Optional["Mininterface"
271
284
  elif f.default is not MISSING:
272
285
  v = f.default
273
286
  else:
274
- v = _process_field(f.name, f.type, MISSING, wf, m)
287
+ v = _process_field(f.name, f.type, MISSING_NONPROP, wf, m)
275
288
  yield f.name, v
276
289
 
277
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
 
@@ -255,7 +260,7 @@ class Mininterface(Generic[EnvClass]):
255
260
  # Output:
256
261
  return assure_tag(annotation, validation)._make_default_value()
257
262
 
258
- def confirm(self, text: str, default: bool = True) -> bool:
263
+ def confirm(self, text: str, default: bool = True, *, timeout: int = 0) -> bool:
259
264
  """Display confirm box and returns bool.
260
265
 
261
266
  ```python
@@ -265,9 +270,19 @@ class Mininterface(Generic[EnvClass]):
265
270
 
266
271
  ![Is yes window](asset/is_yes.avif "A prompted dialog")
267
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
+
268
282
  Args:
269
283
  text: Displayed text.
270
284
  default: Focus the button with this value.
285
+ timeout: Auto-confirm after N seconds (0 = disabled).
271
286
 
272
287
  Returns:
273
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:
@@ -75,7 +75,7 @@ def _assure_blank_or_bool(args):
75
75
  BlankTrue = Annotated[
76
76
  bool | None,
77
77
  PrimitiveConstructorSpec(
78
- nargs=(0,1), # TODO test 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
@@ -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'
@@ -914,12 +916,30 @@ class Tag(Generic[TagValue]):
914
916
  elif get_origin(cast_to) is tuple:
915
917
  cast_to = get_args(cast_to)
916
918
  if len(cast_to) == 2 and cast_to[1] is Ellipsis:
919
+ # ex. `list[tuple[int, ...]]`
917
920
  cast_to = cast_to[0]
918
921
  candidate = origin(tuple(cast_to(v) for v in tuple_) for tuple_ in literal_eval(ui_value))
919
- else:
920
- candidate = origin(tuple(cast_to_(v) for v, cast_to_ in zip(tuple_, cast_to)) 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)
921
939
  else:
922
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) #
923
943
  else:
924
944
  candidate = cast_to(ui_value)
925
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.1"
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