Skip to content

Commit

Permalink
auto verbosity
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Sep 4, 2024
1 parent c055bcb commit df19406
Show file tree
Hide file tree
Showing 13 changed files with 236 additions and 99 deletions.
90 changes: 53 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand All @@ -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")
Expand Down
48 changes: 41 additions & 7 deletions mininterface/FormDict.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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. """
Expand Down Expand Up @@ -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 {}
Expand All @@ -71,29 +78,48 @@ 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
params[param] = FormField(val, descr.get(f"{_path}{param}"), annotation, param, src_obj=(env, param))
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.
Expand All @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions mininterface/GuiInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,23 +37,24 @@ 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.
The default value might be `mininterface.FormField` that allows you to add descriptions.
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

Expand Down
39 changes: 16 additions & 23 deletions mininterface/Mininterface.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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()
Expand Down Expand Up @@ -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. """
Expand Down
4 changes: 3 additions & 1 deletion mininterface/Redirectable.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 5 additions & 6 deletions mininterface/TextInterface.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pprint import pprint

from .FormDict import EnvClass, FormDict
from .FormDict import EnvClass, FormDict, FormDictOrEnv
from .Mininterface import Cancelled, Mininterface


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

0 comments on commit df19406

Please sign in to comment.