From 150e3d14fa3b470d6b717cd6d5f1464fd36dfedd Mon Sep 17 00:00:00 2001 From: Edvard Rejthar Date: Wed, 25 Sep 2024 19:02:35 +0200 Subject: [PATCH] missing attrs with underscores --- docs/Overview.md | 21 ++++++++++++++++++--- docs/index.md | 26 +++++++++++++++++--------- mininterface/__init__.py | 2 +- mininterface/cli_parser.py | 23 +++++++++++++++-------- mininterface/gui_interface/utils.py | 1 - mininterface/mininterface.py | 19 +++++++++++++++++-- mininterface/tag.py | 1 - mininterface/types.py | 10 ++++++---- mkdocs.yml | 7 ++++++- tests/configs.py | 5 +++++ tests/tests.py | 15 +++++++++++++-- 11 files changed, 98 insertions(+), 32 deletions(-) diff --git a/docs/Overview.md b/docs/Overview.md index 379abd1..0d57e08 100644 --- a/docs/Overview.md +++ b/docs/Overview.md @@ -1,5 +1,19 @@ +Via the [run][mininterface.run] function you get access to the CLI, possibly enriched from the config file. Then, you receive all data as [`m.env`][mininterface.Mininterface.env] object and dialog methods in a proper UI. + +```mermaid +graph LR + subgraph mininterface + run --> GUI + run --> TUI + run --> env + CLI --> run + id1[config file] --> CLI + end + program --> run +``` + ## Basic usage -Use a common [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), a Pydantic [BaseModel](https://brentyi.github.io/tyro/examples/04_additional/08_pydantic/) or an [attrs](https://brentyi.github.io/tyro/examples/04_additional/09_attrs/) model to store the configuration. Wrap it to the [run][mininterface.run] method that returns an interface `m`. Access the configuration via [`m.env`][mininterface.Mininterface.env] or use it to prompt the user [`m.is_yes("Is that alright?")`][mininterface.Mininterface.is_yes]. +Use a common [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), a Pydantic [BaseModel](https://brentyi.github.io/tyro/examples/04_additional/08_pydantic/) or an [attrs](https://brentyi.github.io/tyro/examples/04_additional/09_attrs/) model to store the configuration. Wrap it to the [run][mininterface.run] function that returns an interface `m`. Access the configuration via [`m.env`][mininterface.Mininterface.env] or use it to prompt the user [`m.is_yes("Is that alright?")`][mininterface.Mininterface.is_yes]. To do any advanced things, stick the value to a powerful [`Tag`][mininterface.Tag] or its subclassed [types][mininterface.types]. Ex. for a validation only, use its [`Validation alias`](Validation.md/#validation-alias). @@ -87,11 +101,12 @@ class FurtherConfig: host: str = "example.org" @dataclass -class Config: +class Env: further: FurtherConfig ... -print(config.further.host) # example.org +m = run(Env) +print(m.env.further.host) # example.org ``` The attributes can by defaulted by CLI: diff --git a/docs/index.md b/docs/index.md index e0e569e..15fed22 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,19 +15,19 @@ from mininterface import run @dataclass class Env: - """Set of options.""" + """ This calculates something. """ - test: bool = False - """ My testing flag """ + my_flag: bool = False + """ This switches the functionality """ - important_number: int = 4 + my_number: int = 4 """ This number is very important """ if __name__ == "__main__": env = run(Env, prog="My application").env # Attributes are suggested by the IDE # along with the hint text 'This number is very important'. - print(env.important_number) + print(env.my_number) ``` # Contents @@ -42,11 +42,14 @@ if __name__ == "__main__": ## You got CLI It was all the code you need. No lengthy blocks of code imposed by an external dependency. Besides the GUI/TUI, you receive powerful YAML-configurable CLI parsing. +TODO regenerate output and all the images +TODO it seems that input missing dataclass fields is broken, it just shows it must be set via cli + ```bash $ ./hello.py usage: My application [-h] [--test | --no-test] [--important-number INT] -Set of options. +This calculates something. ╭─ options ──────────────────────────────────────────────────────────╮ │ -h, --help show this help message and exit │ @@ -59,7 +62,7 @@ Set of options. Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml` and put there some of the arguments. They are seamlessly taken as defaults. ```yaml -important_number: 555 +my_number: 555 ``` ## You got dialogues @@ -67,7 +70,7 @@ Check out several useful methods to handle user dialogues. Here we bound the int ```python with run(Env) as m: - print(f"Your important number is {m.env.important_number}") + print(f"Your important number is {m.env.my_number}") boolean = m.is_yes("Is that alright?") ``` @@ -82,7 +85,7 @@ Writing a small and useful program might be a task that takes fifteen minutes. A The config variables needed by your program are kept in cozy dataclasses. Write less! The syntax of [tyro](https://github.com/brentyi/tyro) does not require any overhead (as its `argparse` alternatives do). You just annotate a class attribute, append a simple docstring and get a fully functional application: * Call it as `program.py --help` to display full help. -* Use any flag in CLI: `program.py --test` causes `env.test` be set to `True`. +* Use any flag in CLI: `program.py --my-flag` causes `env.my_flag` be set to `True`. * 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. @@ -271,6 +274,11 @@ m.form(my_dictionary) + + + + + diff --git a/mininterface/__init__.py b/mininterface/__init__.py index 7908b12..55020cf 100644 --- a/mininterface/__init__.py +++ b/mininterface/__init__.py @@ -93,7 +93,7 @@ class Env: ```python @dataclass class Env: - required_number: int + required_number: int m = run(Env, ask_for_missing=True) ``` diff --git a/mininterface/cli_parser.py b/mininterface/cli_parser.py index d51ee16..9b2191e 100644 --- a/mininterface/cli_parser.py +++ b/mininterface/cli_parser.py @@ -121,18 +121,25 @@ def run_tyro_parser(env_class: Type[EnvClass], # (with a graceful message from tyro) pass else: + # get the original attribute name (argparse uses dash instead of underscores) + field_name = argument.dest + if field_name not in env_class.__annotations__: + field_name = field_name.replace("-", "_") + if field_name not in env_class.__annotations__: + raise ValueError(f"Cannot find {field_name} in the configuration object") + # NOTE: We put '' to the UI to clearly state that the value is missing. # However, the UI then is not able to use the number filtering capabilities. - tag = wf[argument.dest] = Tag("", - argument.help.replace("(required)", ""), - validation=not_empty, - _src_class=env_class, - _src_key=argument.dest - ) + tag = wf[field_name] = Tag("", + argument.help.replace("(required)", ""), + validation=not_empty, + _src_class=env_class, + _src_key=field_name + ) # Why `type_()`? We need to put a default value so that the parsing will not fail. # A None would be enough because Mininterface will ask for the missing values # promply, however, Pydantic model would fail. - setattr(kwargs["default"], argument.dest, tag.annotation()) + setattr(kwargs["default"], field_name, tag.annotation()) # Second attempt to parse CLI # Why catching warnings? All the meaningful warnings @@ -185,7 +192,7 @@ def _parse_cli(env_class: Type[EnvClass], # 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. static = {key: field.default - for key,field in attr.fields_dict(env_class).items() if not key.startswith("__") and not key in disk} + for key, field in attr.fields_dict(env_class).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. diff --git a/mininterface/gui_interface/utils.py b/mininterface/gui_interface/utils.py index fa58625..8308d3a 100644 --- a/mininterface/gui_interface/utils.py +++ b/mininterface/gui_interface/utils.py @@ -102,7 +102,6 @@ def _fetch(variable): # File dialog if path_tag := tag._morph(PathTag, Path): grid_info = widget.grid_info() - master.grid(row=grid_info['row'], column=grid_info['column']) widget2 = Button(master, text='👓', command=choose_file_handler(variable, path_tag)) widget2.grid(row=grid_info['row'], column=grid_info['column']+1) diff --git a/mininterface/mininterface.py b/mininterface/mininterface.py index 5db6a68..b71843e 100644 --- a/mininterface/mininterface.py +++ b/mininterface/mininterface.py @@ -96,7 +96,22 @@ def ask(self, text: str) -> str: raise Cancelled(".. cancelled") def ask_number(self, text: str) -> int: - """ Prompt the user to input a number. Empty input = 0. """ + """ Prompt the user to input a number. Empty input = 0. + + ```python + m = run() # receives a Mininterface object + m.ask_number("What's your age?") + ``` + + ![Ask number dialog](asset/standalone_number.avif) + + Args: + text: The question text. + + Returns: + Number + + """ print("Asking number", text) return 0 @@ -110,7 +125,7 @@ def choice(self, choices: ChoicesType, title: str = "", _guesses=None, Either put options in an iterable: ```python - from mininterface import run, Tag + from mininterface import run m = run() m.choice([1, 2]) ``` diff --git a/mininterface/tag.py b/mininterface/tag.py index 7b37cd7..d310634 100644 --- a/mininterface/tag.py +++ b/mininterface/tag.py @@ -347,7 +347,6 @@ class Env: return isinstance(val, origin) and all(isinstance(item, subtype) for item in val) def _is_subclass(self, class_type): - print("350: self.annotation", self.annotation) # TODO try: return issubclass(self.annotation, class_type) except TypeError: # None, Union etc cast an error diff --git a/mininterface/types.py b/mininterface/types.py index 31f2b54..a2bae30 100644 --- a/mininterface/types.py +++ b/mininterface/types.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable +from typing import Callable, override from typing_extensions import Self from .tag import Tag, ValidationResult, TagValue @@ -58,7 +58,7 @@ class PathTag(Tag): ![File picker](asset/file_picker.avif) """ # 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. + # 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 """ The user can select multiple files. """ @@ -67,5 +67,7 @@ def __post_init__(self): super().__post_init__() self.annotation = list[Path] if self.multiple else Path - def _morph(self, *_): - return self + @override + def _morph(self, class_type: "Self", morph_if: type): + if class_type == PathTag: + return self diff --git a/mkdocs.yml b/mkdocs.yml index ff142bf..d8fbadb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,7 @@ theme: plugins: - search + - mermaid2 - mkdocstrings: handlers: python: @@ -29,7 +30,11 @@ markdown_extensions: - pymdownx.snippets - admonition - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: # make exceptions to highlighting of code: + - name: mermaid + class: mermaid + format: !!python/name:mermaid2.fence_mermaid_custom - pymdownx.keys # keyboard keys - tables - footnotes diff --git a/tests/configs.py b/tests/configs.py index 93fbdb0..e179a6b 100644 --- a/tests/configs.py +++ b/tests/configs.py @@ -32,6 +32,11 @@ class FurtherEnv2: token: str host: str = "example.org" +@dataclass +class MissingUnderscore: + token_underscore: str + host: str = "example.org" + @dataclass class NestedMissingEnv: diff --git a/tests/tests.py b/tests/tests.py index 55fdffa..b314464 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -9,7 +9,7 @@ from unittest.mock import patch from attrs_configs import AttrsModel, AttrsNested, AttrsNestedRestraint -from configs import (ConstrainedEnv, FurtherEnv2, NestedDefaultedEnv, +from configs import (ConstrainedEnv, FurtherEnv2, FurtherEnv3, MissingUnderscore, NestedDefaultedEnv, NestedMissingEnv, OptionalFlagEnv, ParametrizedGeneric, SimpleEnv) from pydantic_configs import PydModel, PydNested, PydNestedRestraint @@ -318,10 +318,21 @@ def test_run_ask_for_missing(self): with patch('sys.stdout', new_callable=StringIO) as stdout: run(FurtherEnv2, True, ask_for_missing=True, interface=Mininterface) self.assertEqual("", stdout.getvalue().strip()) - with patch('sys.stdout', new_callable=StringIO) as stdout: run(FurtherEnv2, True, ask_for_missing=False, interface=Mininterface) self.assertEqual("", stdout.getvalue().strip()) + def test_run_ask_for_missing_underscored(self): + # Treating underscores + form2 = """Asking the form {'token_underscore': Tag(val='', description='', annotation=, name='token_underscore')}""" + with patch('sys.stdout', new_callable=StringIO) as stdout: + run(MissingUnderscore, True, interface=Mininterface) + self.assertEqual(form2, stdout.getvalue().strip()) + self.sys("--token-underscore", "1") # dash used instead of an underscore + + with patch('sys.stdout', new_callable=StringIO) as stdout: + run(MissingUnderscore, True, ask_for_missing=True, interface=Mininterface) + self.assertEqual("", stdout.getvalue().strip()) + def test_run_config_file(self): os.chdir("tests") sys.argv = ["SimpleEnv.py"]