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.
- {mininterface-0.4.1 → mininterface-0.4.3}/PKG-INFO +37 -22
- {mininterface-0.4.1 → mininterface-0.4.3}/README.md +34 -20
- mininterface-0.4.3/mininterface/FormDict.py +107 -0
- mininterface-0.4.3/mininterface/FormField.py +132 -0
- {mininterface-0.4.1 → mininterface-0.4.3}/mininterface/GuiInterface.py +17 -18
- {mininterface-0.4.1 → mininterface-0.4.3}/mininterface/Mininterface.py +2 -2
- mininterface-0.4.1/mininterface/TuiInterface.py → mininterface-0.4.3/mininterface/TextInterface.py +5 -3
- {mininterface-0.4.1 → mininterface-0.4.3}/mininterface/TextualInterface.py +35 -45
- {mininterface-0.4.1 → mininterface-0.4.3}/mininterface/__init__.py +15 -6
- mininterface-0.4.3/mininterface/auxiliary.py +76 -0
- {mininterface-0.4.1 → mininterface-0.4.3}/pyproject.toml +3 -2
- mininterface-0.4.1/mininterface/auxiliary.py +0 -327
- {mininterface-0.4.1 → mininterface-0.4.3}/mininterface/__main__.py +0 -0
- {mininterface-0.4.1 → mininterface-0.4.3}/mininterface/common.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: mininterface
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.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
|
-

|
|
31
|
+

|
|
30
32
|
|
|
31
|
-
Check out the code that displays such window
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+

|
|
76
|
+

|
|
59
77
|
|
|
60
|
-
Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml
|
|
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 = '')`](#
|
|
76
|
-
+ [`alert(self, text: str)`](#
|
|
77
|
-
+ [`ask(self, text: str) -> str`](#
|
|
78
|
-
+ [`ask_args(self) -> ~ConfigInstance`](#
|
|
79
|
-
+ [`ask_form(self, args: FormDict, title="") -> int`](#
|
|
80
|
-
+ [`ask_number(self, text: str) -> int`](#
|
|
81
|
-
+ [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#
|
|
82
|
-
+ [`is_no(self, text: str) -> bool`](#
|
|
83
|
-
+ [`is_yes(self, text: str) -> bool`](#
|
|
84
|
-
+ [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | 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
|
-

|
|
8
|
+

|
|
8
9
|
|
|
9
|
-
Check out the code that displays such window
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+

|
|
53
|
+

|
|
37
54
|
|
|
38
|
-
Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml
|
|
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 = '')`](#
|
|
54
|
-
+ [`alert(self, text: str)`](#
|
|
55
|
-
+ [`ask(self, text: str) -> str`](#
|
|
56
|
-
+ [`ask_args(self) -> ~ConfigInstance`](#
|
|
57
|
-
+ [`ask_form(self, args: FormDict, title="") -> int`](#
|
|
58
|
-
+ [`ask_number(self, text: str) -> int`](#
|
|
59
|
-
+ [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#
|
|
60
|
-
+ [`is_no(self, text: str) -> bool`](#
|
|
61
|
-
+ [`is_yes(self, text: str) -> bool`](#
|
|
62
|
-
+ [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | 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 .
|
|
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
|
-
|
|
54
|
+
formDict = config_to_formdict(self.args, self.descriptions)
|
|
50
55
|
|
|
51
|
-
#
|
|
52
|
-
self.window.run_dialog(
|
|
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 = "") ->
|
|
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
|
|
115
|
+
# Set the submit and exit options
|
|
112
116
|
self.form.button.config(command=self._ok)
|
|
113
|
-
|
|
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
|
|
125
|
-
|
|
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 .
|
|
12
|
-
|
|
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
|
|
mininterface-0.4.1/mininterface/TuiInterface.py → mininterface-0.4.3/mininterface/TextInterface.py
RENAMED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from pprint import pprint
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from .FormDict import ConfigInstance, FormDict
|
|
3
4
|
from .Mininterface import Cancelled, Mininterface
|
|
4
5
|
|
|
5
6
|
|
|
6
|
-
class
|
|
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(
|
|
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
|
|
3
|
-
from
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
+
@staticmethod
|
|
83
|
+
def get_widget(ff:FormField) -> Checkbox | Input:
|
|
84
|
+
""" Wrap FormField to a textual widget. """
|
|
98
85
|
|
|
99
|
-
|
|
100
|
-
|
|
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 = "") ->
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|