Skip to content

Commit

Permalink
TuiInterface prototype fully working
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Jul 23, 2024
1 parent 7244a71 commit 9b08a36
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 37 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Upload Python Package
on:
push:
tags:
- '[0-9].[0-9].[0-9]'
- '[0-9].[0-9].[0-9]-rc.[0-9]'

jobs:
pypi:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- run: python3 -m pip install --upgrade build && python3 -m build
- name: Publish package
uses: pypa/[email protected]
with:
password: ${{ secrets.PYPI_GITHUB_MININTERFACE }}
18 changes: 18 additions & 0 deletions .github/workflows/run-unittest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: tests
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", 3.11, 3.12]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -e .
- name: Run tests
run: python3 tests.py
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ TODO img
- [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config)
- [Background](#background)
- [Installation](#installation)
- [Docs](#docs)
* [`mininterface`](#mininterface)
+ [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs)
Expand Down Expand Up @@ -75,6 +76,14 @@ The config variables needed by your program are kept in cozy dataclasses. Write
* The main benefit: Launch it without parameters as `program.py` to get a full working window with all the flags ready to be edited.
* Running on a remote machine? Automatic regression to the text interface.

# Installation

Install with a single command from [PyPi](https://pypi.org/project/mininterface/).

```python3
pip install mininterface
```

# Docs

You can easily nest the configuration. (See also [Tyro Hierarchical Configs](https://brentyi.github.io/tyro/examples/02_nesting/01_nesting/)).
Expand All @@ -84,8 +93,8 @@ Just put another dataclass inside the config file:
```python3
@dataclass
class FurtherConfig:
host: str = "example.org"
token: str
host: str = "example.org"
@dataclass
class Config:
Expand Down
21 changes: 13 additions & 8 deletions mininterface/GuiInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@


from .common import InterfaceNotAvailable
from .auxiliary import FormDict, RedirectText, dataclass_to_dict, dict_to_dataclass, recursive_set_focus
from .auxiliary import FormDict, RedirectText, dataclass_to_dict, dict_to_dataclass, recursive_set_focus, normalize_types
from .Mininterface import Cancelled, ConfigInstance, Mininterface


Expand Down Expand Up @@ -93,7 +93,7 @@ def __init__(self, interface: GuiInterface):
self.pending_buffer = []
""" Text that has been written to the text widget but might not be yet seen by user. Because no mainloop was invoked. """

def run_dialog(self, form: FormDict, title: str = "") -> dict:
def run_dialog(self, formDict: FormDict, title: str = "") -> dict:
""" Let the user edit the form_dict values in a GUI window.
On abrupt window close, the program exits.
"""
Expand All @@ -102,23 +102,28 @@ def run_dialog(self, form: FormDict, title: str = "") -> dict:
label.pack(pady=10)

self.form = Form(self.frame,
name_form="",
form_dict=form,
name_config="Ok",
)
name_form="",
form_dict=formDict,
name_config="Ok",
)
self.form.pack()

# Set the enter and exit options
self.form.button.config(command=self._ok)
# allow Enter for single field, otherwise Ctrl+Enter
tip, keysym = ("Ctrl+Enter", '<Control-Return>') if len(form) > 1 else ("Enter", "<Return>")
tip, keysym = ("Ctrl+Enter", '<Control-Return>') if len(formDict) > 1 else ("Enter", "<Return>")
ToolTip(self.form.button, msg=tip) # NOTE is not destroyed in _clear
self._bind_event(keysym, self._ok)
self.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))

# focus the first element and run
recursive_set_focus(self.form)
return self.mainloop(lambda: self.form.get())
return self.mainloop(lambda: self.validate(formDict, title))

def validate(self, formDict: FormDict, title: str):
if data := normalize_types(formDict, self.form.get()):
return data
return self.run_dialog(formDict, title)

def yes_no(self, text: str, focus_no=True):
return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)+1)
Expand Down
21 changes: 12 additions & 9 deletions mininterface/TuiInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,22 @@ def ask_args(self) -> ConfigInstance:
# params_ = dataclass_to_dict(self.args, self.descriptions)
# data = FormDict → dict self.window.run_dialog(params_)
# dict_to_dataclass(self.args, params_)
return self.ask_form(self.args)

def ask_form(self, args: FormDict) -> dict:
# NOTE: This is minimal implementation that should rather go the ReplInterface.
print("Access `v` (as var) and change values. Then (c)ontinue.")
pprint(self.args)
v = self.args
pprint(args)
v = args
try:
import ipdb; ipdb.set_trace()
import ipdb
ipdb.set_trace()
except ImportError:
import pdb; pdb.set_trace()
import pdb
pdb.set_trace()
print("*Continuing*")
print(self.args)
return self.args

def ask_form(self, args: FormDict) -> dict:
raise NotImplementedError
print(args)
return args

def ask_number(self, text):
"""
Expand Down
80 changes: 67 additions & 13 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import logging
import os
import re
from argparse import Action, ArgumentParser
from typing import Any, Callable, TypeVar, Union
from dataclasses import dataclass
from typing import Any, Callable, Literal, Optional, TypeVar, Union, get_args, get_type_hints
from unittest.mock import patch

try:
# NOTE this shuold be clean up and tested on a machine without tkinter installable
# NOTE this should be clean up and tested on a machine without tkinter installable
from tkinter import END, Entry, Text, Tk, Widget
from tkinter.ttk import Combobox, Checkbutton
from tkinter.ttk import Checkbutton, Combobox
except ImportError:
tkinter = None
END, Entry, Text, Tk, Widget = (None,)*5

from tkinter_form import Value
from tyro import cli
from tyro._argparse_formatter import TyroArgumentParser
try:
from tkinter_form import Value
except ImportError:
Value = None

logger = logging.getLogger(__name__)


@dataclass
class Value(Value):
""" Override val/description class with additional stuff. """

annotation: Any | None = None
""" Used for validation. To convert an empty '' to None. """

def __post_init__(self):
self._original_desc = self.description

def set_error_text(self, s):
self.description = f"{s} {self._original_desc}"


ConfigInstance = TypeVar("ConfigInstance")
ConfigClass = Callable[..., ConfigInstance]
Expand All @@ -29,20 +46,56 @@ def dataclass_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict:
main = ""
params = {main: {}} if not _path else {}
for param, val in vars(args).items():
annotation = None
if val is None:
# TODO tkinter_form does not handle None yet.
# This would fail: `severity: int | None = None`
# We need it to be able to write a number and if empty, return None.
val = False
wanted_type = get_type_hints(args.__class__).get(param)
if wanted_type in (Optional[int], Optional[str]):
# Since tkinter_form does not handle None yet, we have help it.
# We need it to be able to write a number and if empty, return None.
# This would fail: `severity: int | None = None`
# Here, we convert None to str(""), in normalize_types we convert it back.
annotation = wanted_type
val = ""
else:
# An unknown type annotation encountered-
# Since tkinter_form does not handle None yet, this will display as checkbox.
# Which is not probably wanted.
val = False
logger.warn(f"Annotation {wanted_type} of `{param}` not supported by Mininterface."
"None converted to False.")
if hasattr(val, "__dict__"): # nested config hierarchy
params[param] = dataclass_to_dict(val, descr, _path=f"{_path}{param}.")
elif not _path: # scalar value in root
params[main][param] = Value(val, descr.get(param))
params[main][param] = Value(val, descr.get(param), annotation)
else: # scalar value in nested
params[param] = Value(val, descr.get(f"{_path}{param}"))
params[param] = Value(val, descr.get(f"{_path}{param}"), annotation)
return params


def normalize_types(origin: FormDict, data: dict) -> dict:
""" Run validators of all Value objects. If fails, outputs info.
Return corrected data. (Ex: Some values might be nulled from "".)
"""
for (group, params), params2 in zip(data.items(), origin.values()):
for (key, val), pattern in zip(params.items(), params2.values()):
if isinstance(pattern, Value) and pattern.annotation:
if val == "" and type(None) in get_args(pattern.annotation):
# The user is not able to set the value to None, they left it empty.
# Cast back to None as None is one of the allowed types.
# Ex: `severity: int | None = None`
data[group][key] = val = None
elif pattern.annotation == Optional[int]:
try:
data[group][key] = val = int(val)
except ValueError:
pass

if not isinstance(val, pattern.annotation):
pattern.set_error_text(f"Type must be `{pattern.annotation}`!")
return False
return data


def dict_to_dataclass(args: ConfigInstance, data: dict):
""" Convert the dict of dicts from the GUI back into the object holding the configuration. """
for group, params in data.items():
Expand Down Expand Up @@ -132,6 +185,7 @@ def trim(self):
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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "mininterface"
version = "0.1.0"
version = "0.1.1"
description = "A minimal access to GUI, TUI, CLI and config"
authors = ["Edvard Rejthar <[email protected]>"]
license = "GPL-3.0-or-later"
homepage = "https://github.com/e3rd/mininterface"
homepage = "https://github.com/CZ-NIC/mininterface"
readme = "README.md"

[tool.poetry.dependencies]
Expand Down
Loading

0 comments on commit 9b08a36

Please sign in to comment.