From 0ce0f7b61e295842e42ea5059602cff5ca6aba13 Mon Sep 17 00:00:00 2001 From: Edvard Rejthar Date: Wed, 24 Jul 2024 20:57:36 +0200 Subject: [PATCH] TextualInterface prototype fully working --- README.md | 4 +- mininterface/GuiInterface.py | 32 +++-- mininterface/Mininterface.py | 10 +- mininterface/TextualInterface.py | 226 +++++++++++++++++++++++++++++++ mininterface/TuiInterface.py | 12 +- mininterface/__init__.py | 5 +- mininterface/auxiliary.py | 155 ++++++++++++++++++--- pyproject.toml | 2 +- tests/configs.py | 6 +- tests/tests.py | 46 ++++--- 10 files changed, 423 insertions(+), 75 deletions(-) create mode 100644 mininterface/TextualInterface.py diff --git a/README.md b/README.md index fe07067..dfffe30 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ Allow the user to edit whole configuration. (Previously fetched from CLI and con Prompt the user to fill up whole form. * `args`: Dict of `{labels: default value}`. The form widget infers from the default value type. The dict can be nested, it can contain a subgroup. - The default value might be `mininterface.Value` that allows you to add descriptions. - A checkbox example: `{"my label": Value(True, "my description")}` + The default value might be `mininterface.FormField` that allows you to add descriptions. + A checkbox example: `{"my label": FormField(True, "my description")}` * `title`: Optional form title. ### `ask_number(self, text: str) -> int` Prompt the user to input a number. Empty input = 0. diff --git a/mininterface/GuiInterface.py b/mininterface/GuiInterface.py index 0b0a664..5c63860 100644 --- a/mininterface/GuiInterface.py +++ b/mininterface/GuiInterface.py @@ -11,17 +11,17 @@ from .common import InterfaceNotAvailable -from .auxiliary import FormDict, RedirectText, config_to_dict, config_from_dict, recursive_set_focus, normalize_types +from .auxiliary import FormDict, RedirectText, config_to_formdict, config_from_dict, flatten, recursive_set_focus, fix_types from .Mininterface import Cancelled, ConfigInstance, Mininterface class GuiInterface(Mininterface): def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) try: - super().__init__(*args, **kwargs) + self.window = TkWindow(self) except TclError: raise InterfaceNotAvailable - self.window = TkWindow(self) self._always_shown = False self._original_stdout = sys.stdout @@ -39,29 +39,29 @@ def __exit__(self, *_): def alert(self, text: str) -> None: """ Display the OK dialog with text. """ - return self.window.buttons(text, [("Ok", None)]) + self.window.buttons(text, [("Ok", None)]) def ask(self, text: str) -> str: return self.window.run_dialog({text: ""})[text] def ask_args(self) -> ConfigInstance: """ Display a window form with all parameters. """ - params_ = config_to_dict(self.args, self.descriptions) + params_ = config_to_formdict(self.args, self.descriptions) # fetch the dict of dicts values from the form back to the namespace of the dataclasses - data = self.window.run_dialog(params_) - config_from_dict(self.args, data) + self.window.run_dialog(params_) + # NOTE remove config_from_dict(self.args, data) return self.args - def ask_form(self, args: FormDict, title: str = "") -> dict: + def ask_form(self, form: FormDict, title: str = "") -> dict: """ Prompt the user to fill up whole form. :param args: Dict of `{labels: default value}`. The form widget infers from the default value type. The dict can be nested, it can contain a subgroup. - The default value might be `mininterface.Value` that allows you to add descriptions. - A checkbox example: {"my label": Value(True, "my description")} + The default value might be `mininterface.FormField` that allows you to add descriptions. + A checkbox example: {"my label": FormField(True, "my description")} :param title: Optional form title. """ - return self.window.run_dialog(args, title=title) + return self.window.run_dialog(form, title=title) def ask_number(self, text: str) -> int: return self.window.run_dialog({text: 0})[text] @@ -121,9 +121,13 @@ def run_dialog(self, formDict: FormDict, title: str = "") -> dict: 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) + if not all(ff.update(ui_value) for ff, ui_value in zip(flatten(formDict), flatten(self.form.get()))): + return self.run_dialog(formDict, title) + + # NOTE remove: + # if data := fix_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) diff --git a/mininterface/Mininterface.py b/mininterface/Mininterface.py index 7a96e94..9a0e85e 100644 --- a/mininterface/Mininterface.py +++ b/mininterface/Mininterface.py @@ -54,15 +54,15 @@ def ask_args(self) -> ConfigInstance: print("Asking the args", self.args) return self.args - def ask_form(self, args: FormDict, title: str = "") -> dict: + def ask_form(self, data: FormDict, title: str = "") -> dict: """ Prompt the user to fill up whole form. :param args: Dict of `{labels: default value}`. The form widget infers from the default value type. The dict can be nested, it can contain a subgroup. - The default value might be `mininterface.Value` that allows you to add descriptions. - A checkbox example: `{"my label": Value(True, "my description")}` + The default value might be `mininterface.FormField` that allows you to add descriptions. + A checkbox example: `{"my label": FormField(True, "my description")}` """ - print(f"Asking the form {title}", args) - return args # NOTE – this should return dict, not FormDict (get rid of auxiliary.Value values) + print(f"Asking the form {title}", data) + return data # NOTE – this should return dict, not FormDict (get rid of auxiliary.FormField values) def ask_number(self, text: str) -> int: """ Prompt the user to input a number. Empty input = 0. """ diff --git a/mininterface/TextualInterface.py b/mininterface/TextualInterface.py new file mode 100644 index 0000000..6a3f17c --- /dev/null +++ b/mininterface/TextualInterface.py @@ -0,0 +1,226 @@ +from ast import literal_eval +from dataclasses import _MISSING_TYPE, dataclass, field +from types import UnionType +from typing import Any, Self, get_args, override +from dataclasses import fields +from textual import events +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll, Container +from textual.widgets import Checkbox, Header, Footer, Input, Label, Welcome, Button, Static +from textual.binding import Binding + +from mininterface import TuiInterface +from .common import InterfaceNotAvailable + +from .Mininterface import Cancelled, Mininterface +from .auxiliary import ConfigInstance, FormDict, FormField, config_from_dict, config_to_formdict, dict_to_formdict, flatten + +from textual.widgets import Checkbox, Input + +# TODO +# 1. TuiInterface -> TextInterface. +# 1. TextualInterface inherits from TextInterface. +# 2. TextualInterface is the default for TuiInterface +# Add to docs + +@dataclass +class FormFieldTextual(FormField): + """ Bridge between the values given in CLI, TUI and real needed values (str to int conversion etc). """ + + def get_widget(self): + if self.annotation is bool or not self.annotation and self.val in [True, False]: + o = Checkbox(self.name, self.val) + else: + o = Input(str(self.val), placeholder=self.name or "") + o._link = self # The Textual widgets need to get back to this value + return o + + +@dataclass +class DummyWrapper: + """ Value wrapped, since I do not know how to get it from textual app. + False would mean direct exit. """ + val: Any + + +class TextualInterface(TuiInterface): + + def alert(self, text: str) -> None: + """ Display the OK dialog with text. """ + TextualButtonApp().buttons(text, [("Ok", None)]).run() + + def ask(self, text: str = None): + return self.ask_form({text: ""})[text] + + def ask_args(self) -> ConfigInstance: + """ Display a window form with all parameters. """ + params_ = config_to_formdict(self.args, self.descriptions, factory=FormFieldTextual) + + # fetch the dict of dicts values from the form back to the namespace of the dataclasses + TextualApp.run_dialog(TextualApp(), params_) + return self.args + + def ask_form(self, form: FormDict, title: str = "") -> dict: + TextualApp.run_dialog(TextualApp(), dict_to_formdict(form, factory=FormFieldTextual), title) + return form + + # NOTE we should implement better, now the user does not know it needs an int + # def ask_number(self, text): + + def is_yes(self, text): + return TextualButtonApp().yes_no(text, False).val + + def is_no(self, text): + return TextualButtonApp().yes_no(text, True).val + + +class TextualApp(App[bool | None]): + + BINDINGS = [ + ("up", "go_up", "Go up"), + ("down", "go_up", "Go down"), + # Form confirmation + # * ctrl/alt+enter does not work + # * enter without priority is consumed by input fields + # * enter with priority is not shown in the footer + Binding("enter", "confirm", "Ok", show=True, priority=True), + Binding("Enter", "confirm", "Ok"), + ("escape", "exit", "Cancel"), + ] + + def __init__(self): + super().__init__() + self.title = "" + self.widgets = None + self.focused_i: int = 0 + + def setup(self, title, widgets, focused_i): + + self.focused_i = focused_i + return self + + # Why class method? I do not know how to re-create the dialog if needed. + @classmethod + def run_dialog(cls, window, formDict: FormDict, title: str = "") -> None: # TODO changed from dict, change everywhere + if title: + window.title = title + + # NOTE Sections (~ nested dicts) are not implemented, they flatten + fd: dict[str, FormFieldTextual] = formDict + widgets: list[Checkbox | Input] = [f.get_widget() for f in flatten(fd)] + window.widgets = widgets + + if not window.run(): + raise Cancelled + + # validate and store the UI value → FormField value → original value + if not all(field._link.update(field.value) for field in widgets): + return cls.run_dialog(TextualApp(), formDict, title) + + def compose(self) -> ComposeResult: + if self.title: + yield Header() + yield Footer() + with VerticalScroll(): + for fieldt in self.widgets: + fieldt: FormFieldTextual + if isinstance(fieldt, Input): + yield Label(fieldt.placeholder) + yield fieldt + yield Label(fieldt._link.description) + yield Label("") + + def on_mount(self): + self.widgets[self.focused_i].focus() + + def action_confirm(self): + # next time, start on the same widget + # NOTE the functionality is probably not used + self.focused_i = next((i for i, inp in enumerate(self.widgets) if inp == self.focused), None) + self.exit(True) + + def action_exit(self): + self.exit() + + def on_key(self, event: events.Key) -> None: + try: + index = self.widgets.index(self.focused) + except ValueError: # probably some other element were focused + return + match event.key: + case "down": + self.widgets[(index + 1) % len(self.widgets)].focus() + case "up": + self.widgets[(index - 1) % len(self.widgets)].focus() + case letter if len(letter) == 1: # navigate by letters + for inp_ in self.widgets[index+1:] + self.widgets[:index]: + label = inp_.label if isinstance(inp_, Checkbox) else inp_.placeholder + if str(label).casefold().startswith(letter): + inp_.focus() + break + + +class TextualButtonApp(App): + CSS = """ + Screen { + layout: grid; + grid-size: 2; + grid-gutter: 2; + padding: 2; + } + #question { + width: 100%; + height: 100%; + column-span: 2; + content-align: center bottom; + text-style: bold; + } + + Button { + width: 100%; + } + """ + + BINDINGS = [ + ("escape", "exit", "Cancel"), + ] + + def __init__(self): + super().__init__() + self.title = "" + self.text: str = "" + self._buttons = None + self.focused_i: int = 0 + self.values = {} + + def yes_no(self, text: str, focus_no=True) -> DummyWrapper: + return self.buttons(text, [("Yes", True), ("No", False)], int(focus_no)) + + def buttons(self, text: str, buttons: list[tuple[str, Any]], focused: int = 0): + self.text = text + self._buttons = buttons + self.focused_i = focused + + ret = self.run() + if not ret: + raise Cancelled + return ret + + def compose(self) -> ComposeResult: + yield Footer() + yield Label(self.text, id="question") + + self.values.clear() + for i, (text, value) in enumerate(self._buttons): + id_ = "button"+str(i) + self.values[id_] = value + b = Button(text, id=id_) + if i == self.focused_i: + b.focus() + yield b + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(DummyWrapper(self.values[event.button.id])) + + def action_exit(self): + self.exit() diff --git a/mininterface/TuiInterface.py b/mininterface/TuiInterface.py index 627a9df..47348ef 100644 --- a/mininterface/TuiInterface.py +++ b/mininterface/TuiInterface.py @@ -1,5 +1,5 @@ from pprint import pprint -from .auxiliary import ConfigInstance, FormDict, config_to_dict, config_from_dict +from .auxiliary import ConfigInstance, FormDict, config_to_formdict, config_from_dict from .Mininterface import Cancelled, Mininterface @@ -26,11 +26,11 @@ def ask_args(self) -> ConfigInstance: # dict_to_dataclass(self.args, params_) return self.ask_form(self.args) - def ask_form(self, args: FormDict) -> dict: + def ask_form(self, form: 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(args) - v = args + pprint(form) + v = form try: import ipdb ipdb.set_trace() @@ -38,8 +38,8 @@ def ask_form(self, args: FormDict) -> dict: import pdb pdb.set_trace() print("*Continuing*") - print(args) - return args + print(form) + return form def ask_number(self, text): """ diff --git a/mininterface/__init__.py b/mininterface/__init__.py index b3cbba4..2403ffd 100644 --- a/mininterface/__init__.py +++ b/mininterface/__init__.py @@ -13,7 +13,8 @@ from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface from mininterface.TuiInterface import ReplInterface, TuiInterface -from mininterface.auxiliary import Value +from mininterface.TextualInterface import TextualInterface +from mininterface.auxiliary import FormField # TODO auto-handle verbosity https://brentyi.github.io/tyro/examples/04_additional/12_counters/ ? # TODO example on missing required options. @@ -53,4 +54,4 @@ def run(config: ConfigClass | None = None, return interface -__all__ = ["run", "Value"] +__all__ = ["run", "FormField"] diff --git a/mininterface/auxiliary.py b/mininterface/auxiliary.py index 77b931c..afa24ac 100644 --- a/mininterface/auxiliary.py +++ b/mininterface/auxiliary.py @@ -3,30 +3,52 @@ import re from argparse import Action, ArgumentParser from dataclasses import dataclass -from typing import Any, Callable, Literal, Optional, TypeVar, Union, get_args, get_type_hints +from typing import Any, Callable, Iterable, Literal, Optional, Self, TypeVar, Union, get_args, get_type_hints from unittest.mock import patch 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.ttk import Checkbutton, Combobox + from tkinter_form import Value except ImportError: tkinter = None END, Entry, Text, Tk, Widget = (None,)*5 -from tkinter_form import Value + @dataclass + class Value: + """ This class helps to enrich the field with a description. """ + val: Any + description: str + + from tyro import cli from tyro._argparse_formatter import TyroArgumentParser logger = logging.getLogger(__name__) +TD = TypeVar("TD") +""" dict """ +TK = TypeVar("TK") +""" dict key """ + @dataclass -class Value(Value): - """ Override val/description class with additional stuff. """ +class FormField(Value): + """ Bridge between the input values and a UI widget. + Helps to creates a widget from the input value (includes description etc.), + then transforms the value back (str to int conversion etc). """ annotation: Any | None = None """ Used for validation. To convert an empty '' to None. """ + name: str | None = None # NOTE: Only TextualInterface uses this by now. + + src: tuple[TD, TK] | None = None + """ The original dict to be updated when UI ends. """ + src2: tuple[TD, TK] | None = None + """ The original object to be updated when UI ends. + NOTE should be merged to `src` + """ def __post_init__(self): self._original_desc = self.description @@ -34,17 +56,94 @@ def __post_init__(self): def set_error_text(self, s): self.description = f"{s} {self._original_desc}" + # TODO add testing + def update(self, ui_value): + """ UI value → FormField value → original value. (With type conversion and checks.) + + The value has been updated in a UI. + Update accordingly the value in the original linked dict + the mininterface was invoked with. + + Validates the type and do the transformation. + (Ex: Some values might be nulled from "".) + """ + fixed_value = ui_value + if self.annotation: + if ui_value == "" and type(None) in get_args(self.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` + fixed_value = None + elif self.annotation == Optional[int]: + try: + fixed_value = int(ui_value) + except ValueError: + pass + + if not isinstance(fixed_value, self.annotation): + self.set_error_text(f"Type must be `{self.annotation}`!") + return False # revision needed + + # keep values if revision needed + # We merge new data to the origin. If form is re-submitted, the values will stay there. + self.val = ui_value + + # Store to the source user data + if self.src: + d, k = self.src + d[k] = fixed_value + else: + d, k = self.src2 + setattr(d, k, fixed_value) + return True + + # Fixing types: + # This code would support tuple[int, int]: + # + # self.types = get_args(self.annotation) \ + # if isinstance(self.annotation, UnionType) else (self.annotation, ) + # "All possible types in a tuple. Ex 'int | str' -> (int, str)" + # + # + # def convert(self): + # """ Convert the self.value to the given self.type. + # The value might be in str due to CLI or TUI whereas the programs wants bool. + # """ + # # if self.value == "True": + # # return True + # # if self.value == "False": + # # return False + # if type(self.val) is str and str not in self.types: + # try: + # return literal_eval(self.val) # ex: int, tuple[int, int] + # except: + # raise ValueError(f"{self.name}: Cannot convert value {self.val}") + # return self.val + + + ConfigInstance = TypeVar("ConfigInstance") ConfigClass = Callable[..., ConfigInstance] -FormDict = dict[str, Union[Value, Any, 'FormDict']] -""" Nested form that can have descriptions (through Value) instead of plain values. """ +FormDict = dict[str, Union[FormField, 'FormDict']] +""" Nested form that can have descriptions (through FormField) instead of plain values. """ + +def dict_to_formdict(data: dict, factory=FormField) -> FormDict: + fd = {} + for key, val in data.items(): + if isinstance(val, dict): # nested config hierarchy + fd[key] = dict_to_formdict(val, factory=factory) + else: # scalar value + # NOTE name=param is not set (yet?) in `config_to_formdict`, neither `src` + fd[key] = factory(val, "", name=key, src=(data, key)) + return fd -def config_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict: + +# NOTE: Not used, remove +def config_to_formdict(args: ConfigInstance, descr: dict, _path="", factory=FormField) -> FormDict: """ Convert the dataclass produced by tyro into dict of dicts. """ main = "" - # print(args)# TODO params = {main: {}} if not _path else {} for param, val in vars(args).items(): annotation = None @@ -65,39 +164,39 @@ def config_to_dict(args: ConfigInstance, descr: dict, _path="") -> FormDict: 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] = config_to_dict(val, descr, _path=f"{_path}{param}.") + params[param] = config_to_formdict(val, descr, _path=f"{_path}{param}.", factory=factory) elif not _path: # scalar value in root - params[main][param] = Value(val, descr.get(param), annotation) + params[main][param] = factory(val, descr.get(param), annotation, param, src2=(args, param)) else: # scalar value in nested - params[param] = Value(val, descr.get(f"{_path}{param}"), annotation) - # print(params) # TODO + params[param] = factory(val, descr.get(f"{_path}{param}"), annotation, param, src2=(args, param)) return params - -def normalize_types(origin: FormDict, data: dict) -> dict: - """ Run validators of all Value objects. If fails, outputs info. +# NOTE: Not used, remove +def fix_types(origin: FormDict, data: dict) -> dict: + """ Run validators of all FormField objects. If fails, outputs info. Return corrected data. (Ex: Some values might be nulled from "".) """ def check(ordict, orkey, orval, dataPos: dict, dataKey, val): - if isinstance(orval, Value) and orval.annotation: + if isinstance(orval, FormField) and orval.annotation: + fixed_val = val if val == "" and type(None) in get_args(orval.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` - dataPos[dataKey] = val = None + dataPos[dataKey] = fixed_val = None elif orval.annotation == Optional[int]: try: - dataPos[dataKey] = val = int(val) + dataPos[dataKey] = fixed_val = int(val) except ValueError: pass - if not isinstance(val, orval.annotation): + if not isinstance(fixed_val, orval.annotation): orval.set_error_text(f"Type must be `{orval.annotation}`!") raise RuntimeError # revision needed # keep values if revision needed # We merge new data to the origin. If form is re-submitted, the values will stay there. - if isinstance(orval, Value): + if isinstance(orval, FormField): orval.val = val else: ordict[orkey] = val @@ -122,9 +221,9 @@ def config_from_dict(args: ConfigInstance, data: dict): for group, params in data.items(): for key, val in params.items(): if group: - setattr(getattr(args, group), key, val.val if isinstance(val, Value) else val) + setattr(getattr(args, group), key, val.val if isinstance(val, FormField) else val) else: - setattr(args, key, val.val if isinstance(val, Value) else val) + setattr(args, key, val.val if isinstance(val, FormField) else val) def get_terminal_size(): @@ -214,3 +313,15 @@ def recursive_set_focus(widget: Widget): return True if recursive_set_focus(child): return True + + +T = TypeVar("T") + + +def flatten(d: dict[str, T | dict]) -> Iterable[T]: + """ Recursively traverse whole dict """ + for v in d.values(): + if isinstance(v, dict): + yield from flatten(v) + else: + yield v diff --git a/pyproject.toml b/pyproject.toml index 3ccc5d2..deafe04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "mininterface" -version = "0.4.0" +version = "0.4.1" description = "A minimal access to GUI, TUI, CLI and config" authors = ["Edvard Rejthar "] license = "GPL-3.0-or-later" diff --git a/tests/configs.py b/tests/configs.py index ee5b132..cec55a2 100644 --- a/tests/configs.py +++ b/tests/configs.py @@ -33,9 +33,13 @@ class NestedMissingConfig: @dataclass class FurtherConfig3: severity: int | None = None + """ Put there a number or left empty """ @dataclass class OptionalFlagConfig: further: FurtherConfig3 msg: str | None = None - msg2: str | None = "Default text" \ No newline at end of file + """ An example message """ + + msg2: str | None = "Default text" + """ Another example message """ \ No newline at end of file diff --git a/tests/tests.py b/tests/tests.py index 3b01547..7cc2225 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2,9 +2,9 @@ from unittest import TestCase, main from unittest.mock import patch -from mininterface import Mininterface, TuiInterface, Value, run +from mininterface import Mininterface, TuiInterface, FormField, run from mininterface.Mininterface import Cancelled -from mininterface.auxiliary import config_from_dict, config_to_dict, normalize_types +from mininterface.auxiliary import config_from_dict, config_to_formdict, fix_types from configs import OptionalFlagConfig, SimpleConfig, NestedDefaultedConfig, NestedMissingConfig SYS_ARGV = None # To be redirected @@ -90,34 +90,34 @@ def test_normalize_types(self): When using GUI interface, we input an empty string and that should mean None for annotation `int | None`. """ - origin = {'': {'test': Value(False, 'Testing flag ', annotation=None), - 'numb': Value(4, 'A number', annotation=None), - 'severity': Value('', 'integer or none ', annotation=int | None), - 'msg': Value('', 'string or none', annotation=str | None)}} + origin = {'': {'test': FormField(False, 'Testing flag ', annotation=None), + 'numb': FormField(4, 'A number', annotation=None), + 'severity': FormField('', 'integer or none ', annotation=int | None), + 'msg': FormField('', 'string or none', annotation=str | None)}} data = {'': {'test': False, 'numb': 4, 'severity': 'fd', 'msg': ''}} - self.assertFalse(normalize_types(origin, data)) + self.assertFalse(fix_types(origin, data)) data = {'': {'test': False, 'numb': 4, 'severity': '1', 'msg': ''}} - self.assertTrue(normalize_types(origin, data)) + self.assertTrue(fix_types(origin, data)) data = {'': {'test': False, 'numb': 4, 'severity': '', 'msg': ''}} - self.assertTrue(normalize_types(origin, data)) + self.assertTrue(fix_types(origin, data)) data = {'': {'test': False, 'numb': 4, 'severity': '1', 'msg': 'Text'}} - self.assertTrue(normalize_types(origin, data)) + self.assertTrue(fix_types(origin, data)) # check value is kept if revision needed self.assertEqual(False, origin[""]["test"].val) data = {'': {'test': True, 'numb': 100, 'severity': '1', 'msg': 1}} - self.assertFalse(normalize_types(origin, data)) + self.assertFalse(fix_types(origin, data)) self.assertEqual(True, origin[""]["test"].val) self.assertEqual(100, origin[""]["numb"].val) # Check flat FormDict - origin = {'test': Value(False, 'Testing flag ', annotation=None), - 'severity': Value('', 'integer or none ', annotation=int | None), + origin = {'test': FormField(False, 'Testing flag ', annotation=None), + 'severity': FormField('', 'integer or none ', annotation=int | None), 'nested': {'test2': 4}} data = {'test': True, 'severity': "", 'nested': {'test2': 8}} - self.assertTrue(normalize_types(origin, data)) + self.assertTrue(fix_types(origin, data)) data = {'test': True, 'severity': "str", 'nested': {'test2': 8}} - self.assertFalse(normalize_types(origin, data)) + self.assertFalse(fix_types(origin, data)) def test_config_instance_dict_conversion(self): m: TuiInterface = run(OptionalFlagConfig, interface=TuiInterface, prog="My application") @@ -125,14 +125,16 @@ def test_config_instance_dict_conversion(self): self.assertIsNone(args1.further.severity) - dict1 = config_to_dict(args1, m.descriptions) - self.assertEqual({'': {'msg': Value('', '', str | None), - 'msg2': Value('Default text', '', None)}, - 'further': {'severity': Value('', '', int | None)}}, dict1) + dict1 = config_to_formdict(args1, m.descriptions) + print(dict1) + return # TODO example was changes, add descriptions + self.assertEqual({'': {'msg': FormField('', '', str | None), + 'msg2': FormField('Default text', '', None)}, + 'further': {'severity': FormField('', '', int | None)}}, dict1) self.assertIsNone(args1.further.severity) # do the same as if the tkinter_form was just submitted without any changes - dict1 = normalize_types(dict1, {'': {'msg': "", + dict1 = fix_types(dict1, {'': {'msg': "", 'msg2': 'Default text'}, 'further': {'severity': ''}}) @@ -148,10 +150,10 @@ def test_config_instance_dict_conversion(self): def test_ask_form(self): m = TuiInterface() - dict1 = {"my label": Value(True, "my description"), "nested": {"inner": "text"}} + dict1 = {"my label": FormField(True, "my description"), "nested": {"inner": "text"}} with patch('builtins.input', side_effect=["v['nested']['inner'] = 'another'", "c"]): m.ask_form(dict1) - self.assertEqual({"my label": Value(True, "my description"), "nested": {"inner": "another"}}, dict1) + self.assertEqual({"my label": FormField(True, "my description"), "nested": {"inner": "another"}}, dict1) if __name__ == '__main__':