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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mininterface
3
- Version: 0.4.0
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.Value` that allows you to add descriptions.
187
- A checkbox example: `{"my label": Value(True, "my description")}`
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.Value` that allows you to add descriptions.
165
- A checkbox example: `{"my label": Value(True, "my description")}`
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, config_to_dict, config_from_dict, recursive_set_focus, normalize_types
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
- super().__init__(*args, **kwargs)
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
- return self.window.buttons(text, [("Ok", None)])
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_ = config_to_dict(self.args, self.descriptions)
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
- data = self.window.run_dialog(params_)
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, args: FormDict, title: str = "") -> dict:
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.Value` that allows you to add descriptions.
61
- A checkbox example: {"my label": Value(True, "my description")}
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(args, title=title)
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 data := normalize_types(formDict, self.form.get()):
125
- return data
126
- return self.run_dialog(formDict, title)
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, args: FormDict, title: str = "") -> dict:
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.Value` that allows you to add descriptions.
62
- A checkbox example: `{"my label": Value(True, "my description")}`
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}", args)
65
- return args # NOTE – this should return dict, not FormDict (get rid of auxiliary.Value values)
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, config_to_dict, config_from_dict
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, args: FormDict) -> dict:
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(args)
33
- v = args
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(args)
42
- return args
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.auxiliary import Value
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", "Value"]
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
- from tkinter_form import Value
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 Value(Value):
26
- """ Override val/description class with additional stuff. """
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[Value, Any, 'FormDict']]
41
- """ Nested form that can have descriptions (through Value) instead of plain values. """
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
- def config_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
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] = config_to_dict(val, descr, _path=f"{_path}{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] = Value(val, descr.get(param), annotation)
169
+ params[main][param] = factory(val, descr.get(param), annotation, param, src2=(args, param))
71
170
  else: # scalar value in nested
72
- params[param] = Value(val, descr.get(f"{_path}{param}"), annotation)
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 normalize_types(origin: FormDict, data: dict) -> dict:
78
- """ Run validators of all Value objects. If fails, outputs info.
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, Value) and orval.annotation:
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] = val = None
186
+ dataPos[dataKey] = fixed_val = None
88
187
  elif orval.annotation == Optional[int]:
89
188
  try:
90
- dataPos[dataKey] = val = int(val)
189
+ dataPos[dataKey] = fixed_val = int(val)
91
190
  except ValueError:
92
191
  pass
93
192
 
94
- if not isinstance(val, orval.annotation):
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, Value):
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, Value) else 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, Value) else 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.0"
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