Skip to content

Commit

Permalink
remove warning when missing an inherited attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Nov 8, 2024
1 parent 8594ba3 commit d4bb15b
Show file tree
Hide file tree
Showing 18 changed files with 154 additions and 61 deletions.
Binary file added asset/subcommands-1.avif
Binary file not shown.
Binary file added asset/subcommands-2.avif
Binary file not shown.
Binary file added asset/subcommands-3.avif
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/Changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## 0.6.3 (unreleased)
## 0.7.0
* hidden [`--integrate-to-system`](Overview.md#bash-completion) argument
* interfaces migrated to [`mininterface.interfaces`](Interfaces.md) to save around 50 ms starting time due to lazy loading
* [SubcommandPlaceholder][mininterface.subcommands.Command]
4 changes: 0 additions & 4 deletions docs/Interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ with TuiInterface("My program") as m:
number = m.ask_number("Returns number")
```

TODO imgs

# `GuiInterface` = `TkInterface`

A tkinter window.
Expand All @@ -28,12 +26,10 @@ An interactive terminal.

If [textual](https://github.com/Textualize/textual) installed, rich and mouse clickable interface is used.


## `TextInterface`

Plain text only interface with no dependency as a fallback.


# `ReplInterface`

A debug terminal. Invokes a breakpoint after every dialog.
2 changes: 2 additions & 0 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
# class Env:
# files: Positional[list[Path]]

# NOTE: imgs missing in Interfaces.md


@dataclass
class _Empty:
Expand Down
5 changes: 5 additions & 0 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import is_dataclass
import os
import re
from argparse import ArgumentParser
Expand Down Expand Up @@ -61,3 +62,7 @@ def get_descriptions(parser: ArgumentParser) -> dict:

def get_description(obj, param: str) -> str:
return get_descriptions(get_parser(obj))[param]


def yield_annotations(dataclass):
yield from (cl.__annotations__ for cl in dataclass.__mro__ if is_dataclass(cl))
31 changes: 17 additions & 14 deletions mininterface/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@
import warnings
from argparse import Action, ArgumentParser
from contextlib import ExitStack
from dataclasses import MISSING, is_dataclass
from dataclasses import MISSING
from pathlib import Path
from types import SimpleNamespace, UnionType
from typing import Optional, Sequence, Type, Union, get_origin
from types import SimpleNamespace
from typing import Optional, Sequence, Type, Union
from unittest.mock import patch

import yaml
from tyro import cli
from tyro._argparse_formatter import TyroArgumentParser
from tyro.extras import get_parser
from tyro._fields import NonpropagatingMissingType
from tyro.extras import get_parser

from .auxiliary import yield_annotations
from .form_dict import EnvClass
from .tag import Tag
from .tag_factory import tag_factory
Expand All @@ -27,12 +28,12 @@
try: # Pydantic is not a dependency but integration
from pydantic import BaseModel
pydantic = True
except:
except ImportError:
pydantic = False
BaseModel = False
try: # Attrs is not a dependency but integration
import attr
except:
except ImportError:
attr = None


Expand Down Expand Up @@ -133,16 +134,16 @@ def run_tyro_parser(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
try:
with ExitStack() as stack:
[stack.enter_context(p) for p in patches] # apply just the chosen mocks
match = cli(type_form, args=args, **kwargs)
if isinstance(match, NonpropagatingMissingType):
res = cli(type_form, args=args, **kwargs)
if isinstance(res, NonpropagatingMissingType):
# NOTE tyro does not work if a required positional is missing tyro.cli() returns just NonpropagatingMissingType.
# If this is supported, I might set other attributes like required (date, time).
# Fail if missing:
# files: Positional[list[Path]]
# Works if missing but imposes following attributes are non-required (have default values):
# files: Positional[list[Path]] = field(default_factory=list)
pass
return match, {}
return res, {}
except BaseException as e:
if ask_for_missing and getattr(e, "code", None) == 2 and eavesdrop:
# Some required arguments are missing. Determine which.
Expand All @@ -159,7 +160,6 @@ def run_tyro_parser(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
# so there is probably no more warning to be caught.)
with warnings.catch_warnings():
warnings.simplefilter('ignore')
# spot the warning here? see tests TODO
return cli(type_form, args=args, **kwargs), wf
raise

Expand All @@ -184,9 +184,9 @@ def treat_missing(env_class, kwargs: dict, parser: ArgumentParser, wf: dict, arg
# Why using mro? Find the field in the dataclass and all of its parents.
# Useful when handling subcommands, they share a common field.
field_name = argument.dest
if not any(field_name in cl.__annotations__ for cl in env_class.__mro__ if is_dataclass(cl)):
if not any(field_name in ann for ann in yield_annotations(env_class)):
field_name = field_name.replace("-", "_")
if not any(field_name in cl.__annotations__ for cl in env_class.__mro__ if is_dataclass(cl)):
if not any(field_name in ann for ann in yield_annotations(env_class)):
raise ValueError(f"Cannot find {field_name} in the configuration object")

# NOTE: We put '' to the UI to clearly state that the value is missing.
Expand Down Expand Up @@ -246,17 +246,20 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
# Unfortunately, pydantic needs to fill the default with the actual values,
# the default value takes the precedence over the hard coded one, even if missing.
static = {key: env_or_list.model_fields.get(key).default
for key in env_or_list.__annotations__ if not key.startswith("__") and not key in disk}
for ann in yield_annotations(env_or_list) for key in ann if not key.startswith("__") and not key in disk}
# static = {key: env_or_list.model_fields.get(key).default
# for key, _ in iterate_attributes(env_or_list) if not key in disk}
elif attr and attr.has(env_or_list):
# Unfortunately, attrs needs to fill the default with the actual values,
# the default value takes the precedence over the hard coded one, even if missing.
# NOTE Might not work for inherited models.
static = {key: field.default
for key, field in attr.fields_dict(env_or_list).items() if not key.startswith("__") and not key in disk}
else:
# To ensure the configuration file does not need to contain all keys, we have to fill in the missing ones.
# Otherwise, tyro will spawn warnings about missing fields.
static = {key: getattr(env_or_list, key, MISSING)
for key in env_or_list.__annotations__ if not key.startswith("__") and not key in disk}
for ann in yield_annotations(env_or_list) for key in ann if not key.startswith("__") and not key in disk}
kwargs["default"] = SimpleNamespace(**(disk | static))

# Load configuration from CLI
Expand Down
2 changes: 1 addition & 1 deletion mininterface/facet.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Callable, Generic, Literal, Optional

from .ValidationFail import ValidationFail
from .exceptions import ValidationFail


from .form_dict import EnvClass, TagDict
Expand Down
26 changes: 25 additions & 1 deletion mininterface/form_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
FormDict is not a real class, just a normal dict. But we need to put somewhere functions related to it.
"""
import logging
from warnings import warn
from dataclasses import fields, is_dataclass
from types import FunctionType, MethodType
from types import FunctionType, MethodType, SimpleNamespace
from typing import (TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar,
Union, get_args, get_type_hints)

Expand Down Expand Up @@ -140,6 +141,26 @@ def iterate_attributes(env: DataClass):
yield param, val


def iterate_attributes_keys(env: DataClass):
""" Iterate public attributes of a model, including its parents. """
if is_dataclass(env):
# Why using fields instead of vars(env)? There might be some helper parameters in the dataclasses that should not be form editable.
for f in fields(env):
yield f.name
elif BaseModel and isinstance(env, BaseModel):
for param, val in vars(env).items():
yield param
# NOTE private pydantic attributes might be printed to forms, because this makes test fail for nested models
# for param, val in env.model_dump().items():
# yield param, val
elif attr and attr.has(env):
for f in attr.fields(env.__class__):
yield f.name
else: # might be a normal class; which is unsupported but mostly might work
for param, val in vars(env).items():
yield param


def dataclass_to_tagdict(env: EnvClass | Type[EnvClass], mininterface: Optional["Mininterface"] = None, _nested=False) -> TagDict:
""" Convert the dataclass produced by tyro into dict of dicts. """
main = {}
Expand All @@ -148,6 +169,9 @@ def dataclass_to_tagdict(env: EnvClass | Type[EnvClass], mininterface: Optional[
else:
subdict = {}

if isinstance(env, SimpleNamespace):
raise ValueError(f"We got a namespace instead of class, CLI probably failed: {env}")

for param, val in iterate_attributes(env):
annotation = get_type_hints(env.__class__).get(param)
if val is None:
Expand Down
2 changes: 1 addition & 1 deletion mininterface/mininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ def _form(self,
return formdict_resolve(adaptor.run_dialog(dict_to_tagdict(_form, self), title=title, submit=submit), extract_main=True)
if isinstance(_form, type): # form is a class, not an instance
_form, wf = run_tyro_parser(_form, {}, False, False, args=[]) # NOTE what to do with wf?
if is_dataclass(_form): # -> dataclass or its instance
if is_dataclass(_form): # -> dataclass or its instance (now it's an instance)
# the original dataclass is updated, hence we do not need to catch the output from launch_callback
adaptor.run_dialog(dataclass_to_tagdict(_form, self), title=title, submit=submit)
return _form
Expand Down
84 changes: 64 additions & 20 deletions mininterface/subcommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,63 +12,107 @@
@dataclass
class Command(ABC):
""" The Command is automatically run while instantanied.
TODO Example
Experimental. Should it receive _facet?
Experimental – how should it receive _facet?
Put list of Commands to the [mininterface.run][mininterface.run] and divide your application into different sections.
Alternative to argparse [subcommands](https://docs.python.org/3/library/argparse.html#sub-commands).
Commands might inherit from the same parent to share the common attributes.
What if I TODO
## SubcommandPlaceholder
What if I need to use my program
Special placeholder class SubcommandPlaceholder.
This special class let the user to choose the subcommands via UI,
while still benefiniting from default CLI arguments.
Alternative to argparse [subcommands](https://docs.python.org/3/library/argparse.html#sub-commands).
from tyro.conf import Positional
The CLI behaviour:
* `./program.py` -> UI started with choose_subcommand
* `./program.py subcommand --flag` -> special class SubcommandPlaceholder allows using flag
while still starting UI with choose_subcommand
### The CLI behaviour:
* `./program.py` -> UI started with subcommand choice
* `./program.py subcommand --flag` -> special class `SubcommandPlaceholder` allows defining a common `--flag`
while still starting UI with subcommand choice
* `./program.py subcommand1 --flag` -> program run
* `./program.py subcommand1` -> fails with tyro now # NOTE nice to have implemented
* `./program.py subcommand1` -> fails to CLI for now
An example of Command usage:
## An example of Command usage
```python
from mininterface.subcommands import Command
from dataclasses import dataclass, field
from pathlib import Path
from mininterface import run
from mininterface.exceptions import ValidationFail
from mininterface.subcommands import Command, SubcommandPlaceholder
from tyro.conf import Positional
@dataclass
class SharedArgs:
class SharedArgs(Command):
common: int
files: Positional[list[Path]] = field(default_factory=list)
def __post_init__(self):
def init(self):
self.internal = "value"
@dataclass
class Subcommand1(SharedArgs):
my_local: int = 1
def run(self):
print("Common", self.common) # user input
print("Number", self.my_local) # 1 or user input
ValidationFail("The submit button blocked!")
print("Common:", self.common) # user input
print("Number:", self.my_local) # 1 or user input
print("Internal:", self.internal)
raise ValidationFail("The submit button blocked!")
@dataclass
class Subcommand2(SharedArgs):
def run(self):
self._facet.set_title("Button clicked") # you can access internal self._facet: Facet
print("Common files", self.files)
subcommand = run(Subcommand1 | Subcommand2)
m = run([Subcommand1, Subcommand2, SubcommandPlaceholder])
m.alert("App continue")
```
Let's start the program, passing there common flags, all HTML files in a folder and setting `--common` to 7.
```bash
$ program.py subcommand *.html --common 7
```
TODO img
![Subcommand](asset/subcommands-1.avif)
As you see, thanks to `SubcommandPlaceholder`, subcommand was not chosen yet. Click to the first button.
![Subcommand](asset/subcommands-2.avif)
and the terminal got:
```
Common: 7
Number: 1
Internal: value
```
Click to the second button.
![Subcommand](asset/subcommands-3.avif)
Terminal output:
```
Common files [PosixPath('page1.html'), PosixPath('page2.html')]
```
### Powerful automation
Note we use `from tyro.conf import Positional` to denote the positional argument. We did not have to write `--files` to put there HTML files.
"""
# Why to not document the Subcommand in the Subcommand class itself? It would be output to the user with --help,
# I need the text to be available to the developer in the docs, not to the user.
# NOTE * `./program.py subcommand1` -> fails to CLI for now # nice to have implemented

def __post_init__(self):
self._facet: "Facet" = None # As this is dataclass, the internal facet cannot be defined as one of the fields.
Expand Down
2 changes: 1 addition & 1 deletion mininterface/tk_interface/tk_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class TkWindow(Tk, BackendAdaptor):
""" An editing window. """

def __init__(self, interface: "TkInterface"):
# TODO scrollbar if content is long
# NOTE I really need scrollbar if content is long
super().__init__()
self.facet = interface.facet = TkFacet(self, interface.env)
self.params = None
Expand Down
4 changes: 2 additions & 2 deletions mininterface/type_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@


class TagCallback(Callable):
""" TODO docs submit button """
""" NOTE docs submit button """
pass


class TagType:
""" TODO a mere Tag should work for a type too but Tyro interpretes it as a nested conf
""" NOTE a mere Tag should work for a type too but Tyro interpretes it as a nested conf
Correct, Tag cannot be an annotation as it is not frozen.
@dataclass
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.6.2"
version = "0.7.0"
description = "A minimal access to GUI, TUI, CLI and config"
authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
license = "GPL-3.0-or-later"
Expand Down
Loading

0 comments on commit d4bb15b

Please sign in to comment.