Skip to content

Commit

Permalink
parametrized generic in union with None
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Sep 25, 2024
1 parent 4702c49 commit c6667d0
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 50 deletions.
4 changes: 2 additions & 2 deletions mininterface/form_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""
import logging
from types import FunctionType, MethodType
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, get_type_hints
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, get_args, get_type_hints

if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self`
from typing import Self
Expand Down Expand Up @@ -116,7 +116,7 @@ def dataclass_to_tagdict(env: EnvClass, descr: dict, facet: "Facet" = None, _pat
for param, val in vars(env).items():
annotation = get_type_hints(env.__class__).get(param)
if val is None:
if annotation in (Optional[int], Optional[str]):
if type(None) in get_args(annotation):
# Since tkinter_form does not handle None yet, we have help it.
# We need it to be able to write a number and if empty, return None.
# This would fail: `severity: int | None = None`
Expand Down
2 changes: 1 addition & 1 deletion mininterface/gui_interface/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def _fetch(variable):
if path_tag := tag._morph(PathTag, Path):
grid_info = widget.grid_info()

widget2 = Button(master, text='👓', command=choose_file_handler(variable, path_tag))
widget2 = Button(master, text='', command=choose_file_handler(variable, path_tag))
widget2.grid(row=grid_info['row'], column=grid_info['column']+1)

# Special type: Submit button
Expand Down
77 changes: 50 additions & 27 deletions mininterface/tag.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from ast import literal_eval
from dataclasses import dataclass, fields
from datetime import datetime
from types import FunctionType, MethodType, UnionType
from types import FunctionType, MethodType, NoneType, UnionType
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, TypeVar, get_args, get_origin, get_type_hints
from warnings import warn

Expand Down Expand Up @@ -334,41 +334,61 @@ class Env:
'TypeError: cannot be a parameterized generic'
"""
if self.annotation is None:
if self.annotation is None: # no annotation check, everything is fine then
return True
elif self.annotation is SubmitButton: # NOTE EXPERIMENTAL
return val is True or val is False

try:
return isinstance(val, self.annotation)
except TypeError:
if complex_ := self._get_annotation_parametrized():
origin, subtype = complex_
return isinstance(val, origin) and all(isinstance(item, subtype) for item in val)
if val is None and NoneType in get_args(self.annotation):
return True
for origin, subtype in self._get_possible_types():
if isinstance(val, origin) and all(isinstance(item, subtype) for item in val):
return True
return False

def _is_subclass(self, class_type):
try:
return issubclass(self.annotation, class_type)
except TypeError: # None, Union etc cast an error
for _, subtype in self._get_possible_types():
if issubclass(class_type, subtype):
return True
return False

def _morph(self, class_type: "Self", morph_if: type):
""" To be overrided by the subclasses.
The user used a Path within a Tag and that will turn it into a PathTag when the UI needs it.
"""
if self._is_subclass(morph_if): # return a blank PathTag
return class_type(self.val)

def _get_annotation_parametrized(self):
if origin := get_origin(self.annotation): # list[str] -> list, list -> None
if origin == UnionType: # might be just `int | None`
return
subtype = get_args(self.annotation) # list[str] -> (str,), list -> ()
if (len(subtype) == 1):
return origin, subtype[0]
else:
warn(f"This parametrized generic not implemented: {self.annotation}")
return None
return class_type(self.val, annotation=self.annotation)

def _get_possible_types(self) -> list[tuple]:
""" Possible types we can cast the value to.
For annotation `list[int] | tuple[str] | str | None`,
it returns `[(list,int), (tuple,str), (None,str)]`.
Filters out None.
"""
def _(annot):
if origin := get_origin(annot): # list[str] -> list, list -> None
subtype = get_args(annot) # list[str] -> (str,), list -> ()
if origin == UnionType: # ex: `int | None`, `list[int] | None``
return [_(subt) for subt in subtype]
if (len(subtype) == 1):
return origin, subtype[0]
else:
warn(f"This parametrized generic not implemented: {annot}")
elif annot is not None and annot is not NoneType:
# from UnionType, we get a NoneType
return None, annot
return None # to be filtered out
out = _(self.annotation)
return [x for x in (out if isinstance(out, list) else [out]) if x is not None]
# return out if isinstance(out, list) else [out]


