mininterface 0.4.2__tar.gz → 0.4.4rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mininterface
3
- Version: 0.4.2
3
+ Version: 0.4.4rc1
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
@@ -17,7 +17,7 @@ Requires-Dist: pyyaml
17
17
  Requires-Dist: requests
18
18
  Requires-Dist: textual
19
19
  Requires-Dist: tkinter-tooltip
20
- Requires-Dist: tkinter_form
20
+ Requires-Dist: tkinter_form (==0.1.5.2)
21
21
  Requires-Dist: tyro
22
22
  Description-Content-Type: text/markdown
23
23
 
@@ -27,9 +27,10 @@ Description-Content-Type: text/markdown
27
27
 
28
28
  Write the program core, do not bother with the input/output.
29
29
 
30
- ![hello world example](asset/hello-world.png "A minimal use case")
30
+ ![Hello world example: GUI window](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-world.png?raw=True "A minimal use case – GUI")
31
+ ![Hello world example: TUI fallback](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-tui.webp?raw=True "A minimal use case – TUI fallback")
31
32
 
32
- Check out the code that displays such window, just the code you need. No lengthy blocks of code imposed by an external dependency.
33
+ Check out the code, which is surprisingly short, that displays such a window or its textual fallback.
33
34
 
34
35
  ```python
35
36
  from dataclasses import dataclass
@@ -38,34 +39,50 @@ from mininterface import run
38
39
  @dataclass
39
40
  class Config:
40
41
  """Set of options."""
41
- test: bool = False
42
- """My testing flag"""
43
- important_number: int = 4
44
- """This number is very important"""
42
+ test: bool = False # My testing flag
43
+ important_number: int = 4 # This number is very important
45
44
 
46
45
  if __name__ == "__main__":
47
- args: Config = run(Config, prog="My application").get_args()
48
- print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
46
+ args = run(Config, prog="My application").get_args()
47
+ print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
49
48
  ```
50
49
 
51
- Or bound the interface to a `with` statement that redirects stdout directly to the window.
50
+ ## You got CLI
51
+ It was 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.
52
52
 
53
- ```python
54
- with run(Config) as m:
55
- print(f"Your important number is {m}")
56
- boolean = m.is_yes("Is that alright?")
57
- ```
53
+ ```bash
54
+ $ ./hello.py
55
+ usage: My application [-h] [--test | --no-test] [--important-number INT]
56
+
57
+ Set of options.
58
58
 
59
- TODO img
59
+ ╭─ options ──────────────────────────────────────────────────────────╮
60
+ │ -h, --help show this help message and exit │
61
+ │ --test, --no-test My testing flag (default: False) │
62
+ │ --important-number INT This number is very important (default: 4) │
63
+ ╰────────────────────────────────────────────────────────────────────╯
64
+ ```
60
65
 
61
- Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml`. Instantly loaded.
66
+ ## You got config file management
67
+ Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml` and put there some of the arguments. They are seamlessly taken as defaults.
62
68
 
63
69
  ```yaml
64
70
  important_number: 555
65
71
  ```
66
72
 
67
- TODO img
73
+ ## You got dialogues
74
+ Check out several useful methods to handle user dialogues. Here we bound the interface to a `with` statement that redirects stdout directly to the window.
68
75
 
