Skip to content

Commit

Permalink
WIP2
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Nov 7, 2024
1 parent 164a1cf commit 639aa37
Show file tree
Hide file tree
Showing 12 changed files with 184 additions and 43 deletions.
15 changes: 14 additions & 1 deletion mininterface/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from tyro import cli
from tyro._argparse_formatter import TyroArgumentParser
from tyro.extras import get_parser
from tyro._fields import NonpropagatingMissingType

from .form_dict import EnvClass
from .tag import Tag
Expand Down Expand Up @@ -132,7 +133,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
return cli(type_form, args=args, **kwargs), {}
match = cli(type_form, args=args, **kwargs)
if isinstance(match, 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, {}
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 @@ -149,6 +159,9 @@ 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')
# print("152: cli(type_form, args=args, **kwargs)", cli(type_form, args=args, **kwargs)) # TODO
# import ipdb
# ipdb.set_trace() # TODO
return cli(type_form, args=args, **kwargs), wf
raise

Expand Down
13 changes: 10 additions & 3 deletions mininterface/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass

# TODO create an exceptions.py

@dataclass
class Command(ABC):
Expand All @@ -17,26 +18,27 @@ class Command(ABC):
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
"""
# 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.

@abstractmethod
def run(self):
""" This method is run automatically. """
""" This method is run automatically in CLI or by a button button it generates in a UI."""
...


@dataclass
class SubcommandPlaceholder(Command):
""" Use this placeholder to choose the subcomannd via a UI. """
# __name__ = "subcommand"

def run(self):
...


SubcommandPlaceholder.__name__ = "subcommand"
SubcommandPlaceholder.__name__ = "subcommand" # show just the shortcut in the CLI


class Cancelled(SystemExit):
Expand All @@ -46,6 +48,11 @@ class Cancelled(SystemExit):
# We inherit from SystemExit so that the program exits without a traceback on ex. GUI escape.
pass

class ValidationFail(ValueError):
""" Signal to the form that submit failed and we want to restore it.
TODO
"""
pass

class DependencyRequired(ImportError):
def __init__(self, extras_name):
Expand Down
15 changes: 12 additions & 3 deletions mininterface/facet.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Callable, Generic, Optional
from typing import TYPE_CHECKING, Callable, Generic, Literal, Optional

from .common import ValidationFail


from .form_dict import EnvClass, TagDict
Expand All @@ -12,6 +14,7 @@
class BackendAdaptor(ABC):
facet: "Facet"
post_submit_action: Optional[Callable] = None
interface: "Mininterface"

@staticmethod
@abstractmethod
Expand All @@ -26,15 +29,21 @@ def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True)
"""
self.facet._fetch_from_adaptor(form)

def submit_done(self):
def submit_done(self) -> str | Literal[True]:
if self.post_submit_action:
self.post_submit_action()
try:
self.post_submit_action()
except ValidationFail as e:
self.interface.alert(str(e))
return False
return True


class MinAdaptor(BackendAdaptor):
def __init__(self, interface: "Mininterface"):
super().__init__()
self.facet = Facet(self, interface.env)
self.interface = interface

def widgetize(tag: Tag):
pass
Expand Down
47 changes: 37 additions & 10 deletions mininterface/form_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,34 @@
FormDict is not a real class, just a normal dict. But we need to put somewhere functions related to it.
"""
import logging
from dataclasses import fields, is_dataclass
from types import FunctionType, MethodType
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar, Union, get_args, get_type_hints
from typing import (TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar,
Union, get_args, get_type_hints)

from tyro.extras import get_parser

from .tag_factory import tag_factory
from .auxiliary import get_description
from .tag import Tag, TagValue
from .tag_factory import tag_factory

if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self`
from typing import Self

from . import Mininterface

from .tag import Tag, TagValue
try:
import attr
except ImportError:
attr = None
try:
from pydantic import BaseModel
except ImportError:
BaseModel = None

if TYPE_CHECKING:
from . import Mininterface

logger = logging.getLogger(__name__)

DataClass = TypeVar("DataClass")
""" Any dataclass. """
""" Any dataclass. Or a pydantic model or attrs. """
EnvClass = TypeVar("EnvClass", bound=DataClass)
""" Any dataclass. Its instance will be available through [miniterface.env] after CLI parsing. """
FormDict = dict[str, TypeVar("FormDictRecursiveValue", TagValue, Tag, "Self")]
Expand Down Expand Up @@ -113,15 +120,35 @@ def formdict_to_widgetdict(d: FormDict | Any, widgetize_callback: Callable, _key
return d


def dataclass_to_tagdict(env: EnvClass, mininterface: Optional["Mininterface"] = None, _nested=False) -> TagDict:
def iterate_attributes(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, getattr(env, f.name)
elif BaseModel and isinstance(env, BaseModel):
for param, val in vars(env).items():
yield param, val
# 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, getattr(env, f.name)
else: # might be a normal class; which is unsupported but mostly might work
for param, val in vars(env).items():
yield param, val


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 = {}
if not _nested: # root is nested under "" path
subdict = {"": main} if not _nested else {}
else:
subdict = {}

for param, val in vars(env).items():
for param, val in iterate_attributes(env):
annotation = get_type_hints(env.__class__).get(param)
if val is None:
if type(None) in get_args(annotation):
Expand Down
2 changes: 1 addition & 1 deletion mininterface/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def __getattr__(name):
return globals()[name]
except InterfaceNotAvailable:
return None

return None # such attribute does not exist
raise AttributeError(f"Module {__name__} has no attribute {name}")


Expand Down
1 change: 0 additions & 1 deletion mininterface/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,3 @@ def choose_subcommand(self, env_classes: list[Type[EnvClass]], args=None):
forms[name] = tags

m.form(forms, submit=False)
# TODO test, docs
18 changes: 12 additions & 6 deletions mininterface/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@
from .facet import Facet
from .form_dict import TagDict
from typing import Self # remove the line as of Python3.11 and make `"Self" -> Self`
else:
Facet = object # TODO needed for tyro dataclass serialization (do a test for that)

# Pydantic is not a project dependency, that is just an optional integration
try: # Pydantic is not a dependency but integration
from pydantic import ValidationError as PydanticValidationError
from pydantic import create_model
pydantic = True
except:
except ImportError:
pydantic = False
PydanticValidationError = None
create_model = None
try: # Attrs is not a dependency but integration
import attr
except:
except ImportError:
attr = None


Expand All @@ -39,14 +41,15 @@
""" dict """
TK = TypeVar("TK")
""" dict key """
TagValue = TypeVar("TagValue")
# Why TagValue bounded to Any? This might help in the future to allow a dataclass to have a Tag as the attribute value. (It is not frozen now.)
TagValue = TypeVar("TagValue", bound=Any)
""" Any value. It is being wrapped by a [Tag][mininterface.Tag]. """
ErrorMessage = TypeVar("ErrorMessage")
""" A string, callback validation error message. """
ValidationResult = bool | ErrorMessage
""" Callback validation result is either boolean or an error message. """
PydanticFieldInfo = TypeVar("PydanticFieldInfo")
AttrsFieldInfo = TypeVar("AttrsFieldInfo")
PydanticFieldInfo = TypeVar("PydanticFieldInfo", bound=Any) # see why TagValue bounded to Any?
AttrsFieldInfo = TypeVar("AttrsFieldInfo", bound=Any) # see why TagValue bounded to Any?
ChoiceLabel = str
ChoicesType = list[TagValue] | tuple[TagValue] | set[TagValue] | dict[ChoiceLabel, TagValue] | list[Enum] | Type[Enum]
""" You can denote the choices in many ways.
Expand Down Expand Up @@ -248,6 +251,9 @@ def check(tag.val):

def __post_init__(self):
# Fetch information from the nested tag: `Tag(Tag(...))`
# print("254: self", self) # TODO
# import ipdb
# ipdb.set_trace() # TODO
if isinstance(self.val, Tag):
if self._src_obj or self._src_key:
raise ValueError("Wrong Tag inheritance, submit a bug report.")
Expand All @@ -262,7 +268,7 @@ def __post_init__(self):
self._pydantic_field: dict | None = getattr(self._src_class, "model_fields", {}).get(self._src_key)
if attr: # Attrs integration
try:
self._attrs_field: dict | None = attr.fields_dict(self._src_class.__class__).get(self._src_key)
self._attrs_field: dict | None = attr.fields_dict(self._src_class).get(self._src_key)
except attr.exceptions.NotAnAttrsClassError:
pass
if not self.annotation and self.val is not None and not self.choices:
Expand Down
58 changes: 47 additions & 11 deletions mininterface/tag_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,59 @@
from typing import get_type_hints


# TODO
def _get_annotation_from_class_hierarchy(cls, key):
"""Získá anotaci pro daný klíč ze třídy nebo jejích předků."""
for base in cls.__mro__:
if key in getattr(base, '__annotations__', {}):
return base.__annotations__[key]
return None


def get_type_hint_from_class_hierarchy(cls, key):
"""Získá anotaci typu pro daný klíč z třídy nebo jejích předků."""
for base in cls.__mro__:
hints = get_type_hints(base)
if key in hints:
return hints[key]
return None


def tag_factory(val=None, description=None, annotation=None, *args, _src_obj=None, _src_key=None, _src_class=None, **kwargs):
if _src_obj and not _src_class:
_src_class = _src_obj
# NOTE it seems _src_obj is sometimes accepts Type[DataClass], and not a DataClass,
# unless I find out why, here is the workaround:
if isinstance(_src_obj, type): # form is a class, not an instance
_src_class = _src_obj
else:
_src_class = _src_obj.__class__
kwargs |= {"_src_obj": _src_obj, "_src_key": _src_key, "_src_class": _src_class}
if _src_class:
if not annotation: # when we have _src_class, we assume to have _src_key too
annotation = get_type_hints(_src_class).get(_src_key)
# raise ValueError(_src_class.__mro__)
# annotation = get_type_hints(_src_class).get(_src_key)
annotation = get_type_hint_from_class_hierarchy(_src_class, _src_key)
# raise ValueError(annotation)
# print("25: annotation", annotation) # TODO
# print("35: _src_class", _src_class) # TODO
# if annotation != get_type_hint_from_class_hierarchy(_src_class, _src_key):
# print("35: annotation", annotation, get_type_hint_from_class_hierarchy(_src_class, _src_key)) # TODO
# print("37: cls.__class__", _src_class, _src_class.__class__, _src_class.__class__.__mro__) # TODO
if annotation is TagCallback:
return CallbackTag(val, description, *args, **kwargs)
else:
field_type = _src_class.__annotations__.get(_src_key)
if field_type and hasattr(field_type, '__metadata__'):
for metadata in field_type.__metadata__:
if isinstance(metadata, Tag): # NOTE might fetch from a pydantic model too
# The type of the Tag is another Tag
# Ex: `my_field: Validation(...) = 4`
# Why fetching metadata name? The name would be taken from _src_obj.
# But the user defined in metadata is better.
return Tag(val, description, name=metadata.name, *args, **kwargs)._fetch_from(metadata)
# We now have annotation from `field: list[Path]` or `field: Annotated[list[Path], ...]`.
# But there might be still a better annotation in metadata `field: Annotated[list[Path], Tag(...)]`.
field_type = _get_annotation_from_class_hierarchy(_src_class, _src_key)
# field_type = _src_class.__annotations__.get(_src_key)
if field_type:
if hasattr(field_type, '__metadata__'):
for metadata in field_type.__metadata__:
if isinstance(metadata, Tag): # NOTE might fetch from a pydantic model too
# The type of the Tag is another Tag
# Ex: `my_field: Validation(...) = 4`
# Why fetching metadata name? The name would be taken from _src_obj.
# But the user defined in metadata is better.
return Tag(val, description, name=metadata.name, *args, **kwargs)._fetch_from(metadata)
# print("62: ---------") # TODO
return Tag(val, description, annotation, *args, **kwargs)
5 changes: 3 additions & 2 deletions mininterface/textual_interface/textual_adaptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True)
raise Cancelled

# validate and store the UI value → Tag value → original value
if not Tag._submit_values((field._link, field.get_ui_value()) for field in widgets if hasattr(field, "_link")):
vals = ((field._link, field.get_ui_value()) for field in widgets if hasattr(field, "_link"))
if not Tag._submit_values(vals) or not self.submit_done():
return self.run_dialog(form, title, submit)
self.submit_done()

return form
5 changes: 2 additions & 3 deletions mininterface/tk_interface/tk_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from ..facet import BackendAdaptor
from ..form_dict import TagDict, formdict_to_widgetdict
from ..common import Cancelled
from ..common import Cancelled, ValidationFail
from ..tag import Tag
from .tk_facet import TkFacet
from .utils import recursive_set_focus, replace_widgets
Expand Down Expand Up @@ -87,9 +87,8 @@ def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True)
return self.mainloop(lambda: self.validate(form, title, submit))

def validate(self, form: TagDict, title: str, submit) -> TagDict:
if not Tag._submit(form, self.form.get()):
if not Tag._submit(form, self.form.get()) or not self.submit_done():
return self.run_dialog(form, title, submit)
self.submit_done()
return form

def yes_no(self, text: str, focus_no=True):
Expand Down
Loading

0 comments on commit 639aa37

Please sign in to comment.