autotui
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)
This uses type hints to convert NamedTuple
's (short struct-like classes) to JSON/YAML, and back to python objects.
It also wraps prompt_toolkit
to prompt the user and validate the input for common types, and is extendible to whatever types you want.
Supported Types
This has built-ins to prompt, validate and serialize:
int
float
bool
str
datetime
Enum
Decimal
Optional[<type>]
(or <type> | None
)List[<type>]
(or list[<type>]
)Set[<type>]
(or set[<type>]
)- other
NamedTuple
s (recursively)
I wrote this so that I don't have to repeatedly write boilerplate-y python code to validate/serialize/deserialize data. As a more extensive example of its usage, you can see my ttally
repo, which I use to track things like calories/water etc...
Install
This requires python3.8+
, specifically for modern typing
support.
To install with pip, run:
pip install autotui
Usage
As an example, if I want to log whenever I drink water to a file:
from datetime import datetime
from typing import NamedTuple
from autotui.shortcuts import load_prompt_and_writeback
class Water(NamedTuple):
at: datetime
glass_count: float
if __name__ == "__main__":
load_prompt_and_writeback(Water, "~/.local/share/water.json")
Which, after running a few times, would create:
~/.local/share/water.json
[
{
"at": 1598856786,
"glass_count": 2.0
},
{
"at": 1598856800,
"glass_count": 1.0
}
]
(datetimes are serialized into epoch time)
If I want to load the values back into python, its just:
from autotui.shortcuts import load_from
class Water(NamedTuple):
if __name__ == "__main__":
print(load_from(Water, "~/.local/share/water.json"))
A lot of my usage of this only ever uses 3 functions in the autotui.shortcuts
module; dump_to
to dump a sequence of my NamedTuple
s to a file, load_from
to do the opposite, and load_prompt_and_writeback
, to load values in, prompt me, and write back to the file.
Enabling Options
Some options/features can be enabled using global environment variables, or by using a contextmanager to temporarily enable certain prompts/features.
As an example, there are two versions of the datetime
prompt
- The one you see above using a dialog
- A live version which displays the parsed datetime while typing. Since that can cause some lag, it can be enabled by setting the
LIVE_DATETIME
option.
You can enable that by:
- setting the
AUTOTUI_LIVE_DATETIME
(prefix the name of the option with AUTOTUI_
) environment variable, e.g., add export AUTOTUI_LIVE_DATETIME=1
to your .bashrc
/.zshrc
- using the
options
contextmanager:
import autotui
with autotui.options("LIVE_DATETIME"):
autotui.prompt_namedtuple(...)
Options:
LIVE_DATETIME
: Enables the live datetime promptCONVERT_UNKNOWN_ENUM_TO_NONE
: If an enum value is not found on the enumeration (e.g. you remove some enum value), convert it to None
instead of raising a ValueError
ENUM_FZF
: Use fzf
to prompt for enumsCLICK_PROMPT
- Where possible, use click
to prompt for values instead of prompt_toolkit
Partial prompts
If you want to prompt for only a few fields, you can supply the attr_use_values
or type_use_values
to supply default values:
from datetime import datetime
from typing import NamedTuple
from autotui import prompt_namedtuple
from autotui.shortcuts import load_prompt_and_writeback
class Water(NamedTuple):
at: datetime
glass_count: float
load_prompt_and_writeback(Water, "./water.json", type_use_values={datetime: datetime.now()})
val = prompt_namedtuple(Water, attr_use_values={"at": datetime.now})
Since you can specify a function to either of those arguments -- you're free to write a completely custom prompt function to prompt/grab data for that field however you want
For example, to prompt for strings by opening vim
instead:
from datetime import datetime
from typing import NamedTuple, List, Optional
from autotui.shortcuts import load_prompt_and_writeback
import click
def edit_in_vim() -> str:
m = click.edit(text=None, editor="vim")
return m if m is None else m.strip()
class JournalEntry(NamedTuple):
creation_date: datetime
tags: Optional[List[str]]
content: str
if __name__ == "__main__":
load_prompt_and_writeback(
JournalEntry,
"~/Documents/journal.json",
attr_use_values={"content": edit_in_vim},
)
Can also define those as a staticmethod
on the class, so you don't have to pass around the extra state:
class JournalEntry(NamedTuple):
...
@staticmethod
def attr_use_values() -> Dict:
return {"content": edit_in_vim}
prompt_namedtuple(JournalEntry, "~/Documents/journal.json")
Yaml
Since YAML is a superset of JSON, this can also be used with YAML files. autotui.shortcuts
will automatically decode/write to YAML files based on the file extension.
if __name__ == "__main__":
load_prompt_and_writeback(Water, "~/.local/share/water.yaml")
Results in:
- at: 1645840523
glass_count: 1.0
- at: 1645839340
glass_count: 1.0
You can also pass format="yaml"
to the namedtuple_sequence_dumps/namedtuple_sequence_loads
functions (shown below)
Picking
This has a basic fzf
picker using pyfzf-iter
, which lets you pick one item from a list/iterator:
from autotui import pick_namedtuple
from autotui.shortcuts import load_from
picked = pick_namedtuple(load_from(Water, "~/.local/share/water.json"))
print(picked)
To install the required dependencies, install fzf
and pip install 'autotui[pick]'
Editing
This also provides a basic editor, which lets you edit a single field of a NamedTuple
.
$ python3 ./examples/edit.py
Water(at=datetime.datetime(2023, 3, 5, 18, 55, 59, 519320), glass_count=1)
Which field to edit:
1. at
2. glass_count
'glass_count' (float) > 30
Water(at=datetime.datetime(2023, 3, 5, 18, 55, 59, 519320), glass_count=30.0)
In python:
from autotui.edit import edit_namedtuple
water = edit_namedtuple(water, print_namedtuple=True)
water = edit_namedtuple(water, print_namedtuple=True, loop=True)
Any additional arguments to edit_namedtuple
are passed to prompt_namedtuple
, so you can specify type_validators
to attr_validators
to prompt in some custom way
To install, pip install 'autotui[edit]'
or pip install click
Custom Types
If you want to support custom types, or specify a special way to serialize another NamedTuple recursively, you can specify type_validators
, and type_[de]serializer
to handle the validation, serialization, deserialization for that type/attribute name.
As a more complicated example, heres a validator for timedelta
(duration of time), being entered as MM:SS, and the corresponding serializers.
def _timedelta(user_input: str) -> timedelta:
if len(user_input.strip()) == 0:
raise ValueError("Not enough input!")
minutes, _, seconds = user_input.partition(":")
return timedelta(minutes=float(minutes), seconds=float(seconds))
def to_seconds(t: timedelta) -> int:
return int(t.total_seconds())
def from_seconds(seconds: int) -> timedelta:
return timedelta(seconds=seconds)
class Action(NamedTuple):
name: str
duration: timedelta
timedelta_handler = AutoHandler(
func=_timedelta,
catch_errors=[ValueError],
)
a = prompt_namedtuple(
Action,
type_validators={
timedelta: timedelta_handler,
},
)
a_str: str = namedtuple_sequence_dumps(
[a],
type_serializers={
timedelta: to_seconds,
},
indent=None,
)
a_load = namedtuple_sequence_loads(
a_str,
to=Action,
type_deserializers={
timedelta: from_seconds,
},
)[0]
a_load2 = namedtuple_sequence_loads(
a_str,
to=Action,
attr_deserializers={
"duration": from_seconds,
},
)[0]
print(a)
print(a_str)
print(a_load)
print(a_load2)
Output:
$ python3 ./examples/timedelta_serializer.py
'name' (str) > on the bus
'duration' (_timedelta) > 30:00
Action(name='on the bus', duration=datetime.timedelta(seconds=1800))
[{"name": "on the bus", "duration": 1800}]
Action(name='on the bus', duration=datetime.timedelta(seconds=1800))
Action(name='on the bus', duration=datetime.timedelta(seconds=1800))
The general philosophy I've taken for serialization and deserialization is send a warning if the types aren't what the NamedTuple expects, but load the values anyways. If serialization can't serialize something, it warns, and if json.dump
doesn't have a way to handle it, it throws an error. When deserializing, all values are loaded from their JSON primitives, and then converted into their corresponding python equivalents; If the value doesn't exist, it warns and sets it to None, if there's a deserializer supplied, it uses that. This is meant to help facilitate quick TUIs, I don't want to have to fight with it.
(If you know what you're doing and want to ignore those warnings, you can set the AUTOTUI_DISABLE_WARNINGS=1
environment variable)
There are lots of examples on how this is handled/edge-cases in the tests
.
You can also take a look at the examples
Testing
git clone https://github.com/seanbreckenridge/autotui
cd ./autotui
pip install '.[testing]'
mypy ./autotui
pytest