From 7defc95322bebaf895c005b005af946f45edd2a1 Mon Sep 17 00:00:00 2001 From: Edvard Rejthar Date: Wed, 8 Jan 2025 17:01:36 +0100 Subject: [PATCH] gui descriptions back to the bottom --- docs/index.md | 4 ++ mininterface/cli_parser.py | 55 ++++++++++------- mininterface/tk_interface/external_fix.py | 73 +++++++++++++++++++++++ mininterface/tk_interface/utils.py | 1 + mininterface/types.py | 13 +--- 5 files changed, 114 insertions(+), 32 deletions(-) create mode 100644 mininterface/tk_interface/external_fix.py diff --git a/docs/index.md b/docs/index.md index 2393c02..594f7a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -114,6 +114,10 @@ pip install --no-dependencies mininterface pip install tyro typing_extensions pyyaml ``` +## MacOS GUI + +If the GUI does not work on MacOS, you might need to launch: `brew install python-tk` + # Docs See the docs overview at [https://cz-nic.github.io/mininterface/](https://cz-nic.github.io/mininterface/Overview/). diff --git a/mininterface/cli_parser.py b/mininterface/cli_parser.py index 5814d0c..9cd2f3a 100644 --- a/mininterface/cli_parser.py +++ b/mininterface/cli_parser.py @@ -150,7 +150,7 @@ def run_tyro_parser(env_or_list: Type[EnvClass] | list[Type[EnvClass]], if ask_for_missing and getattr(e, "code", None) == 2 and eavesdrop: # Some required arguments are missing. Determine which. wf = {} - for arg in eavesdrop.partition(":")[2].strip().split(", "): + for arg in _fetch_eavesdrop_args(): treat_missing(type_form, kwargs, parser, wf, arg) # Second attempt to parse CLI @@ -195,7 +195,6 @@ def treat_missing(env_class, kwargs: dict, parser: ArgumentParser, wf: dict, arg # However, the UI then is not able to use ex. the number filtering capabilities. # Putting there None is not a good idea as dataclass_to_tagdict fails if None is not allowed by the annotation. tag = wf[field_name] = tag_factory(MissingTagValue(), - # tag = wf[field_name] = tag_factory(MISSING, argument.help.replace("(required)", ""), validation=not_empty, _src_class=env_class, @@ -205,9 +204,17 @@ def treat_missing(env_class, kwargs: dict, parser: ArgumentParser, wf: dict, arg # A None would be enough because Mininterface will ask for the missing values # promply, however, Pydantic model would fail. # As it serves only for tyro parsing and the field is marked wrong, the made up value is never used or seen. - if "default" not in kwargs: - kwargs["default"] = SimpleNamespace() - setattr(kwargs["default"], field_name, tag._make_default_value()) + set_default(kwargs, field_name, tag._make_default_value()) + + +def _fetch_eavesdrop_args(): + return eavesdrop.partition(":")[2].strip().split(", ") + + +def set_default(kwargs, field_name, val): + if "default" not in kwargs: + kwargs["default"] = SimpleNamespace() + setattr(kwargs["default"], field_name, val) def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]], @@ -228,15 +235,20 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]], Returns: Configuration namespace. """ + if isinstance(env_or_list, list): + subcommands, env = env_or_list, None + else: + subcommands, env = None, env_or_list + # Load config file - if config_file and isinstance(env_or_list, list): - # NOTE. Reading config files when using subcommands is not implemented. + if config_file and subcommands: + # Reading config files when using subcommands is not implemented. static = {} kwargs["default"] = None warnings.warn(f"Config file {config_file} is ignored because subcommands are used." - "It is not easy to set who this should work. " - "Describe the developer your usecase so that they might implement this.") - if "default" not in kwargs and not isinstance(env_or_list, list): + " It is not easy to set how this should work." + " Describe the developer your usecase so that they might implement this.") + if "default" not in kwargs and not subcommands: # Undocumented feature. User put a namespace into kwargs["default"] # that already serves for defaults. We do not fetch defaults yet from a config file. disk = {} @@ -244,29 +256,28 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]], disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok # Nested dataclasses have to be properly initialized. YAML gave them as dicts only. for key in (key for key, val in disk.items() if isinstance(val, dict)): - disk[key] = env_or_list.__annotations__[key](**disk[key]) + disk[key] = env.__annotations__[key](**disk[key]) # Fill default fields - if pydantic and issubclass(env_or_list, BaseModel): + if pydantic and issubclass(env, BaseModel): # Unfortunately, pydantic needs to fill the default with the actual values, # the default value takes the precedence over the hard coded one, even if missing. - static = {key: env_or_list.model_fields.get(key).default - for ann in yield_annotations(env_or_list) for key in ann if not key.startswith("__") and not key in disk} - # static = {key: env_or_list.model_fields.get(key).default - # for key, _ in iterate_attributes(env_or_list) if not key in disk} - elif attr and attr.has(env_or_list): + static = {key: env.model_fields.get(key).default + for ann in yield_annotations(env) for key in ann if not key.startswith("__") and not key in disk} + # static = {key: env_.model_fields.get(key).default + # for key, _ in iterate_attributes(env_) if not key in disk} + elif attr and attr.has(env): # Unfortunately, attrs needs to fill the default with the actual values, # the default value takes the precedence over the hard coded one, even if missing. # NOTE Might not work for inherited models. static = {key: field.default - for key, field in attr.fields_dict(env_or_list).items() if not key.startswith("__") and not key in disk} + for key, field in attr.fields_dict(env).items() if not key.startswith("__") and not key in disk} else: # To ensure the configuration file does not need to contain all keys, we have to fill in the missing ones. # Otherwise, tyro will spawn warnings about missing fields. static = {key: val - for key, val in yield_defaults(env_or_list) if not key.startswith("__") and not key in disk} - kwargs["default"] = SimpleNamespace(**(disk | static)) + for key, val in yield_defaults(env) if not key.startswith("__") and not key in disk} + kwargs["default"] = SimpleNamespace(**(static | disk)) # Load configuration from CLI - env, wrong_fields = run_tyro_parser(env_or_list, kwargs, add_verbosity, ask_for_missing, args) - return env, wrong_fields + return run_tyro_parser(subcommands or env, kwargs, add_verbosity, ask_for_missing, args) diff --git a/mininterface/tk_interface/external_fix.py b/mininterface/tk_interface/external_fix.py new file mode 100644 index 0000000..8f998e2 --- /dev/null +++ b/mininterface/tk_interface/external_fix.py @@ -0,0 +1,73 @@ +# The purpose of the file is to put the descriptions to the bottom of the widgets as it was in the former version of the tkinter_form. +from tkinter import ttk + +from tkinter_form import Form, Value, FieldForm + +orig = Form._Form__create_widgets + + +def __create_widgets_monkeypatched( + self, form_dict: dict, name_config: str, button_command: callable +) -> None: + """ + Create form widgets + + Args: + form_dict (dict): form dict base + name_config (str): name_config + button (bool): button_config + """ + + index = 0 + for _, (name_key, value) in enumerate(form_dict.items()): + index += 1 + description = None + if isinstance(value, Value): + value, description = value.val, value.description + + self.rowconfigure(index, weight=1) + + if isinstance(value, dict): + widget = Form(self, name_key, value) + widget.grid(row=index, column=0, columnspan=3, sticky="nesw") + + self.fields[name_key] = widget + last_index = index + continue + + variable = self._Form__type_vars[type(value)]() + widget = self._Form__type_widgets[type(value)](self) + + self.columnconfigure(1, weight=1) + widget.grid(row=index, column=1, sticky="nesw", padx=2, pady=2) + label = ttk.Label(self, text=name_key) + self.columnconfigure(0, weight=1) + label.grid(row=index, column=0, sticky="nes", padx=2, pady=2) + + # Add a further description to the row below the widget + description_label = None + if not description is None: + index += 1 + description_label = ttk.Label(self, text=description) + description_label.grid(row=index, column=1, columnspan=2, sticky="nesw", padx=2, pady=2) + + self.fields[name_key] = FieldForm( + master=self, + label=label, + widget=widget, + variable=variable, + value=value, + description=description_label, + ) + + last_index = index + + if button_command: + self._Form__command = button_command + self.button = ttk.Button( + self, text=name_config, command=self._Form__command_button + ) + self.button.grid(row=last_index + 1, column=0, columnspan=3, sticky="nesw") + + +Form._Form__create_widgets = __create_widgets_monkeypatched diff --git a/mininterface/tk_interface/utils.py b/mininterface/tk_interface/utils.py index 2dbe966..4d51384 100644 --- a/mininterface/tk_interface/utils.py +++ b/mininterface/tk_interface/utils.py @@ -14,6 +14,7 @@ from ..tag import Tag from ..types import DatetimeTag, PathTag from .date_entry import DateEntryFrame +from .external_fix import __create_widgets_monkeypatched if TYPE_CHECKING: from tk_window import TkWindow diff --git a/mininterface/types.py b/mininterface/types.py index 08385cc..58daecb 100644 --- a/mininterface/types.py +++ b/mininterface/types.py @@ -159,10 +159,7 @@ def __post_init__(self): @dataclass(repr=False) class DatetimeTag(Tag): """ - !!! warning - Experimental. Still in development. - - Datetime is supported. + Datetime, date and time types are supported. ```python3 from datetime import datetime @@ -214,13 +211,12 @@ class Env: # ![Time only](asset/datetime_time.avif) # NOTE: It would be nice we might put any date format to be parsed. - # NOTE: The parameters are still ignored. date: bool = False - """ The date part is active """ + """ The date part is active. True for datetime and date. """ time: bool = False - """ The time part is active """ + """ The time part is active. True for datetime and time. """ full_precision: bool = False """ Include full time precison, seconds, microseconds. """ @@ -230,9 +226,6 @@ def __post_init__(self): if self.annotation: self.date = issubclass(self.annotation, date) self.time = issubclass(self.annotation, time) or issubclass(self.annotation, datetime) - # NOTE: remove - # if not self.time and self.full_precision: - # self.full_precision = False def _make_default_value(self): return datetime.now()