mininterface 0.4.1__tar.gz → 0.4.3__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.1
3
+ Version: 0.4.3
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,8 +15,9 @@ 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
- Requires-Dist: tkinter_form
20
+ Requires-Dist: tkinter_form (==0.1.5.2)
20
21
  Requires-Dist: tyro
21
22
  Description-Content-Type: text/markdown
22
23
 
@@ -26,9 +27,10 @@ Description-Content-Type: text/markdown
26
27
 
27
28
  Write the program core, do not bother with the input/output.
28
29
 
29
- ![hello world example](asset/hello-world.png "A minimal use case")
30
+ ![Hello world example: GUI window](asset/hello-world.png "A minimal use case – GUI")
31
+ ![Hello world example: TUI fallback](asset/hello-tui.webp "A minimal use case – TUI fallback")
30
32
 
31
- Check out the code that displays such window, just the code you need. No lengthy blocks of code imposed by an external dependency.
33
+ Check out the code that displays such window or its textual fallback.
32
34
 
33
35
  ```python
34
36
  from dataclasses import dataclass
@@ -47,7 +49,22 @@ if __name__ == "__main__":
47
49
  print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
48
50
  ```
49
51
 
50
- Or bound the interface to a `with` statement that redirects stdout directly to the window.
52
+ It's all the code you need. No lengthy blocks of code imposed by an external dependency. Besides the GUI/TUI, you receive powerful YAML-configurable CLI parsing.
53
+
54
+ ```bash
55
+ $ ./hello.py
56
+ usage: My application [-h] [--test | --no-test] [--important-number INT]
57
+
58
+ Set of options.
59
+
60
+ ╭─ options ──────────────────────────────────────────────────────────╮
61
+ │ -h, --help show this help message and exit │
62
+ │ --test, --no-test My testing flag (default: False) │
63
+ │ --important-number INT This number is very important (default: 4) │
64
+ ╰────────────────────────────────────────────────────────────────────╯
65
+ ```
66
+
67
+ You get several useful methods to handle user dialogues. Here we bound the interface to a `with` statement that redirects stdout directly to the window.
51
68
 
52
69
  ```python
53
70
  with run(Config) as m:
@@ -55,16 +72,15 @@ with run(Config) as m:
55
72
  boolean = m.is_yes("Is that alright?")
56
73
  ```
57
74
 
