mininterface 0.1.1__tar.gz → 0.4.1__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.1.1
3
+ Version: 0.4.1
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
@@ -22,6 +22,7 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # Mininterface – access to GUI, TUI, CLI and config files
24
24
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
25
+ [![Build Status](https://github.com/CZ-NIC/mininterface/actions/workflows/run-unittest.yml/badge.svg)](https://github.com/CZ-NIC/mininterface/actions)
25
26
 
26
27
  Write the program core, do not bother with the input/output.
27
28
 
@@ -182,8 +183,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con
182
183
  Prompt the user to fill up whole form.
183
184
  * `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
184
185
  The dict can be nested, it can contain a subgroup.
185
- The default value might be `mininterface.Value` that allows you to add descriptions.
186
- A checkbox example: `{"my label": Value(True, "my description")}`
186
+ The default value might be `mininterface.FormField` that allows you to add descriptions.
187
+ A checkbox example: `{"my label": FormField(True, "my description")}`
187
188
  * `title`: Optional form title.
188
189
  ### `ask_number(self, text: str) -> int`
189
190
  Prompt the user to input a number. Empty input = 0.
@@ -1,5 +1,6 @@
1
1
  # Mininterface – access to GUI, TUI, CLI and config files
2
2
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
3
+ [![Build Status](https://github.com/CZ-NIC/mininterface/actions/workflows/run-unittest.yml/badge.svg)](https://github.com/CZ-NIC/mininterface/actions)
3
4
 
4
5
  Write the program core, do not bother with the input/output.
5
6
 
@@ -160,8 +161,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con
160
161
  Prompt the user to fill up whole form.
161
162
  * `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
162
163
  The dict can be nested, it can contain a subgroup.
163
- The default value might be `mininterface.Value` that allows you to add descriptions.
164
- 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")}`
165
166
  * `title`: Optional form title.
166
167
  ### `ask_number(self, text: str) -> int`
167
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, dataclass_to_dict, dict_to_dataclass, 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_ = dataclass_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
- dict_to_dataclass(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, Self, get_args, override
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, dataclass_to_dict, dict_to_dataclass
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"]
@@ -0,0 +1,327 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from argparse import Action, ArgumentParser
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, Iterable, Literal, Optional, Self, TypeVar, Union, get_args, get_type_hints
7
+ from unittest.mock import patch
8
+
9
+ try:
10
+ # NOTE this should be clean up and tested on a machine without tkinter installable
11
+ from tkinter import END, Entry, Text, Tk, Widget
12
+ from tkinter.ttk import Checkbutton, Combobox
13
+ from tkinter_form import Value
14
+ except ImportError:
15
+ tkinter = None
16
+ END, Entry, Text, Tk, Widget = (None,)*5
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
+
25
+ from tyro import cli
26
+ from tyro._argparse_formatter import TyroArgumentParser
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ TD = TypeVar("TD")
31
+ """ dict """
32
+ TK = TypeVar("TK")
33
+ """ dict key """
34
+
35
+
36
+ @dataclass
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). """
41
+
42
+ annotation: Any | None = None
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
+ """
52
+
53
+ def __post_init__(self):
54
+ self._original_desc = self.description
55
+
56
+ def set_error_text(self, s):
57
+ self.description = f"{s} {self._original_desc}"
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
+
125
+
126
+ ConfigInstance = TypeVar("ConfigInstance")
127
+ ConfigClass = Callable[..., ConfigInstance]
128
+ FormDict = dict[str, Union[FormField, 'FormDict']]
129
+ """ Nested form that can have descriptions (through FormField) instead of plain values. """
130
+
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
141
+
142
+
143
+ # NOTE: Not used, remove
144
+ def config_to_formdict(args: ConfigInstance, descr: dict, _path="", factory=FormField) -> FormDict:
145
+ """ Convert the dataclass produced by tyro into dict of dicts. """
146
+ main = ""
147
+ params = {main: {}} if not _path else {}
148
+ for param, val in vars(args).items():
149
+ annotation = None
150
+ if val is None:
151
+ wanted_type = get_type_hints(args.__class__).get(param)
152
+ if wanted_type in (Optional[int], Optional[str]):
153
+ # Since tkinter_form does not handle None yet, we have help it.
154
+ # We need it to be able to write a number and if empty, return None.
155
+ # This would fail: `severity: int | None = None`
156
+ # Here, we convert None to str(""), in normalize_types we convert it back.
157
+ annotation = wanted_type
158
+ val = ""
159
+ else:
160
+ # An unknown type annotation encountered-
161
+ # Since tkinter_form does not handle None yet, this will display as checkbox.
162
+ # Which is not probably wanted.
163
+ val = False
164
+ logger.warn(f"Annotation {wanted_type} of `{param}` not supported by Mininterface."
165
+ "None converted to False.")
166
+ if hasattr(val, "__dict__"): # nested config hierarchy
167
+ params[param] = config_to_formdict(val, descr, _path=f"{_path}{param}.", factory=factory)
168
+ elif not _path: # scalar value in root
169
+ params[main][param] = factory(val, descr.get(param), annotation, param, src2=(args, param))
170
+ else: # scalar value in nested
171
+ params[param] = factory(val, descr.get(f"{_path}{param}"), annotation, param, src2=(args, param))
172
+ return params
173
+
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.
177
+ Return corrected data. (Ex: Some values might be nulled from "".)
178
+ """
179
+ def check(ordict, orkey, orval, dataPos: dict, dataKey, val):
180
+ if isinstance(orval, FormField) and orval.annotation:
181
+ fixed_val = val
182
+ if val == "" and type(None) in get_args(orval.annotation):
183
+ # The user is not able to set the value to None, they left it empty.
184
+ # Cast back to None as None is one of the allowed types.
185
+ # Ex: `severity: int | None = None`
186
+ dataPos[dataKey] = fixed_val = None
187
+ elif orval.annotation == Optional[int]:
188
+ try:
189
+ dataPos[dataKey] = fixed_val = int(val)
190
+ except ValueError:
191
+ pass
192
+
193
+ if not isinstance(fixed_val, orval.annotation):
194
+ orval.set_error_text(f"Type must be `{orval.annotation}`!")
195
+ raise RuntimeError # revision needed
196
+
197
+ # keep values if revision needed
198
+ # We merge new data to the origin. If form is re-submitted, the values will stay there.
199
+ if isinstance(orval, FormField):
200
+ orval.val = val
201
+ else:
202
+ ordict[orkey] = val
203
+
204
+ try:
205
+ for (key1, val1), (orkey1, orval1) in zip(data.items(), origin.items()):
206
+ if isinstance(val1, dict): # nested config hierarchy
207
+ # NOTE: This allows only single nested dict.
208
+ for (key2, val2), (orkey2, orval2) in zip(val1.items(), orval1.items()):
209
+ check(orval1, orkey2, orval2, data[key1], key2, val2)
210
+ else:
211
+ check(origin, orkey1, orval1, data, key1, val1)
212
+ except RuntimeError:
213
+ return False
214
+
215
+ return data
216
+
217
+
218
+ def config_from_dict(args: ConfigInstance, data: dict):
219
+ """ Fetch back data.
220
+ Merge the dict of dicts from the GUI back into the object holding the configuration. """
221
+ for group, params in data.items():
222
+ for key, val in params.items():
223
+ if group:
224
+ setattr(getattr(args, group), key, val.val if isinstance(val, FormField) else val)
225
+ else:
226
+ setattr(args, key, val.val if isinstance(val, FormField) else val)
227
+
228
+
229
+ def get_terminal_size():
230
+ try:
231
+ # XX when piping the input IN, it writes
232
+ # echo "434" | convey -f base64 --debug
233
+ # stty: 'standard input': Inappropriate ioctl for device
234
+ # I do not know how to suppress this warning.
235
+ height, width = (int(s) for s in os.popen('stty size', 'r').read().split())
236
+ return height, width
237
+ except (OSError, ValueError):
238
+ return 0, 0
239
+
240
+
241
+ def get_args_allow_missing(config: ConfigClass, kwargs: dict, parser: ArgumentParser):
242
+ """ Fetch missing required options in GUI. """
243
+ # On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting
244
+ # the error message function. Then, we reconstruct the missing options.
245
+ # NOTE But we should rather invoke a GUI with the missing options only.
246
+ original_error = TyroArgumentParser.error
247
+ eavesdrop = ""
248
+
249
+ def custom_error(self, message: str):
250
+ nonlocal eavesdrop
251
+ if not message.startswith("the following arguments are required:"):
252
+ return original_error(self, message)
253
+ eavesdrop = message
254
+ raise SystemExit(2) # will be catched
255
+ try:
256
+ with patch.object(TyroArgumentParser, 'error', custom_error):
257
+ return cli(config, **kwargs)
258
+ except BaseException as e:
259
+ if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which.
260
+ for arg in eavesdrop.partition(":")[2].strip().split(", "):
261
+ argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
262
+ argument.default = "HALO"
263
+ if "." in argument.dest: # missing nested required argument handler not implemented, we make tyro fail in CLI
264
+ pass
265
+ else:
266
+ match argument.metavar:
267
+ case "INT":
268
+ setattr(kwargs["default"], argument.dest, 0)
269
+ case "STR":
270
+ setattr(kwargs["default"], argument.dest, "")
271
+ case _:
272
+ pass # missing handler not implemented, we make tyro fail in CLI
273
+ return cli(config, **kwargs) # second attempt
274
+ raise
275
+
276
+
277
+ def get_descriptions(parser: ArgumentParser) -> dict:
278
+ """ Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
279
+ return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help)
280
+ for action in parser._actions}
281
+
282
+
283
+ class RedirectText:
284
+ """ Helps to redirect text from stdout to a text widget. """
285
+
286
+ def __init__(self, widget: Text, pending_buffer: list, window: Tk) -> None:
287
+ self.widget = widget
288
+ self.max_lines = 1000
289
+ self.pending_buffer = pending_buffer
290
+ self.window = window
291
+
292
+ def write(self, text):
293
+ self.widget.pack(expand=True, fill='both')
294
+ self.widget.insert(END, text)
295
+ self.widget.see(END) # scroll to the end
296
+ self.trim()
297
+ self.window.update_idletasks()
298
+ self.pending_buffer.append(text)
299
+
300
+ def flush(self):
301
+ pass # required by sys.stdout
302
+
303
+ def trim(self):
304
+ lines = int(self.widget.index('end-1c').split('.')[0])
305
+ if lines > self.max_lines:
306
+ self.widget.delete(1.0, f"{lines - self.max_lines}.0")
307
+
308
+
309
+ def recursive_set_focus(widget: Widget):
310
+ for child in widget.winfo_children():
311
+ if isinstance(child, (Entry, Checkbutton, Combobox)):
312
+ child.focus_set()
313
+ return True
314
+ if recursive_set_focus(child):
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.1.1"
7
+ version = "0.4.1"
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"
@@ -1,195 +0,0 @@
1
- import logging
2
- import os
3
- import re
4
- from argparse import Action, ArgumentParser
5
- from dataclasses import dataclass
6
- from typing import Any, Callable, Literal, Optional, TypeVar, Union, get_args, get_type_hints
7
- from unittest.mock import patch
8
-
9
- try:
10
- # NOTE this should be clean up and tested on a machine without tkinter installable
11
- from tkinter import END, Entry, Text, Tk, Widget
12
- from tkinter.ttk import Checkbutton, Combobox
13
- except ImportError:
14
- tkinter = None
15
- END, Entry, Text, Tk, Widget = (None,)*5
16
-
17
- from tkinter_form import Value
18
- from tyro import cli
19
- from tyro._argparse_formatter import TyroArgumentParser
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- @dataclass
25
- class Value(Value):
26
- """ Override val/description class with additional stuff. """
27
-
28
- annotation: Any | None = None
29
- """ Used for validation. To convert an empty '' to None. """
30
-
31
- def __post_init__(self):
32
- self._original_desc = self.description
33
-
34
- def set_error_text(self, s):
35
- self.description = f"{s} {self._original_desc}"
36
-
37
-
38
- ConfigInstance = TypeVar("ConfigInstance")
39
- ConfigClass = Callable[..., ConfigInstance]
40
- FormDict = dict[str, Union[Value, Any, 'FormDict']]
41
- """ Nested form that can have descriptions (through Value) instead of plain values. """
42
-
43
-
44
- def dataclass_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
45
- """ Convert the dataclass produced by tyro into dict of dicts. """
46
- main = ""
47
- params = {main: {}} if not _path else {}
48
- for param, val in vars(args).items():
49
- annotation = None
50
- if val is None:
51
- wanted_type = get_type_hints(args.__class__).get(param)
52
- if wanted_type in (Optional[int], Optional[str]):
53
- # Since tkinter_form does not handle None yet, we have help it.
54
- # We need it to be able to write a number and if empty, return None.
55
- # This would fail: `severity: int | None = None`
56
- # Here, we convert None to str(""), in normalize_types we convert it back.
57
- annotation = wanted_type
58
- val = ""
59
- else:
60
- # An unknown type annotation encountered-
61
- # Since tkinter_form does not handle None yet, this will display as checkbox.
62
- # Which is not probably wanted.
63
- val = False
64
- logger.warn(f"Annotation {wanted_type} of `{param}` not supported by Mininterface."
65
- "None converted to False.")
66
- if hasattr(val, "__dict__"): # nested config hierarchy
67
- params[param] = dataclass_to_dict(val, descr, _path=f"{_path}{param}.")
68
- elif not _path: # scalar value in root
69
- params[main][param] = Value(val, descr.get(param), annotation)
70
- else: # scalar value in nested
71
- params[param] = Value(val, descr.get(f"{_path}{param}"), annotation)
72
- return params
73
-
74
-
75
- def normalize_types(origin: FormDict, data: dict) -> dict:
76
- """ Run validators of all Value objects. If fails, outputs info.
77
- Return corrected data. (Ex: Some values might be nulled from "".)
78
- """
79
- for (group, params), params2 in zip(data.items(), origin.values()):
80
- for (key, val), pattern in zip(params.items(), params2.values()):
81
- if isinstance(pattern, Value) and pattern.annotation:
82
- if val == "" and type(None) in get_args(pattern.annotation):
83
- # The user is not able to set the value to None, they left it empty.
84
- # Cast back to None as None is one of the allowed types.
85
- # Ex: `severity: int | None = None`
86
- data[group][key] = val = None
87
- elif pattern.annotation == Optional[int]:
88
- try:
89
- data[group][key] = val = int(val)
90
- except ValueError:
91
- pass
92
-
93
- if not isinstance(val, pattern.annotation):
94
- pattern.set_error_text(f"Type must be `{pattern.annotation}`!")
95
- return False
96
- return data
97
-
98
-
99
- def dict_to_dataclass(args: ConfigInstance, data: dict):
100
- """ Convert the dict of dicts from the GUI back into the object holding the configuration. """
101
- for group, params in data.items():
102
- for key, val in params.items():
103
- if group:
104
- setattr(getattr(args, group), key, val)
105
- else:
106
- setattr(args, key, val)
107
-
108
-
109
- def get_terminal_size():
110
- try:
111
- # XX when piping the input IN, it writes
112
- # echo "434" | convey -f base64 --debug
113
- # stty: 'standard input': Inappropriate ioctl for device
114
- # I do not know how to suppress this warning.
115
- height, width = (int(s) for s in os.popen('stty size', 'r').read().split())
116
- return height, width
117
- except (OSError, ValueError):
118
- return 0, 0
119
-
120
-
121
- def get_args_allow_missing(config: ConfigClass, kwargs: dict, parser: ArgumentParser):
122
- """ Fetch missing required options in GUI. """
123
- # On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting
124
- # the error message function. Then, we reconstruct the missing options.
125
- # NOTE But we should rather invoke a GUI with the missing options only.
126
- original_error = TyroArgumentParser.error
127
- eavesdrop = ""
128
-
129
- def custom_error(self, message: str):
130
- nonlocal eavesdrop
131
- if not message.startswith("the following arguments are required:"):
132
- return original_error(self, message)
133
- eavesdrop = message
134
- raise SystemExit(2) # will be catched
135
- try:
136
- with patch.object(TyroArgumentParser, 'error', custom_error):
137
- return cli(config, **kwargs)
138
- except BaseException as e:
139
- if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which.
140
- for arg in eavesdrop.partition(":")[2].strip().split(", "):
141
- argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
142
- argument.default = "HALO"
143
- if "." in argument.dest: # missing nested required argument handler not implemented, we make tyro fail in CLI
144
- pass
145
- else:
146
- match argument.metavar:
147
- case "INT":
148
- setattr(kwargs["default"], argument.dest, 0)
149
- case "STR":
150
- setattr(kwargs["default"], argument.dest, "")
151
- case _:
152
- pass # missing handler not implemented, we make tyro fail in CLI
153
- return cli(config, **kwargs) # second attempt
154
- raise
155
-
156
-
157
- def get_descriptions(parser: ArgumentParser) -> dict:
158
- """ Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
159
- return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help)
160
- for action in parser._actions}
161
-
162
-
163
- class RedirectText:
164
- """ Helps to redirect text from stdout to a text widget. """
165
-
166
- def __init__(self, widget: Text, pending_buffer: list, window: Tk) -> None:
167
- self.widget = widget
168
- self.max_lines = 1000
169
- self.pending_buffer = pending_buffer
170
- self.window = window
171
-
172
- def write(self, text):
173
- self.widget.pack(expand=True, fill='both')
174
- self.widget.insert(END, text)
175
- self.widget.see(END) # scroll to the end
176
- self.trim()
177
- self.window.update_idletasks()
178
- self.pending_buffer.append(text)
179
-
180
- def flush(self):
181
- pass # required by sys.stdout
182
-
183
- def trim(self):
184
- lines = int(self.widget.index('end-1c').split('.')[0])
185
- if lines > self.max_lines:
186
- self.widget.delete(1.0, f"{lines - self.max_lines}.0")
187
-
188
-
189
- def recursive_set_focus(widget: Widget):
190
- for child in widget.winfo_children():
191
- if isinstance(child, (Entry, Checkbutton, Combobox)):
192
- child.focus_set()
193
- return True
194
- if recursive_set_focus(child):
195
- return True