76
+ ```python
77
+ with run(Config) as m:
78
+ print(f"Your important number is {m}")
79
+ boolean = m.is_yes("Is that alright?")
80
+ ```
81
+
82
+ ![Small window with the text 'Your important number'](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-with-statement.webp?raw=True "With statement to redirect the output")
83
+ ![The same in terminal'](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-with-statement-tui.webp?raw=True "With statement in TUI fallback")
84
+
85
+ # Contents
69
86
  - [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
70
87
  - [Background](#background)
71
88
  - [Installation](#installation)
@@ -73,21 +90,18 @@ TODO img
73
90
  * [`mininterface`](#mininterface)
74
91
  + [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
75
92
  * [Interfaces](#interfaces)
76
- + [`Mininterface(title: str = '')`](#Mininterface-title-str--)
77
- + [`alert(self, text: str)`](#alert-self-text-str)
78
- + [`ask(self, text: str) -> str`](#ask-self-text-str-str)
79
- + [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
80
- + [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
81
- + [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
82
- + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
83
- + [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
84
- + [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
85
- + [`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)
93
+ + [`Mininterface(title: str = '')`](#mininterfacetitle-str--)
94
+ + [`alert(text: str)`](#alerttext-str)
95
+ + [`ask(text: str) -> str`](#asktext-str---str)
96
+ + [`ask_args() -> ConfigInstance`](#ask_args--configinstance)
97
+ + [`ask_number(text: str) -> int`](#ask_numbertext-str---int)
98
+ + [`form(args: FormDict, title="") -> int`](#formargs-formdict-title---dict)
99
+ + [`get_args(ask_on_empty_cli=True) -> ~ConfigInstance`](#get_argsask_on_empty_clitrue---configinstance)
100
+ + [`is_no(text: str) -> bool`](#is_notext-str---bool)
101
+ + [`is_yes(text: str) -> bool`](#is_yestext-str---bool)
102
+ + [`parse_args(config: Type[ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ConfigInstance`](#parse_argsconfig-type-configinstance-config_file-pathlibpath--none--none-kwargs---configinstance)
86
103
  * [Standalone](#standalone)
87
104
 
88
- <small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>
89
-
90
-
91
105
  # Background
92
106
 
93
107
  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.
@@ -147,7 +161,7 @@ $./program.py --further.host example.net
147
161
  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.
148
162
  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`.
149
163
 
150
- * `config:ConfigClass`: Dataclass with the configuration.
164
+ * `config:Type[ConfigInstance]`: Dataclass with the configuration.
151
165
  * `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the REPL.
152
166
  * `**kwargs`: The same as for [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html).
153
167
  * Returns: `interface` Interface used.
@@ -163,6 +177,8 @@ Several interfaces exist:
163
177
  * `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
164
178
  * `GuiInterface` – A tkinter window.
165
179
  * `TuiInterface` – An interactive terminal.
180
+ * `TextualInterface` – If [textual](https://github.com/Textualize/textual) installed, rich interface is used.
181
+ * `TextInterface` – Plain text only interface with no dependency as a fallback.
166
182
  * `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
167
183
 
168
184
  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.
@@ -174,27 +190,27 @@ with TuiInterface("My program") as m:
174
190
 
175
191
  ### `Mininterface(title: str = '')`
176
192
  Initialize.
177
- ### `alert(self, text: str)`
193
+ ### `alert(text: str)`
178
194
  Prompt the user to confirm the text.
179
- ### `ask(self, text: str) -> str`
195
+ ### `ask(text: str) -> str`
180
196
  Prompt the user to input a text.
181
- ### `ask_args(self) -> ~ConfigInstance`
197
+ ### `ask_args() -> ConfigInstance`
182
198
  Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.)
183
- ### `ask_form(self, args: FormDict, title="") -> dict`
199
+ ### `form(args: FormDict, title="") -> dict`
184
200
  Prompt the user to fill up whole form.
185
201
  * `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
186
202
  The dict can be nested, it can contain a subgroup.
187
203
  The default value might be `mininterface.FormField` that allows you to add descriptions.
188
204
  A checkbox example: `{"my label": FormField(True, "my description")}`
189
205
  * `title`: Optional form title.
190
- ### `ask_number(self, text: str) -> int`
206
+ ### `ask_number(text: str) -> int`
191
207
  Prompt the user to input a number. Empty input = 0.
192
- ### `get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`
208
+ ### `get_args(ask_on_empty_cli=True) -> ConfigInstance`
193
209
  Returns whole configuration (previously fetched from CLI and config file by parse_args).
194
210
  If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields.
195
- ### `is_no(self, text: str) -> bool`
211
+ ### `is_no(text: str) -> bool`
196
212
  Display confirm box, focusing no.
197
- ### `is_yes(self, text: str) -> bool`
213
+ ### `is_yes(text: str) -> bool`
198
214
  Display confirm box, focusing yes.
199
215
 
200
216
  ```python
@@ -202,7 +218,7 @@ m = run(prog="My program")
202
218
  print(m.ask_yes("Is it true?")) # True/False
203
219
  ```
204
220
 
205
- ### `parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
221
+ ### `parse_args(config: Type[ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
206
222
  Parse CLI arguments, possibly merged from a config file.
207
223
  * `config`: Dataclass with the configuration.
208
224
  * `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.
@@ -4,9 +4,10 @@
4
4
 
5
5
  Write the program core, do not bother with the input/output.
6
6
 
7
- ![hello world example](asset/hello-world.png "A minimal use case")
7
+ ![Hello world example: GUI window](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-world.png?raw=True "A minimal use case – GUI")
8
+ ![Hello world example: TUI fallback](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-tui.webp?raw=True "A minimal use case – TUI fallback")
8
9
 
9
- Check out the code that displays such window, just the code you need. No lengthy blocks of code imposed by an external dependency.
10
+ Check out the code, which is surprisingly short, that displays such a window or its textual fallback.
10
11
 
11
12
  ```python
12
13
  from dataclasses import dataclass
@@ -15,34 +16,50 @@ from mininterface import run
15
16
  @dataclass
16
17
  class Config:
17
18
  """Set of options."""
18
- test: bool = False
19
- """My testing flag"""
20
- important_number: int = 4
21
- """This number is very important"""
19
+ test: bool = False # My testing flag
20
+ important_number: int = 4 # This number is very important
22
21
 
23
22
  if __name__ == "__main__":
24
- args: Config = run(Config, prog="My application").get_args()
25
- print(args.important_number) # suggested by the IDE with the hint text "This number is very important"
23
+ args = 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"
26
25
  ```
27
26
 
28
- Or bound the interface to a `with` statement that redirects stdout directly to the window.
27
+ ## You got CLI
28
+ It was 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.
29
29
 
30
- ```python
31
- with run(Config) as m:
32
- print(f"Your important number is {m}")
33
- boolean = m.is_yes("Is that alright?")
34
- ```
30
+ ```bash
31
+ $ ./hello.py
32
+ usage: My application [-h] [--test | --no-test] [--important-number INT]
33
+
34
+ Set of options.
35
35
 
36
- TODO img
36
+ ╭─ options ──────────────────────────────────────────────────────────╮
37
+ │ -h, --help show this help message and exit │
38
+ │ --test, --no-test My testing flag (default: False) │
39
+ │ --important-number INT This number is very important (default: 4) │
40
+ ╰────────────────────────────────────────────────────────────────────╯
41
+ ```
37
42
 
38
- Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml`. Instantly loaded.
43
+ ## You got config file management
44
+ Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml` and put there some of the arguments. They are seamlessly taken as defaults.
39
45
 
40
46
  ```yaml
41
47
  important_number: 555
42
48
  ```
43
49
 
44
- TODO img
50
+ ## You got dialogues
51
+ Check out several useful methods to handle user dialogues. Here we bound the interface to a `with` statement that redirects stdout directly to the window.
45
52
 
53
+ ```python
54
+ with run(Config) as m:
55
+ print(f"Your important number is {m}")
56
+ boolean = m.is_yes("Is that alright?")
57
+ ```
58
+
59
+ ![Small window with the text 'Your important number'](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-with-statement.webp?raw=True "With statement to redirect the output")
60
+ ![The same in terminal'](https://github.com/CZ-NIC/mininterface/blob/main/](asset/hello-with-statement-tui.webp?raw=True "With statement in TUI fallback")
61
+
62
+ # Contents
46
63
  - [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
47
64
  - [Background](#background)
48
65
  - [Installation](#installation)
@@ -50,21 +67,18 @@ TODO img
50
67
  * [`mininterface`](#mininterface)
51
68
  + [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
52
69
  * [Interfaces](#interfaces)
53
- + [`Mininterface(title: str = '')`](#Mininterface-title-str--)
54
- + [`alert(self, text: str)`](#alert-self-text-str)
55
- + [`ask(self, text: str) -> str`](#ask-self-text-str-str)
56
- + [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
57
- + [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
58
- + [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
59
- + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
60
- + [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
61
- + [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
62
- + [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`](#parse-args-self-config-callable-configinstance-config-file-pathlibpath-none-none-kwargs-configinstance)
70
+ + [`Mininterface(title: str = '')`](#mininterfacetitle-str--)
71
+ + [`alert(text: str)`](#alerttext-str)
72
+ + [`ask(text: str) -> str`](#asktext-str---str)
73
+ + [`ask_args() -> ConfigInstance`](#ask_args--configinstance)
74
+ + [`ask_number(text: str) -> int`](#ask_numbertext-str---int)
75
+ + [`form(args: FormDict, title="") -> int`](#formargs-formdict-title---dict)
76
+ + [`get_args(ask_on_empty_cli=True) -> ~ConfigInstance`](#get_argsask_on_empty_clitrue---configinstance)
77
+ + [`is_no(text: str) -> bool`](#is_notext-str---bool)
78
+ + [`is_yes(text: str) -> bool`](#is_yestext-str---bool)
79
+ + [`parse_args(config: Type[ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ConfigInstance`](#parse_argsconfig-type-configinstance-config_file-pathlibpath--none--none-kwargs---configinstance)
63
80
  * [Standalone](#standalone)
64
81
 
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
82
  # Background
69
83
 
70
84
  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.
@@ -124,7 +138,7 @@ $./program.py --further.host example.net
124
138
  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.
125
139
  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`.
126
140
 
127
- * `config:ConfigClass`: Dataclass with the configuration.
141
+ * `config:Type[ConfigInstance]`: Dataclass with the configuration.
128
142
  * `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the REPL.
129
143
  * `**kwargs`: The same as for [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html).
130
144
  * Returns: `interface` Interface used.
@@ -140,6 +154,8 @@ Several interfaces exist:
140
154
  * `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
141
155
  * `GuiInterface` – A tkinter window.
142
156
  * `TuiInterface` – An interactive terminal.
157
+ * `TextualInterface` – If [textual](https://github.com/Textualize/textual) installed, rich interface is used.
158
+ * `TextInterface` – Plain text only interface with no dependency as a fallback.
143
159
  * `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
144
160
 
145
161
  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.
@@ -151,27 +167,27 @@ with TuiInterface("My program") as m:
151
167
 
152
168
  ### `Mininterface(title: str = '')`
153
169
  Initialize.
154
- ### `alert(self, text: str)`
170
+ ### `alert(text: str)`
155
171
  Prompt the user to confirm the text.
156
- ### `ask(self, text: str) -> str`
172
+ ### `ask(text: str) -> str`
157
173
  Prompt the user to input a text.
158
- ### `ask_args(self) -> ~ConfigInstance`
174
+ ### `ask_args() -> ConfigInstance`
159
175
  Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.)
160
- ### `ask_form(self, args: FormDict, title="") -> dict`
176
+ ### `form(args: FormDict, title="") -> dict`
161
177
  Prompt the user to fill up whole form.
162
178
  * `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
163
179
  The dict can be nested, it can contain a subgroup.
164
180
  The default value might be `mininterface.FormField` that allows you to add descriptions.
165
181
  A checkbox example: `{"my label": FormField(True, "my description")}`
166
182
  * `title`: Optional form title.
167
- ### `ask_number(self, text: str) -> int`
183
+ ### `ask_number(text: str) -> int`
168
184
  Prompt the user to input a number. Empty input = 0.
169
- ### `get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`
185
+ ### `get_args(ask_on_empty_cli=True) -> ConfigInstance`
170
186
  Returns whole configuration (previously fetched from CLI and config file by parse_args).
171
187
  If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields.
172
- ### `is_no(self, text: str) -> bool`
188
+ ### `is_no(text: str) -> bool`
173
189
  Display confirm box, focusing no.
174
- ### `is_yes(self, text: str) -> bool`
190
+ ### `is_yes(text: str) -> bool`
175
191
  Display confirm box, focusing yes.
176
192
 
177
193
  ```python
@@ -179,7 +195,7 @@ m = run(prog="My program")
179
195
  print(m.ask_yes("Is it true?")) # True/False
180
196
  ```
181
197
 
182
- ### `parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
198
+ ### `parse_args(config: Type[ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
183
199
  Parse CLI arguments, possibly merged from a config file.
184
200
  * `config`: Dataclass with the configuration.
185
201
  * `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.
@@ -0,0 +1,120 @@
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, Type, 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 = Type[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: Type[ConfigInstance], kwargs: dict, parser: ArgumentParser) -> ConfigInstance:
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
+
89
+ # Set args to determine whether to use sys.argv.
90
+ # Why settings args? Prevent tyro using sys.argv if we are in an interactive shell like Jupyter,
91
+ # as sys.argv is non-related there.
92
+ try:
93
+ # Note wherease `"get_ipython" in globals()` returns True in Jupyter, it is still False
94
+ # in a script a Jupyter cell runs. Hence we must put here this lengthty statement.
95
+ global get_ipython
96
+ get_ipython()
97
+ except:
98
+ args = None
99
+ else:
100
+ args = []
101
+ try:
102
+ with patch.object(TyroArgumentParser, 'error', custom_error):
103
+ return cli(config, args=args, **kwargs)
104
+ except BaseException as e:
105
+ if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which.
106
+ for arg in eavesdrop.partition(":")[2].strip().split(", "):
107
+ argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
108
+ argument.default = "DEFAULT" # NOTE I do not know whether used
109
+ if "." in argument.dest: # missing nested required argument handler not implemented, we make tyro fail in CLI
110
+ pass
111
+ else:
112
+ match argument.metavar:
113
+ case "INT":
114
+ setattr(kwargs["default"], argument.dest, 0)
115
+ case "STR":
116
+ setattr(kwargs["default"], argument.dest, "")
117
+ case _:
118
+ pass # missing handler not implemented, we make tyro fail in CLI
119
+ return cli(config, **kwargs) # second attempt
120
+ raise
@@ -0,0 +1,133 @@
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
+ # TODO put into GuiInterface create_ui(ff: FormField)
13
+ @dataclass
14
+ class Value:
15
+ """ This class helps to enrich the field with a description. """
16
+ val: Any
17
+ description: str
18
+
19
+
20
+ FFValue = TypeVar("FFValue")
21
+ TD = TypeVar("TD")
22
+ """ dict """
23
+ TK = TypeVar("TK")
24
+ """ dict key """
25
+
26
+
27
+ @dataclass
28
+ class FormField(Value):
29
+ """ Bridge between the input values and a UI widget.
30
+ Helps to creates a widget from the input value (includes description etc.),
31
+ then transforms the value back (str to int conversion etc).
32
+
33
+ (Ex: Merge the dict of dicts from the GUI back into the object holding the configuration.)
34
+ """
35
+
36
+ annotation: Any | None = None
37
+ """ Used for validation. To convert an empty '' to None. """
38
+ name: str | None = None # NOTE: Only TextualInterface uses this by now.
39
+
40
+ src: tuple[TD, TK] | None = None
41
+ """ The original dict to be updated when UI ends. """
42
+ src2: tuple[TD, TK] | None = None
43
+ """ The original object to be updated when UI ends.
44
+ NOTE should be merged to `src`
45
+ """
46
+
47
+ def __post_init__(self):
48
+ self._original_desc = self.description
49
+
50
+ def set_error_text(self, s):
51
+ self.description = f"{s} {self._original_desc}"
52
+
53
+ def update(self, ui_value):
54
+ """ UI value → FormField value → original value. (With type conversion and checks.)
55
+
56
+ The value has been updated in a UI.
57
+ Update accordingly the value in the original linked dict
58
+ the mininterface was invoked with.
59
+
60
+ Validates the type and do the transformation.
61
+ (Ex: Some values might be nulled from "".)
62
+ """
63
+ fixed_value = ui_value
64
+ if self.annotation:
65
+ if ui_value == "" and type(None) in get_args(self.annotation):
66
+ # The user is not able to set the value to None, they left it empty.
67
+ # Cast back to None as None is one of the allowed types.
68
+ # Ex: `severity: int | None = None`
69
+ fixed_value = None
70
+ elif self.annotation == Optional[int]:
71
+ try:
72
+ fixed_value = int(ui_value)
73
+ except ValueError:
74
+ pass
75
+
76
+ if not isinstance(fixed_value, self.annotation):
77
+ self.set_error_text(f"Type must be `{self.annotation}`!")
78
+ return False # revision needed
79
+
80
+ # keep values if revision needed
81
+ # We merge new data to the origin. If form is re-submitted, the values will stay there.
82
+ self.val = ui_value
83
+
84
+ # Store to the source user data
85
+ if self.src:
86
+ d, k = self.src
87
+ d[k] = fixed_value
88
+ elif self.src2:
89
+ d, k = self.src2
90
+ setattr(d, k, fixed_value)
91
+ else:
92
+ # This might be user-created object. The user reads directly from this. There is no need to update anything.
93
+ pass
94
+ return True
95
+ # Fixing types:
96
+ # This code would support tuple[int, int]:
97
+ #
98
+ # self.types = get_args(self.annotation) \
99
+ # if isinstance(self.annotation, UnionType) else (self.annotation, )
100
+ # "All possible types in a tuple. Ex 'int | str' -> (int, str)"
101
+ #
102
+ #
103
+ # def convert(self):
104
+ # """ Convert the self.value to the given self.type.
105
+ # The value might be in str due to CLI or TUI whereas the programs wants bool.
106
+ # """
107
+ # # if self.value == "True":
108
+ # # return True
109
+ # # if self.value == "False":
110
+ # # return False
111
+ # if type(self.val) is str and str not in self.types:
112
+ # try:
113
+ # return literal_eval(self.val) # ex: int, tuple[int, int]
114
+ # except:
115
+ # raise ValueError(f"{self.name}: Cannot convert value {self.val}")
116
+ # return self.val
117
+
118
+ @staticmethod
119
+ def submit_values(updater: Iterable[tuple["FormField", FFValue]]) -> bool:
120
+ """ Returns whether the form is alright or whether we should revise it.
121
+ Input is tuple of the FormFields and their new values from the UI.
122
+ """
123
+ # Why list? We need all the FormField values be updates from the UI.
124
+ # If the revision is needed, the UI fetches the values from the FormField.
125
+ # We need the keep the values so that the user does not have to re-write them.
126
+ return all(list(ff.update(ui_value) for ff, ui_value in updater))
127
+
128
+ @staticmethod
129
+ def submit(fd: "FormDict", ui: dict):
130
+ """ Returns whether the form is alright or whether we should revise it.
131
+ Input is the FormDict and the UI dict in the very same form.
132
+ """
133
+ return FormField.submit_values(zip(flatten(fd), flatten(ui)))