Skip to content

Commit

Permalink
descriptions on the fly, CallbackTag
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Oct 11, 2024
1 parent 3cd1fad commit 7371b82
Show file tree
Hide file tree
Showing 31 changed files with 674 additions and 245 deletions.
Binary file added asset/callback_button.avif
Binary file not shown.
Binary file added asset/callback_choice.avif
Binary file not shown.
Binary file added asset/suggestion_dataclass_annotated.avif
Binary file not shown.
Binary file added asset/suggestion_dataclass_expanded.avif
Binary file not shown.
Binary file added asset/suggestion_dataclass_instance.avif
Binary file not shown.
Binary file added asset/suggestion_dataclass_type.avif
Binary file not shown.
Binary file added asset/suggestion_dict.avif
Binary file not shown.
Binary file added asset/suggestion_form_env.avif
Binary file not shown.
Binary file added asset/suggestion_run.avif
Binary file not shown.
80 changes: 29 additions & 51 deletions docs/Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,79 +15,57 @@ graph LR
## 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] 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).
There are a lot of supported [types](/Types) you can use, not only scalars and well-known objects (`Path`, `datetime`), but also functions, iterables (like `list[Path]`) and union types (like `int | None`). To do even more advanced things, stick the value to a powerful [`Tag`][mininterface.Tag] or its subclasses. Ex. for a validation only, use its [`Validation alias`](Validation.md/#validation-alias).

At last, use [`Facet`](Facet.md) to tackle the interface from the back-end (`m`) or the front-end (`Tag`) side.

## IDE suggestions

## Supported types

Various types are supported:

* scalars
* functions
* well-known objects (`Path`, `datetime`)
* iterables (like `list[Path]`)
* custom classes (somewhat)
* union types (like `int | None`)

Take a look how it works with the variables organized in a dataclass:
The immediate benefit is the type suggestions you see in an IDE. Imagine following code:

```python
from dataclasses import dataclass
from pathlib import Path

from mininterface import run


@dataclass
class Env:
my_number: int = 1
""" A dummy number """
my_boolean: bool = True
""" A dummy boolean """
my_conditional_number: int | None = None
""" A number that can be null if left empty """
my_path: Path = Path("/tmp")
""" A dummy path """


m = run(Env) # m.env contains an Env instance
m.form() # Prompt a dialog; m.form() without parameter edits m.env
print(m.env)
# Env(my_number=1, my_boolean=True, my_path=PosixPath('/tmp'),
# my_point=<__main__.Point object at 0x7ecb5427fdd0>)
my_paths: list[Path]
""" The user is forced to input Paths. """


@dataclass
class Dialog:
my_number: int = 2
""" A number """
```

![GUI window](asset/supported_types_1.avif "A prompted dialog")
Now, accessing the main [env][mininterface.Mininterface.env] will trigger the hint.
![Suggestion run](asset/suggestion_run.avif)

Variables organized in a dict:
Calling the [form][mininterface.Mininterface.form] with an empty parameter will trigger editing the main [env][mininterface.Mininterface.env]

Along scalar types, there is (basic) support for common iterables or custom classes.
![Suggestion form](asset/suggestion_form_env.avif)

```python
from mininterface import run
Putting there a dict will return the dict too.

class Point:
def __init__(self, i: int):
self.i = i
![Suggestion form](asset/suggestion_dict.avif)

def __str__(self):
return str(self.i)
Putting there a dataclass type causes it to be resolved.

![Suggestion dataclass type](asset/suggestion_dataclass_type.avif)

values = {"my_number": 1,
"my_list": [1, 2, 3],
"my_point": Point(10)
}
Should you have a resolved dataclass instance, put it there.

m = run()
m.form(values) # Prompt a dialog
print(values) # {'my_number': 2, 'my_list': [2, 3], 'my_point': <__main__.Point object...>}
print(values["my_point"].i) # 100
```
![Suggestion dataclass instance](asset/suggestion_dataclass_instance.avif)

As you see, its attributes are hinted alongside their description.

![Suggestion dataclass expanded](asset/suggestion_dataclass_expanded.avif)


Should the dataclass cannot be easily investigated by the IDE (i.e. a required field), just annotate the output.

![GUI window](asset/supported_types_2.avif "A prompted dialog after editation")
![Suggestion annotation possible](asset/suggestion_dataclass_annotated.avif)

## Nested configuration
You can easily nest the configuration. (See also [Tyro Hierarchical Configs](https://brentyi.github.io/tyro/examples/02_nesting/01_nesting/)).
Expand Down
69 changes: 69 additions & 0 deletions docs/Types.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,71 @@
# Types

Various types are supported:

* scalars
* functions
* well-known objects (`Path`, `datetime`)
* iterables (like `list[Path]`)
* custom classes (somewhat)
* union types (like `int | None`)

Take a look how it works with the variables organized in a dataclass:

```python
from dataclasses import dataclass
from pathlib import Path

from mininterface import run


@dataclass
class Env:
my_number: int = 1
""" A dummy number """
my_boolean: bool = True
""" A dummy boolean """
my_conditional_number: int | None = None
""" A number that can be null if left empty """
my_path: Path = Path("/tmp")
""" A dummy path """


m = run(Env) # m.env contains an Env instance
m.form() # Prompt a dialog; m.form() without parameter edits m.env
print(m.env)
# Env(my_number=1, my_boolean=True, my_path=PosixPath('/tmp'),
# my_point=<__main__.Point object at 0x7ecb5427fdd0>)
```

![GUI window](asset/supported_types_1.avif "A prompted dialog")

Variables organized in a dict:

Along scalar types, there is (basic) support for common iterables or custom classes.

```python
from mininterface import run

class Point:
def __init__(self, i: int):
self.i = i

def __str__(self):
return str(self.i)


values = {"my_number": 1,
"my_list": [1, 2, 3],
"my_point": Point(10)
}

m = run()
m.form(values) # Prompt a dialog
print(values) # {'my_number': 2, 'my_list': [2, 3], 'my_point': <__main__.Point object...>}
print(values["my_point"].i) # 100
```

![GUI window](asset/supported_types_2.avif "A prompted dialog after editation")


::: mininterface.types
13 changes: 13 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,16 @@ m.form(my_dictionary)
```

![List of paths](asset/list_of_paths.avif)













13 changes: 7 additions & 6 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from .types import Validation, Choices, PathTag
from .cli_parser import _parse_cli
from .common import InterfaceNotAvailable, Cancelled
from .form_dict import EnvClass
from .form_dict import DataClass, EnvClass
from .tag import Tag
from .mininterface import EnvClass, Mininterface
from .text_interface import ReplInterface, TextInterface
Expand All @@ -31,9 +31,10 @@ class TuiInterface(TextualInterface or TextInterface):
# NOTE:
# ask_for_missing does not work with tyro Positional, stays missing.
# @dataclass
#class Env:
# class Env:
# files: Positional[list[Path]]


def run(env_class: Type[EnvClass] | None = None,
ask_on_empty_cli: bool = False,
title: str = "",
Expand Down Expand Up @@ -156,9 +157,9 @@ class Env:
config_file = Path(config_file)

# Load configuration from CLI and a config file
env, descriptions, wrong_fields = None, {}, {}
env, wrong_fields = None, {}
if env_class:
env, descriptions, wrong_fields = _parse_cli(env_class, config_file, add_verbosity, ask_for_missing, **kwargs)
env, wrong_fields = _parse_cli(env_class, config_file, add_verbosity, ask_for_missing, **kwargs)

# Build the interface
title = title or kwargs.get("prog") or Path(sys.argv[0]).name
Expand All @@ -171,9 +172,9 @@ class Env:
interface = GuiInterface
if interface is None:
raise InterfaceNotAvailable # GuiInterface might be None when import fails
interface = interface(title, env, descriptions)
interface = interface(title, env)
except InterfaceNotAvailable: # Fallback to a different interface
interface = TuiInterface(title, env, descriptions)
interface = TuiInterface(title, env)

# Empty CLI → GUI edit
if ask_for_missing and wrong_fields:
Expand Down
12 changes: 8 additions & 4 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import os
import re
from argparse import ArgumentParser
from tkinter import StringVar
from types import SimpleNamespace
from typing import TYPE_CHECKING, Iterable, TypeVar
from typing import Iterable, TypeVar

from tyro.extras import get_parser

T = TypeVar("T")
KT = str
Expand Down Expand Up @@ -53,5 +52,10 @@ def get_terminal_size():

def get_descriptions(parser: ArgumentParser) -> dict:
""" Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
return {action.dest.replace("-", "_"): re.sub(r"\(default.*\)", "", action.help or "")
# clean-up tyro stuff that may have a meaning in the CLI, but not in the UI
return {action.dest.replace("-", "_"): re.sub(r"\((default|fixed to).*\)", "", action.help or "")
for action in parser._actions}


def get_description(obj, param: str) -> str:
return get_descriptions(get_parser(obj))[param]
55 changes: 29 additions & 26 deletions mininterface/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
from tyro._argparse_formatter import TyroArgumentParser
from tyro.extras import get_parser

from .auxiliary import get_descriptions
from .tag_factory import tag_factory

from .form_dict import EnvClass
from .tag import Tag
from .validators import not_empty
Expand Down Expand Up @@ -81,21 +82,25 @@ def custom_parse_known_args(self: TyroArgumentParser, args=None, namespace=None)

def run_tyro_parser(env_class: Type[EnvClass],
kwargs: dict,
parser: ArgumentParser,
add_verbosity: bool,
ask_for_missing: bool) -> tuple[EnvClass, WrongFields]:
# 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.
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:
env = None
else:
env = []
ask_for_missing: bool,
args=None) -> tuple[EnvClass, WrongFields]:
parser: ArgumentParser = get_parser(env_class, **kwargs)

if args is None:
# 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.
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 # Fetch from the CLI
else:
args = []

try:
# Mock parser
patches = []
Expand All @@ -108,7 +113,7 @@ def run_tyro_parser(env_class: Type[EnvClass],
))
with ExitStack() as stack:
[stack.enter_context(p) for p in patches] # apply just the chosen mocks
return cli(env_class, args=env, **kwargs), {}
return cli(env_class, args=args, **kwargs), {}
except BaseException as e:
if ask_for_missing and hasattr(e, "code") and e.code == 2 and eavesdrop:
# Some arguments are missing. Determine which.
Expand All @@ -130,12 +135,12 @@ def run_tyro_parser(env_class: Type[EnvClass],

# 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[field_name] = Tag("",
argument.help.replace("(required)", ""),
validation=not_empty,
_src_class=env_class,
_src_key=field_name
)
tag = wf[field_name] = tag_factory("",
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.
Expand Down Expand Up @@ -201,7 +206,5 @@ def _parse_cli(env_class: Type[EnvClass],
kwargs["default"] = SimpleNamespace(**(disk | static))

# Load configuration from CLI
parser: ArgumentParser = get_parser(env_class, **kwargs)
descriptions = get_descriptions(parser)
env, wrong_fields = run_tyro_parser(env_class, kwargs, parser, add_verbosity, ask_for_missing)
return env, descriptions, wrong_fields
env, wrong_fields = run_tyro_parser(env_class, kwargs, add_verbosity, ask_for_missing)
return env, wrong_fields
2 changes: 1 addition & 1 deletion mininterface/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class FacetCallback():
A button should be created. When clicked, it gets the facet as the argument.
"""
pass
# NOTE, just a stub
# NOTE, just a stub. Deprecated, use CallbackTag instead.


# NOTE should we use the dataclasses, isn't that slow?
Expand Down
Loading

0 comments on commit 7371b82

Please sign in to comment.