mininterface 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mininterface-0.1.1/PKG-INFO +236 -0
- mininterface-0.1.1/README.md +213 -0
- mininterface-0.1.1/mininterface/GuiInterface.py +174 -0
- mininterface-0.1.1/mininterface/Mininterface.py +116 -0
- mininterface-0.1.1/mininterface/TuiInterface.py +77 -0
- mininterface-0.1.1/mininterface/__init__.py +56 -0
- mininterface-0.1.1/mininterface/__main__.py +40 -0
- mininterface-0.1.1/mininterface/auxiliary.py +195 -0
- mininterface-0.1.1/mininterface/common.py +2 -0
- mininterface-0.1.1/pyproject.toml +24 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: mininterface
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A minimal access to GUI, TUI, CLI and config
|
|
5
|
+
Home-page: https://github.com/CZ-NIC/mininterface
|
|
6
|
+
License: GPL-3.0-or-later
|
|
7
|
+
Author: Edvard Rejthar
|
|
8
|
+
Author-email: edvard.rejthar@nic.cz
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Dist: envelope
|
|
16
|
+
Requires-Dist: pyyaml
|
|
17
|
+
Requires-Dist: requests
|
|
18
|
+
Requires-Dist: tkinter-tooltip
|
|
19
|
+
Requires-Dist: tkinter_form
|
|
20
|
+
Requires-Dist: tyro
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Mininterface – access to GUI, TUI, CLI and config files
|
|
24
|
+
[](https://www.gnu.org/licenses/gpl-3.0)
|
|
25
|
+
|
|
26
|
+
Write the program core, do not bother with the input/output.
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
Check out the code that displays such window, just the code you need. No lengthy blocks of code imposed by an external dependency.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from mininterface import run
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Config:
|
|
38
|
+
"""Set of options."""
|
|
39
|
+
test: bool = False
|
|
40
|
+
"""My testing flag"""
|
|
41
|
+
important_number: int = 4
|
|
42
|
+
"""This number is very important"""
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
args: Config = run(Config, prog="My application").get_args()
|
|
46
|
+
print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or bound the interface to a `with` statement that redirects stdout directly to the window.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
with run(Config) as m:
|
|
53
|
+
print(f"Your important number is {m}")
|
|
54
|
+
boolean = m.is_yes("Is that alright?")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
TODO img
|
|
58
|
+
|
|
59
|
+
Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml`. Instantly loaded.
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
important_number: 555
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
TODO img
|
|
66
|
+
|
|
67
|
+
- [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
|
|
68
|
+
- [Background](#background)
|
|
69
|
+
- [Installation](#installation)
|
|
70
|
+
- [Docs](#docs)
|
|
71
|
+
* [`mininterface`](#mininterface)
|
|
72
|
+
+ [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
|
|
73
|
+
* [Interfaces](#interfaces)
|
|
74
|
+
+ [`Mininterface(title: str = '')`](#Mininterface-title-str--)
|
|
75
|
+
+ [`alert(self, text: str)`](#alert-self-text-str)
|
|
76
|
+
+ [`ask(self, text: str) -> str`](#ask-self-text-str-str)
|
|
77
|
+
+ [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
|
|
78
|
+
+ [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
|
|
79
|
+
+ [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
|
|
80
|
+
+ [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
|
|
81
|
+
+ [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
|
|
82
|
+
+ [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
|
|
83
|
+
+ [`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)
|
|
84
|
+
* [Standalone](#standalone)
|
|
85
|
+
|
|
86
|
+
<small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Background
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
Writing a small and useful program might be a task that takes fifteen minutes. Adding a CLI to specify the parameters is not so much overhead. But building a simple GUI around it? HOURS! Hours spent on researching GUI libraries, wondering why the Python desktop app ecosystem lags so far behind the web world. All you need is a few input fields validated through a clickable window... You do not deserve to add hundred of lines of the code just to define some editable fields. `mininterface` is here to help.
|
|
94
|
+
|
|
95
|
+
The config variables needed by your program are kept in cozy dataclasses. Write less! The syntax of [tyro](https://github.com/brentyi/tyro) does not require any overhead (as its `argparse` alternatives do). You just annotate a class attribute, append a simple docstring and get a fully functional application:
|
|
96
|
+
* Call it as `program.py --help` to display full help.
|
|
97
|
+
* Use any flag in CLI: `program.py --test` causes `args.test` be set to `True`.
|
|
98
|
+
* The main benefit: Launch it without parameters as `program.py` to get a full working window with all the flags ready to be edited.
|
|
99
|
+
* Running on a remote machine? Automatic regression to the text interface.
|
|
100
|
+
|
|
101
|
+
# Installation
|
|
102
|
+
|
|
103
|
+
Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
|
|
104
|
+
|
|
105
|
+
```python3
|
|
106
|
+
pip install mininterface
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
# Docs
|
|
110
|
+
|
|
111
|
+
You can easily nest the configuration. (See also [Tyro Hierarchical Configs](https://brentyi.github.io/tyro/examples/02_nesting/01_nesting/)).
|
|
112
|
+
|
|
113
|
+
Just put another dataclass inside the config file:
|
|
114
|
+
|
|
115
|
+
```python3
|
|
116
|
+
@dataclass
|
|
117
|
+
class FurtherConfig:
|
|
118
|
+
token: str
|
|
119
|
+
host: str = "example.org"
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class Config:
|
|
123
|
+
further: FurtherConfig
|
|
124
|
+
|
|
125
|
+
...
|
|
126
|
+
print(config.further.host) # example.org
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A subset might be defaulted in YAML:
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
further:
|
|
133
|
+
host: example.com
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Or by CLI:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
$./program.py --further.host example.net
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## `mininterface`
|
|
143
|
+
|
|
144
|
+
### `run(config=None, interface=GuiInterface, **kwargs)`
|
|
145
|
+
Wrap your configuration dataclass into `run` to access the interface. Normally, an interface is chosen automatically. We prefer the graphical one, regressed to a text interface on a machine without display.
|
|
146
|
+
Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly with the default from a config file if such exists. It searches the config file in the current working directory, with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`.
|
|
147
|
+
|
|
148
|
+
* `config:ConfigClass`: Dataclass with the configuration.
|
|
149
|
+
* `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the REPL.
|
|
150
|
+
* `**kwargs`: The same as for [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html).
|
|
151
|
+
* Returns: `interface` Interface used.
|
|
152
|
+
|
|
153
|
+
You cay context manage the function by a `with` statement. The stdout will be redirected to the interface (GUI window).
|
|
154
|
+
|
|
155
|
+
See the [initial examples](#mininterface-gui-tui-cli-and-config).
|
|
156
|
+
|
|
157
|
+
## Interfaces
|
|
158
|
+
|
|
159
|
+
Several interfaces exist:
|
|
160
|
+
|
|
161
|
+
* `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
|
|
162
|
+
* `GuiInterface` – A tkinter window.
|
|
163
|
+
* `TuiInterface` – An interactive terminal.
|
|
164
|
+
* `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
with TuiInterface("My program") as m:
|
|
170
|
+
number = m.ask_number("Returns number")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `Mininterface(title: str = '')`
|
|
174
|
+
Initialize.
|
|
175
|
+
### `alert(self, text: str)`
|
|
176
|
+
Prompt the user to confirm the text.
|
|
177
|
+
### `ask(self, text: str) -> str`
|
|
178
|
+
Prompt the user to input a text.
|
|
179
|
+
### `ask_args(self) -> ~ConfigInstance`
|
|
180
|
+
Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.)
|
|
181
|
+
### `ask_form(self, args: FormDict, title="") -> dict`
|
|
182
|
+
Prompt the user to fill up whole form.
|
|
183
|
+
* `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
184
|
+
The dict can be nested, it can contain a subgroup.
|
|
185
|
+
The default value might be `mininterface.Value` that allows you to add descriptions.
|
|
186
|
+
A checkbox example: `{"my label": Value(True, "my description")}`
|
|
187
|
+
* `title`: Optional form title.
|
|
188
|
+
### `ask_number(self, text: str) -> int`
|
|
189
|
+
Prompt the user to input a number. Empty input = 0.
|
|
190
|
+
### `get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`
|
|
191
|
+
Returns whole configuration (previously fetched from CLI and config file by parse_args).
|
|
192
|
+
If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields.
|
|
193
|
+
### `is_no(self, text: str) -> bool`
|
|
194
|
+
Display confirm box, focusing no.
|
|
195
|
+
### `is_yes(self, text: str) -> bool`
|
|
196
|
+
Display confirm box, focusing yes.
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
m = run(prog="My program")
|
|
200
|
+
print(m.ask_yes("Is it true?")) # True/False
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
|
|
204
|
+
Parse CLI arguments, possibly merged from a config file.
|
|
205
|
+
* `config`: Dataclass with the configuration.
|
|
206
|
+
* `config_file`: File to load YAML to be merged with the configuration. You do not have to re-define all the settings, you can choose a few.
|
|
207
|
+
* `**kwargs` The same as for argparse.ArgumentParser.
|
|
208
|
+
* Returns: `ConfigInstance` Configuration namespace.
|
|
209
|
+
|
|
210
|
+
## Standalone
|
|
211
|
+
|
|
212
|
+
When invoked directly, it creates simple GUI dialogs.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
$ mininterface --help
|
|
216
|
+
usage: Mininterface [-h] [OPTIONS]
|
|
217
|
+
|
|
218
|
+
Simple GUI dialog. Outputs the value the user entered.
|
|
219
|
+
|
|
220
|
+
╭─ options ─────────────────────────────────────────────────────────────────────────────────╮
|
|
221
|
+
│ -h, --help show this help message and exit │
|
|
222
|
+
│ --alert STR Display the OK dialog with text. (default: '') │
|
|
223
|
+
│ --ask STR Prompt the user to input a text. (default: '') │
|
|
224
|
+
│ --ask-number STR Prompt the user to input a number. Empty input = 0. (default: '') │
|
|
225
|
+
│ --is-yes STR Display confirm box, focusing yes. (default: '') │
|
|
226
|
+
│ --is-no STR Display confirm box, focusing no. (default: '') │
|
|
227
|
+
╰───────────────────────────────────────────────────────────────────────────────────────────╯
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
You can fetch a value to i.e. a bash script.
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
$ mininterface --ask-number "What's your age?" # GUI window invoked
|
|
234
|
+
18
|
|
235
|
+
```
|
|
236
|
+
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Mininterface – access to GUI, TUI, CLI and config files
|
|
2
|
+
[](https://www.gnu.org/licenses/gpl-3.0)
|
|
3
|
+
|
|
4
|
+
Write the program core, do not bother with the input/output.
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
Check out the code that displays such window, just the code you need. No lengthy blocks of code imposed by an external dependency.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from mininterface import run
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Config:
|
|
16
|
+
"""Set of options."""
|
|
17
|
+
test: bool = False
|
|
18
|
+
"""My testing flag"""
|
|
19
|
+
important_number: int = 4
|
|
20
|
+
"""This number is very important"""
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
args: Config = run(Config, prog="My application").get_args()
|
|
24
|
+
print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or bound the interface to a `with` statement that redirects stdout directly to the window.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
with run(Config) as m:
|
|
31
|
+
print(f"Your important number is {m}")
|
|
32
|
+
boolean = m.is_yes("Is that alright?")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
TODO img
|
|
36
|
+
|
|
37
|
+
Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml`. Instantly loaded.
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
important_number: 555
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
TODO img
|
|
44
|
+
|
|
45
|
+
- [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
|
|
46
|
+
- [Background](#background)
|
|
47
|
+
- [Installation](#installation)
|
|
48
|
+
- [Docs](#docs)
|
|
49
|
+
* [`mininterface`](#mininterface)
|
|
50
|
+
+ [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
|
|
51
|
+
* [Interfaces](#interfaces)
|
|
52
|
+
+ [`Mininterface(title: str = '')`](#Mininterface-title-str--)
|
|
53
|
+
+ [`alert(self, text: str)`](#alert-self-text-str)
|
|
54
|
+
+ [`ask(self, text: str) -> str`](#ask-self-text-str-str)
|
|
55
|
+
+ [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
|
|
56
|
+
+ [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
|
|
57
|
+
+ [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
|
|
58
|
+
+ [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
|
|
59
|
+
+ [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
|
|
60
|
+
+ [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
|
|
61
|
+
+ [`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)
|
|
62
|
+
* [Standalone](#standalone)
|
|
63
|
+
|
|
64
|
+
<small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Background
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
Writing a small and useful program might be a task that takes fifteen minutes. Adding a CLI to specify the parameters is not so much overhead. But building a simple GUI around it? HOURS! Hours spent on researching GUI libraries, wondering why the Python desktop app ecosystem lags so far behind the web world. All you need is a few input fields validated through a clickable window... You do not deserve to add hundred of lines of the code just to define some editable fields. `mininterface` is here to help.
|
|
72
|
+
|
|
73
|
+
The config variables needed by your program are kept in cozy dataclasses. Write less! The syntax of [tyro](https://github.com/brentyi/tyro) does not require any overhead (as its `argparse` alternatives do). You just annotate a class attribute, append a simple docstring and get a fully functional application:
|
|
74
|
+
* Call it as `program.py --help` to display full help.
|
|
75
|
+
* Use any flag in CLI: `program.py --test` causes `args.test` be set to `True`.
|
|
76
|
+
* The main benefit: Launch it without parameters as `program.py` to get a full working window with all the flags ready to be edited.
|
|
77
|
+
* Running on a remote machine? Automatic regression to the text interface.
|
|
78
|
+
|
|
79
|
+
# Installation
|
|
80
|
+
|
|
81
|
+
Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
|
|
82
|
+
|
|
83
|
+
```python3
|
|
84
|
+
pip install mininterface
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
# Docs
|
|
88
|
+
|
|
89
|
+
You can easily nest the configuration. (See also [Tyro Hierarchical Configs](https://brentyi.github.io/tyro/examples/02_nesting/01_nesting/)).
|
|
90
|
+
|
|
91
|
+
Just put another dataclass inside the config file:
|
|
92
|
+
|
|
93
|
+
```python3
|
|
94
|
+
@dataclass
|
|
95
|
+
class FurtherConfig:
|
|
96
|
+
token: str
|
|
97
|
+
host: str = "example.org"
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Config:
|
|
101
|
+
further: FurtherConfig
|
|
102
|
+
|
|
103
|
+
...
|
|
104
|
+
print(config.further.host) # example.org
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A subset might be defaulted in YAML:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
further:
|
|
111
|
+
host: example.com
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Or by CLI:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
$./program.py --further.host example.net
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## `mininterface`
|
|
121
|
+
|
|
122
|
+
### `run(config=None, interface=GuiInterface, **kwargs)`
|
|
123
|
+
Wrap your configuration dataclass into `run` to access the interface. Normally, an interface is chosen automatically. We prefer the graphical one, regressed to a text interface on a machine without display.
|
|
124
|
+
Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly with the default from a config file if such exists. It searches the config file in the current working directory, with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`.
|
|
125
|
+
|
|
126
|
+
* `config:ConfigClass`: Dataclass with the configuration.
|
|
127
|
+
* `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the REPL.
|
|
128
|
+
* `**kwargs`: The same as for [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html).
|
|
129
|
+
* Returns: `interface` Interface used.
|
|
130
|
+
|
|
131
|
+
You cay context manage the function by a `with` statement. The stdout will be redirected to the interface (GUI window).
|
|
132
|
+
|
|
133
|
+
See the [initial examples](#mininterface-gui-tui-cli-and-config).
|
|
134
|
+
|
|
135
|
+
## Interfaces
|
|
136
|
+
|
|
137
|
+
Several interfaces exist:
|
|
138
|
+
|
|
139
|
+
* `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
|
|
140
|
+
* `GuiInterface` – A tkinter window.
|
|
141
|
+
* `TuiInterface` – An interactive terminal.
|
|
142
|
+
* `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
with TuiInterface("My program") as m:
|
|
148
|
+
number = m.ask_number("Returns number")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `Mininterface(title: str = '')`
|
|
152
|
+
Initialize.
|
|
153
|
+
### `alert(self, text: str)`
|
|
154
|
+
Prompt the user to confirm the text.
|
|
155
|
+
### `ask(self, text: str) -> str`
|
|
156
|
+
Prompt the user to input a text.
|
|
157
|
+
### `ask_args(self) -> ~ConfigInstance`
|
|
158
|
+
Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.)
|
|
159
|
+
### `ask_form(self, args: FormDict, title="") -> dict`
|
|
160
|
+
Prompt the user to fill up whole form.
|
|
161
|
+
* `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
162
|
+
The dict can be nested, it can contain a subgroup.
|
|
163
|
+
The default value might be `mininterface.Value` that allows you to add descriptions.
|
|
164
|
+
A checkbox example: `{"my label": Value(True, "my description")}`
|
|
165
|
+
* `title`: Optional form title.
|
|
166
|
+
### `ask_number(self, text: str) -> int`
|
|
167
|
+
Prompt the user to input a number. Empty input = 0.
|
|
168
|
+
### `get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`
|
|
169
|
+
Returns whole configuration (previously fetched from CLI and config file by parse_args).
|
|
170
|
+
If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields.
|
|
171
|
+
### `is_no(self, text: str) -> bool`
|
|
172
|
+
Display confirm box, focusing no.
|
|
173
|
+
### `is_yes(self, text: str) -> bool`
|
|
174
|
+
Display confirm box, focusing yes.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
m = run(prog="My program")
|
|
178
|
+
print(m.ask_yes("Is it true?")) # True/False
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
|
|
182
|
+
Parse CLI arguments, possibly merged from a config file.
|
|
183
|
+
* `config`: Dataclass with the configuration.
|
|
184
|
+
* `config_file`: File to load YAML to be merged with the configuration. You do not have to re-define all the settings, you can choose a few.
|
|
185
|
+
* `**kwargs` The same as for argparse.ArgumentParser.
|
|
186
|
+
* Returns: `ConfigInstance` Configuration namespace.
|
|
187
|
+
|
|
188
|
+
## Standalone
|
|
189
|
+
|
|
190
|
+
When invoked directly, it creates simple GUI dialogs.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
$ mininterface --help
|
|
194
|
+
usage: Mininterface [-h] [OPTIONS]
|
|
195
|
+
|
|
196
|
+
Simple GUI dialog. Outputs the value the user entered.
|
|
197
|
+
|
|
198
|
+
╭─ options ─────────────────────────────────────────────────────────────────────────────────╮
|
|
199
|
+
│ -h, --help show this help message and exit │
|
|
200
|
+
│ --alert STR Display the OK dialog with text. (default: '') │
|
|
201
|
+
│ --ask STR Prompt the user to input a text. (default: '') │
|
|
202
|
+
│ --ask-number STR Prompt the user to input a number. Empty input = 0. (default: '') │
|
|
203
|
+
│ --is-yes STR Display confirm box, focusing yes. (default: '') │
|
|
204
|
+
│ --is-no STR Display confirm box, focusing no. (default: '') │
|
|
205
|
+
╰───────────────────────────────────────────────────────────────────────────────────────────╯
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
You can fetch a value to i.e. a bash script.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
$ mininterface --ask-number "What's your age?" # GUI window invoked
|
|
212
|
+
18
|
|
213
|
+
```
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from tkinter import TclError, LEFT, Button, Frame, Label, Text, Tk
|
|
6
|
+
from tktooltip import ToolTip
|
|
7
|
+
from tkinter_form import Form
|
|
8
|
+
except ImportError:
|
|
9
|
+
from mininterface.common import InterfaceNotAvailable
|
|
10
|
+
raise InterfaceNotAvailable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from .common import InterfaceNotAvailable
|
|
14
|
+
from .auxiliary import FormDict, RedirectText, dataclass_to_dict, dict_to_dataclass, recursive_set_focus, normalize_types
|
|
15
|
+
from .Mininterface import Cancelled, ConfigInstance, Mininterface
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GuiInterface(Mininterface):
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
try:
|
|
21
|
+
super().__init__(*args, **kwargs)
|
|
22
|
+
except TclError:
|
|
23
|
+
raise InterfaceNotAvailable
|
|
24
|
+
self.window = TkWindow(self)
|
|
25
|
+
self._always_shown = False
|
|
26
|
+
self._original_stdout = sys.stdout
|
|
27
|
+
|
|
28
|
+
def __enter__(self) -> "Mininterface":
|
|
29
|
+
""" When used in the with statement, the GUI window does not vanish between dialogues. """
|
|
30
|
+
self._always_shown = True
|
|
31
|
+
sys.stdout = RedirectText(self.window.text_widget, self.window.pending_buffer, self.window)
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, *_):
|
|
35
|
+
self._always_shown = False
|
|
36
|
+
sys.stdout = self._original_stdout
|
|
37
|
+
if self.window.pending_buffer: # display text sent to the window but not displayed
|
|
38
|
+
print("".join(self.window.pending_buffer), end="")
|
|
39
|
+
|
|
40
|
+
def alert(self, text: str) -> None:
|
|
41
|
+
""" Display the OK dialog with text. """
|
|
42
|
+
return self.window.buttons(text, [("Ok", None)])
|
|
43
|
+
|
|
44
|
+
def ask(self, text: str) -> str:
|
|
45
|
+
return self.window.run_dialog({text: ""})[text]
|
|
46
|
+
|
|
47
|
+
def ask_args(self) -> ConfigInstance:
|
|
48
|
+
""" Display a window form with all parameters. """
|
|
49
|
+
params_ = dataclass_to_dict(self.args, self.descriptions)
|
|
50
|
+
|
|
51
|
+
# fetch the dict of dicts values from the form back to the namespace of the dataclasses
|
|
52
|
+
data = self.window.run_dialog(params_)
|
|
53
|
+
dict_to_dataclass(self.args, data)
|
|
54
|
+
return self.args
|
|
55
|
+
|
|
56
|
+
def ask_form(self, args: FormDict, title: str = "") -> dict:
|
|
57
|
+
""" Prompt the user to fill up whole form.
|
|
58
|
+
:param args: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
59
|
+
The dict can be nested, it can contain a subgroup.
|
|
60
|
+
The default value might be `mininterface.Value` that allows you to add descriptions.
|
|
61
|
+
A checkbox example: {"my label": Value(True, "my description")}
|
|
62
|
+
:param title: Optional form title.
|
|
63
|
+
"""
|
|
64
|
+
return self.window.run_dialog(args, title=title)
|
|
65
|
+
|
|
66
|
+
def ask_number(self, text: str) -> int:
|
|
67
|
+
return self.window.run_dialog({text: 0})[text]
|
|
68
|
+
|
|
69
|
+
def is_yes(self, text):
|
|
70
|
+
return self.window.yes_no(text, False)
|
|
71
|
+
|
|
72
|
+
def is_no(self, text):
|
|
73
|
+
return self.window.yes_no(text, True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TkWindow(Tk):
|
|
77
|
+
""" An editing window. """
|
|
78
|
+
|
|
79
|
+
def __init__(self, interface: GuiInterface):
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.params = None
|
|
82
|
+
self._result = None
|
|
83
|
+
self._event_bindings = {}
|
|
84
|
+
self.interface = interface
|
|
85
|
+
self.title(interface.title)
|
|
86
|
+
self.bind('<Escape>', lambda _: self._ok(Cancelled))
|
|
87
|
+
|
|
88
|
+
self.frame = Frame(self)
|
|
89
|
+
""" dialog frame """
|
|
90
|
+
|
|
91
|
+
self.text_widget = Text(self, wrap='word', height=20, width=80)
|
|
92
|
+
self.text_widget.pack_forget()
|
|
93
|
+
self.pending_buffer = []
|
|
94
|
+
""" Text that has been written to the text widget but might not be yet seen by user. Because no mainloop was invoked. """
|
|
95
|
+
|
|
96
|
+
def run_dialog(self, formDict: FormDict, title: str = "") -> dict:
|
|
97
|
+
""" Let the user edit the form_dict values in a GUI window.
|
|
98
|
+
On abrupt window close, the program exits.
|
|
99
|
+
"""
|
|
100
|
+
if title:
|
|
101
|
+
label = Label(self.frame, text=title)
|
|
102
|
+
label.pack(pady=10)
|
|
103
|
+
|
|
104
|
+
self.form = Form(self.frame,
|
|
105
|
+
name_form="",
|
|
106
|
+
form_dict=formDict,
|
|
107
|
+
name_config="Ok",
|
|
108
|
+
)
|
|
109
|
+
self.form.pack()
|
|
110
|
+
|
|
111
|
+
# Set the enter and exit options
|
|
112
|
+
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>")
|
|
115
|
+
ToolTip(self.form.button, msg=tip) # NOTE is not destroyed in _clear
|
|
116
|
+
self._bind_event(keysym, self._ok)
|
|
117
|
+
self.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
|
|
118
|
+
|
|
119
|
+
# focus the first element and run
|
|
120
|
+
recursive_set_focus(self.form)
|
|
121
|
+
return self.mainloop(lambda: self.validate(formDict, title))
|
|
122
|
+
|
|
123
|
+
def validate(self, formDict: FormDict, title: str):
|
|
124
|
+
if data := normalize_types(formDict, self.form.get()):
|
|
125
|
+
return data
|
|
126
|
+
return self.run_dialog(formDict, title)
|
|
127
|
+
|
|
128
|
+
def yes_no(self, text: str, focus_no=True):
|
|
129
|
+
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)
|
|
130
|
+
|
|
131
|
+
def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1):
|
|
132
|
+
label = Label(self.frame, text=text)
|
|
133
|
+
label.pack(pady=10)
|
|
134
|
+
|
|
135
|
+
for text, value in buttons:
|
|
136
|
+
button = Button(self.frame, text=text, command=lambda v=value: self._ok(v))
|
|
137
|
+
button.bind("<Return>", lambda _: button.invoke())
|
|
138
|
+
button.pack(side=LEFT, padx=10)
|
|
139
|
+
self.frame.winfo_children()[focused].focus_set()
|
|
140
|
+
return self.mainloop()
|
|
141
|
+
|
|
142
|
+
def _bind_event(self, event, handler):
|
|
143
|
+
self._event_bindings[event] = handler
|
|
144
|
+
self.bind(event, handler)
|
|
145
|
+
|
|
146
|
+
def mainloop(self, callback: Callable = None):
|
|
147
|
+
self.frame.pack(pady=5)
|
|
148
|
+
self.deiconify() # show if hidden
|
|
149
|
+
self.pending_buffer.clear()
|
|
150
|
+
super().mainloop()
|
|
151
|
+
if not self.interface._always_shown:
|
|
152
|
+
self.withdraw() # hide
|
|
153
|
+
|
|
154
|
+
if self._result is Cancelled:
|
|
155
|
+
raise Cancelled
|
|
156
|
+
if callback:
|
|
157
|
+
return callback()
|
|
158
|
+
return self._result
|
|
159
|
+
|
|
160
|
+
def _ok(self, val=None):
|
|
161
|
+
# self.destroy()
|
|
162
|
+
self.quit()
|
|
163
|
+
# self.withdraw()
|
|
164
|
+
self._clear_dialog()
|
|
165
|
+
self._result = val
|
|
166
|
+
|
|
167
|
+
def _clear_dialog(self):
|
|
168
|
+
self.frame.pack_forget()
|
|
169
|
+
for widget in self.frame.winfo_children():
|
|
170
|
+
widget.destroy()
|
|
171
|
+
for key in self._event_bindings:
|
|
172
|
+
self.unbind(key)
|
|
173
|
+
self._event_bindings.clear()
|
|
174
|
+
self._result = None
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from argparse import ArgumentParser
|
|
4
|
+
from dataclasses import MISSING
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from tyro.extras import get_parser
|
|
10
|
+
|
|
11
|
+
from .auxiliary import (ConfigClass, ConfigInstance, FormDict, get_args_allow_missing,
|
|
12
|
+
get_descriptions)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cancelled(SystemExit):
|
|
18
|
+
# We inherit from SystemExit so that the program exits without a traceback on GUI Escape.
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Mininterface:
|
|
23
|
+
""" The base interface.
|
|
24
|
+
Does not require any user input and hence is suitable for headless testing.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, title: str = ""):
|
|
28
|
+
self.title = title or "Mininterface"
|
|
29
|
+
self.args: ConfigInstance = SimpleNamespace()
|
|
30
|
+
""" Parsed arguments, fetched from cli by parse.args """
|
|
31
|
+
self.descriptions = {}
|
|
32
|
+
""" Field descriptions """
|
|
33
|
+
|
|
34
|
+
def __enter__(self) -> "Mininterface":
|
|
35
|
+
""" When used in the with statement, the GUI window does not vanish between dialogs
|
|
36
|
+
and it redirects the stdout to a text area. """
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, *_):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def alert(self, text: str) -> None:
|
|
43
|
+
""" Prompt the user to confirm the text. """
|
|
44
|
+
print("Alert text", text)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
def ask(self, text: str) -> str:
|
|
48
|
+
""" Prompt the user to input a text. """
|
|
49
|
+
print("Asking", text)
|
|
50
|
+
raise Cancelled(".. cancelled")
|
|
51
|
+
|
|
52
|
+
def ask_args(self) -> ConfigInstance:
|
|
53
|
+
""" Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.) """
|
|
54
|
+
print("Asking the args", self.args)
|
|
55
|
+
return self.args
|
|
56
|
+
|
|
57
|
+
def ask_form(self, args: FormDict, title: str = "") -> dict:
|
|
58
|
+
""" Prompt the user to fill up whole form.
|
|
59
|
+
:param args: Dict of `{labels: default value}`. The form widget infers from the default value type.
|
|
60
|
+
The dict can be nested, it can contain a subgroup.
|
|
61
|
+
The default value might be `mininterface.Value` that allows you to add descriptions.
|
|
62
|
+
A checkbox example: `{"my label": Value(True, "my description")}`
|
|
63
|
+
"""
|
|
64
|
+
print(f"Asking the form {title}", args)
|
|
65
|
+
return args # NOTE – this should return dict, not FormDict (get rid of auxiliary.Value values)
|
|
66
|
+
|
|
67
|
+
def ask_number(self, text: str) -> int:
|
|
68
|
+
""" Prompt the user to input a number. Empty input = 0. """
|
|
69
|
+
print("Asking number", text)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
def get_args(self, ask_on_empty_cli=True) -> ConfigInstance:
|
|
73
|
+
""" Returns whole configuration (previously fetched from CLI and config file by parse_args).
|
|
74
|
+
If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields. """
|
|
75
|
+
# Empty CLI → GUI edit
|
|
76
|
+
if ask_on_empty_cli and len(sys.argv) <= 1:
|
|
77
|
+
return self.ask_args()
|
|
78
|
+
return self.args
|
|
79
|
+
|
|
80
|
+
def parse_args(self, config: ConfigClass,
|
|
81
|
+
config_file: Path | None = None,
|
|
82
|
+
**kwargs) -> ConfigInstance:
|
|
83
|
+
""" Parse CLI arguments, possibly merged from a config file.
|
|
84
|
+
|
|
85
|
+
:param config: Class with the configuration.
|
|
86
|
+
:param config_file: File to load YAML to be merged with the configuration. You do not have to re-define all the settings, you can choose a few.
|
|
87
|
+
:param **kwargs The same as for argparse.ArgumentParser.
|
|
88
|
+
:return: Configuration namespace.
|
|
89
|
+
"""
|
|
90
|
+
# Load config file
|
|
91
|
+
if config_file:
|
|
92
|
+
disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
|
|
93
|
+
# Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
|
|
94
|
+
for key in (key for key, val in disk.items() if isinstance(val, dict)):
|
|
95
|
+
disk[key] = config.__annotations__[key](**disk[key])
|
|
96
|
+
# To ensure the configuration file does not need to contain all keys, we have to fill in the missing ones.
|
|
97
|
+
# Otherwise, tyro will spawn warnings about missing fields.
|
|
98
|
+
static = {key: getattr(config, key, MISSING)
|
|
99
|
+
for key in config.__annotations__ if not key.startswith("__") and not key in disk}
|
|
100
|
+
kwargs["default"] = SimpleNamespace(**(disk | static))
|
|
101
|
+
|
|
102
|
+
# Load configuration from CLI
|
|
103
|
+
parser: ArgumentParser = get_parser(config, **kwargs)
|
|
104
|
+
self.descriptions = get_descriptions(parser)
|
|
105
|
+
self.args = get_args_allow_missing(config, kwargs, parser)
|
|
106
|
+
return self.args
|
|
107
|
+
|
|
108
|
+
def is_yes(self, text: str) -> bool:
|
|
109
|
+
""" Display confirm box, focusing yes. """
|
|
110
|
+
print("Asking yes:", text)
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def is_no(self, text: str) -> bool:
|
|
114
|
+
""" Display confirm box, focusing no. """
|
|
115
|
+
print("Asking no:", text)
|
|
116
|
+
return False
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from pprint import pprint
|
|
2
|
+
from .auxiliary import ConfigInstance, FormDict, dataclass_to_dict, dict_to_dataclass
|
|
3
|
+
from .Mininterface import Cancelled, Mininterface
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TuiInterface(Mininterface):
|
|
7
|
+
|
|
8
|
+
def alert(self, text: str):
|
|
9
|
+
""" Display text and let the user hit any key. """
|
|
10
|
+
input(text + " Hit any key.")
|
|
11
|
+
|
|
12
|
+
def ask(self, text: str = None):
|
|
13
|
+
try:
|
|
14
|
+
txt = input(text + ": ") if text else input()
|
|
15
|
+
except EOFError:
|
|
16
|
+
txt = "x"
|
|
17
|
+
if txt == "x":
|
|
18
|
+
raise Cancelled(".. cancelled")
|
|
19
|
+
return txt
|
|
20
|
+
|
|
21
|
+
def ask_args(self) -> ConfigInstance:
|
|
22
|
+
# NOTE: This is minimal implementation that should rather go the ReplInterface.
|
|
23
|
+
# I might build some menu of changing dict through:
|
|
24
|
+
# params_ = dataclass_to_dict(self.args, self.descriptions)
|
|
25
|
+
# data = FormDict → dict self.window.run_dialog(params_)
|
|
26
|
+
# dict_to_dataclass(self.args, params_)
|
|
27
|
+
return self.ask_form(self.args)
|
|
28
|
+
|
|
29
|
+
def ask_form(self, args: FormDict) -> dict:
|
|
30
|
+
# NOTE: This is minimal implementation that should rather go the ReplInterface.
|
|
31
|
+
print("Access `v` (as var) and change values. Then (c)ontinue.")
|
|
32
|
+
pprint(args)
|
|
33
|
+
v = args
|
|
34
|
+
try:
|
|
35
|
+
import ipdb
|
|
36
|
+
ipdb.set_trace()
|
|
37
|
+
except ImportError:
|
|
38
|
+
import pdb
|
|
39
|
+
pdb.set_trace()
|
|
40
|
+
print("*Continuing*")
|
|
41
|
+
print(args)
|
|
42
|
+
return args
|
|
43
|
+
|
|
44
|
+
def ask_number(self, text):
|
|
45
|
+
"""
|
|
46
|
+
Let user write number. Empty input = 0.
|
|
47
|
+
"""
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
t = self.ask(text=text)
|
|
51
|
+
if not t:
|
|
52
|
+
return 0
|
|
53
|
+
return int(t)
|
|
54
|
+
except ValueError:
|
|
55
|
+
print("This is not a number")
|
|
56
|
+
|
|
57
|
+
def is_yes(self, text: str):
|
|
58
|
+
return self.ask(text=text + " [y]/n").lower() in ("y", "yes", "")
|
|
59
|
+
|
|
60
|
+
def is_no(self, text):
|
|
61
|
+
return self.ask(text=text + " y/[n]").lower() in ("n", "no", "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ReplInterface(TuiInterface):
|
|
65
|
+
""" Same as the base TuiInterface, except it starts the REPL. """
|
|
66
|
+
|
|
67
|
+
def __getattr__(self, name):
|
|
68
|
+
""" Run _Mininterface method if exists and starts a REPL. """
|
|
69
|
+
attr = getattr(super(), name, None)
|
|
70
|
+
if callable(attr):
|
|
71
|
+
def wrapper(*args, **kwargs):
|
|
72
|
+
result = attr(*args, **kwargs)
|
|
73
|
+
breakpoint()
|
|
74
|
+
return result
|
|
75
|
+
return wrapper
|
|
76
|
+
else:
|
|
77
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING, Type
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from mininterface.GuiInterface import GuiInterface
|
|
8
|
+
except ImportError:
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
else:
|
|
12
|
+
GuiInterface = None
|
|
13
|
+
|
|
14
|
+
from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface
|
|
15
|
+
from mininterface.TuiInterface import ReplInterface, TuiInterface
|
|
16
|
+
from mininterface.auxiliary import Value
|
|
17
|
+
|
|
18
|
+
# TODO auto-handle verbosity https://brentyi.github.io/tyro/examples/04_additional/12_counters/ ?
|
|
19
|
+
# TODO example on missing required options.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(config: ConfigClass | None = None,
|
|
23
|
+
interface: Type[Mininterface] = GuiInterface or ReplInterface, # NOTE we shuold use TuiInterface as a fallback
|
|
24
|
+
**kwargs) -> Mininterface:
|
|
25
|
+
"""
|
|
26
|
+
Wrap your configuration dataclass into `run` to access the interface. Normally, an interface is chosen automatically.
|
|
27
|
+
We prefer the graphical one, regressed to a text interface on a machine without display.
|
|
28
|
+
Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly
|
|
29
|
+
with the default from a config file if such exists.
|
|
30
|
+
It searches the config file in the current working directory, with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`.
|
|
31
|
+
|
|
32
|
+
:param config: Dataclass with the configuration.
|
|
33
|
+
:param interface: Which interface to prefer. By default, we use the GUI, the fallback is the REPL.
|
|
34
|
+
:param **kwargs The same as for [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html).
|
|
35
|
+
:return: Interface used.
|
|
36
|
+
|
|
37
|
+
Undocumented: The `config` may be function as well. We invoke its paramters.
|
|
38
|
+
However, Mininterface.args stores the output of the function instead of the Argparse namespace
|
|
39
|
+
and methods like `Mininterface.ask_args()` will work unpredictibly..
|
|
40
|
+
"""
|
|
41
|
+
# Build the interface
|
|
42
|
+
prog = kwargs.get("prog") or sys.argv[0]
|
|
43
|
+
# try:
|
|
44
|
+
interface: GuiInterface | Mininterface = interface(prog)
|
|
45
|
+
# except InterfaceNotAvailable: # Fallback to a different interface
|
|
46
|
+
# interface = TuiInterface(prog)
|
|
47
|
+
|
|
48
|
+
# Load configuration from CLI and a config file
|
|
49
|
+
if config:
|
|
50
|
+
cf = Path(sys.argv[0]).with_suffix(".yaml")
|
|
51
|
+
interface.parse_args(config, cf if cf.exists() and not kwargs.get("default") else None, **kwargs)
|
|
52
|
+
|
|
53
|
+
return interface
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["run", "Value"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from .GuiInterface import GuiInterface
|
|
5
|
+
|
|
6
|
+
from . import run
|
|
7
|
+
|
|
8
|
+
from tyro.conf import UseCounterAction, UseAppendAction
|
|
9
|
+
__doc__ = """Simple GUI dialog. Outputs the value the user entered."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CliInteface:
|
|
14
|
+
alert: str = ""
|
|
15
|
+
""" Display the OK dialog with text. """
|
|
16
|
+
ask: str = ""
|
|
17
|
+
""" Prompt the user to input a text. """
|
|
18
|
+
ask_number: str = ""
|
|
19
|
+
""" Prompt the user to input a number. Empty input = 0. """
|
|
20
|
+
is_yes: str = ""
|
|
21
|
+
""" Display confirm box, focusing yes. """
|
|
22
|
+
is_no: str = ""
|
|
23
|
+
""" Display confirm box, focusing no. """
|
|
24
|
+
|
|
25
|
+
# TODO does not work in REPL interface: mininterface --alert "ahoj"
|
|
26
|
+
def main():
|
|
27
|
+
# It does make sense to invoke GuiInterface only. Other interface would use STDOUT, hence make this impractical when fetching variable to i.e. a bash script.
|
|
28
|
+
# TODO It DOES make sense. Change in README. It s a good fallback.
|
|
29
|
+
result = []
|
|
30
|
+
with run(CliInteface, prog="Mininterface", description=__doc__) as m:
|
|
31
|
+
for method, label in vars(m.args).items():
|
|
32
|
+
if label:
|
|
33
|
+
result.append(getattr(m, method)(label))
|
|
34
|
+
# Displays each result on a new line. Currently, this is an undocumented feature.
|
|
35
|
+
# As we use the script for a single value only and it is not currently possible
|
|
36
|
+
# to ask two numbers or determine a dialog order etc.
|
|
37
|
+
[print(val) for val in result]
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from argparse import Action, ArgumentParser
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Literal, Optional, TypeVar, Union, get_args, get_type_hints
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# NOTE this should be clean up and tested on a machine without tkinter installable
|
|
11
|
+
from tkinter import END, Entry, Text, Tk, Widget
|
|
12
|
+
from tkinter.ttk import Checkbutton, Combobox
|
|
13
|
+
except ImportError:
|
|
14
|
+
tkinter = None
|
|
15
|
+
END, Entry, Text, Tk, Widget = (None,)*5
|
|
16
|
+
|
|
17
|
+
from tkinter_form import Value
|
|
18
|
+
from tyro import cli
|
|
19
|
+
from tyro._argparse_formatter import TyroArgumentParser
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Value(Value):
|
|
26
|
+
""" Override val/description class with additional stuff. """
|
|
27
|
+
|
|
28
|
+
annotation: Any | None = None
|
|
29
|
+
""" Used for validation. To convert an empty '' to None. """
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
self._original_desc = self.description
|
|
33
|
+
|
|
34
|
+
def set_error_text(self, s):
|
|
35
|
+
self.description = f"{s} {self._original_desc}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ConfigInstance = TypeVar("ConfigInstance")
|
|
39
|
+
ConfigClass = Callable[..., ConfigInstance]
|
|
40
|
+
FormDict = dict[str, Union[Value, Any, 'FormDict']]
|
|
41
|
+
""" Nested form that can have descriptions (through Value) instead of plain values. """
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dataclass_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
|
|
45
|
+
""" Convert the dataclass produced by tyro into dict of dicts. """
|
|
46
|
+
main = ""
|
|
47
|
+
params = {main: {}} if not _path else {}
|
|
48
|
+
for param, val in vars(args).items():
|
|
49
|
+
annotation = None
|
|
50
|
+
if val is None:
|
|
51
|
+
wanted_type = get_type_hints(args.__class__).get(param)
|
|
52
|
+
if wanted_type in (Optional[int], Optional[str]):
|
|
53
|
+
# Since tkinter_form does not handle None yet, we have help it.
|
|
54
|
+
# We need it to be able to write a number and if empty, return None.
|
|
55
|
+
# This would fail: `severity: int | None = None`
|
|
56
|
+
# Here, we convert None to str(""), in normalize_types we convert it back.
|
|
57
|
+
annotation = wanted_type
|
|
58
|
+
val = ""
|
|
59
|
+
else:
|
|
60
|
+
# An unknown type annotation encountered-
|
|
61
|
+
# Since tkinter_form does not handle None yet, this will display as checkbox.
|
|
62
|
+
# Which is not probably wanted.
|
|
63
|
+
val = False
|
|
64
|
+
logger.warn(f"Annotation {wanted_type} of `{param}` not supported by Mininterface."
|
|
65
|
+
"None converted to False.")
|
|
66
|
+
if hasattr(val, "__dict__"): # nested config hierarchy
|
|
67
|
+
params[param] = dataclass_to_dict(val, descr, _path=f"{_path}{param}.")
|
|
68
|
+
elif not _path: # scalar value in root
|
|
69
|
+
params[main][param] = Value(val, descr.get(param), annotation)
|
|
70
|
+
else: # scalar value in nested
|
|
71
|
+
params[param] = Value(val, descr.get(f"{_path}{param}"), annotation)
|
|
72
|
+
return params
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_types(origin: FormDict, data: dict) -> dict:
|
|
76
|
+
""" Run validators of all Value objects. If fails, outputs info.
|
|
77
|
+
Return corrected data. (Ex: Some values might be nulled from "".)
|
|
78
|
+
"""
|
|
79
|
+
for (group, params), params2 in zip(data.items(), origin.values()):
|
|
80
|
+
for (key, val), pattern in zip(params.items(), params2.values()):
|
|
81
|
+
if isinstance(pattern, Value) and pattern.annotation:
|
|
82
|
+
if val == "" and type(None) in get_args(pattern.annotation):
|
|
83
|
+
# The user is not able to set the value to None, they left it empty.
|
|
84
|
+
# Cast back to None as None is one of the allowed types.
|
|
85
|
+
# Ex: `severity: int | None = None`
|
|
86
|
+
data[group][key] = val = None
|
|
87
|
+
elif pattern.annotation == Optional[int]:
|
|
88
|
+
try:
|
|
89
|
+
data[group][key] = val = int(val)
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
if not isinstance(val, pattern.annotation):
|
|
94
|
+
pattern.set_error_text(f"Type must be `{pattern.annotation}`!")
|
|
95
|
+
return False
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def dict_to_dataclass(args: ConfigInstance, data: dict):
|
|
100
|
+
""" Convert the dict of dicts from the GUI back into the object holding the configuration. """
|
|
101
|
+
for group, params in data.items():
|
|
102
|
+
for key, val in params.items():
|
|
103
|
+
if group:
|
|
104
|
+
setattr(getattr(args, group), key, val)
|
|
105
|
+
else:
|
|
106
|
+
setattr(args, key, val)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_terminal_size():
|
|
110
|
+
try:
|
|
111
|
+
# XX when piping the input IN, it writes
|
|
112
|
+
# echo "434" | convey -f base64 --debug
|
|
113
|
+
# stty: 'standard input': Inappropriate ioctl for device
|
|
114
|
+
# I do not know how to suppress this warning.
|
|
115
|
+
height, width = (int(s) for s in os.popen('stty size', 'r').read().split())
|
|
116
|
+
return height, width
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return 0, 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_args_allow_missing(config: ConfigClass, kwargs: dict, parser: ArgumentParser):
|
|
122
|
+
""" Fetch missing required options in GUI. """
|
|
123
|
+
# On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting
|
|
124
|
+
# the error message function. Then, we reconstruct the missing options.
|
|
125
|
+
# NOTE But we should rather invoke a GUI with the missing options only.
|
|
126
|
+
original_error = TyroArgumentParser.error
|
|
127
|
+
eavesdrop = ""
|
|
128
|
+
|
|
129
|
+
def custom_error(self, message: str):
|
|
130
|
+
nonlocal eavesdrop
|
|
131
|
+
if not message.startswith("the following arguments are required:"):
|
|
132
|
+
return original_error(self, message)
|
|
133
|
+
eavesdrop = message
|
|
134
|
+
raise SystemExit(2) # will be catched
|
|
135
|
+
try:
|
|
136
|
+
with patch.object(TyroArgumentParser, 'error', custom_error):
|
|
137
|
+
return cli(config, **kwargs)
|
|
138
|
+
except BaseException as e:
|
|
139
|
+
if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which.
|
|
140
|
+
for arg in eavesdrop.partition(":")[2].strip().split(", "):
|
|
141
|
+
argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
|
|
142
|
+
argument.default = "HALO"
|
|
143
|
+
if "." in argument.dest: # missing nested required argument handler not implemented, we make tyro fail in CLI
|
|
144
|
+
pass
|
|
145
|
+
else:
|
|
146
|
+
match argument.metavar:
|
|
147
|
+
case "INT":
|
|
148
|
+
setattr(kwargs["default"], argument.dest, 0)
|
|
149
|
+
case "STR":
|
|
150
|
+
setattr(kwargs["default"], argument.dest, "")
|
|
151
|
+
case _:
|
|
152
|
+
pass # missing handler not implemented, we make tyro fail in CLI
|
|
153
|
+
return cli(config, **kwargs) # second attempt
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_descriptions(parser: ArgumentParser) -> dict:
|
|
158
|
+
""" Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
|
|
159
|
+
return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help)
|
|
160
|
+
for action in parser._actions}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RedirectText:
|
|
164
|
+
""" Helps to redirect text from stdout to a text widget. """
|
|
165
|
+
|
|
166
|
+
def __init__(self, widget: Text, pending_buffer: list, window: Tk) -> None:
|
|
167
|
+
self.widget = widget
|
|
168
|
+
self.max_lines = 1000
|
|
169
|
+
self.pending_buffer = pending_buffer
|
|
170
|
+
self.window = window
|
|
171
|
+
|
|
172
|
+
def write(self, text):
|
|
173
|
+
self.widget.pack(expand=True, fill='both')
|
|
174
|
+
self.widget.insert(END, text)
|
|
175
|
+
self.widget.see(END) # scroll to the end
|
|
176
|
+
self.trim()
|
|
177
|
+
self.window.update_idletasks()
|
|
178
|
+
self.pending_buffer.append(text)
|
|
179
|
+
|
|
180
|
+
def flush(self):
|
|
181
|
+
pass # required by sys.stdout
|
|
182
|
+
|
|
183
|
+
def trim(self):
|
|
184
|
+
lines = int(self.widget.index('end-1c').split('.')[0])
|
|
185
|
+
if lines > self.max_lines:
|
|
186
|
+
self.widget.delete(1.0, f"{lines - self.max_lines}.0")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def recursive_set_focus(widget: Widget):
|
|
190
|
+
for child in widget.winfo_children():
|
|
191
|
+
if isinstance(child, (Entry, Checkbutton, Combobox)):
|
|
192
|
+
child.focus_set()
|
|
193
|
+
return True
|
|
194
|
+
if recursive_set_focus(child):
|
|
195
|
+
return True
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=1.0.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "mininterface"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "A minimal access to GUI, TUI, CLI and config"
|
|
9
|
+
authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
|
|
10
|
+
license = "GPL-3.0-or-later"
|
|
11
|
+
homepage = "https://github.com/CZ-NIC/mininterface"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = "^3.10"
|
|
16
|
+
tyro = "*"
|
|
17
|
+
pyyaml = "*"
|
|
18
|
+
envelope = "*"
|
|
19
|
+
requests = "*"
|
|
20
|
+
tkinter-tooltip = "*"
|
|
21
|
+
tkinter_form = "*"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.scripts]
|
|
24
|
+
mininterface = "mininterface.__main__:main"
|