Skip to content

Commit

Permalink
form return value
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Sep 10, 2024
1 parent cd6aef9 commit 879e3c3
Show file tree
Hide file tree
Showing 14 changed files with 151 additions and 36 deletions.
Binary file added .program.py.kate-swp
Binary file not shown.
1 change: 1 addition & 0 deletions docs/Changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Due to an early stage of development, changes are tracked in commit messages only.
7 changes: 7 additions & 0 deletions docs/Tag.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
show_signature: false
show_labels: false

::: mininterface.Choices
options:
show_root_full_path: false
show_root_heading: true
show_labels: false


# Helper types
::: mininterface.tag.ValidationResult
options:
Expand Down
4 changes: 2 additions & 2 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Type

from .aliases import Validation
from .aliases import Validation, Choices
from .cli_parser import _parse_cli
from .common import InterfaceNotAvailable
from .form_dict import EnvClass
Expand Down Expand Up @@ -169,6 +169,6 @@ class Env:


__all__ = ["run", "Tag", "validators", "InterfaceNotAvailable",
"Validation",
"Validation", "Choices",
"Mininterface", "GuiInterface", "TuiInterface", "TextInterface", "TextualInterface"
]
7 changes: 6 additions & 1 deletion mininterface/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ class Env:
Args:
check: Callback function.
"""
return Tag(validation=check)
return Tag(validation=check)


def Choices(*choices: list[str]):
""" An alias, see [`Tag.choices`][mininterface.Tag.choices] """
return Tag(choices=choices)
2 changes: 1 addition & 1 deletion mininterface/auxiliary.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def replace_widget_with(target: Literal["button"] | Literal["radio"], widget: Wi

master = widget.master

# TODO tab order broken, injected to another position
# NOTE tab order broken, injected to another position
match target:
case "radio":
choices = value.choices
Expand Down
18 changes: 15 additions & 3 deletions mininterface/form_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,28 @@
# is_dataclass(v) -> dataclass or its instance
# isinstance(v, type) -> class, not an instance
# Then, we might get rid of ._descriptions because we will read from the model itself
# TODO this should be DictOrEnv
FormDictOrEnv = TypeVar('FormT', bound=FormDict) # | EnvClass)


def formdict_repr(d: FormDict) -> dict:
""" For the testing purposes, returns a new dict when all Tags are replaced with their values. """
def formdict_resolve(d: FormDict, extract_main=False, _root=True) -> dict:
""" For the testing purposes, returns a new dict when all Tags are replaced with their values.
Args:
extract_main: UI need the main section act as nested.
At least `dataclass_to_formdict` does this.
This extracts it back.
{"": {"key": "val"}, "nested": {...}} -> {"key": "val", "nested": {...}}
"""
out = {}
for k, v in d.items():
if isinstance(v, Tag):
v = v.val
out[k] = formdict_repr(v) if isinstance(v, dict) else v
out[k] = formdict_resolve(v, _root=False) if isinstance(v, dict) else v
if extract_main and _root and "" in out:
main = out[""]
del out[""]
return {**main, **out}
return out


Expand Down
18 changes: 13 additions & 5 deletions mininterface/gui_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


from .common import InterfaceNotAvailable
from .form_dict import FormDict, FormDictOrEnv, dataclass_to_formdict, dict_to_formdict, formdict_to_widgetdict
from .form_dict import FormDict, FormDictOrEnv, dataclass_to_formdict, dict_to_formdict, formdict_resolve, formdict_to_widgetdict
from .auxiliary import replace_widget_with, widgets_to_dict, recursive_set_focus, flatten
from .redirectable import RedirectTextTkinter, Redirectable
from .tag import Tag
Expand Down Expand Up @@ -44,22 +44,30 @@ def _ask_env(self) -> EnvClass:
formDict = dataclass_to_formdict(self.env, self._descriptions)

# formDict automatically fetches the edited values back to the EnvInstance
self.window.run_dialog(formDict)
return self.window.run_dialog(formDict) # TODO
return self.env

def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass:
""" Prompt the user to fill up whole form.
Args:
form: 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.Tag` that allows you to add descriptions.
The value might be a [`Tag`][mininterface.Tag] that allows you to add descriptions.
A checkbox example: {"my label": Tag(True, "my description")}
title: Optional form title.
"""
if form is None:
return self._ask_env() # NOTE should be integrated here when we integrate dataclass, see FormDictOrEnv
self.window.run_dialog(dict_to_formdict(form), title=title)
# NOTE should be integrated here when we integrate dataclass, see FormDictOrEnv
# TODO tohle mi přijde praktičnější, jak to dělá textual?
return self._ask_env()
else:
return formdict_resolve(self.window.run_dialog(dict_to_formdict(form), title=title), extract_main=True)
return out
# import ipdb; ipdb.set_trace() # TODO
aa = formdict_resolve(aa)
# TODO what does it return? A clean dict? Should we return a clean (without Tag) dict, always?
# print("62: aa", aa) # TODO
return aa
return form

def ask_number(self, text: str) -> int:
Expand Down
35 changes: 29 additions & 6 deletions mininterface/mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
else:
from typing import Generic

from .form_dict import EnvClass, FormDictOrEnv
from .form_dict import EnvClass, FormDictOrEnv, dataclass_to_formdict, formdict_resolve

logger = logging.getLogger(__name__)

Expand All @@ -17,7 +17,6 @@ class Cancelled(SystemExit):
pass



class Mininterface(Generic[EnvClass]):
""" The base interface.
You get one through [`mininterface.run`](run.md) which fills CLI arguments and config file to `mininterface.env`
Expand Down Expand Up @@ -93,10 +92,36 @@ def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOr
(Previously fetched from CLI and config file.)
A checkbox example: `{"my label": Tag(True, "my description")}`
title: Optional form title
Returns:
dict or dataclass
If the `form` is a dict, the output is a dict too.
Whereas the original form stays intact (with the values update),
we return a new raw dict with all values resolved
(all [`Tag`][mininterface.Tag] objects are resolved to their value).
```python
original = {"my label": Tag(True, "my description")}
output = m.form(original) # Sets the label to False in the dialog
# Original dict was updated
print(original["my label"]) # Tag(False, "my description")
# Output dict is resolved, contains only raw values
print(output["my label"]) # False
```
If the `form` is null, the output is [`self.env`][mininterface.Mininterface.env].
"""
f = self.env if form is None else form
# NOTE in the future, support form=arbitrary dataclass too
if form is None:
print(f"Asking the form {title}".strip(), self.env)
return self.env
f = form
print(f"Asking the form {title}".strip(), f)
return f # NOTE – this should return dict, not FormDict (get rid of auxiliary.Tag values)
# NOTE – this should return dict, not FormDict (get rid of auxiliary.Tag values)
# TODO už jsem to asi implementoval, udělej na to test
return formdict_resolve(f, extract_main=True)

def is_yes(self, text: str) -> bool:
""" Display confirm box, focusing yes.
Expand All @@ -115,5 +140,3 @@ def is_no(self, text: str) -> bool:
""" Display confirm box, focusing no. """
print("Asking no:", text)
return False


43 changes: 37 additions & 6 deletions mininterface/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
attr = None


FFValue = TypeVar("FFValue")
UiValue = TypeVar("UiValue")
""" Candidate for the TagValue. """
TD = TypeVar("TD")
""" dict """
TK = TypeVar("TK")
Expand Down Expand Up @@ -118,9 +119,29 @@ class Env:
"""

choices: list[str] | None = None
""" TODO """
# TODO docs missing
# TODO impementation in TextualInterface missing
""" Print the radio buttons. Constraint the value.
```python
from dataclasses import dataclass
from typing import Annotated
from mininterface import run, Choices
@dataclass
class Env:
foo: Annotated["str", Choices("one", "two")] = "one"
# `Choices` is an alias for `Tag(choices=)`
m = run(Env)
m.form() # prompts a dialog
```
"""
# NOTE we should support
# * Enums: Tag(enum) # no `choice` param`
# * more date types (now only str possible)
# * mininterface.choice `def choice(choices=, guesses=)`

