diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index a28f906..706b3f9 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -13,7 +13,10 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - run: python3 -m pip install --upgrade build && python3 -m build + - name: Replace media paths in README.md + run: sed -E 's#(\]\(asset/[a-zA-Z0-9._-]+)#](https://github.com/CZ-NIC/mininterface/blob/main/\1?raw=True#g' README.md > README.md.tmp && mv README.md.tmp README.md + - name: Build the package + run: python3 -m pip install --upgrade build && python3 -m build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.8.10 with: diff --git a/README.md b/README.md index 7c19785..ef676ce 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Write the program core, do not bother with the input/output. ![Hello world example: GUI window](asset/hello-world.png "A minimal use case – GUI") ![Hello world example: TUI fallback](asset/hello-tui.webp "A minimal use case – TUI fallback") -Check out the code that displays such window or its textual fallback. +Check out the code, which is surprisingly short, that displays such a window or its textual fallback. ```python from dataclasses import dataclass @@ -16,17 +16,16 @@ from mininterface import run @dataclass class Config: """Set of options.""" - test: bool = False - """My testing flag""" - important_number: int = 4 - """This number is very important""" + test: bool = False # My testing flag + important_number: int = 4 # This number is very important if __name__ == "__main__": - args: Config = run(Config, prog="My application").get_args() - print(args.important_number) # suggested by the IDE with the hint text "This number is very important" + args = run(Config, prog="My application").get_args() + print(args.important_number) # suggested by the IDE with the hint text "This number is very important" ``` -It's 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. +## 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. ```bash $ ./hello.py @@ -41,7 +40,15 @@ Set of options. ╰────────────────────────────────────────────────────────────────────╯ ``` -You get several useful methods to handle user dialogues. Here we bound the interface to a `with` statement that redirects stdout directly to the window. +## You got config file management +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 +``` + +## You got dialogues +Check out several useful methods to handle user dialogues. Here we bound the interface to a `with` statement that redirects stdout directly to the window. ```python with run(Config) as m: @@ -52,12 +59,7 @@ with run(Config) as m: ![Small window with the text 'Your important number'](asset/hello-with-statement.webp "With statement to redirect the output") ![The same in terminal'](asset/hello-with-statement-tui.webp "With statement in TUI fallback") -Loading config file is a piece of cake. Alongside `program.py`, put `program.yaml` and put there some of the arguments. Instantly loaded. - -```yaml -important_number: 555 -``` - +# Contents - [Mininterface – GUI, TUI, CLI and config](#mininterface-gui-tui-cli-and-config) - [Background](#background) - [Installation](#installation) @@ -66,15 +68,15 @@ important_number: 555 + [`run(config=None, interface=GuiInterface, **kwargs)`](#runconfignone-interfaceguiinterface-kwargs) * [Interfaces](#interfaces) + [`Mininterface(title: str = '')`](#mininterfacetitle-str--) - + [`alert(self, text: str)`](#alertself-text-str) - + [`ask(self, text: str) -> str`](#askself-text-str---str) - + [`ask_args(self) -> ~ConfigInstance`](#ask_argsself---configinstance) - + [`ask_form(self, args: FormDict, title="") -> int`](#ask_formself-args-formdict-title---dict) - + [`ask_number(self, text: str) -> int`](#ask_numberself-text-str---int) - + [`get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance`](#get_argsself-ask_on_empty_clitrue---configinstance) - + [`is_no(self, text: str) -> bool`](#is_noself-text-str---bool) - + [`is_yes(self, text: str) -> bool`](#is_yesself-text-str---bool) - + [`parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance`](#parse_argsself-config-callable-configinstance-config_file-pathlibpath--none--none-kwargs---configinstance) + + [`alert(text: str)`](#alerttext-str) + + [`ask(text: str) -> str`](#asktext-str---str) + + [`ask_args() -> ConfigInstance`](#ask_args--configinstance) + + [`ask_number(text: str) -> int`](#ask_numbertext-str---int) + + [`form(args: FormDict, title="") -> int`](#formargs-formdict-title---dict) + + [`get_args(ask_on_empty_cli=True) -> ~ConfigInstance`](#get_argsask_on_empty_clitrue---configinstance) + + [`is_no(text: str) -> bool`](#is_notext-str---bool) + + [`is_yes(text: str) -> bool`](#is_yestext-str---bool) + + [`parse_args(config: Type[ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ConfigInstance`](#parse_argsconfig-type-configinstance-config_file-pathlibpath--none--none-kwargs---configinstance) * [Standalone](#standalone) # Background @@ -136,7 +138,7 @@ $./program.py --further.host example.net 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:ConfigClass`: Dataclass with the configuration. +* `config:Type[ConfigInstance]`: 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). * Returns: `interface` Interface used. @@ -165,27 +167,27 @@ with TuiInterface("My program") as m: ### `Mininterface(title: str = '')` Initialize. -### `alert(self, text: str)` +### `alert(text: str)` Prompt the user to confirm the text. -### `ask(self, text: str) -> str` +### `ask(text: str) -> str` Prompt the user to input a text. -### `ask_args(self) -> ~ConfigInstance` +### `ask_args() -> ConfigInstance` Allow the user to edit whole configuration. (Previously fetched from CLI and config file by parse_args.) -### `ask_form(self, args: FormDict, title="") -> dict` +### `form(args: FormDict, title="") -> dict` Prompt the user to fill up whole form. * `args`: 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. A checkbox example: `{"my label": FormField(True, "my description")}` * `title`: Optional form title. -### `ask_number(self, text: str) -> int` +### `ask_number(text: str) -> int` Prompt the user to input a number. Empty input = 0. -### `get_args(self, ask_on_empty_cli=True) -> ~ConfigInstance` +### `get_args(ask_on_empty_cli=True) -> ConfigInstance` Returns whole configuration (previously fetched from CLI and config file by parse_args). If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields. -### `is_no(self, text: str) -> bool` +### `is_no(text: str) -> bool` Display confirm box, focusing no. -### `is_yes(self, text: str) -> bool` +### `is_yes(text: str) -> bool` Display confirm box, focusing yes. ```python @@ -193,7 +195,7 @@ m = run(prog="My program") print(m.ask_yes("Is it true?")) # True/False ``` -### `parse_args(self, config: Callable[..., ~ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance` +### `parse_args(config: Type[ConfigInstance], config_file: pathlib.Path | None = None, **kwargs) -> ~ConfigInstance` Parse CLI arguments, possibly merged from a config file. * `config`: Dataclass with the configuration. * `config_file`: File to load YAML to be merged with the configuration. You do not have to re-define all the settings, you can choose a few. diff --git a/mininterface/FormDict.py b/mininterface/FormDict.py index 1668c0f..6ab2e95 100644 --- a/mininterface/FormDict.py +++ b/mininterface/FormDict.py @@ -3,7 +3,7 @@ """ import logging from argparse import Action, ArgumentParser -from typing import Callable, Optional, TypeVar, Union, get_type_hints +from typing import Callable, Optional, Type, TypeVar, Union, get_type_hints from unittest.mock import patch from tyro import cli @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) ConfigInstance = TypeVar("ConfigInstance") -ConfigClass = Callable[..., ConfigInstance] +ConfigClass = Type[ConfigInstance] FormDict = dict[str, Union[FormField, 'FormDict']] """ Nested form that can have descriptions (through FormField) instead of plain values. """ @@ -71,7 +71,7 @@ def config_to_formdict(args: ConfigInstance, descr: dict, _path="") -> FormDict: return params -def get_args_allow_missing(config: ConfigClass, kwargs: dict, parser: ArgumentParser): +def get_args_allow_missing(config: Type[ConfigInstance], kwargs: dict, parser: ArgumentParser) -> ConfigInstance: """ 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. @@ -85,9 +85,22 @@ def custom_error(self, message: str): return original_error(self, message) eavesdrop = message raise SystemExit(2) # will be catched + + # Set args to determine whether to use sys.argv. + # Why settings args? Prevent tyro using sys.argv if we are in an interactive shell like Jupyter, + # as sys.argv is non-related there. + try: + # Note wherease `"get_ipython" in globals()` returns True in Jupyter, it is still False + # in a script a Jupyter cell runs. Hence we must put here this lengthty statement. + global get_ipython + get_ipython() + except: + args = None + else: + args = [] try: with patch.object(TyroArgumentParser, 'error', custom_error): - return cli(config, **kwargs) + return cli(config, args=args, **kwargs) except BaseException as e: if hasattr(e, "code") and e.code == 2 and eavesdrop: # Some arguments are missing. Determine which. for arg in eavesdrop.partition(":")[2].strip().split(", "): diff --git a/mininterface/FormField.py b/mininterface/FormField.py index d6f8202..90fcd46 100644 --- a/mininterface/FormField.py +++ b/mininterface/FormField.py @@ -9,6 +9,7 @@ try: from tkinter_form import Value except ImportError: + # TODO put into GuiInterface create_ui(ff: FormField) @dataclass class Value: """ This class helps to enrich the field with a description. """ diff --git a/mininterface/GuiInterface.py b/mininterface/GuiInterface.py index 501f85d..12c71b4 100644 --- a/mininterface/GuiInterface.py +++ b/mininterface/GuiInterface.py @@ -57,7 +57,7 @@ def ask_args(self) -> ConfigInstance: self.window.run_dialog(formDict) return self.args - def ask_form(self, form: FormDict, title: str = "") -> dict: + def form(self, form: FormDict, title: str = "") -> dict: """ Prompt the user to fill up whole form. :param args: Dict of `{labels: default value}`. The form widget infers from the default value type. The dict can be nested, it can contain a subgroup. diff --git a/mininterface/Mininterface.py b/mininterface/Mininterface.py index 0256707..bdd5b5a 100644 --- a/mininterface/Mininterface.py +++ b/mininterface/Mininterface.py @@ -4,6 +4,7 @@ from dataclasses import MISSING from pathlib import Path from types import SimpleNamespace +from typing import Generic, Type import yaml from tyro.extras import get_parser @@ -19,7 +20,7 @@ class Cancelled(SystemExit): pass -class Mininterface: +class Mininterface(Generic[ConfigInstance]): """ The base interface. Does not require any user input and hence is suitable for headless testing. """ @@ -54,7 +55,12 @@ def ask_args(self) -> ConfigInstance: print("Asking the args", self.args) return self.args - def ask_form(self, data: FormDict, title: str = "") -> dict: + 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, data: FormDict, title: str = "") -> dict: """ Prompt the user to fill up whole form. :param args: Dict of `{labels: default value}`. The form widget infers from the default value type. The dict can be nested, it can contain a subgroup. @@ -64,11 +70,6 @@ def ask_form(self, data: FormDict, title: str = "") -> dict: print(f"Asking the form {title}", data) return data # NOTE – this should return dict, not FormDict (get rid of auxiliary.FormField values) - def ask_number(self, text: str) -> int: - """ Prompt the user to input a number. Empty input = 0. """ - print("Asking number", text) - return 0 - def get_args(self, ask_on_empty_cli=True) -> ConfigInstance: """ Returns whole configuration (previously fetched from CLI and config file by parse_args). If program was launched with no arguments (empty CLI), invokes self.ask_args() to edit the fields. """ @@ -77,13 +78,14 @@ def get_args(self, ask_on_empty_cli=True) -> ConfigInstance: return self.ask_args() return self.args - def parse_args(self, config: ConfigClass, + def parse_args(self, config: Type[ConfigInstance], config_file: Path | None = None, **kwargs) -> ConfigInstance: """ Parse CLI arguments, possibly merged from a config file. :param config: Class with the configuration. - :param config_file: File to load YAML to be merged with the configuration. You do not have to re-define all the settings, you can choose a few. + :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. :param **kwargs The same as for argparse.ArgumentParser. :return: Configuration namespace. """ diff --git a/mininterface/TextInterface.py b/mininterface/TextInterface.py index 0686ea2..1a42c16 100644 --- a/mininterface/TextInterface.py +++ b/mininterface/TextInterface.py @@ -26,9 +26,9 @@ def ask_args(self) -> ConfigInstance: # params_ = dataclass_to_dict(self.args, self.descriptions) # data = FormDict → dict self.window.run_dialog(params_) # dict_to_dataclass(self.args, params_) - return self.ask_form(self.args) + return self.form(self.args) - def ask_form(self, form: FormDict) -> dict: + def form(self, form: FormDict) -> dict: # NOTE: This is minimal implementation that should rather go the ReplInterface. print("Access `v` (as var) and change values. Then (c)ontinue.") pprint(form) diff --git a/mininterface/TextualInterface.py b/mininterface/TextualInterface.py index df4d07d..7c234a5 100644 --- a/mininterface/TextualInterface.py +++ b/mininterface/TextualInterface.py @@ -36,7 +36,7 @@ def alert(self, text: str) -> None: TextualButtonApp().buttons(text, [("Ok", None)]).run() def ask(self, text: str = None): - return self.ask_form({text: ""})[text] + return self.form({text: ""})[text] def ask_args(self) -> ConfigInstance: """ Display a window form with all parameters. """ @@ -46,7 +46,7 @@ def ask_args(self) -> ConfigInstance: TextualApp.run_dialog(TextualApp(), params_) return self.args - def ask_form(self, form: FormDict, title: str = "") -> dict: + def form(self, form: FormDict, title: str = "") -> dict: return TextualApp.run_dialog(TextualApp(), dict_to_formdict(form), title) # NOTE we should implement better, now the user does not know it needs an int diff --git a/mininterface/__init__.py b/mininterface/__init__.py index f1158a7..f9a6e60 100644 --- a/mininterface/__init__.py +++ b/mininterface/__init__.py @@ -4,7 +4,7 @@ from unittest.mock import patch -from mininterface.Mininterface import ConfigClass, ConfigInstance, Mininterface +from mininterface.Mininterface import ConfigInstance, Mininterface from mininterface.TextInterface import ReplInterface, TextInterface from mininterface.FormField import FormField @@ -29,10 +29,11 @@ class TuiInterface(TextualInterface or TextInterface): pass -def run(config: ConfigClass | None = None, +def run(config: Type[ConfigInstance] | None = None, interface: Type[Mininterface] = GuiInterface or TuiInterface, - **kwargs) -> Mininterface: + **kwargs) -> Mininterface[ConfigInstance]: """ + Main access. 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 @@ -50,16 +51,23 @@ def run(config: ConfigClass | None = None, """ # Build the interface prog = kwargs.get("prog") or sys.argv[0] - # try: interface: GuiInterface | Mininterface = interface(prog) - # except InterfaceNotAvailable: # Fallback to a different interface - # interface = TuiInterface(prog) # Load configuration from CLI and a config file if config: cf = Path(sys.argv[0]).with_suffix(".yaml") interface.parse_args(config, cf if cf.exists() and not kwargs.get("default") else None, **kwargs) + # NOTE draft – move the functionality inside Mininterface? + # What will be the most used params? + # run(config: Type[ConfigInstance], + # prog="merge to kwargs later", + # config_file:Path|str="", + # interface: Type[Mininterface] = GuiInterface or TuiInterface, + # **kwargs) + # title = prog or sys.argv + # Mininterface(title, configClass, configFile, **kwargs) + return interface diff --git a/mininterface/__main__.py b/mininterface/__main__.py index 06e883d..a435700 100644 --- a/mininterface/__main__.py +++ b/mininterface/__main__.py @@ -1,11 +1,6 @@ from dataclasses import dataclass -from typing import List - -from .GuiInterface import GuiInterface - from . import run -from tyro.conf import UseCounterAction, UseAppendAction __doc__ = """Simple GUI dialog. Outputs the value the user entered.""" diff --git a/pyproject.toml b/pyproject.toml index 216deaf..384a1be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "mininterface" -version = "0.4.3" +version = "0.4.4rc1" description = "A minimal access to GUI, TUI, CLI and config" authors = ["Edvard Rejthar "] license = "GPL-3.0-or-later" diff --git a/tests/tests.py b/tests/tests.py index f75e8bb..304439a 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -160,7 +160,7 @@ def test_ask_form(self): m = TextInterface() dict1 = {"my label": FormField(True, "my description"), "nested": {"inner": "text"}} with patch('builtins.input', side_effect=["v['nested']['inner'] = 'another'", "c"]): - m.ask_form(dict1) + m.form(dict1) self.assertEqual({"my label": FormField(True, "my description"), "nested": {"inner": "another"}}, dict1)