Skip to content

Commit

Permalink
textual stdout redirect
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Jul 30, 2024
1 parent 92c691e commit d8db30b
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 79 deletions.
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 10 additions & 22 deletions mininterface/GuiInterface.py
Original file line number Diff line number Diff line change
@@ -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. """
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions mininterface/Mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions mininterface/Redirectable.py
Original file line number Diff line number Diff line change
@@ -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="")
35 changes: 25 additions & 10 deletions mininterface/TextualInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]
Expand All @@ -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]):
Expand All @@ -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]:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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%;
Expand All @@ -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))
Expand All @@ -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()
Expand Down
32 changes: 21 additions & 11 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion mininterface/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 2 additions & 32 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)):
Expand Down
Loading

0 comments on commit d8db30b

Please sign in to comment.