Skip to content

Commit

Permalink
generic types, shorter autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Jul 29, 2024
1 parent a3a6da1 commit b7d2319
Show file tree
Hide file tree
Showing 12 changed files with 90 additions and 66 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
with:
Expand Down
70 changes: 36 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -165,35 +167,35 @@ 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
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.
Expand Down
21 changes: 17 additions & 4 deletions mininterface/FormDict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. """

Expand Down Expand Up @@ -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.
Expand All @@ -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(", "):
Expand Down
1 change: 1 addition & 0 deletions mininterface/FormField.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. """
Expand Down
2 changes: 1 addition & 1 deletion mininterface/GuiInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 11 additions & 9 deletions mininterface/Mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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.
Expand All @@ -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. """
Expand All @@ -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.
"""
Expand Down
4 changes: 2 additions & 2 deletions mininterface/TextInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions mininterface/TextualInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. """
Expand All @@ -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
Expand Down
20 changes: 14 additions & 6 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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


Expand Down
5 changes: 0 additions & 5 deletions mininterface/__main__.py
Original file line number Diff line number Diff line change
@@ -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."""


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "mininterface"
version = "0.4.3"
version = "0.4.4-rc1"
description = "A minimal access to GUI, TUI, CLI and config"
authors = ["Edvard Rejthar <[email protected]>"]
license = "GPL-3.0-or-later"
Expand Down
2 changes: 1 addition & 1 deletion tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down

0 comments on commit b7d2319

Please sign in to comment.