mininterface 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,169 @@
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
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, form: 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=form,
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(form) > 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.form.get())
122
+
123
+ def yes_no(self, text: str, focus_no=True):
124
+ return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)
125
+
126
+ def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 1):
127
+ label = Label(self.frame, text=text)
128
+ label.pack(pady=10)
129
+
130
+ for text, value in buttons:
131
+ button = Button(self.frame, text=text, command=lambda v=value: self._ok(v))
132
+ button.bind("<Return>", lambda _: button.invoke())
133
+ button.pack(side=LEFT, padx=10)
134
+ self.frame.winfo_children()[focused].focus_set()
135
+ return self.mainloop()
136
+
137
+ def _bind_event(self, event, handler):
138
+ self._event_bindings[event] = handler
139
+ self.bind(event, handler)
140
+
141
+ def mainloop(self, callback: Callable = None):
142
+ self.frame.pack(pady=5)
143
+ self.deiconify() # show if hidden
144
+ self.pending_buffer.clear()
145
+ super().mainloop()
146
+ if not self.interface._always_shown:
147
+ self.withdraw() # hide
148
+
149
+ if self._result is Cancelled:
150
+ raise Cancelled
151
+ if callback:
152
+ return callback()
153
+ return self._result
154
+
155
+ def _ok(self, val=None):
156
+ # self.destroy()
157
+ self.quit()
158
+ # self.withdraw()
159
+ self._clear_dialog()
160
+ self._result = val
161
+
162
+ def _clear_dialog(self):
163
+ self.frame.pack_forget()
164
+ for widget in self.frame.winfo_children():
165
+ widget.destroy()
166
+ for key in self._event_bindings:
167
+ self.unbind(key)
168
+ self._event_bindings.clear()
169
+ 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,74 @@
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
+ print("Access `v` (as var) and change values. Then (c)ontinue.")
28
+ pprint(self.args)
29
+ v = self.args
30
+ try:
31
+ import ipdb; ipdb.set_trace()
32
+ except ImportError:
33
+ import pdb; pdb.set_trace()
34
+ print("*Continuing*")
35
+ print(self.args)
36
+ return self.args
37
+
38
+ def ask_form(self, args: FormDict) -> dict:
39
+ raise NotImplementedError
40
+
41
+ def ask_number(self, text):
42
+ """
43
+ Let user write number. Empty input = 0.
44
+ """
45
+ while True:
46
+ try:
47
+ t = self.ask(text=text)
48
+ if not t:
49
+ return 0
50
+ return int(t)
51
+ except ValueError:
52
+ print("This is not a number")
53
+
54
+ def is_yes(self, text: str):
55
+ return self.ask(text=text + " [y]/n").lower() in ("y", "yes", "")
56
+
57
+ def is_no(self, text):
58
+ return self.ask(text=text + " y/[n]").lower() in ("n", "no", "")
59
+
60
+
61
+ class ReplInterface(TuiInterface):
62
+ """ Same as the base TuiInterface, except it starts the REPL. """
63
+
64
+ def __getattr__(self, name):
65
+ """ Run _Mininterface method if exists and starts a REPL. """
66
+ attr = getattr(super(), name, None)
67
+ if callable(attr):
68
+ def wrapper(*args, **kwargs):
69
+ result = attr(*args, **kwargs)
70
+ breakpoint()
71
+ return result
72
+ return wrapper
73
+ else:
74
+ 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,141 @@
1
+ import os
2
+ import re
3
+ from argparse import Action, ArgumentParser
4
+ from typing import Any, Callable, TypeVar, Union
5
+ from unittest.mock import patch
6
+ try:
7
+ # NOTE this shuold 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 Combobox, Checkbutton
10
+ except ImportError:
11
+ tkinter = None
12
+ END, Entry, Text, Tk, Widget = (None,)*5
13
+
14
+ from tyro import cli
15
+ from tyro._argparse_formatter import TyroArgumentParser
16
+ try:
17
+ from tkinter_form import Value
18
+ except ImportError:
19
+ Value = None
20
+
21
+ ConfigInstance = TypeVar("ConfigInstance")
22
+ ConfigClass = Callable[..., ConfigInstance]
23
+ FormDict = dict[str, Union[Value, Any, 'FormDict']]
24
+ """ Nested form that can have descriptions (through Value) instead of plain values. """
25
+
26
+
27
+ def dataclass_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
28
+ """ Convert the dataclass produced by tyro into dict of dicts. """
29
+ main = ""
30
+ params = {main: {}} if not _path else {}
31
+ for param, val in vars(args).items():
32
+ if val is None:
33
+ # TODO tkinter_form does not handle None yet.
34
+ # This would fail: `severity: int | None = None`
35
+ # We need it to be able to write a number and if empty, return None.
36
+ val = False
37
+ if hasattr(val, "__dict__"): # nested config hierarchy
38
+ params[param] = dataclass_to_dict(val, descr, _path=f"{_path}{param}.")
39
+ elif not _path: # scalar value in root
40
+ params[main][param] = Value(val, descr.get(param))
41
+ else: # scalar value in nested
42
+ params[param] = Value(val, descr.get(f"{_path}{param}"))
43
+ return params
44
+
45
+
46
+ def dict_to_dataclass(args: ConfigInstance, data: dict):
47
+ """ Convert the dict of dicts from the GUI back into the object holding the configuration. """
48
+ for group, params in data.items():
49
+ for key, val in params.items():
50
+ if group:
51
+ setattr(getattr(args, group), key, val)
52
+ else:
53
+ setattr(args, key, val)
54
+
55
+
56
+ def get_terminal_size():
57
+ try:
58
+ # XX when piping the input IN, it writes
59
+ # echo "434" | convey -f base64 --debug
60
+ # stty: 'standard input': Inappropriate ioctl for device
61
+ # I do not know how to suppress this warning.
62
+ height, width = (int(s) for s in os.popen('stty size', 'r').read().split())
63
+ return height, width
64
+ except (OSError, ValueError):
65
+ return 0, 0
66
+
67
+
68
+ def get_args_allow_missing(config: ConfigClass, kwargs: dict, parser: ArgumentParser):
69
+ """ Fetch missing required options in GUI. """
70
+ # On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting
71
+ # the error message function. Then, we reconstruct the missing options.
72
+ # NOTE But we should rather invoke a GUI with the missing options only.
73
+ original_error = TyroArgumentParser.error
74
+ eavesdrop = ""
75
+
76
+ def custom_error(self, message: str):
77
+ nonlocal eavesdrop
78
+ if not message.startswith("the following arguments are required:"):
79
+ return original_error(self, message)
80
+ eavesdrop = message
81
+ raise SystemExit(2) # will be catched
82
+ try:
83
+ with patch.object(TyroArgumentParser, 'error', custom_error):
84
+ return cli(config, **kwargs)
85
+ except BaseException as e:
86
+ if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which.
87
+ for arg in eavesdrop.partition(":")[2].strip().split(", "):
88
+ argument: Action = next(iter(p for p in parser._actions if arg in p.option_strings))
89
+ argument.default = "HALO"
90
+ if "." in argument.dest: # missing nested required argument handler not implemented, we make tyro fail in CLI
91
+ pass
92
+ else:
93
+ match argument.metavar:
94
+ case "INT":
95
+ setattr(kwargs["default"], argument.dest, 0)
96
+ case "STR":
97
+ setattr(kwargs["default"], argument.dest, "")
98
+ case _:
99
+ pass # missing handler not implemented, we make tyro fail in CLI
100
+ return cli(config, **kwargs) # second attempt
101
+ raise
102
+
103
+
104
+ def get_descriptions(parser: ArgumentParser) -> dict:
105
+ """ Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
106
+ return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help)
107
+ for action in parser._actions}
108
+
109
+
110
+ class RedirectText:
111
+ """ Helps to redirect text from stdout to a text widget. """
112
+
113
+ def __init__(self, widget: Text, pending_buffer: list, window: Tk) -> None:
114
+ self.widget = widget
115
+ self.max_lines = 1000
116
+ self.pending_buffer = pending_buffer
117
+ self.window = window
118
+
119
+ def write(self, text):
120
+ self.widget.pack(expand=True, fill='both')
121
+ self.widget.insert(END, text)
122
+ self.widget.see(END) # scroll to the end
123
+ self.trim()
124
+ self.window.update_idletasks()
125
+ self.pending_buffer.append(text)
126
+
127
+ def flush(self):
128
+ pass # required by sys.stdout
129
+
130
+ def trim(self):
131
+ lines = int(self.widget.index('end-1c').split('.')[0])
132
+ if lines > self.max_lines:
133
+ self.widget.delete(1.0, f"{lines - self.max_lines}.0")
134
+
135
+ def recursive_set_focus(widget: Widget):
136
+ for child in widget.winfo_children():
137
+ if isinstance(child, (Entry, Checkbutton, Combobox)):
138
+ child.focus_set()
139
+ return True
140
+ if recursive_set_focus(child):
141
+ return True
mininterface/common.py ADDED
@@ -0,0 +1,2 @@
1
+ class InterfaceNotAvailable(ImportError):
2
+ pass
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.1
2
+ Name: mininterface
3
+ Version: 0.1.0
4
+ Summary: A minimal access to GUI, TUI, CLI and config
5
+ Home-page: https://github.com/e3rd/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
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
25
+
26
+ Write the program core, do not bother with the input/output.
27
+
28
+ ![hello world example](asset/hello-world.png "A minimal use case")
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
+ - [Docs](#docs)
70
+ * [`mininterface`](#mininterface)
71
+ + [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
72
+ * [Interfaces](#interfaces)
73
+ + [`Mininterface(title: str = '')`](#Mininterface-title-str--)
74
+ + [`alert(self, text: str)`](#alert-self-text-str)
75
+ + [`ask(self, text: str) -> str`](#ask-self-text-str-str)
76
+ + [`ask_args(self) -> ~ConfigInstance`](#ask-args-self-configinstance)
77
+ + [`ask_form(self, args: FormDict, title="") -> int`](#ask-form-self-args-FormDict-title)
78
+ + [`ask_number(self, text: str) -> int`](#ask-number-self-text-str-int)
79
+ + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get-args-self-ask-on-empty-cli-true-configinstance)
80
+ + [`is_no(self, text: str) -> bool`](#is-no-self-text-str-bool)
81
+ + [`is_yes(self, text: str) -> bool`](#is-yes-self-text-str-bool)
82
+ + [`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)
83
+ * [Standalone](#standalone)
84
+
85
+ <small><i><a href='http://ecotrust-canada.github.io/markdown-toc/'>Table of contents generated with markdown-toc</a></i></small>
86
+
87
+
88
+ # Background
89
+
90
+ 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.
91
+
92
+ 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.
93
+
94
+ 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:
95
+ * Call it as `program.py --help` to display full help.
96
+ * Use any flag in CLI: `program.py --test` causes `args.test` be set to `True`.
97
+ * The main benefit: Launch it without parameters as `program.py` to get a full working window with all the flags ready to be edited.
98
+ * Running on a remote machine? Automatic regression to the text interface.
99
+
100
+ # Docs
101
+
102
+ You can easily nest the configuration. (See also [Tyro Hierarchical Configs](https://brentyi.github.io/tyro/examples/02_nesting/01_nesting/)).
103
+
104
+ Just put another dataclass inside the config file:
105
+
106
+ ```python3
107
+ @dataclass
108
+ class FurtherConfig:
109
+ host: str = "example.org"
110
+ token: str
111
+
112
+ @dataclass
113
+ class Config:
114
+ further: FurtherConfig
115
+
116
+ ...
117
+ print(config.further.host) # example.org
118
+ ```
119
+
120
+ A subset might be defaulted in YAML:
121
+
122
+ ```yaml
123
+ further:
124
+ host: example.com
125
+ ```
126
+
127
+ Or by CLI:
128
+
129
+ ```
130
+ $./program.py --further.host example.net
131
+ ```
132
+
133
+ ## `mininterface`
134
+
135
+ ### `run(config=None, interface=GuiInterface, **kwargs)`
136
+ 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.
137
+ 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`.
138
+
139
+ * `config:ConfigClass`: Dataclass with the configuration.
140
+ * `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the REPL.
141
+ * `**kwargs`: The same as for [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html).
142
+ * Returns: `interface` Interface used.
143
+
144
+ You cay context manage the function by a `with` statement. The stdout will be redirected to the interface (GUI window).
145
+
146
+ See the [initial examples](#mininterface-gui-tui-cli-and-config).
147
+
148
+ ## Interfaces
149
+
150
+ Several interfaces exist:
151
+
152
+ * `Mininterface` – The base interface. Does not require any user input and hence is suitable for headless testing.
153
+ * `GuiInterface` – A tkinter window.
154
+ * `TuiInterface` – An interactive terminal.
155
+ * `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog.
156
+
157
+ 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.
158
+
159
+ ```python
160
+ with TuiInterface("My program") as m:
161
+ number = m.ask_number("Returns number")
162
+ ```
163
+
164
+ ### `Mininterface(title: str = '')`
165
+ Initialize.
166
+ ### `alert(self, text: str)`
167
+ Prompt the user to confirm the text.
168
+ ### `ask(self, text: str) -> str`
169
+ Prompt the user to input a text.
170
+ ### `ask_args(self) -> ~ConfigInstance`
171
+ Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.)
172
+ ### `ask_form(self, args: FormDict, title="") -> dict`
173
+ Prompt the user to fill up whole form.
174
+ * `args`: Dict of `{labels: default value}`. The form widget infers from the default value type.
175
+ The dict can be nested, it can contain a subgroup.
176
+ The default value might be `mininterface.Value` that allows you to add descriptions.
177
+ A checkbox example: `{"my label": Value(True, "my description")}`
178
+ * `title`: Optional form title.
179
+ ### `ask_number(self, text: str) -> int`
180
+ Prompt the user to input a number. Empty input = 0.
181
+ ### `get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`
182
+ Returns whole configuration (previously fetched from CLI and config file by parse_args).
183
+ If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields.
184
+ ### `is_no(self, text: str) -> bool`
185
+ Display confirm box, focusing no.
186
+ ### `is_yes(self, text: str) -> bool`
187
+ Display confirm box, focusing yes.
188
+
189
+ ```python
190
+ m = run(prog="My program")
191
+ print(m.ask_yes("Is it true?")) # True/False
192
+ ```
193
+
194
+ ### `parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`
195
+ Parse CLI arguments, possibly merged from a config file.
196
+ * `config`: Dataclass with the configuration.
197
+ * `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.
198
+ * `**kwargs` The same as for argparse.ArgumentParser.
199
+ * Returns: `ConfigInstance` Configuration namespace.
200
+
201
+ ## Standalone
202
+
203
+ When invoked directly, it creates simple GUI dialogs.
204
+
205
+ ```bash
206
+ $ mininterface --help
207
+ usage: Mininterface [-h] [OPTIONS]
208
+
209
+ Simple GUI dialog. Outputs the value the user entered.
210
+
211
+ ╭─ options ─────────────────────────────────────────────────────────────────────────────────╮
212
+ │ -h, --help show this help message and exit │
213
+ │ --alert STR Display the OK dialog with text. (default: '') │
214
+ │ --ask STR Prompt the user to input a text. (default: '') │
215
+ │ --ask-number STR Prompt the user to input a number. Empty input = 0. (default: '') │
216
+ │ --is-yes STR Display confirm box, focusing yes. (default: '') │
217
+ │ --is-no STR Display confirm box, focusing no. (default: '') │
218
+ ╰───────────────────────────────────────────────────────────────────────────────────────────╯
219
+ ```
220
+
221
+ You can fetch a value to i.e. a bash script.
222
+
223
+ ```bash
224
+ $ mininterface --ask-number "What's your age?" # GUI window invoked
225
+ 18
226
+ ```
227
+
@@ -0,0 +1,11 @@
1
+ mininterface/GuiInterface.py,sha256=FAFUY9xoWhTS9_PUGsgjf9ki_Wldiyx7S7heFvbdL5k,6210
2
+ mininterface/Mininterface.py,sha256=NluVWN3Z09QZDvK1Ut6FGQRwbK6Oj2YYaPWJqVa7rtY,4835
3
+ mininterface/TuiInterface.py,sha256=zQvy5gM6F_jtmNKTi3QPKM1Nb6Ur_LGgOp0V9Y8vxAg,2479
4
+ mininterface/__init__.py,sha256=IwhvS0Fj1Clr66V6kUsvX0gPWs53b5IMHYKf2_uRSvg,2427
5
+ mininterface/__main__.py,sha256=SOhhd1th0_mUW5Fo1PZXmIF9dcmi1OKaaDGwzDeVUvc,1465
6
+ mininterface/auxiliary.py,sha256=tUTEGm_W-dgFc6S67_phfdEW-z6ITFEE5XaTdoJKXcE,5719
7
+ mininterface/common.py,sha256=lXdmGM07Zs36wuC_ZU3c4KTraXyVE3LobF-1c-3PXUQ,50
8
+ mininterface-0.1.0.dist-info/METADATA,sha256=jGoa8GVs7SJAbHn84WtjaXd9FCR2FRXdJQ5MyDE1t-s,10305
9
+ mininterface-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
10
+ mininterface-0.1.0.dist-info/entry_points.txt,sha256=NCl7bPZhh1T3J3-S0m2HLWuuPcjzjhY0UbP7KUEp-Vw,59
11
+ mininterface-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mininterface=mininterface.__main__:main
3
+