def set_error_text(self, s):
self._original_desc = o = self.description
Expand Down Expand Up @@ -402,9 +422,9 @@ def _get_ui_val(self):
Ex: [Path("/tmp"), Path("/usr")] -> ["/tmp", "/usr"].
We need the latter in the UI because in the first case, ast_literal would not not reconstruct it later.
"""
if complex_ := self._get_annotation_parametrized():
origin, _ = complex_
return origin(str(v)for v in self.val)
for origin, _ in self._get_possible_types():
if origin:
return origin(str(v)for v in self.val)
return self.val

def _get_choices(self) -> dict[ChoiceLabel, TagValue]:
Expand Down Expand Up @@ -505,7 +525,7 @@ def update(self, ui_value: TagValue) -> bool:
# Even though GuiInterface does some type conversion (str → int) independently,
# other interfaces does not guarantee that. Hence, we need to do the type conversion too.
if self.annotation:
if ui_value == "" and type(None) in get_args(self.annotation):
if ui_value == "" and NoneType in get_args(self.annotation):
# The user is not able to set the value to None, they left it empty.
# Cast back to None as None is one of the allowed types.
# Ex: `severity: int | None = None`
Expand All @@ -530,14 +550,17 @@ def update(self, ui_value: TagValue) -> bool:

if not self._is_right_instance(out_value) and isinstance(out_value, str):
try:
if complex_ := self._get_annotation_parametrized():
origin, cast_to = complex_
# Textual ask_number -> user writes '123', this has to be converted to int 123
# NOTE: Unfortunately, type(list) looks awful here. @see TextualInterface.form comment.
# (Maybe that's better now.)
candidate = origin(cast_to(v) for v in literal_eval(ui_value))
for origin, cast_to in self._get_possible_types():
if origin:
# Textual ask_number -> user writes '123', this has to be converted to int 123
# NOTE: Unfortunately, type(list) looks awful here. @see TextualInterface.form comment.
# (Maybe that's better now.)
candidate = origin(cast_to(v) for v in literal_eval(ui_value))
else:
candidate = cast_to(ui_value)
if self._is_right_instance(candidate):
out_value = candidate
break
else:
out_value = self.annotation(ui_value)
except (TypeError, ValueError, SyntaxError):
Expand Down
14 changes: 10 additions & 4 deletions mininterface/types.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, override
from typing_extensions import Self
from .tag import Tag, ValidationResult, TagValue
from typing import Callable
from typing_extensions import Self, override
from .tag import Tag, ValidationResult, TagValue, common_iterables


def Validation(check: Callable[["Tag"], ValidationResult | tuple[ValidationResult, TagValue]]):
Expand Down Expand Up @@ -65,7 +65,13 @@ class PathTag(Tag):

def __post_init__(self):
super().__post_init__()
self.annotation = list[Path] if self.multiple else Path
if not self.annotation:
self.annotation = list[Path] if self.multiple else Path
else:
for origin, _ in self._get_possible_types():
if origin in common_iterables:
self.multiple = True
break

@override
def _morph(self, class_type: "Self", morph_if: type):
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.0rc2"
version = "0.6.0rc4"
description = "A minimal access to GUI, TUI, CLI and config"
authors = ["Edvard Rejthar <[email protected]>"]
license = "GPL-3.0-or-later"
Expand Down
61 changes: 46 additions & 15 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from unittest.mock import patch

from attrs_configs import AttrsModel, AttrsNested, AttrsNestedRestraint
from configs import (ConstrainedEnv, FurtherEnv2, FurtherEnv3, MissingUnderscore, NestedDefaultedEnv,
NestedMissingEnv, OptionalFlagEnv, ParametrizedGeneric, SimpleEnv)
from configs import (ConstrainedEnv, FurtherEnv2, MissingUnderscore, NestedDefaultedEnv, NestedMissingEnv,
OptionalFlagEnv, ParametrizedGeneric, SimpleEnv)
from pydantic_configs import PydModel, PydNested, PydNestedRestraint

from mininterface import Mininterface, TextInterface, run
Expand Down Expand Up @@ -136,11 +136,11 @@ def test_form_output(self):

class TestConversion(TestAbstract):
def test_tagdict_resolve(self):
self.assertEqual({"one":1}, formdict_resolve({"one":1}))
self.assertEqual({"one":1}, formdict_resolve({"one":Tag(1)}))
self.assertEqual({"one":1}, formdict_resolve({"one":Tag(Tag(1))}))
self.assertEqual({"":{"one":1}}, formdict_resolve({"":{"one":Tag(Tag(1))}}))
self.assertEqual({"one":1}, formdict_resolve({"":{"one":Tag(Tag(1))}},extract_main=True))
self.assertEqual({"one": 1}, formdict_resolve({"one": 1}))
self.assertEqual({"one": 1}, formdict_resolve({"one": Tag(1)}))
self.assertEqual({"one": 1}, formdict_resolve({"one": Tag(Tag(1))}))
self.assertEqual({"": {"one": 1}}, formdict_resolve({"": {"one": Tag(Tag(1))}}))
self.assertEqual({"one": 1}, formdict_resolve({"": {"one": Tag(Tag(1))}}, extract_main=True))

def test_normalize_types(self):
""" Conversion str("") to None and back.
Expand Down Expand Up @@ -503,7 +503,23 @@ def test_annotated(self):
self.assertTrue(d[""]["test2"].update(" "))


