diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc7f4eb --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +export TAG := `grep version pyproject.toml | pz --search '"(\d+\.\d+\.\d+(?:rc\d+))?"'` + +release: + git tag $(TAG) + git push origin $(TAG) \ No newline at end of file diff --git a/mininterface/GuiInterface.py b/mininterface/GuiInterface.py index 12c71b4..0e6f197 100644 --- a/mininterface/GuiInterface.py +++ b/mininterface/GuiInterface.py @@ -1,46 +1,34 @@ import sys from typing import Any, Callable -from .auxiliary import flatten - - try: from tkinter import TclError, LEFT, Button, Frame, Label, Text, Tk from tktooltip import ToolTip from tkinter_form import Form except ImportError: - from mininterface.common import InterfaceNotAvailable + from .common import InterfaceNotAvailable raise InterfaceNotAvailable from .common import InterfaceNotAvailable from .FormDict import FormDict, config_to_formdict -from .auxiliary import RedirectText, recursive_set_focus +from .auxiliary import recursive_set_focus, flatten +from .Redirectable import RedirectTextTkinter, Redirectable from .FormField import FormField from .Mininterface import Cancelled, ConfigInstance, Mininterface -class GuiInterface(Mininterface): +class GuiInterface(Redirectable, Mininterface): + """ When used in the with statement, the GUI window does not vanish between dialogues. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) try: self.window = TkWindow(self) - except TclError: # I am not sure whether there might be reasons the Tkinter is not available even when installed + except TclError: + # even when installed the libraries are installed, display might not be available, hence tkinter fails raise InterfaceNotAvailable - self._always_shown = False - self._original_stdout = sys.stdout - - def __enter__(self) -> "Mininterface": - """ When used in the with statement, the GUI window does not vanish between dialogues. """ - self._always_shown = True - sys.stdout = RedirectText(self.window.text_widget, self.window.pending_buffer, self.window) - return self - - def __exit__(self, *_): - self._always_shown = False - sys.stdout = self._original_stdout - if self.window.pending_buffer: # display text sent to the window but not displayed - print("".join(self.window.pending_buffer), end="") + self._redirected = RedirectTextTkinter(self.window.text_widget, self.window) def alert(self, text: str) -> None: """ Display the OK dialog with text. """ @@ -125,7 +113,7 @@ def run_dialog(self, formDict: FormDict, title: str = "") -> FormDict: def validate(self, formDict: FormDict, title: str) -> FormDict: if not FormField.submit_values(zip(flatten(formDict), flatten(self.form.get()))): - return self.run_dialog(formDict, title) + return self.run_dialog(formDict, title) return formDict def yes_no(self, text: str, focus_no=True): diff --git a/mininterface/Mininterface.py b/mininterface/Mininterface.py index bdd5b5a..aebf1b4 100644 --- a/mininterface/Mininterface.py +++ b/mininterface/Mininterface.py @@ -3,7 +3,7 @@ from argparse import ArgumentParser from dataclasses import MISSING from pathlib import Path -from types import SimpleNamespace +from types import FunctionType, SimpleNamespace from typing import Generic, Type import yaml @@ -104,7 +104,9 @@ def parse_args(self, config: Type[ConfigInstance], # Load configuration from CLI parser: ArgumentParser = get_parser(config, **kwargs) self.descriptions = get_descriptions(parser) - self.args = get_args_allow_missing(config, kwargs, parser) + # Why `or self.args`? If Config is not a dataclass but a function, it has no attributes. + # Still, we want to prevent error raised in `ask_args()` if self.args would have been set to None. + self.args = get_args_allow_missing(config, kwargs, parser) or self.args return self.args def is_yes(self, text: str) -> bool: diff --git a/mininterface/Redirectable.py b/mininterface/Redirectable.py new file mode 100644 index 0000000..da0f39a --- /dev/null +++ b/mininterface/Redirectable.py @@ -0,0 +1,77 @@ +import sys +from typing import Self, Type + +try: + from tkinter import END, Text, Tk +except ImportError: + pass + + +class RedirectText: + """ Helps to redirect text from stdout to a text widget. """ + + def __init__(self) -> None: + self.max_lines = 1000 + self.pending_buffer = [] + + def write(self, text): + self.pending_buffer.append(text) + + def flush(self): + pass # required by sys.stdout + + def join(self): + t = "".join(self.pending_buffer) + self.pending_buffer.clear() + return t + + +class RedirectTextTkinter(RedirectText): + """ Helps to redirect text from stdout to a text widget. """ + + def __init__(self, widget: Text, window: Tk) -> None: + super().__init__() + self.widget = widget + self.window = window + + def write(self, text): + self.widget.pack(expand=True, fill='both') + self.widget.insert(END, text) + self.widget.see(END) # scroll to the end + self.trim() + self.window.update_idletasks() + super().write(text) + + def trim(self): + lines = int(self.widget.index('end-1c').split('.')[0]) + if lines > self.max_lines: + self.widget.delete(1.0, f"{lines - self.max_lines}.0") + + +class Redirectable: + # NOTE When used in the with statement, the TUI window should not vanish between dialogues. + # The same way the GUI does not vanish. + # NOTE: Current implementation will show only after a dialog submit, not continuously. + # # with run(Config) as m: + # print("First") + # sleep(1) + # print("Second") + # m.is_yes("Was it shown continuously?") + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._always_shown = False + self._redirected: Type[RedirectText] = RedirectText() + self._original_stdout = sys.stdout + + def __enter__(self) -> Self: + self._always_shown = True + sys.stdout = self._redirected + return self + + def __exit__(self, *_): + self._always_shown = False + sys.stdout = self._original_stdout + if t := self._redirected.join(): # display text sent to the window but not displayed + print(t, end="") \ No newline at end of file diff --git a/mininterface/TextualInterface.py b/mininterface/TextualInterface.py index 7c234a5..5c7e610 100644 --- a/mininterface/TextualInterface.py +++ b/mininterface/TextualInterface.py @@ -12,15 +12,16 @@ from mininterface.common import InterfaceNotAvailable raise InterfaceNotAvailable -from .TextInterface import TextInterface - from .auxiliary import flatten from .FormDict import (ConfigInstance, FormDict, config_to_formdict, dict_to_formdict) from .FormField import FormField from .Mininterface import Cancelled +from .Redirectable import Redirectable +from .TextInterface import TextInterface + +# TODO with statement hello world example image is wrong (Textual already redirects the output as GuiInterface does) -# TODO with statement hello world example image is wrong – Textual still does not redirect the output as GuiInterface does @dataclass class DummyWrapper: @@ -29,11 +30,11 @@ class DummyWrapper: val: Any -class TextualInterface(TextInterface): +class TextualInterface(Redirectable, TextInterface): def alert(self, text: str) -> None: """ Display the OK dialog with text. """ - TextualButtonApp().buttons(text, [("Ok", None)]).run() + TextualButtonApp(self).buttons(text, [("Ok", None)]).run() def ask(self, text: str = None): return self.form({text: ""})[text] @@ -53,10 +54,10 @@ def form(self, form: FormDict, title: str = "") -> dict: # def ask_number(self, text): def is_yes(self, text): - return TextualButtonApp().yes_no(text, False).val + return TextualButtonApp(self).yes_no(text, False).val def is_no(self, text): - return TextualButtonApp().yes_no(text, True).val + return TextualButtonApp(self).yes_no(text, True).val class TextualApp(App[bool | None]): @@ -73,14 +74,15 @@ class TextualApp(App[bool | None]): ("escape", "exit", "Cancel"), ] - def __init__(self): + def __init__(self, interface: TextualInterface): super().__init__() self.title = "" self.widgets = None self.focused_i: int = 0 + self.interface = interface @staticmethod - def get_widget(ff:FormField) -> Checkbox | Input: + def get_widget(ff: FormField) -> Checkbox | Input: """ Wrap FormField to a textual widget. """ if ff.annotation is bool or not ff.annotation and ff.val in [True, False]: @@ -112,6 +114,8 @@ def compose(self) -> ComposeResult: if self.title: yield Header() yield Footer() + if text := self.interface._redirected.join(): + yield Label(text, id="buffered_text") with VerticalScroll(): for fieldt in self.widgets: if isinstance(fieldt, Input): @@ -158,6 +162,14 @@ class TextualButtonApp(App): grid-gutter: 2; padding: 2; } + #buffered_text { + width: 100%; + height: 100%; + column-span: 2; + # content-align: center bottom; + text-style: bold; + } + #question { width: 100%; height: 100%; @@ -175,13 +187,14 @@ class TextualButtonApp(App): ("escape", "exit", "Cancel"), ] - def __init__(self): + def __init__(self, interface: TextualInterface): super().__init__() self.title = "" self.text: str = "" self._buttons = None self.focused_i: int = 0 self.values = {} + self.interface = interface def yes_no(self, text: str, focus_no=True) -> DummyWrapper: return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)) @@ -198,6 +211,8 @@ def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 0): def compose(self) -> ComposeResult: yield Footer() + if text := self.interface._redirected.join(): + yield Label(text, id="buffered_text") yield Label(self.text, id="question") self.values.clear() diff --git a/mininterface/__init__.py b/mininterface/__init__.py index f9a6e60..7d02ec0 100644 --- a/mininterface/__init__.py +++ b/mininterface/__init__.py @@ -4,9 +4,10 @@ from unittest.mock import patch -from mininterface.Mininterface import ConfigInstance, Mininterface -from mininterface.TextInterface import ReplInterface, TextInterface -from mininterface.FormField import FormField +from .Mininterface import ConfigInstance, Mininterface +from .TextInterface import ReplInterface, TextInterface +from .FormField import FormField +from .common import InterfaceNotAvailable # Import optional interfaces try: @@ -34,24 +35,33 @@ def run(config: Type[ConfigInstance] | None = None, **kwargs) -> Mininterface[ConfigInstance]: """ Main access. - 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. + Wrap your configuration dataclass into `run` to access the interface. An interface is chosen automatically, + with the preference of the graphical one, regressed to a text interface for machines without display. 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`. + It searches the config file in the current working directory, + with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`. :param config: Dataclass with the configuration. - :param interface: Which interface to prefer. By default, we use the GUI, the fallback is the REPL. + :param interface: Which interface to prefer. By default, we use the GUI, the fallback is the Tui. :param **kwargs The same as for [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html). :return: Interface used. - Undocumented: The `config` may be function as well. We invoke its paramters. - However, Mininterface.args stores the output of the function instead of the Argparse namespace - and methods like `Mininterface.ask_args()` will work unpredictibly.. + Undocumented: The `config` may be function as well. We invoke its parameters. + However, as Mininterface.args stores the output of the function instead of the Argparse namespace, + methods like `Mininterface.ask_args()` will work unpredictibly. + Also, the config file seems to be fetched only for positional (missing) parameters, + and ignored for keyword (filled) parameters. + It seems to be this is the tyro's deal and hence it might start working any time. + If not, we might help it this way: + `if isinstance(config, FunctionType): config = lambda: config(**kwargs["default"])` """ # Build the interface prog = kwargs.get("prog") or sys.argv[0] - interface: GuiInterface | Mininterface = interface(prog) + try: + interface = interface(prog) + except InterfaceNotAvailable: # Fallback to a different interface + interface = TuiInterface(prog) # Load configuration from CLI and a config file if config: diff --git a/mininterface/__main__.py b/mininterface/__main__.py index a435700..9916499 100644 --- a/mininterface/__main__.py +++ b/mininterface/__main__.py @@ -17,7 +17,7 @@ class CliInteface: is_no: str = "" """ Display confirm box, focusing no. """ -# TODO does not work in REPL interface: mininterface --alert "ahoj" +# TODO does not work in REPL interface: mininterface --alert hello def main(): # 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. # TODO It DOES make sense. Change in README. It s a good fallback. diff --git a/mininterface/auxiliary.py b/mininterface/auxiliary.py index 5d20ca0..0b0c59e 100644 --- a/mininterface/auxiliary.py +++ b/mininterface/auxiliary.py @@ -4,13 +4,10 @@ from typing import Iterable, TypeVar try: - # NOTE this should be clean up and tested on a machine without tkinter installable - from tkinter import END, Entry, Text, Tk, Widget + from tkinter import Entry, Widget from tkinter.ttk import Checkbutton, Combobox except ImportError: - tkinter = None - END, Entry, Text, Tk, Widget = (None,)*5 - + pass T = TypeVar("T") @@ -40,33 +37,6 @@ def get_descriptions(parser: ArgumentParser) -> dict: return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help) for action in parser._actions} - -class RedirectText: - """ Helps to redirect text from stdout to a text widget. """ - - def __init__(self, widget: Text, pending_buffer: list, window: Tk) -> None: - self.widget = widget - self.max_lines = 1000 - self.pending_buffer = pending_buffer - self.window = window - - def write(self, text): - self.widget.pack(expand=True, fill='both') - self.widget.insert(END, text) - self.widget.see(END) # scroll to the end - self.trim() - self.window.update_idletasks() - self.pending_buffer.append(text) - - def flush(self): - pass # required by sys.stdout - - def trim(self): - lines = int(self.widget.index('end-1c').split('.')[0]) - if lines > self.max_lines: - self.widget.delete(1.0, f"{lines - self.max_lines}.0") - - def recursive_set_focus(widget: Widget): for child in widget.winfo_children(): if isinstance(child, (Entry, Checkbutton, Combobox)): diff --git a/pyproject.toml b/pyproject.toml index 1d80e8c..ad75102 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "mininterface" -version = "0.4.4rc2" +version = "0.4.4rc3" description = "A minimal access to GUI, TUI, CLI and config" authors = ["Edvard Rejthar "] license = "GPL-3.0-or-later"