diff --git a/mininterface/form_dict.py b/mininterface/form_dict.py index c681671..c94e4eb 100644 --- a/mininterface/form_dict.py +++ b/mininterface/form_dict.py @@ -10,7 +10,7 @@ from .auxiliary import get_description from .tag import Tag, TagValue -from .tag_factory import tag_factory +from .tag_factory import tag_assure_type, tag_fetch, tag_factory if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self` from typing import Self @@ -105,7 +105,8 @@ def dict_to_tagdict(data: dict, mininterface: Optional["Mininterface"] = None) - if not isinstance(val, Tag): tag = Tag(val, "", name=key, _src_dict=data, _src_key=key, **d) else: - tag = val._fetch_from(Tag(**d)) + tag = tag_fetch(val, d) + tag = tag_assure_type(tag) fd[key] = tag return fd @@ -198,6 +199,7 @@ def dataclass_to_tagdict(env: EnvClass | Type[EnvClass], mininterface: Optional[ if not isinstance(val, Tag): tag = tag_factory(val, _src_key=param, _src_obj=env, **d) else: - tag = val._fetch_from(Tag(**d)) + tag = tag_fetch(val, d) + tag = tag_assure_type(tag) (subdict if _nested else main)[param] = tag return subdict diff --git a/mininterface/mininterface.py b/mininterface/mininterface.py index 49c1ff6..1ed59e7 100644 --- a/mininterface/mininterface.py +++ b/mininterface/mininterface.py @@ -345,7 +345,7 @@ class Color(Enum): # The form dict might be a default dict but we want output just the dict (it's shorter). f = dict(f) print(f"Asking the form {title}".strip(), f) - return self._form(form, title, MinAdaptor(self)) + return self._form(form, title, MinAdaptor(self), submit) def _form(self, form: DataClass | Type[DataClass] | FormDict | None, diff --git a/mininterface/tag.py b/mininterface/tag.py index 8bd0d36..e1e801c 100644 --- a/mininterface/tag.py +++ b/mininterface/tag.py @@ -417,13 +417,6 @@ def _is_subclass(self, class_type: type | tuple[type]): return True return False - def _morph(self, class_type: "Self", morph_if: type | tuple[type]): - """ To be overrided by the subclasses. - The user used a Path within a Tag and that will turn it into a PathTag when the UI needs it. - """ - if self._is_subclass(morph_if): # return a blank PathTag - return class_type(self.val, annotation=self.annotation) - def _get_possible_types(self) -> list[tuple]: """ Possible types we can cast the value to. For annotation `list[int] | tuple[str] | str | None`, @@ -443,7 +436,7 @@ def _(annot): elif annot is not None and annot is not NoneType: # from UnionType, we get a NoneType return None, annot - return None # to be filtered out + return None # to be filtered out out = _(self.annotation) return [x for x in (out if isinstance(out, list) else [out]) if x is not None] @@ -498,7 +491,7 @@ def _get_ui_val(self): """ for origin, _ in self._get_possible_types(): if origin: - return origin(str(v)for v in self.val) + return origin(str(v) for v in self.val) if isinstance(self.val, Enum): return self.val.value return self.val diff --git a/mininterface/tag_factory.py b/mininterface/tag_factory.py index 790eae2..f9528f3 100644 --- a/mininterface/tag_factory.py +++ b/mininterface/tag_factory.py @@ -1,9 +1,10 @@ +from copy import copy +from pathlib import Path +from typing import Type, get_type_hints + from .tag import Tag from .type_stubs import TagCallback -from .types import CallbackTag - - -from typing import get_type_hints +from .types import CallbackTag, PathTag def _get_annotation_from_class_hierarchy(cls, key): @@ -21,6 +22,23 @@ def get_type_hint_from_class_hierarchy(cls, key): return None +def _get_tag_type(tag: Tag) -> Type[Tag]: + if tag._is_subclass(Path): + return PathTag + return Tag + + +def tag_fetch(tag: Tag, ref: dict | None): + return tag._fetch_from(Tag(**ref)) + + +def tag_assure_type(tag: Tag): + # morph to correct class `Tag("", annotation=Path)` -> `PathTag("", annotation=Path)` + if (type_ := _get_tag_type(tag)) is not Tag: + return type_(annotation=tag.annotation)._fetch_from(tag) + return tag + + def tag_factory(val=None, description=None, annotation=None, *args, _src_obj=None, _src_key=None, _src_class=None, **kwargs): if _src_obj and not _src_class: # NOTE it seems _src_obj is sometimes accepts Type[DataClass], and not a DataClass, @@ -45,7 +63,13 @@ def tag_factory(val=None, description=None, annotation=None, *args, _src_obj=Non if isinstance(metadata, Tag): # NOTE might fetch from a pydantic model too # The type of the Tag is another Tag # Ex: `my_field: Validation(...) = 4` - # Why fetching metadata name? The name would be taken from _src_obj. - # But the user defined in metadata is better. - return Tag(val, description, name=metadata.name, *args, **kwargs)._fetch_from(metadata) - return Tag(val, description, annotation, *args, **kwargs) + + new = copy(metadata) + new.val = val if val is not None else new.val + new.description = description or new.description + return new._fetch_from(Tag(*args, **kwargs)) + # NOTE The mechanism is not perfect. When done, we may test configs.PathTagClass. + # * fetch_from will not transfer PathTag.multiple + # * copy will not transfer list[Path] from `Annotated[list[Path], Tag(...)]` + return type(metadata)(val, description, name=metadata.name, annotation=annotation, *args, **kwargs)._fetch_from(metadata) + return tag_assure_type(Tag(val, description, annotation, *args, **kwargs)) diff --git a/mininterface/tk_interface/dategui.py b/mininterface/tk_interface/dategui.py new file mode 100644 index 0000000..b6b7561 --- /dev/null +++ b/mininterface/tk_interface/dategui.py @@ -0,0 +1,127 @@ +import tkinter as tk +from tkcalendar import Calendar +import re +from datetime import datetime + +def increment_date(event=None): + change_date(1) + +def decrement_date(event=None): + change_date(-1) + +def change_date(delta): + date_str = spinbox.get() + caret_pos = spinbox.index(tk.INSERT) + + # Split the date string by multiple delimiters + split_input = re.split(r'[- :.]', date_str) + + # Determine which part of the date the caret is on + # 0 -> day + # 1 -> month + # 2 -> year + # 3 -> hour + # 4 -> minute + # 5 -> second + # 6 -> microsecond + if caret_pos < 3: + part_index = 0 + elif caret_pos < 6: + part_index = 1 + elif caret_pos < 11: + part_index = 2 + elif caret_pos < 14: + part_index = 3 + elif caret_pos < 17: + part_index = 4 + elif caret_pos < 20: + part_index = 5 + else: + part_index = 6 + + # Increment or decrement the relevant part + number = int(split_input[part_index]) + new_number = number + delta + split_input[part_index] = str(new_number).zfill(len(split_input[part_index])) + + # Reconstruct the date string + new_date_str = f"{split_input[0]}-{split_input[1]}-{split_input[2]} {split_input[3]}:{split_input[4]}:{split_input[5]}.{split_input[6][:2]}" + + # Validate the new date + try: + datetime.strptime(new_date_str, '%d-%m-%Y %H:%M:%S.%f') + spinbox.delete(0, tk.END) + spinbox.insert(0, new_date_str) + spinbox.icursor(caret_pos) + update_calendar(new_date_str) + except ValueError: + pass + +def on_spinbox_click(event): + # Check if the click was on the spinbox arrows + if spinbox.identify(event.x, event.y) == "buttonup": + increment_date() + elif spinbox.identify(event.x, event.y) == "buttondown": + decrement_date() + +def on_date_select(event): + selected_date = calendar.selection_get() + current_time = datetime.now().strftime("%H:%M:%S.%f")[:-4] + new_date_str = f"{selected_date.strftime('%d-%m-%Y')} {current_time}" + spinbox.delete(0, tk.END) + spinbox.insert(0, new_date_str) + update_calendar(new_date_str) + +def update_calendar(date_str): + try: + date_obj = datetime.strptime(date_str, '%d-%m-%Y %H:%M:%S.%f') + calendar.selection_set(date_obj) + except ValueError: + pass + +def on_spinbox_change(event): + update_calendar(spinbox.get()) + +def copy_to_clipboard(): + root.clipboard_clear() + root.clipboard_append(spinbox.get()) + root.update() # now it stays on the clipboard after the window is closed + +root = tk.Tk() +root.geometry("800x600") +root.title("Date Editor") + +spinbox = tk.Spinbox(root, font=("Arial", 16), width=30, wrap=True) +spinbox.pack(padx=20, pady=20) +spinbox.insert(0, datetime.now().strftime("%d-%m-%Y %H:%M:%S.%f")[:-4]) + +# Bind up/down arrow keys +spinbox.bind("", increment_date) +spinbox.bind("", decrement_date) + +# Bind mouse click on spinbox arrows +spinbox.bind("", on_spinbox_click) + +# Bind key release event to update calendar when user changes the input field +spinbox.bind("", on_spinbox_change) + +# Create a frame to hold the calendar and copy button +frame = tk.Frame(root) +frame.pack(padx=20, pady=20, expand=True, fill=tk.BOTH) + +# Add a calendar widget +calendar = Calendar(frame, selectmode='day', date_pattern='dd-mm-yyyy') +calendar.place(relwidth=0.7, relheight=0.8, anchor='n', relx=0.5) + +# Bind date selection event +calendar.bind("<>", on_date_select) + +# Add a copy-to-clipboard button +copy_button = tk.Button(frame, text="Copy to Clipboard", command=copy_to_clipboard, height=1) +copy_button.place(relwidth=0.2, relheight=0.1, anchor='n', relx=0.5, rely=0.85) + +# Initialize calendar with the current date +update_calendar(spinbox.get()) + +root.mainloop() + diff --git a/mininterface/tk_interface/utils.py b/mininterface/tk_interface/utils.py index 3a73a11..6c17e2d 100644 --- a/mininterface/tk_interface/utils.py +++ b/mininterface/tk_interface/utils.py @@ -126,11 +126,10 @@ def _fetch(variable): variable.set(choice_label) # File dialog - elif path_tag := tag._morph(PathTag, (PosixPath, Path)): - # TODO this probably happens at ._factoryTime, get rid of _morph. I do not know, touch-timestamp uses nested Tag. + elif isinstance(tag, PathTag): grid_info = widget.grid_info() - widget2 = Button(master, text='…', command=choose_file_handler(variable, path_tag)) + widget2 = Button(master, text='…', command=choose_file_handler(variable, tag)) widget2.grid(row=grid_info['row'], column=grid_info['column']+1) # Special type: Submit button diff --git a/mininterface/types.py b/mininterface/types.py index 16adb14..7bf0f41 100644 --- a/mininterface/types.py +++ b/mininterface/types.py @@ -113,7 +113,7 @@ def _run_callable(self): @dataclass class PathTag(Tag): """ - Use this helper object to select files. + Contains a Path or their list. Use this helper object to select files. In the following example, we see that it is not always needed to use this object. @@ -141,7 +141,7 @@ class PathTag(Tag): # NOTE turn SubmitButton into a Tag too and turn this into a types module. # NOTE Missing in textual. Might implement file filter and be used for validation. (ex: file_exist, is_dir) # NOTE Path multiple is not recognized: "File 4": Tag([], annotation=list[Path]) - multiple: str = False + multiple: bool = False """ The user can select multiple files. """ def __post_init__(self): @@ -153,8 +153,3 @@ def __post_init__(self): if origin in common_iterables: self.multiple = True break - - @override - def _morph(self, class_type: "Self", morph_if: type | tuple[type]): - if class_type == PathTag: - return self diff --git a/tests/configs.py b/tests/configs.py index 0eccbe7..6358068 100644 --- a/tests/configs.py +++ b/tests/configs.py @@ -7,7 +7,7 @@ from mininterface import Tag from mininterface.subcommands import Command -from mininterface.types import CallbackTag, Choices, Validation +from mininterface.types import CallbackTag, Choices, PathTag, Validation from mininterface.validators import not_empty @@ -124,13 +124,21 @@ class ParametrizedGeneric: @dataclass class ComplicatedTypes: + # NOTE not used yet p1: Callable = callback_raw p2: Annotated[Callable, CallbackTag(description="Foo")] = callback_tag # Not supported: p3: CallbackTag = callback_tag # Not supported: p4: CallbackTag = field(default_factory=CallbackTag(callback_tag)) # Not supported: p5: Annotated[Callable, Tag(description="Bar", annotation=CallbackTag)] = callback_tag - # NOTE add PathTag - # NOTE not used yet + + +@dataclass +class PathTagClass: + files: Positional[list[Path]] = field(default_factory=list) + # NOTE this should become PathTag(multiple=True) + # files2: Annotated[list, Tag(name="Custom name")] = field(default_factory=list) + # NOTE this should become PathTag(multiple=True) + # files2: Annotated[list, PathTag(name="Custom name")] = field(default_factory=list) @dataclass diff --git a/tests/tests.py b/tests/tests.py index 994d72d..8975787 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -14,7 +14,7 @@ from configs import (AnnotatedClass, MissingPositional, InheritedAnnotatedClass, ColorEnum, ColorEnumSingle, ConflictingEnv, ConstrainedEnv, FurtherEnv2, MissingUnderscore, NestedDefaultedEnv, NestedMissingEnv, OptionalFlagEnv, - ParametrizedGeneric, SimpleEnv, Subcommand1, Subcommand2, + ParametrizedGeneric, PathTagClass, SimpleEnv, Subcommand1, Subcommand2, callback_raw, callback_tag, callback_tag2) from mininterface.exceptions import Cancelled from pydantic_configs import PydModel, PydNested, PydNestedRestraint @@ -22,17 +22,17 @@ from mininterface import EnvClass, Mininterface, TextInterface, run from mininterface.auxiliary import flatten from mininterface.subcommands import SubcommandPlaceholder -from mininterface.form_dict import (TagDict, dataclass_to_tagdict, +from mininterface.form_dict import (TagDict, dataclass_to_tagdict, dict_to_tagdict, formdict_resolve) from mininterface.start import Start from mininterface.tag import Tag -from mininterface.types import CallbackTag +from mininterface.types import CallbackTag, PathTag from mininterface.validators import limit, not_empty SYS_ARGV = None # To be redirected -def runm(env_class: Type[EnvClass] | list[Type[EnvClass]], args=None, **kwargs) -> Mininterface[EnvClass]: +def runm(env_class: Type[EnvClass] | list[Type[EnvClass]] | None = None, args=None, **kwargs) -> Mininterface[EnvClass]: return run(env_class, interface=Mininterface, args=args, **kwargs) @@ -211,7 +211,7 @@ def test_choice_callback(self): self.assertEqual(50, m.choice(choices, default=callback_raw)) - # TODO This test does not work. We have to formalize the callback. + # NOTE This test does not work. We have to formalize the callback. # self.assertEqual(100, m.choice(choices, default=choices["My choice2"])) @@ -462,6 +462,44 @@ def test_nested_tag(self): self.assertEqual(3, inner.val) +class TestInheritedTag(TestAbstract): + def test_inherited_path(self): + PathType = type(Path("")) # PosixPath on Linux + m = runm() + d = dict_to_tagdict({ + "1": Path("/tmp"), + "2": Tag("", annotation=Path), + "3": PathTag([Path("/tmp")], multiple=True), + "4": PathTag([Path("/tmp")]), + "5": PathTag(Path("/tmp")), + }, m) + + # every item became PathTag + [self.assertEqual(type(v), PathTag) for v in d.values()] + # val stayed the same + self.assertEqual(d["1"].val, Path('/tmp')) + # correct annotation + self.assertEqual(d["1"].annotation, PathType) + self.assertEqual(d["2"].annotation, Path) + self.assertEqual(d["3"].annotation, list[PathType]) + self.assertEqual(d["3"].annotation, list[PathType]) + self.assertEqual(d["4"].annotation, list[PathType]) + # PathTag specific attribute + [self.assertTrue(v.multiple) for k, v in d.items() if k in ("3", "4")] + [self.assertFalse(v.multiple) for k, v in d.items() if k in ("1", "2", "5")] + + def test_path_class(self): + m = runm(PathTagClass, ["/tmp"]) # , "--files2", "/usr"]) + d = dataclass_to_tagdict(m.env)[""] + + [self.assertEqual(type(v), PathTag) for v in d.values()] + self.assertEqual(d["files"].name, "files") + self.assertEqual(d["files"].multiple, True) + + # self.assertEqual(d["files2"].name, "Custom name") + # self.assertEqual(d["files2"].multiple, True) + + class TestRun(TestAbstract): def test_run_ask_empty(self): with self.assertOutputs("Asking the form SimpleEnv(test=False, important_number=4)"):