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.
- {mininterface-1.1.0 → mininterface-1.1.2}/PKG-INFO +5 -3
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/__main__.py +1 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/auxiliary.py +6 -3
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/cli_utils.py +19 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/dataclass_creation.py +28 -11
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_mininterface/__init__.py +19 -3
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_mininterface/adaptor.py +15 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_mininterface/mixin.py +6 -6
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_text_interface/__init__.py +10 -6
- mininterface-1.1.2/mininterface/_text_interface/timeout.py +126 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/adaptor.py +6 -5
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/button_contents.py +3 -3
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/textual_app.py +13 -10
- mininterface-1.1.2/mininterface/_textual_interface/timeout.py +35 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/widgets.py +8 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/adaptor.py +15 -6
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/select_input.py +1 -1
- mininterface-1.1.2/mininterface/_tk_interface/timeout.py +38 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/child_adaptor.py +6 -1
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/flag.py +12 -8
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/select_tag.py +11 -1
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/tag.py +28 -1
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/tag_factory.py +9 -1
- {mininterface-1.1.0 → mininterface-1.1.2}/pyproject.toml +2 -2
- {mininterface-1.1.0 → mininterface-1.1.2}/LICENSE +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/README.md +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/argparse_support.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/cli_flags.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/cli_parser.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/config_file.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/form_dict.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/future_compatibility.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/redirectable.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/run.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/shortcuts.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/showcase.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/start.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_lib/tyro_patches.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_text_interface/adaptor.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_text_interface/facet.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/facet.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/file_picker_input.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/form_contents.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/secret_input.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/style.tcss +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/date_entry.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/external_fix.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/facet.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/redirect_text_tkinter.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/secret_entry.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/utils.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/app.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_web_interface/parent_adaptor.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/cli.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/exceptions.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/experimental.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/facet/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/interfaces.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/settings.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/__init__.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/alias.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/callback_tag.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/datetime_tag.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/internal.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/path_tag.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/secret_tag.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/tag/type_stubs.py +0 -0
- {mininterface-1.1.0 → mininterface-1.1.2}/mininterface/validators.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: mininterface
|
|
3
|
-
Version: 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
|
+
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
|
|
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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,
|
|
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
|

|
|
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
|
+

|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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()
|
{mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/button_contents.py
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
from typing import TYPE_CHECKING,
|
|
1
|
+
from typing import TYPE_CHECKING, Optional
|
|
2
2
|
|
|
3
3
|
from textual import events
|
|
4
|
-
from textual.app import
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
5
|
from textual.containers import Center, Container
|
|
6
|
-
from textual.widgets import Footer, Label
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
76
|
+
bool | None,
|
|
77
77
|
PrimitiveConstructorSpec(
|
|
78
|
-
nargs=
|
|
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
|
-
|
|
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
|
-
|
|
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
|

|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_textual_interface/file_picker_input.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mininterface-1.1.0 → mininterface-1.1.2}/mininterface/_tk_interface/redirect_text_tkinter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|