_src_dict: TD | None = None
""" The original dict to be updated when UI ends."""
Expand Down Expand Up @@ -285,9 +306,13 @@ def _validate(self, out_value) -> TagValue:
* self.validation callback
* pydantic validation
* annotation type validation
* choice check
Returns:
If succeeded, return the (possibly transformed) value.
If failed, raises ValueError.
Raises:
ValueError: If failed, raises ValueError.
"""
if self.validation:
last = self.val
Expand Down Expand Up @@ -325,6 +350,12 @@ def _validate(self, out_value) -> TagValue:
if self.annotation and not self._is_right_instance(out_value):
self.set_error_text(f"Type must be {self._repr_annotation()}!")
raise ValueError

# Choice check
if self.choices and out_value not in self.choices:
self.set_error_text(f"Must be one of {self.choices}")
raise ValueError

return out_value

def update(self, ui_value: TagValue) -> bool:
Expand Down Expand Up @@ -429,7 +460,7 @@ def update(self, ui_value: TagValue) -> bool:
# return self.val

@staticmethod
def _submit_values(updater: Iterable[tuple["Tag", FFValue]]) -> bool:
def _submit_values(updater: Iterable[tuple["Tag", UiValue]]) -> bool:
""" Returns whether the form is alright or whether we should revise it.
Input is tuple of the Tags and their new values from the UI.
"""
Expand Down
20 changes: 14 additions & 6 deletions mininterface/textual_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll
from textual.widgets import Button, Checkbox, Footer, Header, Input, Label
from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, RadioSet, RadioButton
except ImportError:
from mininterface.common import InterfaceNotAvailable
raise InterfaceNotAvailable

from .auxiliary import flatten
from .facet import BackendAdaptor
from .form_dict import (EnvClass, FormDict, FormDictOrEnv,
dataclass_to_formdict, dict_to_formdict,
dataclass_to_formdict, dict_to_formdict, formdict_resolve,
formdict_to_widgetdict)
from .mininterface import Cancelled
from .redirectable import Redirectable
Expand Down Expand Up @@ -43,16 +43,18 @@ def _ask_env(self) -> EnvClass:
params_ = dataclass_to_formdict(self.env, self._descriptions)

# fetch the dict of dicts values from the form back to the namespace of the dataclasses
TextualApp.run_dialog(TextualApp(self), params_)
return TextualApp.run_dialog(TextualApp(self), params_)
# TODO
print("48: aa", aa) # TODO
return self.env

# NOTE: This works bad with lists. GuiInterface considers list as combobox (which is now suppressed by str conversion),
# TextualInterface as str. We should decide what should happen.
def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass:
if form is None:
return self._ask_env() # NOTE should be integrated here when we integrate dataclass, see FormDictOrEnv
TextualApp.run_dialog(TextualApp(self), dict_to_formdict(form), title)
return form
else:
return formdict_resolve(TextualApp.run_dialog(TextualApp(self), dict_to_formdict(form), title), extract_main=True)

# NOTE we should implement better, now the user does not know it needs an int
def ask_number(self, text: str):
Expand Down Expand Up @@ -93,6 +95,8 @@ def widgetize(tag: Tag) -> Checkbox | Input:
v = tag._get_ui_val()
if tag.annotation is bool or not tag.annotation and (v is True or v is False):
o = Checkbox(tag.name or "", v)
elif tag.choices:
o = RadioSet(*(RadioButton(ch, value=ch == tag.val) for ch in tag.choices))
else:
if not isinstance(v, (float, int, str, bool)):
v = str(v)
Expand All @@ -115,7 +119,11 @@ def run_dialog(cls, window: "TextualApp", formDict: FormDict, title: str = "") -
raise Cancelled

# validate and store the UI value → Tag value → original value
if not Tag._submit_values((field._link, field.value) for field in widgets):
candidates = ((
field._link,
str(field.pressed_button.label) if isinstance(field, RadioSet) else field.value
) for field in widgets)
if not Tag._submit_values(candidates):
return cls.run_dialog(TextualApp(window.interface), formDict, title)
return formDict

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ nav:
- Tag: "Tag.md"
- Validation: "Validation.md"
- Standalone: "Standalone.md"
- Changelog: "Changelog.md"
6 changes: 4 additions & 2 deletions tests/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Annotated

from mininterface import Tag
from mininterface.aliases import Validation
from mininterface.aliases import Validation, Choices
from mininterface.validators import not_empty


Expand Down Expand Up @@ -63,14 +63,16 @@ class OptionalFlagEnv:


@dataclass
class ConstrinedEnv:
class ConstrainedEnv:
"""Set of options."""

test: Annotated[str, Tag(validation=not_empty)] = "hello"
"""My testing flag"""

test2: Annotated[str, Validation(not_empty)] = "hello"

choices: Annotated[str, Choices("one", "two")] = "one"

@dataclass
class ParametrizedGeneric:
paths: list[Path]
Loading

0 comments on commit 879e3c3

Please sign in to comment.