class TestParametrizedGeneric(TestAbstract):
class TestAnnotation(TestAbstract):
""" Tests tag annotation. """

def test_type_discovery(self):
def _(compared, annotation):
self.assertListEqual(compared, Tag(annotation=annotation)._get_possible_types())

_([], None)
_([(None, str)], str)
_([(None, str)], None | str)
_([(None, str)], str | None)
_([(list, str)], list[str])
_([(list, str)], list[str] | None)
_([(list, str)], None | list[str])
_([(list, str), (tuple, int)], None | list[str] | tuple[int])
_([(list, int), (tuple, str), (None, str)], list[int] | tuple[str] | str | None)

def test_generic(self):
t = Tag("", annotation=list)
t.update("")
Expand All @@ -521,15 +537,32 @@ def test_parametrized_generic(self):
t.update("[1,'2',3]")
self.assertEqual(["1", "2", "3"], t.val)

def test_single_path_union(self):
t = Tag("", annotation=Path | None)
t.update("/tmp/")
self.assertEqual(Path("/tmp"), t.val)
t.update("")
self.assertIsNone(t.val)

def test_path(self):
t = Tag("", annotation=list[Path])
t.update("['/tmp/','/usr']")
self.assertEqual([Path("/tmp"), Path("/usr")], t.val)
self.assertFalse(t.update("[1,2,3]"))
self.assertFalse(t.update("[/home, /usr]")) # missing parenthesis

def test_path_union(self):
t = Tag("", annotation=list[Path]|None)
t.update("['/tmp/','/usr']")
self.assertEqual([Path("/tmp"), Path("/usr")], t.val)
self.assertFalse(t.update("[1,2,3]"))
self.assertFalse(t.update("[/home, /usr]")) # missing parenthesis
self.assertTrue(t.update("[]"))
self.assertEqual([],t.val)
self.assertTrue(t.update(""))
self.assertIsNone(t.val)

def test_path_cli(self):
# self.sys("--paths")
m = run(ParametrizedGeneric, interface=Mininterface)
f = dataclass_to_tagdict(m.env, m._descriptions)[""]["paths"]
self.assertEqual("", f.val)
Expand All @@ -546,13 +579,11 @@ def test_path_cli(self):

def test_choice(self):
m = run(interface=Mininterface)
self.assertIsNone(None, m.choice((1,2,3)))
self.assertEqual(2, m.choice((1,2,3), default=2))
self.assertEqual(2, m.choice((1,2,3), default=2))
self.assertIsNone(None, m.choice((1, 2, 3)))
self.assertEqual(2, m.choice((1, 2, 3), default=2))
self.assertEqual(2, m.choice((1, 2, 3), default=2))
self.assertEqual(2, m.choice({"one": 1, "two": 2}, default=2))
self.assertEqual(2, m.choice([Tag(1, name="one"), Tag(2, name="two")],default=2))


self.assertEqual(2, m.choice([Tag(1, name="one"), Tag(2, name="two")], default=2))


if __name__ == '__main__':
Expand Down

0 comments on commit c6667d0

Please sign in to comment.