mininterface 0.4.0__tar.gz → 0.4.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-0.4.0 → mininterface-0.4.2}/PKG-INFO +4 -3
- {mininterface-0.4.0 → mininterface-0.4.2}/README.md +2 -2
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/GuiInterface.py +18 -14
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/Mininterface.py +5 -5
- mininterface-0.4.2/mininterface/TextualInterface.py +226 -0
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/TuiInterface.py +6 -6
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/__init__.py +3 -2
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/auxiliary.py +133 -22
- {mininterface-0.4.0 → mininterface-0.4.2}/pyproject.toml +2 -1
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/__main__.py +0 -0
- {mininterface-0.4.0 → mininterface-0.4.2}/mininterface/common.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: mininterface
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: A minimal access to GUI, TUI, CLI and config
|
|
5
5
|
Home-page: https://github.com/CZ-NIC/mininterface
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Requires-Dist: envelope
|
|
16
16
|
Requires-Dist: pyyaml
|
|
17
17
|
Requires-Dist: requests
|
|
18
|
+
Requires-Dist: textual
|
|
18
19
|
Requires-Dist: tkinter-tooltip
|
|
19
20
|
Requires-Dist: tkinter_form
|
|
20
21
|
Requires-Dist: tyro
|
|
@@ -183,8 +184,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con
|
|
|
183
184
|
Prompt the user to fill up whole form.
|
|
184
185
|
* `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
185
186
|
The dict can be nested, it can contain a subgroup.
|
|
186
|
-
The default value might be `mininterface.
|
|
187
|
-
A checkbox example: `{"my label":
|
|
187
|
+
The default value might be `mininterface.FormField` that allows you to add descriptions.
|
|
188
|
+
A checkbox example: `{"my label": FormField(True, "my description")}`
|
|
188
189
|
* `title`: Optional form title.
|
|
189
190
|
### `ask_number(self, text: str) -> int`
|
|
190
191
|
Prompt the user to input a number. Empty input = 0.
|
|
@@ -161,8 +161,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con
|
|
|
161
161
|
Prompt the user to fill up whole form.
|
|
162
162
|
* `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
163
163
|
The dict can be nested, it can contain a subgroup.
|
|
164
|
-
The default value might be `mininterface.
|
|
165
|
-
A checkbox example: `{"my label":
|
|
164
|
+
The default value might be `mininterface.FormField` that allows you to add descriptions.
|
|
165
|
+
A checkbox example: `{"my label": FormField(True, "my description")}`
|
|
166
166
|
* `title`: Optional form title.
|
|
167
167
|
### `ask_number(self, text: str) -> int`
|
|
168
168
|
Prompt the user to input a number. Empty input = 0.
|
|
@@ -11,17 +11,17 @@ except ImportError:
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
from .common import InterfaceNotAvailable
|
|
14
|
-
from .auxiliary import FormDict, RedirectText,
|
|
14
|
+
from .auxiliary import FormDict, RedirectText, config_to_formdict, config_from_dict, flatten, recursive_set_focus, fix_types
|
|
15
15
|
from .Mininterface import Cancelled, ConfigInstance, Mininterface
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class GuiInterface(Mininterface):
|
|
19
19
|
def __init__(self, *args, **kwargs):
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
20
21
|
try:
|
|
21
|
-
|
|
22
|
+
self.window = TkWindow(self)
|
|
22
23
|
except TclError:
|
|
23
24
|
raise InterfaceNotAvailable
|
|
24
|
-
self.window = TkWindow(self)
|
|
25
25
|
self._always_shown = False
|
|
26
26
|
self._original_stdout = sys.stdout
|
|
27
27
|
|
|
@@ -39,29 +39,29 @@ class GuiInterface(Mininterface):
|
|
|
39
39
|
|
|
40
40
|
def alert(self, text: str) -> None:
|
|
41
41
|
""" Display the OK dialog with text. """
|
|
42
|
-
|
|
42
|
+
self.window.buttons(text, [("Ok", None)])
|
|
43
43
|
|
|
44
44
|
def ask(self, text: str) -> str:
|
|
45
45
|
return self.window.run_dialog({text: ""})[text]
|
|
46
46
|
|
|
47
47
|
def ask_args(self) -> ConfigInstance:
|
|
48
48
|
""" Display a window form with all parameters. """
|
|
49
|
-
params_ =
|
|
49
|
+
params_ = config_to_formdict(self.args, self.descriptions)
|
|
50
50
|
|
|
51
51
|
# fetch the dict of dicts values from the form back to the namespace of the dataclasses
|
|
52
|
-
|
|
53
|
-
config_from_dict(self.args, data)
|
|
52
|
+
self.window.run_dialog(params_)
|
|
53
|
+
# NOTE remove config_from_dict(self.args, data)
|
|
54
54
|
return self.args
|
|
55
55
|
|
|
56
|
-
def ask_form(self,
|
|
56
|
+
def ask_form(self, form: FormDict, title: str = "") -> dict:
|
|
57
57
|
""" Prompt the user to fill up whole form.
|
|
58
58
|
:param args: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
59
59
|
The dict can be nested, it can contain a subgroup.
|
|
60
|
-
The default value might be `mininterface.
|
|
61
|
-
A checkbox example: {"my label":
|
|
60
|
+
The default value might be `mininterface.FormField` that allows you to add descriptions.
|
|
61
|
+
A checkbox example: {"my label": FormField(True, "my description")}
|
|
62
62
|
:param title: Optional form title.
|
|
63
63
|
"""
|
|
64
|
-
return self.window.run_dialog(
|
|
64
|
+
return self.window.run_dialog(form, title=title)
|
|
65
65
|
|
|
66
66
|
def ask_number(self, text: str) -> int:
|
|
67
67
|
return self.window.run_dialog({text: 0})[text]
|
|
@@ -121,9 +121,13 @@ class TkWindow(Tk):
|
|
|
121
121
|
return self.mainloop(lambda: self.validate(formDict, title))
|
|
122
122
|
|
|
123
123
|
def validate(self, formDict: FormDict, title: str):
|
|
124
|
-
if
|
|
125
|
-
return
|
|
126
|
-
|
|
124
|
+
if not all(ff.update(ui_value) for ff, ui_value in zip(flatten(formDict), flatten(self.form.get()))):
|
|
125
|
+
return self.run_dialog(formDict, title)
|
|
126
|
+
|
|
127
|
+
# NOTE remove:
|
|
128
|
+
# if data := fix_types(formDict, self.form.get()):
|
|
129
|
+
# return data
|
|
130
|
+
# return self.run_dialog(formDict, title)
|
|
127
131
|
|
|
128
132
|
def yes_no(self, text: str, focus_no=True):
|
|
129
133
|
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)
|
|
@@ -54,15 +54,15 @@ class Mininterface:
|
|
|
54
54
|
print("Asking the args", self.args)
|
|
55
55
|
return self.args
|
|
56
56
|
|
|
57
|
-
def ask_form(self,
|
|
57
|
+
def ask_form(self, data: FormDict, title: str = "") -> dict:
|
|
58
58
|
""" Prompt the user to fill up whole form.
|
|
59
59
|
:param args: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
60
60
|
The dict can be nested, it can contain a subgroup.
|
|
61
|
-
The default value might be `mininterface.
|
|
62
|
-
A checkbox example: `{"my label":
|
|
61
|
+
The default value might be `mininterface.FormField` that allows you to add descriptions.
|
|
62
|
+
A checkbox example: `{"my label": FormField(True, "my description")}`
|
|
63
63
|
"""
|
|
64
|
-
print(f"Asking the form {title}",
|
|
65
|
-
return
|
|
64
|
+
print(f"Asking the form {title}", data)
|
|
65
|
+
return data # NOTE – this should return dict, not FormDict (get rid of auxiliary.FormField values)
|
|
66
66
|
|
|
67
67
|
def ask_number(self, text: str) -> int:
|
|
68
68
|
""" Prompt the user to input a number. Empty input = 0. """
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from ast import literal_eval
|
|
2
|
+
from dataclasses import _MISSING_TYPE, dataclass, field
|
|
3
|
+
from types import UnionType
|
|
4
|
+
from typing import Any
|
|
5
|
+
from dataclasses import fields
|
|
6
|
+
from textual import events
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.containers import VerticalScroll, Container
|
|
9
|
+
from textual.widgets import Checkbox, Header, Footer, Input, Label, Welcome, Button, Static
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
|
|
12
|
+
from mininterface import TuiInterface
|
|
13
|
+
from .common import InterfaceNotAvailable
|
|
14
|
+
|
|
15
|
+
from .Mininterface import Cancelled, Mininterface
|
|
16
|
+
from .auxiliary import ConfigInstance, FormDict, FormField, config_from_dict, config_to_formdict, dict_to_formdict, flatten
|
|
17
|
+
|
|
18
|
+
from textual.widgets import Checkbox, Input
|
|
19
|
+
|
|
20
|
+
# TODO
|
|
21
|
+
# 1. TuiInterface -> TextInterface.
|
|
22
|
+
# 1. TextualInterface inherits from TextInterface.
|
|
23
|
+
# 2. TextualInterface is the default for TuiInterface
|
|
24
|
+
# Add to docs
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FormFieldTextual(FormField):
|
|
28
|
+
""" Bridge between the values given in CLI, TUI and real needed values (str to int conversion etc). """
|
|
29
|
+
|
|
30
|
+
def get_widget(self):
|
|
31
|
+
if self.annotation is bool or not self.annotation and self.val in [True, False]:
|
|
32
|
+
o = Checkbox(self.name, self.val)
|
|
33
|
+
else:
|
|
34
|
+
o = Input(str(self.val), placeholder=self.name or "")
|
|
35
|
+
o._link = self # The Textual widgets need to get back to this value
|
|
36
|
+
return o
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DummyWrapper:
|
|
41
|
+
""" Value wrapped, since I do not know how to get it from textual app.
|
|
42
|
+
False would mean direct exit. """
|
|
43
|
+
val: Any
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TextualInterface(TuiInterface):
|
|
47
|
+
|
|
48
|
+
def alert(self, text: str) -> None:
|
|
49
|
+
""" Display the OK dialog with text. """
|
|
50
|
+
TextualButtonApp().buttons(text, [("Ok", None)]).run()
|
|
51
|
+
|
|
52
|
+
def ask(self, text: str = None):
|
|
53
|
+
return self.ask_form({text: ""})[text]
|
|
54
|
+
|
|
55
|
+
def ask_args(self) -> ConfigInstance:
|
|
56
|
+
""" Display a window form with all parameters. """
|
|
57
|
+
params_ = config_to_formdict(self.args, self.descriptions, factory=FormFieldTextual)
|
|
58
|
+
|
|
59
|
+
# fetch the dict of dicts values from the form back to the namespace of the dataclasses
|
|
60
|
+
TextualApp.run_dialog(TextualApp(), params_)
|
|
61
|
+
return self.args
|
|
62
|
+
|
|
63
|
+
def ask_form(self, form: FormDict, title: str = "") -> dict:
|
|
64
|
+
TextualApp.run_dialog(TextualApp(), dict_to_formdict(form, factory=FormFieldTextual), title)
|
|
65
|
+
return form
|
|
66
|
+
|
|
67
|
+
# NOTE we should implement better, now the user does not know it needs an int
|
|
68
|
+
# def ask_number(self, text):
|
|
69
|
+
|
|
70
|
+
def is_yes(self, text):
|
|
71
|
+
return TextualButtonApp().yes_no(text, False).val
|
|
72
|
+
|
|
73
|
+
def is_no(self, text):
|
|
74
|
+
return TextualButtonApp().yes_no(text, True).val
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TextualApp(App[bool | None]):
|
|
78
|
+
|
|
79
|
+
BINDINGS = [
|
|
80
|
+
("up", "go_up", "Go up"),
|
|
81
|
+
("down", "go_up", "Go down"),
|
|
82
|
+
# Form confirmation
|
|
83
|
+
# * ctrl/alt+enter does not work
|
|
84
|
+
# * enter without priority is consumed by input fields
|
|
85
|
+
# * enter with priority is not shown in the footer
|
|
86
|
+
Binding("enter", "confirm", "Ok", show=True, priority=True),
|
|
87
|
+
Binding("Enter", "confirm", "Ok"),
|
|
88
|
+
("escape", "exit", "Cancel"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def __init__(self):
|
|
92
|
+
super().__init__()
|
|
93
|
+
self.title = ""
|
|
94
|
+
self.widgets = None
|
|
95
|
+
self.focused_i: int = 0
|
|
96
|
+
|
|
97
|
+
def setup(self, title, widgets, focused_i):
|
|
98
|
+
|
|
99
|
+
self.focused_i = focused_i
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
# Why class method? I do not know how to re-create the dialog if needed.
|
|
103
|
+
@classmethod
|
|
104
|
+
def run_dialog(cls, window, formDict: FormDict, title: str = "") -> None: # TODO changed from dict, change everywhere
|
|
105
|
+
if title:
|
|
106
|
+
window.title = title
|
|
107
|
+
|
|
108
|
+
# NOTE Sections (~ nested dicts) are not implemented, they flatten
|
|
109
|
+
fd: dict[str, FormFieldTextual] = formDict
|
|
110
|
+
widgets: list[Checkbox | Input] = [f.get_widget() for f in flatten(fd)]
|
|
111
|
+
window.widgets = widgets
|
|
112
|
+
|
|
113
|
+
if not window.run():
|
|
114
|
+
raise Cancelled
|
|
115
|
+
|
|
116
|
+
# validate and store the UI value → FormField value → original value
|
|
117
|
+
if not all(field._link.update(field.value) for field in widgets):
|
|
118
|
+
return cls.run_dialog(TextualApp(), formDict, title)
|
|
119
|
+
|
|
120
|
+
def compose(self) -> ComposeResult:
|
|
121
|
+
if self.title:
|
|
122
|
+
yield Header()
|
|
123
|
+
yield Footer()
|
|
124
|
+
with VerticalScroll():
|
|
125
|
+
for fieldt in self.widgets:
|
|
126
|
+
fieldt: FormFieldTextual
|
|
127
|
+
if isinstance(fieldt, Input):
|
|
128
|
+
yield Label(fieldt.placeholder)
|
|
129
|
+
yield fieldt
|
|
130
|
+
yield Label(fieldt._link.description)
|
|
131
|
+
yield Label("")
|
|
132
|
+
|
|
133
|
+
def on_mount(self):
|
|
134
|
+
self.widgets[self.focused_i].focus()
|
|
135
|
+
|
|
136
|
+
def action_confirm(self):
|
|
137
|
+
# next time, start on the same widget
|
|
138
|
+
# NOTE the functionality is probably not used
|
|
139
|
+
self.focused_i = next((i for i, inp in enumerate(self.widgets) if inp == self.focused), None)
|
|
140
|
+
self.exit(True)
|
|
141
|
+
|
|
142
|
+
def action_exit(self):
|
|
143
|
+
self.exit()
|
|
144
|
+
|
|
145
|
+
def on_key(self, event: events.Key) -> None:
|
|
146
|
+
try:
|
|
147
|
+
index = self.widgets.index(self.focused)
|
|
148
|
+
except ValueError: # probably some other element were focused
|
|
149
|
+
return
|
|
150
|
+
match event.key:
|
|
151
|
+
case "down":
|
|
152
|
+
self.widgets[(index + 1) % len(self.widgets)].focus()
|
|
153
|
+
case "up":
|
|
154
|
+
self.widgets[(index - 1) % len(self.widgets)].focus()
|
|
155
|
+
case letter if len(letter) == 1: # navigate by letters
|
|
156
|
+
for inp_ in self.widgets[index+1:] + self.widgets[:index]:
|
|
157
|
+
label = inp_.label if isinstance(inp_, Checkbox) else inp_.placeholder
|
|
158
|
+
if str(label).casefold().startswith(letter):
|
|
159
|
+
inp_.focus()
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TextualButtonApp(App):
|
|
164
|
+
CSS = """
|
|
165
|
+
Screen {
|
|
166
|
+
layout: grid;
|
|
167
|
+
grid-size: 2;
|
|
168
|
+
grid-gutter: 2;
|
|
169
|
+
padding: 2;
|
|
170
|
+
}
|
|
171
|
+
#question {
|
|
172
|
+
width: 100%;
|
|
173
|
+
height: 100%;
|
|
174
|
+
column-span: 2;
|
|
175
|
+
content-align: center bottom;
|
|
176
|
+
text-style: bold;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Button {
|
|
180
|
+
width: 100%;
|
|
181
|
+
}
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
BINDINGS = [
|
|
185
|
+
("escape", "exit", "Cancel"),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
def __init__(self):
|
|
189
|
+
super().__init__()
|
|
190
|
+
self.title = ""
|
|
191
|
+
self.text: str = ""
|
|
192
|
+
self._buttons = None
|
|
193
|
+
self.focused_i: int = 0
|
|
194
|
+
self.values = {}
|
|
195
|
+
|
|
196
|
+
def yes_no(self, text: str, focus_no=True) -> DummyWrapper:
|
|
197
|
+
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no))
|
|
198
|
+
|
|
199
|
+
def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 0):
|
|
200
|
+
self.text = text
|
|
201
|
+
self._buttons = buttons
|
|
202
|
+
self.focused_i = focused
|
|
203
|
+
|
|
204
|
+
ret = self.run()
|
|
205
|
+
if not ret:
|
|
206
|
+
raise Cancelled
|
|
207
|
+
return ret
|
|
208
|
+
|
|
209
|
+
def compose(self) -> ComposeResult:
|
|
210
|
+
yield Footer()
|
|
211
|
+
yield Label(self.text, id="question")
|
|
212
|
+
|
|
213
|
+
self.values.clear()
|
|
214
|
+
for i, (text, value) in enumerate(self._buttons):
|
|
215
|
+
id_ = "button"+str(i)
|
|
216
|
+
self.values[id_] = value
|
|
217
|
+
b = Button(text, id=id_)
|
|
218
|
+
if i == self.focused_i:
|
|
219
|
+
b.focus()
|
|
220
|
+
yield b
|
|
221
|
+
|
|
222
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
223
|
+
self.exit(DummyWrapper(self.values[event.button.id]))
|
|
224
|
+
|
|
225
|
+
def action_exit(self):
|
|
226
|
+
self.exit()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from pprint import pprint
|
|
2
|
-
from .auxiliary import ConfigInstance, FormDict,
|
|
2
|
+
from .auxiliary import ConfigInstance, FormDict, config_to_formdict, config_from_dict
|
|
3
3
|
from .Mininterface import Cancelled, Mininterface
|
|
4
4
|
|
|
5
5
|
|
|
@@ -26,11 +26,11 @@ class TuiInterface(Mininterface):
|
|
|
26
26
|
# dict_to_dataclass(self.args, params_)
|
|
27
27
|
return self.ask_form(self.args)
|
|
28
28
|
|
|
29
|
-
def ask_form(self,
|
|
29
|
+
def ask_form(self, form: FormDict) -> dict:
|
|
30
30
|
# NOTE: This is minimal implementation that should rather go the ReplInterface.
|
|
31
31
|
print("Access `v` (as var) and change values. Then (c)ontinue.")
|
|
32
|
-
pprint(
|
|
33
|
-
v =
|
|
32
|
+
pprint(form)
|
|
33
|
+
v = form
|
|
34
34
|
try:
|
|
35
35
|
import ipdb
|
|
36
36
|
ipdb.set_trace()
|
|
@@ -38,8 +38,8 @@ class TuiInterface(Mininterface):
|
|
|
38
38
|
import pdb
|
|
39
39
|
pdb.set_trace()
|
|
40
40
|
print("*Continuing*")
|
|
41
|
-
print(
|
|
42
|
-
return
|
|
41
|
+
print(form)
|
|
42
|
+
return form
|
|
43
43
|
|
|
44
44
|
def ask_number(self, text):
|
|
45
45
|
"""
|
|
@@ -13,7 +13,8 @@ except ImportError:
|
|
|
13
13
|
|
|
14
14
|
from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface
|
|
15
15
|
from mininterface.TuiInterface import ReplInterface, TuiInterface
|
|
16
|
-
from mininterface.
|
|
16
|
+
from mininterface.TextualInterface import TextualInterface
|
|
17
|
+
from mininterface.auxiliary import FormField
|
|
17
18
|
|
|
18
19
|
# TODO auto-handle verbosity https://brentyi.github.io/tyro/examples/04_additional/12_counters/ ?
|
|
19
20
|
# TODO example on missing required options.
|
|
@@ -53,4 +54,4 @@ def run(config: ConfigClass | None = None,
|
|
|
53
54
|
return interface
|
|
54
55
|
|
|
55
56
|
|
|
56
|
-
__all__ = ["run", "
|
|
57
|
+
__all__ = ["run", "FormField"]
|
|
@@ -3,30 +3,52 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
from argparse import Action, ArgumentParser
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Any, Callable, Literal, Optional, TypeVar, Union, get_args, get_type_hints
|
|
6
|
+
from typing import Any, Callable, Iterable, Literal, Optional, TypeVar, Union, get_args, get_type_hints
|
|
7
7
|
from unittest.mock import patch
|
|
8
8
|
|
|
9
9
|
try:
|
|
10
10
|
# NOTE this should be clean up and tested on a machine without tkinter installable
|
|
11
11
|
from tkinter import END, Entry, Text, Tk, Widget
|
|
12
12
|
from tkinter.ttk import Checkbutton, Combobox
|
|
13
|
+
from tkinter_form import Value
|
|
13
14
|
except ImportError:
|
|
14
15
|
tkinter = None
|
|
15
16
|
END, Entry, Text, Tk, Widget = (None,)*5
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
@dataclass
|
|
19
|
+
class Value:
|
|
20
|
+
""" This class helps to enrich the field with a description. """
|
|
21
|
+
val: Any
|
|
22
|
+
description: str
|
|
23
|
+
|
|
24
|
+
|
|
18
25
|
from tyro import cli
|
|
19
26
|
from tyro._argparse_formatter import TyroArgumentParser
|
|
20
27
|
|
|
21
28
|
logger = logging.getLogger(__name__)
|
|
22
29
|
|
|
30
|
+
TD = TypeVar("TD")
|
|
31
|
+
""" dict """
|
|
32
|
+
TK = TypeVar("TK")
|
|
33
|
+
""" dict key """
|
|
34
|
+
|
|
23
35
|
|
|
24
36
|
@dataclass
|
|
25
|
-
class
|
|
26
|
-
"""
|
|
37
|
+
class FormField(Value):
|
|
38
|
+
""" Bridge between the input values and a UI widget.
|
|
39
|
+
Helps to creates a widget from the input value (includes description etc.),
|
|
40
|
+
then transforms the value back (str to int conversion etc). """
|
|
27
41
|
|
|
28
42
|
annotation: Any | None = None
|
|
29
43
|
""" Used for validation. To convert an empty '' to None. """
|
|
44
|
+
name: str | None = None # NOTE: Only TextualInterface uses this by now.
|
|
45
|
+
|
|
46
|
+
src: tuple[TD, TK] | None = None
|
|
47
|
+
""" The original dict to be updated when UI ends. """
|
|
48
|
+
src2: tuple[TD, TK] | None = None
|
|
49
|
+
""" The original object to be updated when UI ends.
|
|
50
|
+
NOTE should be merged to `src`
|
|
51
|
+
"""
|
|
30
52
|
|
|
31
53
|
def __post_init__(self):
|
|
32
54
|
self._original_desc = self.description
|
|
@@ -34,17 +56,94 @@ class Value(Value):
|
|
|
34
56
|
def set_error_text(self, s):
|
|
35
57
|
self.description = f"{s} {self._original_desc}"
|
|
36
58
|
|
|
59
|
+
# TODO add testing
|
|
60
|
+
def update(self, ui_value):
|
|
61
|
+
""" UI value → FormField value → original value. (With type conversion and checks.)
|
|
62
|
+
|
|
63
|
+
The value has been updated in a UI.
|
|
64
|
+
Update accordingly the value in the original linked dict
|
|
65
|
+
the mininterface was invoked with.
|
|
66
|
+
|
|
67
|
+
Validates the type and do the transformation.
|
|
68
|
+
(Ex: Some values might be nulled from "".)
|
|
69
|
+
"""
|
|
70
|
+
fixed_value = ui_value
|
|
71
|
+
if self.annotation:
|
|
72
|
+
if ui_value == "" and type(None) in get_args(self.annotation):
|
|
73
|
+
# The user is not able to set the value to None, they left it empty.
|
|
74
|
+
# Cast back to None as None is one of the allowed types.
|
|
75
|
+
# Ex: `severity: int | None = None`
|
|
76
|
+
fixed_value = None
|
|
77
|
+
elif self.annotation == Optional[int]:
|
|
78
|
+
try:
|
|
79
|
+
fixed_value = int(ui_value)
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if not isinstance(fixed_value, self.annotation):
|
|
84
|
+
self.set_error_text(f"Type must be `{self.annotation}`!")
|
|
85
|
+
return False # revision needed
|
|
86
|
+
|
|
87
|
+
# keep values if revision needed
|
|
88
|
+
# We merge new data to the origin. If form is re-submitted, the values will stay there.
|
|
89
|
+
self.val = ui_value
|
|
90
|
+
|
|
91
|
+
# Store to the source user data
|
|
92
|
+
if self.src:
|
|
93
|
+
d, k = self.src
|
|
94
|
+
d[k] = fixed_value
|
|
95
|
+
else:
|
|
96
|
+
d, k = self.src2
|
|
97
|
+
setattr(d, k, fixed_value)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
# Fixing types:
|
|
101
|
+
# This code would support tuple[int, int]:
|
|
102
|
+
#
|
|
103
|
+
# self.types = get_args(self.annotation) \
|
|
104
|
+
# if isinstance(self.annotation, UnionType) else (self.annotation, )
|
|
105
|
+
# "All possible types in a tuple. Ex 'int | str' -> (int, str)"
|
|
106
|
+
#
|
|
107
|
+
#
|
|
108
|
+
# def convert(self):
|
|
109
|
+
# """ Convert the self.value to the given self.type.
|
|
110
|
+
# The value might be in str due to CLI or TUI whereas the programs wants bool.
|
|
111
|
+
# """
|
|
112
|
+
# # if self.value == "True":
|
|
113
|
+
# # return True
|
|
114
|
+
# # if self.value == "False":
|
|
115
|
+
# # return False
|
|
116
|
+
# if type(self.val) is str and str not in self.types:
|
|
117
|
+
# try:
|
|
118
|
+
# return literal_eval(self.val) # ex: int, tuple[int, int]
|
|
119
|
+
# except:
|
|
120
|
+
# raise ValueError(f"{self.name}: Cannot convert value {self.val}")
|
|
121
|
+
# return self.val
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
37
125
|
|
|
38
126
|
ConfigInstance = TypeVar("ConfigInstance")
|
|
39
127
|
ConfigClass = Callable[..., ConfigInstance]
|
|
40
|
-
FormDict = dict[str, Union[
|
|
41
|
-
""" Nested form that can have descriptions (through
|
|
128
|
+
FormDict = dict[str, Union[FormField, 'FormDict']]
|
|
129
|
+
""" Nested form that can have descriptions (through FormField) instead of plain values. """
|
|
130
|
+
|
|
42
131
|
|
|
132
|
+
def dict_to_formdict(data: dict, factory=FormField) -> FormDict:
|
|
133
|
+
fd = {}
|
|
134
|
+
for key, val in data.items():
|
|
135
|
+
if isinstance(val, dict): # nested config hierarchy
|
|
136
|
+
fd[key] = dict_to_formdict(val, factory=factory)
|
|
137
|
+
else: # scalar value
|
|
138
|
+
# NOTE name=param is not set (yet?) in `config_to_formdict`, neither `src`
|
|
139
|
+
fd[key] = factory(val, "", name=key, src=(data, key))
|
|
140
|
+
return fd
|
|
43
141
|
|
|
44
|
-
|
|
142
|
+
|
|
143
|
+
# NOTE: Not used, remove
|
|
144
|
+
def config_to_formdict(args: ConfigInstance, descr: dict, _path="", factory=FormField) -> FormDict:
|
|
45
145
|
""" Convert the dataclass produced by tyro into dict of dicts. """
|
|
46
146
|
main = ""
|
|
47
|
-
# print(args)# TODO
|
|
48
147
|
params = {main: {}} if not _path else {}
|
|
49
148
|
for param, val in vars(args).items():
|
|
50
149
|
annotation = None
|
|
@@ -65,39 +164,39 @@ def config_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
|
|
|
65
164
|
logger.warn(f"Annotation {wanted_type} of `{param}` not supported by Mininterface."
|
|
66
165
|
"None converted to False.")
|
|
67
166
|
if hasattr(val, "__dict__"): # nested config hierarchy
|
|
68
|
-
params[param] =
|
|
167
|
+
params[param] = config_to_formdict(val, descr, _path=f"{_path}{param}.", factory=factory)
|
|
69
168
|
elif not _path: # scalar value in root
|
|
70
|
-
params[main][param] =
|
|
169
|
+
params[main][param] = factory(val, descr.get(param), annotation, param, src2=(args, param))
|
|
71
170
|
else: # scalar value in nested
|
|
72
|
-
params[param] =
|
|
73
|
-
# print(params) # TODO
|
|
171
|
+
params[param] = factory(val, descr.get(f"{_path}{param}"), annotation, param, src2=(args, param))
|
|
74
172
|
return params
|
|
75
173
|
|
|
76
|
-
|
|
77
|
-
def
|
|
78
|
-
""" Run validators of all
|
|
174
|
+
# NOTE: Not used, remove
|
|
175
|
+
def fix_types(origin: FormDict, data: dict) -> dict:
|
|
176
|
+
""" Run validators of all FormField objects. If fails, outputs info.
|
|
79
177
|
Return corrected data. (Ex: Some values might be nulled from "".)
|
|
80
178
|
"""
|
|
81
179
|
def check(ordict, orkey, orval, dataPos: dict, dataKey, val):
|
|
82
|
-
if isinstance(orval,
|
|
180
|
+
if isinstance(orval, FormField) and orval.annotation:
|
|
181
|
+
fixed_val = val
|
|
83
182
|
if val == "" and type(None) in get_args(orval.annotation):
|
|
84
183
|
# The user is not able to set the value to None, they left it empty.
|
|
85
184
|
# Cast back to None as None is one of the allowed types.
|
|
86
185
|
# Ex: `severity: int | None = None`
|
|
87
|
-
dataPos[dataKey] =
|
|
186
|
+
dataPos[dataKey] = fixed_val = None
|
|
88
187
|
elif orval.annotation == Optional[int]:
|
|
89
188
|
try:
|
|
90
|
-
dataPos[dataKey] =
|
|
189
|
+
dataPos[dataKey] = fixed_val = int(val)
|
|
91
190
|
except ValueError:
|
|
92
191
|
pass
|
|
93
192
|
|
|
94
|
-
if not isinstance(
|
|
193
|
+
if not isinstance(fixed_val, orval.annotation):
|
|
95
194
|
orval.set_error_text(f"Type must be `{orval.annotation}`!")
|
|
96
195
|
raise RuntimeError # revision needed
|
|
97
196
|
|
|
98
197
|
# keep values if revision needed
|
|
99
198
|
# We merge new data to the origin. If form is re-submitted, the values will stay there.
|
|
100
|
-
if isinstance(orval,
|
|
199
|
+
if isinstance(orval, FormField):
|
|
101
200
|
orval.val = val
|
|
102
201
|
else:
|
|
103
202
|
ordict[orkey] = val
|
|
@@ -122,9 +221,9 @@ def config_from_dict(args: ConfigInstance, data: dict):
|
|
|
122
221
|
for group, params in data.items():
|
|
123
222
|
for key, val in params.items():
|
|
124
223
|
if group:
|
|
125
|
-
setattr(getattr(args, group), key, val.val if isinstance(val,
|
|
224
|
+
setattr(getattr(args, group), key, val.val if isinstance(val, FormField) else val)
|
|
126
225
|
else:
|
|
127
|
-
setattr(args, key, val.val if isinstance(val,
|
|
226
|
+
setattr(args, key, val.val if isinstance(val, FormField) else val)
|
|
128
227
|
|
|
129
228
|
|
|
130
229
|
def get_terminal_size():
|
|
@@ -214,3 +313,15 @@ def recursive_set_focus(widget: Widget):
|
|
|
214
313
|
return True
|
|
215
314
|
if recursive_set_focus(child):
|
|
216
315
|
return True
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
T = TypeVar("T")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def flatten(d: dict[str, T | dict]) -> Iterable[T]:
|
|
322
|
+
""" Recursively traverse whole dict """
|
|
323
|
+
for v in d.values():
|
|
324
|
+
if isinstance(v, dict):
|
|
325
|
+
yield from flatten(v)
|
|
326
|
+
else:
|
|
327
|
+
yield v
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "mininterface"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.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 = "GPL-3.0-or-later"
|
|
@@ -17,6 +17,7 @@ tyro = "*"
|
|
|
17
17
|
pyyaml = "*"
|
|
18
18
|
envelope = "*"
|
|
19
19
|
requests = "*"
|
|
20
|
+
textual = "*"
|
|
20
21
|
tkinter-tooltip = "*"
|
|
21
22
|
tkinter_form = "*"
|
|
22
23
|
|
|
File without changes
|
|
File without changes
|