diff --git a/README.md b/README.md index f790d0d..61693ab 100644 --- a/README.md +++ b/README.md @@ -71,16 +71,15 @@ with run(Env) as m: - [Installation](#installation) - [Docs](#docs) * [`mininterface`](#mininterface) - + [`run(config=None, ask_on_empty_cli=True, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs) + + [`run`](#run) * [Interfaces](#interfaces) - + [`Mininterface(title: str = '')`](#mininterfacetitle-str--) - + [`alert(text: str)`](#alerttext-str) - + [`ask(text: str) -> str`](#asktext-str---str) - + [`ask_env() -> EnvInstance`](#ask_env--configinstance) - + [`ask_number(text: str) -> int`](#ask_numbertext-str---int) - + [`form(env: FormDict, title="") -> int`](#formenv-formdict-title---dict) - + [`is_no(text: str) -> bool`](#is_notext-str---bool) - + [`is_yes(text: str) -> bool`](#is_yestext-str---bool) + + [`Mininterface`](#mininterface) + + [`alert`](#alert) + + [`ask`](#ask) + + [`ask_number`](#ask_number) + + [`form`](#form) + + [`is_no`](#is_no) + + [`is_yes`](#is_yes) * [Standalone](#standalone) # Background @@ -138,16 +137,32 @@ $./program.py --further.host example.net ## `mininterface` -### `run(config=None, interface=GuiInterface, **kwargs)` -Wrap your configuration dataclass into `run` to access the interface. Normally, an interface is chosen automatically. We prefer the graphical one, regressed to a text interface on a machine without display. -Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly with the default from a config file if such exists. It searches the config file in the current working directory, with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`. - -* `config:Type[EnvInstance]`: Dataclass with the configuration. -* `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the REPL. -* `**kwargs`: The same as for [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser). -* Returns: `interface` Interface used. - -You cay context manage the function by a `with` statement. The stdout will be redirected to the interface (GUI window). +### `run` +*(env_class=None, ask_on_empty_cli=False, title="", config_file=True, interface=GuiInterface or TuiInterface, \*\*kwargs)* + +The main access, start here. +Wrap your configuration dataclass into `run` to access the interface. An interface is chosen automatically, +with the preference of the graphical one, regressed to a text interface for machines without display. +Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly +with the default from a config file if such exists. +It searches the config file in the current working directory, +with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`. + +* `env_class`: Dataclass with the configuration. Their values will be modified with the CLI arguments. +* `ask_on_empty`: If program was launched with no arguments (empty CLI), invokes self.form() to edit the fields. +* `title`: The main title. If not set, taken from `prog` or program name. +* `config_file`: File to load YAML to be merged with the configuration. + You do not have to re-define all the settings in the config file, you can choose a few. + If set to True (default)form(, we try to find one in the current working dir, + whose name stem is the same as the program's. + Ex: `program.py` will search for `program.yaml`. + If False, no config file is used. +* `add_verbosity`: Adds the verbose flag that automatically sets the level to `logging.INFO` (*-v*) or `logging.DEBUG` (*-vv*). +* `interface`: Which interface to prefer. By default, we use the GUI, the fallback is the TUI. +* `**kwargs` The same as for [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html). +* Returns: `Mininterface` An interface, ready to be used. + +You cay context manager the function by a `with` statement. The stdout will be redirected to the interface (ex. a GUI window). See the [initial examples](#mininterface-gui-tui-cli-and-config). @@ -162,34 +177,35 @@ Several interfaces exist: * `TextInterface` – Plain text only interface with no dependency as a fallback. * `ReplInterface` – A debug terminal. Invokes a breakpoint after every dialog. -You can invoke one directly instead of using [mininterface.run](#run-config-none-interface-guiinterface-kwargs). TODO advantage? +Normally, you get an interface through [mininterface.run](#run) but if you do not wish to parse CLI and config file, you can invoke one directly. ```python with TuiInterface("My program") as m: number = m.ask_number("Returns number") ``` -### `Mininterface(title: str = '')` -Initialize. -### `alert(text: str)` -Prompt the user to confirm the text. -### `ask(text: str) -> str` -Prompt the user to input a text. -### `ask_env() -> EnvInstance` -Allow the user to edit whole configuration. (Previously fetched from CLI and config file.) -### `ask_number(text: str) -> int` -Prompt the user to input a number. Empty input = 0. -### `form(env: FormDict, title="") -> dict` -Prompt the user to fill up whole form. -* `env`: Dict of `{labels: default value}`. The form widget infers from the default value type. +### `Mininterface` +*(title: str = '')* The base interface. +You get one through `mininterface.run` which fills CLI arguments and config file to `mininterface.env` +or you can create it directly (without benefiting from the CLI parsing). +### `alert` +*(text: str)* Prompt the user to confirm the text. +### `ask` +*(text: str) -> str* Prompt the user to input a text. +### `ask_number` +*(text: str) -> int* Prompt the user to input a number. Empty input = 0. +### `form` +*(env: FormDict, title="") -> dict* Prompt the user to fill up whole form. +* `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.FormField` that allows you to add descriptions. + If None, the `self.env` is being used as a form, allowing the user to edit whole configuration. (Previously fetched from CLI and config file.) A checkbox example: `{"my label": FormField(True, "my description")}` * `title`: Optional form title. -### `is_no(text: str) -> bool` -Display confirm box, focusing no. -### `is_yes(text: str) -> bool` -Display confirm box, focusing yes. +### `is_no` +*(text: str) -> bool* Display confirm box, focusing no. +### `is_yes` +*(text: str) -> bool* Display confirm box, focusing yes. ```python m = run(prog="My program") diff --git a/mininterface/FormDict.py b/mininterface/FormDict.py index a44290e..000a807 100644 --- a/mininterface/FormDict.py +++ b/mininterface/FormDict.py @@ -1,6 +1,7 @@ """ FormDict tools. FormDict is not a real class, just a normal dict. But we need to put somewhere functions related to it. """ +from contextlib import ExitStack import logging from argparse import Action, ArgumentParser from typing import Any, Callable, Optional, Type, TypeVar, Union, get_type_hints @@ -17,6 +18,12 @@ FormDict = dict[str, Union[FormField, 'FormDict']] """ Nested form that can have descriptions (through FormField) instead of plain values. """ +# NOTE: In the future, allow `bound=FormDict | EnvClass`, a dataclass (or its instance) +# to be edited too +# is_dataclass(v) -> dataclass or its instance +# isinstance(v, type) -> class, not an instance +FormDictOrEnv = TypeVar('FormT', bound=FormDict) # | EnvClass) + def formdict_repr(d: FormDict) -> dict: """ For the testing purposes, returns a new dict when all FormFields are replaced with their values. """ @@ -48,7 +55,7 @@ def formdict_to_widgetdict(d: FormDict | Any, widgetize_callback: Callable): return d -def config_to_formdict(env: EnvClass, descr: dict, _path="") -> FormDict: +def dataclass_to_formdict(env: EnvClass, descr: dict, _path="") -> FormDict: """ Convert the dataclass produced by tyro into dict of dicts. """ main = "" params = {main: {}} if not _path else {} @@ -71,7 +78,7 @@ def config_to_formdict(env: EnvClass, 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_formdict(val, descr, _path=f"{_path}{param}.") + params[param] = dataclass_to_formdict(val, descr, _path=f"{_path}{param}.") elif not _path: # scalar value in root params[main][param] = FormField(val, descr.get(param), annotation, param, src_obj=(env, param)) else: # scalar value in nested @@ -79,21 +86,40 @@ def config_to_formdict(env: EnvClass, descr: dict, _path="") -> FormDict: return params -def get_env_allow_missing(config: Type[EnvClass], kwargs: dict, parser: ArgumentParser) -> EnvClass: +def get_env_allow_missing(config: Type[EnvClass], kwargs: dict, parser: ArgumentParser, add_verbosity: bool) -> EnvClass: """ Fetch missing required options in GUI. """ # On missing argument, tyro fail. We cannot determine which one was missing, except by intercepting # the error message function. Then, we reconstruct the missing options. # NOTE But we should rather invoke a GUI with the missing options only. - original_error = TyroArgumentParser.error eavesdrop = "" - def custom_error(self, message: str): + def custom_error(self: TyroArgumentParser, message: str): nonlocal eavesdrop if not message.startswith("the following arguments are required:"): - return original_error(self, message) + return super(TyroArgumentParser, self).error(message) eavesdrop = message raise SystemExit(2) # will be catched + def custom_init(self: TyroArgumentParser, *args, **kwargs): + super(TyroArgumentParser, self).__init__(*args, **kwargs) + default_prefix = '-' if '-' in self.prefix_chars else self.prefix_chars[0] + self.add_argument(default_prefix+'v', default_prefix*2+'verbose', action='count', default=0, + help="Verbosity level. Can be used twice to increase.") + + def custom_parse_known_args(self: TyroArgumentParser, args=None, namespace=None): + namespace, args = super(TyroArgumentParser, self).parse_known_args(args, namespace) + # NOTE We may check that the Env does not have its own `verbose`` + if hasattr(namespace, "verbose"): + if namespace.verbose > 0: + log_level = { + 1: logging.INFO, + 2: logging.DEBUG, + 3: logging.NOTSET + }.get(namespace.verbose, logging.NOTSET) + logging.basicConfig(level=log_level, format='%(levelname)s - %(message)s') + delattr(namespace, "verbose") + return namespace, args + # Set env to determine whether to use sys.argv. # Why settings env? Prevent tyro using sys.argv if we are in an interactive shell like Jupyter, # as sys.argv is non-related there. @@ -107,7 +133,15 @@ def custom_error(self, message: str): else: env = [] try: - with patch.object(TyroArgumentParser, 'error', custom_error): + # Mock parser + patches = [patch.object(TyroArgumentParser, 'error', custom_error)] + if add_verbosity: # Mock parser to add verbosity + patches.extend(( + patch.object(TyroArgumentParser, '__init__', custom_init), + patch.object(TyroArgumentParser, 'parse_known_args', custom_parse_known_args) + )) + with ExitStack() as stack: + [stack.enter_context(p) for p in patches] # apply just the chosen mocks return cli(config, args=env, **kwargs) except BaseException as e: if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which. diff --git a/mininterface/GuiInterface.py b/mininterface/GuiInterface.py index 18c09bc..fbf02cc 100644 --- a/mininterface/GuiInterface.py +++ b/mininterface/GuiInterface.py @@ -11,7 +11,7 @@ from .common import InterfaceNotAvailable -from .FormDict import FormDict, config_to_formdict, dict_to_formdict, formdict_to_widgetdict +from .FormDict import FormDict, FormDictOrEnv, dataclass_to_formdict, dict_to_formdict, formdict_to_widgetdict from .auxiliary import recursive_set_focus, flatten from .Redirectable import RedirectTextTkinter, Redirectable from .FormField import FormField @@ -37,16 +37,15 @@ def alert(self, text: str) -> None: def ask(self, text: str) -> str: return self.form({text: ""})[text] - def ask_env(self) -> EnvClass: + def _ask_env(self) -> EnvClass: """ Display a window form with all parameters. """ - formDict = config_to_formdict(self.env, self._descriptions) + 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.env - # def form(self, form: FormDict, title: str = "") -> dict: - def form(self, form: FormDict, title: str = "") -> EnvClass: + def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass: """ Prompt the user to fill up whole form. :param 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. @@ -54,6 +53,8 @@ def form(self, form: FormDict, title: str = "") -> EnvClass: A checkbox example: {"my label": FormField(True, "my description")} :param 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) return form diff --git a/mininterface/Mininterface.py b/mininterface/Mininterface.py index 3a78943..8cebd11 100644 --- a/mininterface/Mininterface.py +++ b/mininterface/Mininterface.py @@ -1,17 +1,12 @@ import logging -import sys from abc import ABC, abstractmethod -from argparse import ArgumentParser -from dataclasses import MISSING from pathlib import Path from types import SimpleNamespace -from typing import Generic, Self, Type +from typing import TYPE_CHECKING +if TYPE_CHECKING: # remove the line as of Python3.11 + from typing import Generic, Self -import yaml -from tyro.extras import get_parser - -from .auxiliary import get_descriptions -from .FormDict import EnvClass, FormDict, get_env_allow_missing +from .FormDict import EnvClass, FormDict, FormDictOrEnv from .FormField import FormField logger = logging.getLogger(__name__) @@ -22,19 +17,22 @@ class Cancelled(SystemExit): pass + class Mininterface(Generic[EnvClass]): """ The base interface. - Does not require any user input and hence is suitable for headless testing. + You get one through `mininterface.run` which fills CLI arguments and config file to `mininterface.env` + or you can create it directly (without benefiting from the CLI parsing). + + This base interface does not require any user input and hence is suitable for headless testing. """ def __init__(self, title: str = "", _env: EnvClass | None = None, _descriptions: dict | None = None, - # TODO DOCS here and to readme - **kwargs): + ): self.title = title or "Mininterface" # Why `or SimpleNamespace()`? - # We want to prevent error raised in `self.ask_env()` if self.env would have been set to None. + # We want to prevent error raised in `self.form(None)` if self.env would have been set to None. # It would be None if the user created this mininterface (without setting env) # or if __init__.run is used but Env is not a dataclass but a function (which means it has no attributes). self.env: EnvClass = _env or SimpleNamespace() @@ -62,28 +60,23 @@ def ask(self, text: str) -> str: print("Asking", text) raise Cancelled(".. cancelled") - # TODO → remove in favour of self.form(None)? - # Cons: Return type dict|EnvClass. Maybe we could return None too. - def ask_env(self) -> EnvClass: - """ Allow the user to edit whole configuration. (Previously fetched from CLI and config file.) """ - print("Asking the env", self.env) - return self.env - def ask_number(self, text: str) -> int: """ Prompt the user to input a number. Empty input = 0. """ print("Asking number", text) return 0 - def form(self, form: FormDict, title: str = "") -> dict: # EnvClass: # TODO + def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass: """ Prompt the user to fill up whole form. - :param data: Dict of `{labels: default value}`. The form widget infers from the default value type. + :param 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.FormField` that allows you to add descriptions. + If None, the `self.env` is being used as a form, allowing the user to edit whole configuration. + (Previously fetched from CLI and config file.) A checkbox example: `{"my label": FormField(True, "my description")}` :param title: Optional form title """ print(f"Asking the form {title}", form) - return form # NOTE – this should return dict, not FormDict (get rid of auxiliary.FormField values) + return self.env if form is None else form # NOTE – this should return dict, not FormDict (get rid of auxiliary.FormField values) def is_yes(self, text: str) -> bool: """ Display confirm box, focusing yes. """ diff --git a/mininterface/Redirectable.py b/mininterface/Redirectable.py index 6a01af8..81ff769 100644 --- a/mininterface/Redirectable.py +++ b/mininterface/Redirectable.py @@ -1,5 +1,7 @@ import sys -from typing import Self, Type +from typing import TYPE_CHECKING +if TYPE_CHECKING: # remove the line as of Python3.11 + from typing import Self, Type try: from tkinter import END, Text, Tk diff --git a/mininterface/TextInterface.py b/mininterface/TextInterface.py index 77dc05d..056336b 100644 --- a/mininterface/TextInterface.py +++ b/mininterface/TextInterface.py @@ -1,6 +1,6 @@ from pprint import pprint -from .FormDict import EnvClass, FormDict +from .FormDict import EnvClass, FormDict, FormDictOrEnv from .Mininterface import Cancelled, Mininterface @@ -20,16 +20,15 @@ def ask(self, text: str = None): raise Cancelled(".. cancelled") return txt - def ask_env(self) -> EnvClass: + def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass: # NOTE: This is minimal implementation that should rather go the ReplInterface. + # NOTE: Concerning Dataclass form. # I might build some menu of changing dict through: # params_ = dataclass_to_dict(self.env, self.descriptions) # data = FormDict → dict self.window.run_dialog(params_) # dict_to_dataclass(self.env, params_) - return self.form(self.env) - - def form(self, form: FormDict) -> dict: - # NOTE: This is minimal implementation that should rather go the ReplInterface. + if form is None: + form = self.env print("Access `v` (as var) and change values. Then (c)ontinue.") pprint(form) v = form diff --git a/mininterface/TextualInterface.py b/mininterface/TextualInterface.py index b968cc7..3df93c2 100644 --- a/mininterface/TextualInterface.py +++ b/mininterface/TextualInterface.py @@ -13,7 +13,7 @@ raise InterfaceNotAvailable from .auxiliary import flatten -from .FormDict import (EnvClass, FormDict, config_to_formdict, +from .FormDict import (EnvClass, FormDict, FormDictOrEnv, dataclass_to_formdict, dict_to_formdict, formdict_to_widgetdict) from .FormField import FormField from .Mininterface import BackendAdaptor, Cancelled @@ -36,9 +36,9 @@ def alert(self, text: str) -> None: def ask(self, text: str = None): return self.form({text: ""})[text] - def ask_env(self) -> EnvClass: + def _ask_env(self) -> EnvClass: """ Display a window form with all parameters. """ - params_ = config_to_formdict(self.env, self._descriptions) + 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_) @@ -46,7 +46,9 @@ def ask_env(self) -> EnvClass: # NOTE: This works bad with lists. GuiInterface considers list as combobox, # TextualInterface as str. We should decide what should happen. Is there a tyro default for list? - def form(self, form: FormDict, title: str = "") -> dict: + 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 diff --git a/mininterface/__init__.py b/mininterface/__init__.py index dda2115..8239510 100644 --- a/mininterface/__init__.py +++ b/mininterface/__init__.py @@ -29,7 +29,6 @@ TextualInterface = None -# TODO auto-handle verbosity https://brentyi.github.io/tyro/examples/04_additional/12_counters/ ? # TODO example on missing required options. class TuiInterface(TextualInterface or TextInterface): @@ -38,6 +37,7 @@ class TuiInterface(TextualInterface or TextInterface): def _parse_env(env_class: Type[EnvClass], config_file: Path | None = None, + add_verbosity = True, **kwargs) -> tuple[EnvClass|None, dict]: """ Parse CLI arguments, possibly merged from a config file. @@ -62,17 +62,18 @@ def _parse_env(env_class: Type[EnvClass], # Load configuration from CLI parser: ArgumentParser = get_parser(env_class, **kwargs) descriptions = get_descriptions(parser) - env = get_env_allow_missing(env_class, kwargs, parser) + env = get_env_allow_missing(env_class, kwargs, parser, add_verbosity) return env, descriptions def run(env_class: Type[EnvClass] | None = None, - ask_on_empty_cli: bool=False, # TODO + ask_on_empty_cli: bool=False, title: str = "", config_file: Path | str | bool = True, + add_verbosity: bool = True, interface: Type[Mininterface] = GuiInterface or TuiInterface, **kwargs) -> Mininterface[EnvClass]: """ - Main access. + The main access, start here. Wrap your configuration dataclass into `run` to access the interface. An interface is chosen automatically, with the preference of the graphical one, regressed to a text interface for machines without display. Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly @@ -81,7 +82,7 @@ def run(env_class: Type[EnvClass] | None = None, with the program name ending on *.yaml*, ex: `program.py` will fetch `./program.yaml`. :param env_class: Dataclass with the configuration. Their values will be modified with the CLI arguments. - :param ask_on_empty: If program was launched with no arguments (empty CLI), invokes self.ask_env() to edit the fields. + :param ask_on_empty: If program was launched with no arguments (empty CLI), invokes self.form() to edit the fields. :param title: The main title. If not set, taken from `prog` or program name. :param config_file: File to load YAML to be merged with the configuration. You do not have to re-define all the settings in the config file, you can choose a few. @@ -89,14 +90,18 @@ def run(env_class: Type[EnvClass] | None = None, whose name stem is the same as the program's. Ex: `program.py` will search for `program.yaml`. If False, no config file is used. + :param add_verbosity: Adds the verbose flag that automatically sets + the level to `logging.INFO` (*-v*) or `logging.DEBUG` (*-vv*). :param interface: Which interface to prefer. By default, we use the GUI, the fallback is the TUI. :param **kwargs The same as for [argparse.ArgumentParser](https://docs.python.org/3/library/argparse.html). :return: An interface, ready to be used. - # TODO check docs and to readme + + You cay context manager the function by a `with` statement. + The stdout will be redirected to the interface (ex. a GUI window). Undocumented: The `env_class` may be a function as well. We invoke its parameters. However, as Mininterface.env stores the output of the function instead of the Argparse namespace, - methods like `Mininterface.ask_env()` will work unpredictibly. + methods like `Mininterface.form(None)` (to ask for editing the env values) will work unpredictibly. Also, the config file seems to be fetched only for positional (missing) parameters, and ignored for keyword (filled) parameters. It seems to be this is the tyro's deal and hence it might start working any time. @@ -116,8 +121,9 @@ def run(env_class: Type[EnvClass] | None = None, config_file = Path(config_file) # Load configuration from CLI and a config file + env, descriptions = None, {} if env_class: - env, descriptions = _parse_env(env_class, config_file, **kwargs) + env, descriptions = _parse_env(env_class, config_file, add_verbosity, **kwargs) # Build the interface title = title or kwargs.get("prog") or Path(sys.argv[0]).name @@ -128,7 +134,7 @@ def run(env_class: Type[EnvClass] | None = None, # Empty CLI → GUI edit if ask_on_empty_cli and len(sys.argv) <= 1: - interface.ask_env() + interface.form() return interface diff --git a/mininterface/__main__.py b/mininterface/__main__.py index 21939ac..68e3e7d 100644 --- a/mininterface/__main__.py +++ b/mininterface/__main__.py @@ -1,4 +1,5 @@ from dataclasses import dataclass + from . import run __doc__ = """Simple GUI dialog. Outputs the value the user entered.""" @@ -17,12 +18,13 @@ class CliInteface: is_no: str = "" """ Display confirm box, focusing 'no'. """ -# TODO does not work in REPL interface: mininterface --alert hello + def main(): - # It does make sense to invoke GuiInterface only. Other interface would use STDOUT, hence make this impractical when fetching variable to i.e. a bash script. - # TODO It DOES make sense. Change in README. It s a good fallback. result = [] + # We tested both GuiInterface and TextualInterface are able to pass a variable to i.e. a bash script. + # TextInterface fails (`mininterface --ask Test | grep Hello` – pipe causes no visible output). with run(CliInteface, prog="Mininterface", description=__doc__) as m: + print("ENV", m.env) for method, label in vars(m.env).items(): if label: result.append(getattr(m, method)(label)) @@ -31,5 +33,6 @@ def main(): # to ask two numbers or determine a dialog order etc. [print(val) for val in result] + if __name__ == "__main__": main() diff --git a/tests/SimpleEnv.yaml b/tests/SimpleEnv.yaml new file mode 100644 index 0000000..34b5184 --- /dev/null +++ b/tests/SimpleEnv.yaml @@ -0,0 +1 @@ +important_number: 10 \ No newline at end of file diff --git a/tests/SimpleEnv2.yaml b/tests/SimpleEnv2.yaml new file mode 100644 index 0000000..8dfe670 --- /dev/null +++ b/tests/SimpleEnv2.yaml @@ -0,0 +1 @@ +important_number: 20 \ No newline at end of file diff --git a/tests/empty.yaml b/tests/empty.yaml new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests.py b/tests/tests.py index 0fb6d29..368b53d 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,11 +1,16 @@ +from io import StringIO +import logging +import os +from pathlib import Path import sys +from types import SimpleNamespace from unittest import TestCase, main from unittest.mock import patch from mininterface import Mininterface, TextInterface, run from mininterface.FormField import FormField from mininterface.Mininterface import Cancelled -from mininterface.FormDict import config_to_formdict, formdict_repr +from mininterface.FormDict import dataclass_to_formdict, formdict_repr from configs import OptionalFlagEnv, SimpleEnv, NestedDefaultedEnv, NestedMissingEnv from mininterface.auxiliary import flatten @@ -26,6 +31,8 @@ def tearDown(self): def sys(cls, *args): sys.argv = ["running-tests", *args] + +class TestBasic(TestAbstract): def test_basic(self): def go(*_args) -> SimpleEnv: self.sys(*_args) @@ -40,6 +47,26 @@ def go(*_args) -> SimpleEnv: self.sys("--important_number='8'") self.assertRaises(SystemExit, lambda: run(SimpleEnv, interface=Mininterface, prog="My application")) + def test_run_ask_empty(self): + with patch('sys.stdout', new_callable=StringIO) as stdout: + run(SimpleEnv, True, interface=Mininterface) + self.assertEqual("Asking the form None", stdout.getvalue().strip()) + with patch('sys.stdout', new_callable=StringIO) as stdout: + run(SimpleEnv, interface=Mininterface) + self.assertEqual("", stdout.getvalue().strip()) + + def test_run_config_file(self): + os.chdir("tests") + sys.argv = ["SimpleEnv.py"] + self.assertEqual(10, run(SimpleEnv, config_file=True, interface=Mininterface).env.important_number) + self.assertEqual(4, run(SimpleEnv, config_file=False, interface=Mininterface).env.important_number) + self.assertEqual(20, run(SimpleEnv, config_file="SimpleEnv2.yaml", interface=Mininterface).env.important_number) + self.assertEqual(20, run(SimpleEnv, config_file=Path("SimpleEnv2.yaml"), + interface=Mininterface).env.important_number) + self.assertEqual(4, run(SimpleEnv, config_file=Path("empty.yaml"), interface=Mininterface).env.important_number) + with self.assertRaises(FileNotFoundError): + run(SimpleEnv, config_file=Path("not-exists.yaml"), interface=Mininterface) + def test_cli_complex(self): def go(*_args) -> NestedDefaultedEnv: self.sys(*_args) @@ -117,19 +144,19 @@ def test_normalize_types(self): origin = {'test': FormField(False, 'Testing flag ', annotation=None), 'severity': FormField('', 'integer or none ', annotation=int | None), 'nested': {'test2': FormField(4, '')}} - # 'nested': {'test2': 4}} TODO, allow combined FormDict + # 'nested': {'test2': 4}} TODO, allow combined FormDict data = {'test': True, 'severity': "", 'nested': {'test2': 8}} self.assertTrue(FormField.submit(origin, data)) data = {'test': True, 'severity': "str", 'nested': {'test2': 8}} self.assertFalse(FormField.submit(origin, data)) - def test_config_instance_dict_conversion(self): + def test_env_instance_dict_conversion(self): m: TextInterface = run(OptionalFlagEnv, interface=TextInterface, prog="My application") env1: OptionalFlagEnv = m.env self.assertIsNone(env1.severity) - fd = config_to_formdict(env1, m._descriptions) + fd = dataclass_to_formdict(env1, m._descriptions) ui = formdict_repr(fd) self.assertEqual({'': {'severity': '', 'msg': '', 'msg2': 'Default text'}, 'further': {'deep': {'flag': False}, 'numb': 0}}, ui) @@ -163,6 +190,58 @@ def test_ask_form(self): m.form(dict1) self.assertEqual({"my label": FormField(True, "my description"), "nested": {"inner": "another"}}, dict1) + # Empty form invokes editing self.env, which is empty + with patch('builtins.input', side_effect=["c"]): + self.assertEqual(SimpleNamespace(), m.form()) + + # Empty form invokes editing self.env, which contains a dataclass + m2 = run(SimpleEnv, interface=TextInterface, prog="My application") + self.assertFalse(m2.env.test) + with patch('builtins.input', side_effect=["v.test = True", "c"]): + self.assertEqual(m2.env, m2.form()) + self.assertTrue(m2.env.test) + + +class TestLog(TestAbstract): + @staticmethod + def log(): + run(SimpleEnv, interface=Mininterface) + logger = logging.getLogger(__name__) + logger.debug("debug level") + logger.info("info level") + logger.warning("warning level") + logger.error("error level") + + @patch('logging.basicConfig') + def test_run_verbosity0(self, mock_basicConfig): + self.sys("-v") + with self.assertRaises(SystemExit): + run(SimpleEnv, add_verbosity=False, interface=Mininterface) + mock_basicConfig.assert_not_called() + + @patch('logging.basicConfig') + def test_run_verbosity1(self, mock_basicConfig): + self.log() + mock_basicConfig.assert_not_called() + + @patch('logging.basicConfig') + def test_run_verbosity2(self, mock_basicConfig): + self.sys("-v") + self.log() + mock_basicConfig.assert_called_once_with(level=logging.INFO, format='%(levelname)s - %(message)s') + + @patch('logging.basicConfig') + def test_run_verbosity2b(self, mock_basicConfig): + self.sys("--verbose") + self.log() + mock_basicConfig.assert_called_once_with(level=logging.INFO, format='%(levelname)s - %(message)s') + + @patch('logging.basicConfig') + def test_run_verbosity3(self, mock_basicConfig): + self.sys("-vv") + self.log() + mock_basicConfig.assert_called_once_with(level=logging.DEBUG, format='%(levelname)s - %(message)s') + if __name__ == '__main__': main()