58
- TODO img
75
+ ![Small window with the text 'Your important number'](asset/hello-with-statement.webp "With statement to redirect the output")
76
+ ![The same in terminal'](asset/hello-with-statement-tui.webp "With statement in TUI fallback")
59
77
 
60
- Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml`. Instantly loaded.
78
+ Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml` and put there some of the arguments. Instantly loaded.
61
79
 
62
80
  ```yaml
63
81
  important_number: 555
64
82
  ```
65
83
 
66
- TODO img
67
-
68
84
  - [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
69
85
  - [Background](#background)
70
86
  - [Installation](#installation)
@@ -72,21 +88,18 @@ TODO img
72
88
  * [`mininterface`](#mininterface)
73
89
  + [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
74
90
  * [Interfaces](#interfaces)
75
- + [`Mininterface(title: str = '')`](#Mininterface-title-str--)
76
- + [`alert(self, text: str)`](#alert-self-text-str)
77
- + [`ask(self, text: str) -> str`](#ask-self-text-str-str)
78
- + [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
79
- + [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
80
- + [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
81
- + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
82
- + [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
83
- + [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
84
- + [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`](#parse-args-self-config-callable-configinstance-config-file-pathlibpath-none-none-kwargs-configinstance)
91
+ + [`Mininterface(title: str = '')`](#mininterfacetitle-str--)
92
+ + [`alert(self, text: str)`](#alertself-text-str)
93
+ + [`ask(self, text: str) -> str`](#askself-text-str---str)
94
+ + [`ask_args(self) -> ~ConfigInstance`](#ask_argsself---configinstance)
95
+ + [`ask_form(self, args: FormDict, title="") -> int`](#ask_formself-args-formdict-title---dict)
96
+ + [`ask_number(self, text: str) -> int`](#ask_numberself-text-str---int)
97
+ + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get_argsself-ask_on_empty_clitrue---configinstance)
98
+ + [`is_no(self, text: str) -> bool`](#is_noself-text-str---bool)
99
+ + [`is_yes(self, text: str) -> bool`](#is_yesself-text-str---bool)
100
+ + [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`](#parse_argsself-config-callable-configinstance-config_file-pathlibpath--none--none-kwargs---configinstance)
85
101
  * [Standalone](#standalone)
86
102
 
87
- <small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>
88
-
89
-
90
103
  # Background
91
104
 
92
105
  Wrapper between the [tyro](https://github.com/brentyi/tyro) `argparse` replacement and [tkinter_form](https://github.com/JohanEstebanCuervo/tkinter_form/) that converts dicts into a GUI.
@@ -162,6 +175,8 @@ Several interfaces exist:
162
175
  * `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
163
176
  * `GuiInterface` – A tkinter window.
164
177
  * `TuiInterface` – An interactive terminal.
178
+ * `TextualInterface` – If [textual](https://github.com/Textualize/textual) installed, rich interface is used.
179
+ * `TextInterface` – Plain text only interface with no dependency as a fallback.
165
180
  * `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
166
181
 
167
182
  You can invoke one directly instead of using [mininterface.run](#run-config-none-interface-guiinterface-kwargs). Then, you can connect a configuration object to the CLI and config file with `parse_args` if needed.
@@ -4,9 +4,10 @@
4
4
 
5
5
  Write the program core, do not bother with the input/output.
6
6
 
7
- ![hello world example](asset/hello-world.png "A minimal use case")
7
+ ![Hello world example: GUI window](asset/hello-world.png "A minimal use case – GUI")
8
+ ![Hello world example: TUI fallback](asset/hello-tui.webp "A minimal use case – TUI fallback")
8
9
 
9
- Check out the code that displays such window, just the code you need. No lengthy blocks of code imposed by an external dependency.
10
+ Check out the code that displays such window or its textual fallback.
10
11
 
11
12
  ```python
12
13
  from dataclasses import dataclass
@@ -25,7 +26,22 @@ if __name__ == "__main__":
25
26
  print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
26
27
  ```
27
28
 
28
- Or bound the interface to a `with` statement that redirects stdout directly to the window.
29
+ It's all the code you need. No lengthy blocks of code imposed by an external dependency. Besides the GUI/TUI, you receive powerful YAML-configurable CLI parsing.
30
+
31
+ ```bash
32
+ $ ./hello.py
33
+ usage: My application [-h] [--test | --no-test] [--important-number INT]
34
+
35
+ Set of options.
36
+
37
+ ╭─ options ──────────────────────────────────────────────────────────╮
38
+ │ -h, --help show this help message and exit │
39
+ │ --test, --no-test My testing flag (default: False) │
40
+ │ --important-number INT This number is very important (default: 4) │
41
+ ╰────────────────────────────────────────────────────────────────────╯
42
+ ```
43
+
44
+ You get several useful methods to handle user dialogues. Here we bound the interface to a `with` statement that redirects stdout directly to the window.
29
45
 
30
46
  ```python
31
47
  with run(Config) as m:
@@ -33,16 +49,15 @@ with run(Config) as m:
33
49
  boolean = m.is_yes("Is that alright?")
34
50
  ```
35
51
 
36
- TODO img
52
+ ![Small window with the text 'Your important number'](asset/hello-with-statement.webp "With statement to redirect the output")
53
+ ![The same in terminal'](asset/hello-with-statement-tui.webp "With statement in TUI fallback")
37
54
 
38
- Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml`. Instantly loaded.
55
+ Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml` and put there some of the arguments. Instantly loaded.
39
56
 
40
57
  ```yaml
41
58
  important_number: 555
42
59
  ```
43
60
 
44
- TODO img
45
-
46
61
  - [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
47
62
  - [Background](#background)
48
63
  - [Installation](#installation)
@@ -50,21 +65,18 @@ TODO img
50
65
  * [`mininterface`](#mininterface)
51
66
  + [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
52
67
  * [Interfaces](#interfaces)
53
- + [`Mininterface(title: str = '')`](#Mininterface-title-str--)
54
- + [`alert(self, text: str)`](#alert-self-text-str)
55
- + [`ask(self, text: str) -> str`](#ask-self-text-str-str)
56
- + [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
57
- + [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
58
- + [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
59
- + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
60
- + [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
61
- + [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
62
- + [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`](#parse-args-self-config-callable-configinstance-config-file-pathlibpath-none-none-kwargs-configinstance)
68
+ + [`Mininterface(title: str = '')`](#mininterfacetitle-str--)
69
+ + [`alert(self, text: str)`](#alertself-text-str)
70
+ + [`ask(self, text: str) -> str`](#askself-text-str---str)
71
+ + [`ask_args(self) -> ~ConfigInstance`](#ask_argsself---configinstance)
72
+ + [`ask_form(self, args: FormDict, title="") -> int`](#ask_formself-args-formdict-title---dict)
73
+ + [`ask_number(self, text: str) -> int`](#ask_numberself-text-str---int)
74
+ + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get_argsself-ask_on_empty_clitrue---configinstance)
75
+ + [`is_no(self, text: str) -> bool`](#is_noself-text-str---bool)
76
+ + [`is_yes(self, text: str) -> bool`](#is_yesself-text-str---bool)
77
+ + [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`](#parse_argsself-config-callable-configinstance-config_file-pathlibpath--none--none-kwargs---configinstance)
63
78
  * [Standalone](#standalone)
64
79
 
65
- <small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>
66
-
67
-
68
80
  # Background
69
81
 
70
82
  Wrapper between the [tyro](https://github.com/brentyi/tyro) `argparse` replacement and [tkinter_form](https://github.com/JohanEstebanCuervo/tkinter_form/) that converts dicts into a GUI.
@@ -140,6 +152,8 @@ Several interfaces exist:
140
152
  * `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
141
153
  * `GuiInterface` – A tkinter window.
142
154
  * `TuiInterface` – An interactive terminal.
155
+ * `TextualInterface` – If [textual](https://github.com/Textualize/textual) installed, rich interface is used.
156
+ * `TextInterface` – Plain text only interface with no dependency as a fallback.
143
157
  * `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
144
158
 
145
159
  You can invoke one directly instead of using [mininterface.run](#run-config-none-interface-guiinterface-kwargs). Then, you can connect a configuration object to the CLI and config file with `parse_args` if needed.
@@ -0,0 +1,107 @@
1
+ """ FormDict tools.
2
+ FormDict is not a real class, just a normal dict. But we need to put somewhere functions related to it.
3
+ """
4
+ import logging
5
+ from argparse import Action, ArgumentParser
6
+ from typing import Callable, Optional, TypeVar, Union, get_type_hints
7
+ from unittest.mock import patch
8
+
9
+ from tyro import cli
10
+ from tyro._argparse_formatter import TyroArgumentParser
11
+
12
+ from .FormField import FormField
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ConfigInstance = TypeVar("ConfigInstance")
17
+ ConfigClass = Callable[..., ConfigInstance]
18
+ FormDict = dict[str, Union[FormField, 'FormDict']]
19
+ """ Nested form that can have descriptions (through FormField) instead of plain values. """
20
+
21
+
22
+ def formdict_repr(d: FormDict) -> dict:
23
+ """ For the testing purposes, returns a new dict when all FormFields are replaced with their values. """
24
+ out = {}
25
+ for k, v in d.items():
26
+ if isinstance(v, FormField):
27
+ v = v.val
28
+ out[k] = formdict_repr(v) if isinstance(v, dict) else v
29
+ return out
30
+
31
+
32
+ def dict_to_formdict(data: dict) -> FormDict:
33
+ fd = {}
34
+ for key, val in data.items():
35
+ if isinstance(val, dict): # nested config hierarchy
36
+ fd[key] = dict_to_formdict(val)
37
+ else: # scalar value
38
+ # NOTE name=param is not set (yet?) in `config_to_formdict`, neither `src`
39
+ fd[key] = FormField(val, "", name=key, src=(data, key))
40
+ return fd
41
+
42
+
43
+ def config_to_formdict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
44
+ """ Convert the dataclass produced by tyro into dict of dicts. """
45
+ main = ""
46
+ params = {main: {}} if not _path else {}
47
+ for param, val in vars(args).items():
48
+ annotation = None
49
+ if val is None:
50
+ wanted_type = get_type_hints(args.__class__).get(param)
51
+ if wanted_type in (Optional[int], Optional[str]):
52
+ # Since tkinter_form does not handle None yet, we have help it.
53
+ # We need it to be able to write a number and if empty, return None.
54
+ # This would fail: `severity: int | None = None`
55
+ # Here, we convert None to str(""), in normalize_types we convert it back.
56
+ annotation = wanted_type
57
+ val = ""
58
+ else:
59
+ # An unknown type annotation encountered-
60
+ # Since tkinter_form does not handle None yet, this will display as checkbox.
61
+ # Which is not probably wanted.
62
+ val = False
63
+ logger.warn(f"Annotation {wanted_type} of `{param}` not supported by Mininterface."
64
+ "None converted to False.")
65
+ if hasattr(val, "__dict__"): # nested config hierarchy
66
+ params[param] = config_to_formdict(val, descr, _path=f"{_path}{param}.")
67
+ elif not _path: # scalar value in root
68
+ params[main][param] = FormField(val, descr.get(param), annotation, param, src2=(args, param))
69
+ else: # scalar value in nested
70
+ params[param] = FormField(val, descr.get(f"{_path}{param}"), annotation, param, src2=(args, param))
71
+ return params
72
+
73
+
74
+ def get_args_allow_missing(config: ConfigClass, kwargs: dict, parser: ArgumentParser):
75
+ """ Fetch missing required options in GUI. """
76
+ # On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting
77
+ # the error message function. Then, we reconstruct the missing options.
78
+ # NOTE But we should rather invoke a GUI with the missing options only.
79
+ original_error = TyroArgumentParser.error
80
+ eavesdrop = ""
81
+
82
+ def custom_error(self, message: str):
83
+ nonlocal eavesdrop
84
+ if not message.startswith("the following arguments are required:"):
85
+ return original_error(self, message)
86
+ eavesdrop = message
87
+ raise SystemExit(2) # will be catched
88
+ try:
89
+ with patch.object(TyroArgumentParser, 'error', custom_error):
90
+ return cli(config, **kwargs)
91
+ except BaseException as e:
92
+ if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which.
93
+ for arg in eavesdrop.partition(":")[2].strip().split(", "):
94
+ argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
95
+ argument.default = "DEFAULT" # NOTE I do not know whether used
96
+ if "." in argument.dest: # missing nested required argument handler not implemented, we make tyro fail in CLI
97
+ pass
98
+ else:
99
+ match argument.metavar:
100
+ case "INT":
101
+ setattr(kwargs["default"], argument.dest, 0)
102
+ case "STR":
103
+ setattr(kwargs["default"], argument.dest, "")
104
+ case _:
105
+ pass # missing handler not implemented, we make tyro fail in CLI
106
+ return cli(config, **kwargs) # second attempt
107
+ raise
@@ -0,0 +1,132 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Any, Iterable, Optional, TypeVar, get_args
3
+
4
+ from .auxiliary import flatten
5
+
6
+ if TYPE_CHECKING:
7
+ from .FormDict import FormDict
8
+
9
+ try:
10
+ from tkinter_form import Value
11
+ except ImportError:
12
+ @dataclass
13
+ class Value:
14
+ """ This class helps to enrich the field with a description. """
15
+ val: Any
16
+ description: str
17
+
18
+
19
+ FFValue = TypeVar("FFValue")
20
+ TD = TypeVar("TD")
21
+ """ dict """
22
+ TK = TypeVar("TK")
23
+ """ dict key """
24
+
25
+
26
+ @dataclass
27
+ class FormField(Value):
28
+ """ Bridge between the input values and a UI widget.
29
+ Helps to creates a widget from the input value (includes description etc.),
30
+ then transforms the value back (str to int conversion etc).
31
+
32
+ (Ex: Merge the dict of dicts from the GUI back into the object holding the configuration.)
33
+ """
34
+
35
+ annotation: Any | None = None
36
+ """ Used for validation. To convert an empty '' to None. """
37
+ name: str | None = None # NOTE: Only TextualInterface uses this by now.
38
+
39
+ src: tuple[TD, TK] | None = None
40
+ """ The original dict to be updated when UI ends. """
41
+ src2: tuple[TD, TK] | None = None
42
+ """ The original object to be updated when UI ends.
43
+ NOTE should be merged to `src`
44
+ """
45
+
46
+ def __post_init__(self):
47
+ self._original_desc = self.description
48
+
49
+ def set_error_text(self, s):
50
+ self.description = f"{s} {self._original_desc}"
51
+
52
+ def update(self, ui_value):
53
+ """ UI value → FormField value → original value. (With type conversion and checks.)
54
+
55
+ The value has been updated in a UI.
56
+ Update accordingly the value in the original linked dict
57
+ the mininterface was invoked with.
58
+
59
+ Validates the type and do the transformation.
60
+ (Ex: Some values might be nulled from "".)
61
+ """
62
+ fixed_value = ui_value
63
+ if self.annotation:
64
+ if ui_value == "" and type(None) in get_args(self.annotation):
65
+ # The user is not able to set the value to None, they left it empty.
66
+ # Cast back to None as None is one of the allowed types.
67
+ # Ex: `severity: int | None = None`
68
+ fixed_value = None
69
+ elif self.annotation == Optional[int]:
70
+ try:
71
+ fixed_value = int(ui_value)
72
+ except ValueError:
73
+ pass
74
+
75
+ if not isinstance(fixed_value, self.annotation):
76
+ self.set_error_text(f"Type must be `{self.annotation}`!")
77
+ return False # revision needed
78
+
79
+ # keep values if revision needed
80
+ # We merge new data to the origin. If form is re-submitted, the values will stay there.
81
+ self.val = ui_value
82
+
83
+ # Store to the source user data
84
+ if self.src:
85
+ d, k = self.src
86
+ d[k] = fixed_value
87
+ elif self.src2:
88
+ d, k = self.src2
89
+ setattr(d, k, fixed_value)
90
+ else:
91
+ # This might be user-created object. The user reads directly from this. There is no need to update anything.
92
+ pass
93
+ return True
94
+ # Fixing types:
95
+ # This code would support tuple[int, int]:
96
+ #
97
+ # self.types = get_args(self.annotation) \
98
+ # if isinstance(self.annotation, UnionType) else (self.annotation, )
99
+ # "All possible types in a tuple. Ex 'int | str' -> (int, str)"
100
+ #
101
+ #
102
+ # def convert(self):
103
+ # """ Convert the self.value to the given self.type.
104
+ # The value might be in str due to CLI or TUI whereas the programs wants bool.
105
+ # """
106
+ # # if self.value == "True":
107
+ # # return True
108
+ # # if self.value == "False":
109
+ # # return False
110
+ # if type(self.val) is str and str not in self.types:
111
+ # try:
112
+ # return literal_eval(self.val) # ex: int, tuple[int, int]
113
+ # except:
114
+ # raise ValueError(f"{self.name}: Cannot convert value {self.val}")
115
+ # return self.val
116
+
117
+ @staticmethod
118
+ def submit_values(updater: Iterable[tuple["FormField", FFValue]]) -> bool:
119
+ """ Returns whether the form is alright or whether we should revise it.
120
+ Input is tuple of the FormFields and their new values from the UI.
121
+ """
122
+ # Why list? We need all the FormField values be updates from the UI.
123
+ # If the revision is needed, the UI fetches the values from the FormField.
124
+ # We need the keep the values so that the user does not have to re-write them.
125
+ return all(list(ff.update(ui_value) for ff, ui_value in updater))
126
+
127
+ @staticmethod
128
+ def submit(fd: "FormDict", ui: dict):
129
+ """ Returns whether the form is alright or whether we should revise it.
130
+ Input is the FormDict and the UI dict in the very same form.
131
+ """
132
+ return FormField.submit_values(zip(flatten(fd), flatten(ui)))
@@ -1,6 +1,9 @@
1
1
  import sys
2
2
  from typing import Any, Callable
3
3
 
4
+ from .auxiliary import flatten
5
+
6
+
4
7
  try:
5
8
  from tkinter import TclError, LEFT, Button, Frame, Label, Text, Tk
6
9
  from tktooltip import ToolTip
@@ -11,7 +14,9 @@ except ImportError:
11
14
 
12
15
 
13
16
  from .common import InterfaceNotAvailable
14
- from .auxiliary import FormDict, RedirectText, config_to_formdict, config_from_dict, flatten, recursive_set_focus, fix_types
17
+ from .FormDict import FormDict, config_to_formdict
18
+ from .auxiliary import RedirectText, recursive_set_focus
19
+ from .FormField import FormField
15
20
  from .Mininterface import Cancelled, ConfigInstance, Mininterface
16
21
 
17
22
 
@@ -20,7 +25,7 @@ class GuiInterface(Mininterface):
20
25
  super().__init__(*args, **kwargs)
21
26
  try:
22
27
  self.window = TkWindow(self)
23
- except TclError:
28
+ except TclError: # I am not sure whether there might be reasons the Tkinter is not available even when installed
24
29
  raise InterfaceNotAvailable
25
30
  self._always_shown = False
26
31
  self._original_stdout = sys.stdout
@@ -46,11 +51,10 @@ class GuiInterface(Mininterface):
46
51
 
47
52
  def ask_args(self) -> ConfigInstance:
48
53
  """ Display a window form with all parameters. """
49
- params_ = config_to_formdict(self.args, self.descriptions)
54
+ formDict = config_to_formdict(self.args, self.descriptions)
50
55
 
51
- # fetch the dict of dicts values from the form back to the namespace of the dataclasses
52
- self.window.run_dialog(params_)
53
- # NOTE remove config_from_dict(self.args, data)
56
+ # formDict automatically fetches the edited values back to the ConfigInstance
57
+ self.window.run_dialog(formDict)
54
58
  return self.args
55
59
 
56
60
  def ask_form(self, form: FormDict, title: str = "") -> dict:
@@ -93,7 +97,7 @@ class TkWindow(Tk):
93
97
  self.pending_buffer = []
94
98
  """ Text that has been written to the text widget but might not be yet seen by user. Because no mainloop was invoked. """
95
99
 
96
- def run_dialog(self, formDict: FormDict, title: str = "") -> dict:
100
+ def run_dialog(self, formDict: FormDict, title: str = "") -> FormDict:
97
101
  """ Let the user edit the form_dict values in a GUI window.
98
102
  On abrupt window close, the program exits.
99
103
  """
@@ -108,10 +112,9 @@ class TkWindow(Tk):
108
112
  )
109
113
  self.form.pack()
110
114
 
111
- # Set the enter and exit options
115
+ # Set the submit and exit options
112
116
  self.form.button.config(command=self._ok)
113
- # allow Enter for single field, otherwise Ctrl+Enter
114
- tip, keysym = ("Ctrl+Enter", '<Control-Return>') if len(formDict) > 1 else ("Enter", "<Return>")
117
+ tip, keysym = ("Enter", "<Return>")
115
118
  ToolTip(self.form.button, msg=tip) # NOTE is not destroyed in _clear
116
119
  self._bind_event(keysym, self._ok)
117
120
  self.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
@@ -120,14 +123,10 @@ class TkWindow(Tk):
120
123
  recursive_set_focus(self.form)
121
124
  return self.mainloop(lambda: self.validate(formDict, title))
122
125
 
123
- def validate(self, formDict: FormDict, title: str):
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)
126
+ def validate(self, formDict: FormDict, title: str) -> FormDict:
127
+ if not FormField.submit_values(zip(flatten(formDict), flatten(self.form.get()))):
128
+ return self.run_dialog(formDict, title)
129
+ return formDict
131
130
 
132
131
  def yes_no(self, text: str, focus_no=True):
133
132
  return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)
@@ -8,8 +8,8 @@ from types import SimpleNamespace
8
8
  import yaml
9
9
  from tyro.extras import get_parser
10
10
 
11
- from .auxiliary import (ConfigClass, ConfigInstance, FormDict, get_args_allow_missing,
12
- get_descriptions)
11
+ from .FormDict import ConfigClass, ConfigInstance, FormDict, get_args_allow_missing
12
+ from .auxiliary import get_descriptions
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -1,9 +1,11 @@
1
1
  from pprint import pprint
2
- from .auxiliary import ConfigInstance, FormDict, config_to_formdict, config_from_dict
2
+
3
+ from .FormDict import ConfigInstance, FormDict
3
4
  from .Mininterface import Cancelled, Mininterface
4
5
 
5
6
 
6
- class TuiInterface(Mininterface):
7
+ class TextInterface(Mininterface):
8
+ """ Plain text fallback interface. No dependencies. """
7
9
 
8
10
  def alert(self, text: str):
9
11
  """ Display text and let the user hit any key. """
@@ -61,7 +63,7 @@ class TuiInterface(Mininterface):
61
63
  return self.ask(text=text + " y/[n]").lower() in ("n", "no", "")
62
64
 
63
65
 
64
- class ReplInterface(TuiInterface):
66
+ class ReplInterface(TextInterface):
65
67
  """ Same as the base TuiInterface, except it starts the REPL. """
66
68
 
67
69
  def __getattr__(self, name):
@@ -1,40 +1,26 @@
1
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
2
+ from dataclasses import dataclass
3
+ from typing import Any
25
4
 
26
- @dataclass
27
- class FormFieldTextual(FormField):
28
- """ Bridge between the values given in CLI, TUI and real needed values (str to int conversion etc). """
5
+ try:
6
+ from textual import events
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import VerticalScroll
10
+ from textual.widgets import Button, Checkbox, Footer, Header, Input, Label
11
+ except ImportError:
12
+ from mininterface.common import InterfaceNotAvailable
13
+ raise InterfaceNotAvailable
29
14
 
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
15
+ from .TextInterface import TextInterface
37
16
 
17
+ from .auxiliary import flatten
18
+ from .FormDict import (ConfigInstance, FormDict, config_to_formdict,
19
+ dict_to_formdict)
20
+ from .FormField import FormField
21
+ from .Mininterface import Cancelled
22
+
23
+ # TODO with statement hello world example image is wrong – Textual still does not redirect the output as GuiInterface does
38
24
 
39
25
  @dataclass
40
26
  class DummyWrapper:
@@ -43,7 +29,7 @@ class DummyWrapper:
43
29
  val: Any
44
30
 
45
31
 
46
- class TextualInterface(TuiInterface):
32
+ class TextualInterface(TextInterface):
47
33
 
48
34
  def alert(self, text: str) -> None:
49
35
  """ Display the OK dialog with text. """
@@ -54,15 +40,14 @@ class TextualInterface(TuiInterface):
54
40
 
55
41
  def ask_args(self) -> ConfigInstance:
56
42
  """ Display a window form with all parameters. """
57
- params_ = config_to_formdict(self.args, self.descriptions, factory=FormFieldTextual)
43
+ params_ = config_to_formdict(self.args, self.descriptions)
58
44
 
59
45
  # fetch the dict of dicts values from the form back to the namespace of the dataclasses
60
46
  TextualApp.run_dialog(TextualApp(), params_)
61
47
  return self.args
62
48
 
63
49
  def ask_form(self, form: FormDict, title: str = "") -> dict:
64
- TextualApp.run_dialog(TextualApp(), dict_to_formdict(form, factory=FormFieldTextual), title)
65
- return form
50
+ return TextualApp.run_dialog(TextualApp(), dict_to_formdict(form), title)
66
51
 
67
52
  # NOTE we should implement better, now the user does not know it needs an int
68
53
  # def ask_number(self, text):
@@ -94,28 +79,34 @@ class TextualApp(App[bool | None]):
94
79
  self.widgets = None
95
80
  self.focused_i: int = 0
96
81
 
97
- def setup(self, title, widgets, focused_i):
82
+ @staticmethod
83
+ def get_widget(ff:FormField) -> Checkbox | Input:
84
+ """ Wrap FormField to a textual widget. """
98
85
 
99
- self.focused_i = focused_i
100
- return self
86
+ if ff.annotation is bool or not ff.annotation and ff.val in [True, False]:
87
+ o = Checkbox(ff.name, ff.val)
88
+ else:
89
+ o = Input(str(ff.val), placeholder=ff.name or "")
90
+ o._link = ff # The Textual widgets need to get back to this value
91
+ return o
101
92
 
102
93
  # Why class method? I do not know how to re-create the dialog if needed.
103
94
  @classmethod
104
- def run_dialog(cls, window, formDict: FormDict, title: str = "") -> None: # TODO changed from dict, change everywhere
95
+ def run_dialog(cls, window: "TextualApp", formDict: FormDict, title: str = "") -> FormDict:
105
96
  if title:
106
97
  window.title = title
107
98
 
108
99
  # 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)]
100
+ widgets: list[Checkbox | Input] = [cls.get_widget(f) for f in flatten(formDict)]
111
101
  window.widgets = widgets
112
102
 
113
103
  if not window.run():
114
104
  raise Cancelled
115
105
 
116
106
  # validate and store the UI value → FormField value → original value
117
- if not all(field._link.update(field.value) for field in widgets):
107
+ if not FormField.submit_values((field._link, field.value) for field in widgets):
118
108
  return cls.run_dialog(TextualApp(), formDict, title)
109
+ return formDict
119
110
 
120
111
  def compose(self) -> ComposeResult:
121
112
  if self.title:
@@ -123,7 +114,6 @@ class TextualApp(App[bool | None]):
123
114
  yield Footer()
124
115
  with VerticalScroll():
125
116
  for fieldt in self.widgets:
126
- fieldt: FormFieldTextual
127
117
  if isinstance(fieldt, Input):
128
118
  yield Label(fieldt.placeholder)
129
119
  yield fieldt
@@ -3,25 +3,34 @@ from pathlib import Path
3
3
  from typing import TYPE_CHECKING, Type
4
4
  from unittest.mock import patch
5
5
 
6
+
7
+ from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface
8
+ from mininterface.TextInterface import ReplInterface, TextInterface
9
+ from mininterface.FormField import FormField
10
+
11
+ # Import optional interfaces
6
12
  try:
7
13
  from mininterface.GuiInterface import GuiInterface
8
14
  except ImportError:
9
15
  if TYPE_CHECKING:
10
- pass
16
+ pass # Replace TYPE_CHECKING with `type GuiInterface = None` since Python 3.12
11
17
  else:
12
18
  GuiInterface = None
19
+ try:
20
+ from mininterface.TextualInterface import TextualInterface
21
+ except ImportError:
22
+ TextualInterface = None
13
23
 
14
- from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface
15
- from mininterface.TuiInterface import ReplInterface, TuiInterface
16
- from mininterface.TextualInterface import TextualInterface
17
- from mininterface.auxiliary import FormField
18
24
 
19
25
  # TODO auto-handle verbosity https://brentyi.github.io/tyro/examples/04_additional/12_counters/ ?
20
26
  # TODO example on missing required options.
21
27
 
28
+ class TuiInterface(TextualInterface or TextInterface):
29
+ pass
30
+
22
31
 
23
32
  def run(config: ConfigClass | None = None,
24
- interface: Type[Mininterface] = GuiInterface or ReplInterface, # NOTE we shuold use TuiInterface as a fallback
33
+ interface: Type[Mininterface] = GuiInterface or TuiInterface,
25
34
  **kwargs) -> Mininterface:
26
35
  """
27
36
  Wrap your configuration dataclass into `run` to access the interface. Normally, an interface is chosen automatically.
@@ -0,0 +1,76 @@
1
+ import os
2
+ import re
3
+ from argparse import ArgumentParser
4
+ from typing import Iterable, TypeVar
5
+
6
+ try:
7
+ # NOTE this should be clean up and tested on a machine without tkinter installable
8
+ from tkinter import END, Entry, Text, Tk, Widget
9
+ from tkinter.ttk import Checkbutton, Combobox
10
+ except ImportError:
11
+ tkinter = None
12
+ END, Entry, Text, Tk, Widget = (None,)*5
13
+
14
+
15
+
16
+ T = TypeVar("T")
17
+
18
+ def flatten(d: dict[str, T | dict]) -> Iterable[T]:
19
+ """ Recursively traverse whole dict """
20
+ for v in d.values():
21
+ if isinstance(v, dict):
22
+ yield from flatten(v)
23
+ else:
24
+ yield v
25
+
26
+
27
+ def get_terminal_size():
28
+ try:
29
+ # XX when piping the input IN, it writes
30
+ # echo "434" | convey -f base64 --debug
31
+ # stty: 'standard input': Inappropriate ioctl for device
32
+ # I do not know how to suppress this warning.
33
+ height, width = (int(s) for s in os.popen('stty size', 'r').read().split())
34
+ return height, width
35
+ except (OSError, ValueError):
36
+ return 0, 0
37
+
38
+ def get_descriptions(parser: ArgumentParser) -> dict:
39
+ """ Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
40
+ return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help)
41
+ for action in parser._actions}
42
+
43
+
44
+ class RedirectText:
45
+ """ Helps to redirect text from stdout to a text widget. """
46
+
47
+ def __init__(self, widget: Text, pending_buffer: list, window: Tk) -> None:
48
+ self.widget = widget
49
+ self.max_lines = 1000
50
+ self.pending_buffer = pending_buffer
51
+ self.window = window
52
+
53
+ def write(self, text):
54
+ self.widget.pack(expand=True, fill='both')
55
+ self.widget.insert(END, text)
56
+ self.widget.see(END) # scroll to the end
57
+ self.trim()
58
+ self.window.update_idletasks()
59
+ self.pending_buffer.append(text)
60
+
61
+ def flush(self):
62
+ pass # required by sys.stdout
63
+
64
+ def trim(self):
65
+ lines = int(self.widget.index('end-1c').split('.')[0])
66
+ if lines > self.max_lines:
67
+ self.widget.delete(1.0, f"{lines - self.max_lines}.0")
68
+
69
+
70
+ def recursive_set_focus(widget: Widget):
71
+ for child in widget.winfo_children():
72
+ if isinstance(child, (Entry, Checkbutton, Combobox)):
73
+ child.focus_set()
74
+ return True
75
+ if recursive_set_focus(child):
76
+ return True
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "mininterface"
7
- version = "0.4.1"
7
+ version = "0.4.3"
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,8 +17,9 @@ tyro = "*"
17
17
  pyyaml = "*"
18
18
  envelope = "*"
19
19
  requests = "*"
20
+ textual = "*"
20
21
  tkinter-tooltip = "*"
21
- tkinter_form = "*"
22
+ tkinter_form = "0.1.5.2"
22
23
 
23
24
  [tool.poetry.scripts]
24
25
  mininterface = "mininterface.__main__:main"
@@ -1,327 +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, 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