From 7371b82877a4f3296eed30484e09f79bd41d834f Mon Sep 17 00:00:00 2001 From: Edvard Rejthar Date: Fri, 11 Oct 2024 16:57:21 +0200 Subject: [PATCH] descriptions on the fly, CallbackTag --- asset/callback_button.avif | Bin 0 -> 1193 bytes asset/callback_choice.avif | Bin 0 -> 1790 bytes asset/suggestion_dataclass_annotated.avif | Bin 0 -> 1833 bytes asset/suggestion_dataclass_expanded.avif | Bin 0 -> 1736 bytes asset/suggestion_dataclass_instance.avif | Bin 0 -> 1058 bytes asset/suggestion_dataclass_type.avif | Bin 0 -> 1006 bytes asset/suggestion_dict.avif | Bin 0 -> 988 bytes asset/suggestion_form_env.avif | Bin 0 -> 902 bytes asset/suggestion_run.avif | Bin 0 -> 2086 bytes docs/Overview.md | 80 ++++----- docs/Types.md | 69 ++++++++ docs/index.md | 13 ++ mininterface/__init__.py | 13 +- mininterface/auxiliary.py | 12 +- mininterface/cli_parser.py | 55 +++--- mininterface/experimental.py | 2 +- mininterface/form_dict.py | 33 ++-- mininterface/gui_interface/__init__.py | 33 ++-- .../gui_interface/redirect_text_tkinter.py | 25 +++ mininterface/gui_interface/tk_window.py | 5 +- mininterface/gui_interface/utils.py | 23 ++- mininterface/mininterface.py | 79 ++++++--- mininterface/redirectable.py | 30 +--- mininterface/tag.py | 115 +++++++++---- mininterface/tag_factory.py | 28 +++ mininterface/textual_interface/__init__.py | 2 +- mininterface/textual_interface/textual_app.py | 4 +- mininterface/type_stubs.py | 22 +++ mininterface/types.py | 78 ++++++++- tests/configs.py | 39 ++++- tests/tests.py | 159 +++++++++++++++--- 31 files changed, 674 insertions(+), 245 deletions(-) create mode 100644 asset/callback_button.avif create mode 100644 asset/callback_choice.avif create mode 100644 asset/suggestion_dataclass_annotated.avif create mode 100644 asset/suggestion_dataclass_expanded.avif create mode 100644 asset/suggestion_dataclass_instance.avif create mode 100644 asset/suggestion_dataclass_type.avif create mode 100644 asset/suggestion_dict.avif create mode 100644 asset/suggestion_form_env.avif create mode 100644 asset/suggestion_run.avif create mode 100644 mininterface/gui_interface/redirect_text_tkinter.py create mode 100644 mininterface/tag_factory.py create mode 100644 mininterface/type_stubs.py diff --git a/asset/callback_button.avif b/asset/callback_button.avif new file mode 100644 index 0000000000000000000000000000000000000000..f5a22ba5ee165f76bac1fb6da4639f6db2ecf9b0 GIT binary patch literal 1193 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xz0Yiz}TCcT9OEo0|JeVl$;_6lYyZi zGr0uD2GKd0Nibvh3NlM_!E%g1QYSMfKN-YxVPIfl0EqxG(>Wk@8^{Oq7`Fl`Ng&%P zGcynD60nREkdq2h0@9IKW?<-?pOX)E073>R4U%!pDac3!sg%krN`;ufz`&PST9E^` z6J+b*%z~l4DvfF6Y?XGBsg15#`OwAvZOC@spf$}LDu2NOhUY zC8@c^RzNkGIhlz?l~#!`=gVal+Tp#l+R!d~I?4t>zI4#*br14-{U1e0oro-M5-ERQcD^NB3-A2Ly!MDun(mn%S*;&2;NZhUN=brBqfr zZkom>G4+g{L|)+1IbqrsJB}NLCyEKQowB&vYm%m-6e^kCc06@y?H>C}Ki@nHm-?&z z{p7~K4jj728C?4lze@f2y~u0xagBR#Q)6dbQi>E?xbl5N!G@Tv&1$Fozx3`f`?{U) zu8zWA2_@E-x>?-q$x_Ac=kFEG%2<4TbL=dRYukVU!wC);hlYkkqtnbTCzxhsE(y5) zU(H9FA#d;f6~>qE|9_Bk&slzl@{*!QnURdVr+2-GUF5rIW(@Pq$KAVjI?C`Q{Ngpe zAsZoDCd>1y)<@zvjM${C(`VSN1RqD`Cq zzMC`_&ayHHy6?$vYGQ4-Y!$1ei@(>}5OY^eiHU4q9_?1&WA|X+uFU3|<(BUxWJ4c2 zRGY8d>%e(Sb;04UQc7(y+WVJ$($Mh{OTKdU$g=-ZE1xg8nd->0;d1e6r#!I_r#KuG zYNY3^OMNV~D(CPii~LPNdDA_ad5o?-PMjWoHh9sp1A7)tS+k#4=J}G<(&^F()#sTO z-ag-3yt4MqLAvM3R(SuE$tiz#V@`TxME@i)mi}A6>dL~-IV@KwW=$ywX` zyvoYI@F(f*7sI6rGp)%&qbcb+_$~SW2or&#yY2Q LwMU!I{zt0-Vxzmg literal 0 HcmV?d00001 diff --git a/asset/callback_choice.avif b/asset/callback_choice.avif new file mode 100644 index 0000000000000000000000000000000000000000..df0bca33141df165178f9888331686876cfca9d3 GIT binary patch literal 1790 zcmYjQ3pmql8~<+>GM1zW-%u=6nBI*xKC`9QIh2;mA*1168#9N7d=86Oy(45HyjzHx zW6n{%PDN5IIYn>I-(@8vZ)d*$balPoeO=Gp zDKf!8EEA*!I@@5$002v4(j#b64l;w_GYs|*7XVmbK*Ud9iVV>IXBiX=j9_kZFlavj zGC~8S(GdWE1JVq%;1U2ps6z2(1Z#i*2l-?W4B4DylhJ?zx>O=PL5niQQbHM_n*;pA zK(v&xVX%T}Ql$pSrvIn_04Q1{hq2jH+O-p8u~`rnf~~a-1zGifEgI&YFuMg;&VV6YU+w3N8~S-@3x@i?2#OL~zEi@r7i(H94gYXW zU&U-ZSBce^GDwevl)@)ror# zAqh|42j;sfVN}NJuGbs&o+(^icaAKP^CrPn%Q8@dMSVc1JAiO8q zlOIh|{avblog1NSX#1pt)e6e^=Q)GJleW<1Op)dEwUpCF7OLfhsf=4f@ z>gTkrsD41?lg#WaF19a}hoU`;$$FaBE&u%+iQZ7Y*PnjC!bEpBxJ#$oeNTp35UI3X zbP1D39Tp~UC{|FC3WnsQ4Z~JL{wS`!Ow>KOd&lzcx6# z51+&Rs*jz+Pz>c`Ved(F=IaGQ)QKVHqeQ{h{CRVViC-KkrR&$$N8 z;DwIWt=$+9J^b=K*KDv+GB2sSi=dfT$q2qz{Pke_1R=h(@pUrQ?mwQ< zKD#R)UGy)=I_*yHSYL7@-qN!AI33%+S0d3~3LkFyt?6cP>9@0M4R@}}muDj%avg7H zuDjSBQA!EhK8nDOFAfY<3?zBW$`zgHT|hU^abgaJ#TJ_nT|8<}j19bSXhoFCJK0BE zKGg64CXmDC^7fX^+}(G0C621nV|juaVp|{B=15$ZU4MnQ7#KFWLOJ)i)%K1!mYt?F z<(QmLGCG^~rnaeGaak?ha8wyH>-TTJJYjZJ+4~U{GO1WC@BPvjgXYO+<+v;*nNprp z?SwVr!BA(E%hnHGPv0z-&qlFtbB-V1HC2~cjnpdIpR)7rPS>fcWR128St80X*6tg1 z=`ZYO>%_|AvSL$a{jnGQ`Ax+!_h%}VmfN2cA4g{PJEY#|FfB;_6r4U}YqNYh7FV!G zSk$yA-r?XCMJ%TF<(-K$GvJuqd3pc(wD00B|EDq&O@23$Z907)#|4jA3QOo(=pK*M zQx_^59|J^5F7lJop8|0F7FW@>+LFQLql}Hx9y-wK8;s<*>mJSFE1qr6%nf@-yWC$? JP%iY0{{h~+=biun literal 0 HcmV?d00001 diff --git a/asset/suggestion_dataclass_annotated.avif b/asset/suggestion_dataclass_annotated.avif new file mode 100644 index 0000000000000000000000000000000000000000..4f3bf85bf9a10dce3cd909bced05b3df6bfbab05 GIT binary patch literal 1833 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

lLE8mv-u)=_`?8#@-Ny{BM-=2ZuF=YT zvM773gV*6|{>yFdfufzYkZD!`L8m|vShWOI=$UffoCWAUoqzb<)>RrshyCV)^S5QE zQ);2nR2AumP6~?Ma`;x__9Jb*rGxGt8j4Je&R>x2YOA{7#V&@tM$rb|{o1N4RnDA( zrIrxkS0J|vaus{%-nR5^L>$S&(WMNW@0|-FB^NGjZ59HZd>iZARuHEL-*ai>g4ItX z<0Evj8M2$38-X!%I2hHg*_x8+qHnzz)A4uwWHpBRGCRmRU17~izOwt0YsT1&^7UH( zZLTk-NOrP0Wr%FFX9W&^a|H`2|51vV~drM6asOt)z@;{S~T56fOBdWd99R;^c zQl{D;QT8~b=b`tZ#b4qAvsh0o_wwGRq_pFE910f3uL!382;*lC5lkCvN+U-bD$G8Q LS^6SRSM~i59trd5 literal 0 HcmV?d00001 diff --git a/asset/suggestion_dataclass_instance.avif b/asset/suggestion_dataclass_instance.avif new file mode 100644 index 0000000000000000000000000000000000000000..ee4ff3f48902bb575acfaa0b1586fa8342f90ad9 GIT binary patch literal 1058 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xy^nK`jnemk_eIm0*#E6oFWL5fuSHX zxdg@r(K(q(Fk|=%GD~v7a*RMyE;A=T8N_p8U|Q4hVn_V_{@!W&xTekeiZN zBE-POC84^hXN`b@f`o(7cUGShOn0@tt|z#NWHf{}?pmYI{LbUS!dH|1FI-pMXzJOZ zUHcO5Pv5rtsCxB}Ydf~gocsJ{{}lmIeFo3GrI9=qtNqzpUe0GM%-nplZhPLfgO4xV zbazzU#dpkux$ENj#;snm>(oD8WifiO+Sc(v1*_Y4{*!^_O{e0|PxX0(ihtRAE{gk@zjZ>7;EFw^b4^doR6EwYaeL%zh9Z-D zx2?K&n%xNarE^;Pp7auyC{eDO!q8hj_qdkd$=a#%^83jvCoa65v%VmfGyX-G;o&m= z-(PmtPMmj=xq1g*vP2DQU$0;AtF3|)LT%Dg4fg!l?e$!!ex<9y|H2(wjkDjFxh>kj zSZ%rNx0@p4{K9pd2aEE4X0CBEZ4< zk?7pqA5l}ZoC-HyOZle!`mXursZ%AIO8zY5IC_y+CUFngVj1HYv?(*%uT3@Q~&jb(ze?e{9aJNESQW!_&|(@$Khxo%1rf?av$k zejs$jmoM)#<8q$+8qT^uzIwj;a{SejQ+%yjlzlIz3&r=K=AYd)L3#>@YD zg#W(>6PPB4t@Hl*t7m@4ghwmaoxIKe<-QwF)y^58&dRUmI39Dl)-gBzsd(puUspb? sUAs;uWY7HY4L7%&zr6BxTSB6w!PLlUPZ?dGB`DODG06W)ceV`x0Q`5bNB{r; literal 0 HcmV?d00001 diff --git a/asset/suggestion_dataclass_type.avif b/asset/suggestion_dataclass_type.avif new file mode 100644 index 0000000000000000000000000000000000000000..e7129193cffe83c8125a56b3b416ed6ee4edd5b9 GIT binary patch literal 1006 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xy^nK`jnemk_eIm0*#E6oFWL5fuSHX zxdg@r(K(q(Fk|=%GD~v7a*RMyE;A=T8N_p8U|syJ8<&i>N!Wy?URqr{8E&QgY94<^TUX z9o;x5@EQKt^x)s>KZ2!4?)u-0Jh3hAqxAlHcX@1QK4OUJw~F+U=&9KLdRoxbYu=mK z>oql5*RXDlHd?*ve1YMsz10ErQ=S~$z9w2+X8PWa_rF(^%v!m2Re!+arq)Qi+!%N9 zX|7uuUTO6l_HUbcZfDQU)U1fD5p!4Fc;?BUJ5lnS*P|@qMf)rEY(IX(;w*A$gh6ktR)K$=ACbv_EpKE`~QZ`+jqWO{pQmGhtlLJit3$Ksu7tga^HUL zN)Vqd*V@PafBx4`S0bc)KCEv%v-YmpI(Mzj`*ZxPq|(1Q-$~mz)n8%loy~ay&o+3U zX78D*&?jLTy{2>;n|m%_WqRnLV?5s%d;Ph))nLw-i@vedkKUD>W8nXCarLCfdqQ^x zWUC4O|FUlFygt4FG5I)$&Y~TGou0-S63;{8_Die#7b)jIc3r7>iu2kYo;}|d9-3|7 zUTW0CV7A#})xlt60h2pg+e1$LJFAbkZhr9tRwQGCM# z_47ZMF)8G%n5wS&-PFkDsdU{6y%SEAlSLDbYdi29Irp@GF>4w}n#=R|@0st+pO>mJ zrO0&MyqA5wQ_T;)bS_*ecK68a3h^IZo(wH(L(=tR+wJYvO%0ClIw~@4JG;QguOGG9 rW0o%r^X~~M2>-FG=k9AcwT@?kQ?_Wl-*G|k%k8A+GH2Va-e>>-7F(Cc literal 0 HcmV?d00001 diff --git a/asset/suggestion_dict.avif b/asset/suggestion_dict.avif new file mode 100644 index 0000000000000000000000000000000000000000..f26be1174a628160daffb2c88fc15cc5e9174ef9 GIT binary patch literal 988 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 literal 0 HcmV?d00001 diff --git a/docs/Overview.md b/docs/Overview.md index 0d57e08..b76d944 100644 --- a/docs/Overview.md +++ b/docs/Overview.md @@ -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/)). diff --git a/docs/Types.md b/docs/Types.md index 9d1651e..ae1eee9 100644 --- a/docs/Types.md +++ b/docs/Types.md @@ -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 \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index b6ceffb..507bc80 100644 --- a/docs/index.md +++ b/docs/index.md @@ -162,3 +162,16 @@ m.form(my_dictionary) ``` ![List of paths](asset/list_of_paths.avif) + + + + + + + + + + + + + diff --git a/mininterface/__init__.py b/mininterface/__init__.py index 588ecf2..c9c3ea3 100644 --- a/mininterface/__init__.py +++ b/mininterface/__init__.py @@ -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 @@ -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 = "", @@ -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 @@ -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: diff --git a/mininterface/auxiliary.py b/mininterface/auxiliary.py index f3fcd39..366c873 100644 --- a/mininterface/auxiliary.py +++ b/mininterface/auxiliary.py @@ -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 @@ -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] diff --git a/mininterface/cli_parser.py b/mininterface/cli_parser.py index 9b2191e..c69971d 100644 --- a/mininterface/cli_parser.py +++ b/mininterface/cli_parser.py @@ -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 @@ -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 = [] @@ -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. @@ -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. @@ -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 diff --git a/mininterface/experimental.py b/mininterface/experimental.py index f4c34db..765d656 100644 --- a/mininterface/experimental.py +++ b/mininterface/experimental.py @@ -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? diff --git a/mininterface/form_dict.py b/mininterface/form_dict.py index e478982..ab1c5cb 100644 --- a/mininterface/form_dict.py +++ b/mininterface/form_dict.py @@ -3,7 +3,12 @@ """ import logging from types import FunctionType, MethodType -from typing import TYPE_CHECKING, Any, Callable, Optional, 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 if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self` from typing import Self @@ -16,7 +21,8 @@ logger = logging.getLogger(__name__) -EnvClass = TypeVar("EnvClass") +DataClass = TypeVar("DataClass") +EnvClass = TypeVar("EnvClass", bound=DataClass) FormDict = dict[str, TypeVar("FormDictRecursiveValue", TagValue, Tag, "Self")] """ Nested form that can have descriptions (through Tag) instead of plain values. @@ -51,11 +57,11 @@ class Env: # NOTE: In the future, allow `FormDict , EnvClass`, a dataclass (or its instance) # to be edited too -# is_dataclass(v) -> dataclass or its instance -# isinstance(v, type) -> class, not an instance -# Then, we might get rid of ._descriptions because we will read from the model itself # TypeVar('FormDictOrEnv', FormDict, EnvClass) -FormDictOrEnv = TypeVar('FormDictOrEnv', bound=FormDict) # , EnvClass) +# FormDictOrEnv = TypeVar('FormDictOrEnv', bound = FormDict | Type[EnvClass] | EnvClass) +FormDictOrEnv = TypeVar('FormDictOrEnv', bound=FormDict | DataClass) +# FormDictOrEnv = TypeVar('FormDictOrEnv', bound = FormDict | EnvClass) +# FormDictOrEnv = TypeVar('FormDictOrEnv', FormDict, Type[EnvClass], EnvClass) def formdict_resolve(d: FormDict, extract_main=False, _root=True) -> dict: @@ -105,11 +111,12 @@ def formdict_to_widgetdict(d: FormDict | Any, widgetize_callback: Callable, _key return d -def dataclass_to_tagdict(env: EnvClass, descr: dict, facet: "Facet" = None, _path="") -> TagDict: +# TODO accept rather interface and fetch facet from within +def dataclass_to_tagdict(env: EnvClass, facet: "Facet" = None, _nested=False) -> TagDict: """ Convert the dataclass produced by tyro into dict of dicts. """ main = {} - if not _path: # root is nested under "" path - subdict = {"": main} if not _path else {} + if not _nested: # root is nested under "" path + subdict = {"": main} if not _nested else {} else: subdict = {} @@ -133,12 +140,12 @@ def dataclass_to_tagdict(env: EnvClass, descr: dict, facet: "Facet" = None, _pat if hasattr(val, "__dict__") and not isinstance(val, (FunctionType, MethodType)): # nested config hierarchy # nested config hierarchy # Why checking the isinstance? See Tag._is_a_callable. - subdict[param] = dataclass_to_tagdict(val, descr, facet, _path=f"{_path}{param}.") + subdict[param] = dataclass_to_tagdict(val, facet, _nested=True) else: # scalar or Tag value - d = {"description": descr.get(f"{_path}{param}"), "facet": facet} + d = {"description": get_description(env.__class__, param), "facet": facet} if not isinstance(val, Tag): - tag = Tag(val, _src_key=param, _src_obj=env, **d) + tag = tag_factory(val, _src_key=param, _src_obj=env, **d) else: tag = val._fetch_from(Tag(**d)) - (subdict if _path else main)[param] = tag + (subdict if _nested else main)[param] = tag return subdict diff --git a/mininterface/gui_interface/__init__.py b/mininterface/gui_interface/__init__.py index 1ce3eb4..9cc8723 100644 --- a/mininterface/gui_interface/__init__.py +++ b/mininterface/gui_interface/__init__.py @@ -1,15 +1,21 @@ +from dataclasses import is_dataclass +from typing import Type, override + try: + # It seems tkinter is installed either by default or not installable at all. + # Tkinter is not marked as a requirement as other libraries does that neither. from tkinter import TclError except ImportError: from ..common import InterfaceNotAvailable raise InterfaceNotAvailable - from .tk_window import TkWindow +from .redirect_text_tkinter import RedirectTextTkinter from ..common import InterfaceNotAvailable -from ..form_dict import FormDictOrEnv, dataclass_to_tagdict, dict_to_tagdict, formdict_resolve -from ..redirectable import RedirectTextTkinter, Redirectable +from ..form_dict import DataClass, FormDict, dataclass_to_tagdict, dict_to_tagdict, formdict_resolve +from ..redirectable import Redirectable from ..mininterface import EnvClass, Mininterface +from ..cli_parser import run_tyro_parser class GuiInterface(Redirectable, Mininterface): @@ -31,22 +37,11 @@ def alert(self, text: str) -> None: def ask(self, text: str) -> str: return self.form({text: ""})[text] - def _ask_env(self) -> EnvClass: - """ Display a window form with all parameters. """ - form = dataclass_to_tagdict(self.env, self._descriptions, self.facet) - - # formDict automatically fetches the edited values back to the EnvInstance - return self.window.run_dialog(form) - - def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass: - """ Prompt the user to fill up whole form. - See Mininterface.form - """ - if form is None: - # NOTE should be integrated here when we integrate dataclass, see FormDictOrEnv - return self._ask_env() - else: - return formdict_resolve(self.window.run_dialog(dict_to_tagdict(form, self.facet), title=title), extract_main=True) + def form(self, + form: DataClass | Type[DataClass] | FormDict | None = None, + title: str = "" + ) -> FormDict | DataClass | EnvClass: + return self._form(form, title, self.window.run_dialog) def ask_number(self, text: str) -> int: return self.form({text: 0})[text] diff --git a/mininterface/gui_interface/redirect_text_tkinter.py b/mininterface/gui_interface/redirect_text_tkinter.py new file mode 100644 index 0000000..6c2d6a5 --- /dev/null +++ b/mininterface/gui_interface/redirect_text_tkinter.py @@ -0,0 +1,25 @@ +from tkinter import END, Text, Tk + +from ..redirectable import RedirectText + + +class RedirectTextTkinter(RedirectText): + """ Helps to redirect text from stdout to a text widget. """ + + def __init__(self, widget: Text, window: Tk) -> None: + super().__init__() + self.widget = widget + self.window = window + + def write(self, text): + self.widget.pack(expand=True, fill='both') + self.widget.insert(END, text) + self.widget.see(END) # scroll to the end + self.trim() + self.window.update_idletasks() + super().write(text) + + def trim(self): + lines = int(self.widget.index('end-1c').split('.')[0]) + if lines > self.max_lines: + self.widget.delete(1.0, f"{lines - self.max_lines}.0") diff --git a/mininterface/gui_interface/tk_window.py b/mininterface/gui_interface/tk_window.py index 1377b74..1fa421a 100644 --- a/mininterface/gui_interface/tk_window.py +++ b/mininterface/gui_interface/tk_window.py @@ -26,6 +26,7 @@ def __init__(self, interface: "GuiInterface"): self.params = None self._result = None self._event_bindings = {} + self._post_submit_action: Callable | None = None # TODO Migrate to the BackendAdaptor? self.interface = interface self.title(interface.title) self.bind('', lambda _: self._ok(Cancelled)) @@ -71,7 +72,7 @@ def run_dialog(self, form: TagDict, title: str = "") -> TagDict: self.form.pack() # Add radio etc. - replace_widgets(self.form.widgets, form) + replace_widgets(self, self.form.widgets, form) # Set the submit and exit options self.form.button.config(command=self._ok) @@ -87,6 +88,8 @@ def run_dialog(self, form: TagDict, title: str = "") -> TagDict: def validate(self, form: TagDict, title: str) -> TagDict: if not Tag._submit(form, self.form.get()): return self.run_dialog(form, title) + if self._post_submit_action: # TODO, textual implementation + self._post_submit_action() return form def yes_no(self, text: str, focus_no=True): diff --git a/mininterface/gui_interface/utils.py b/mininterface/gui_interface/utils.py index f4d0cd9..1056833 100644 --- a/mininterface/gui_interface/utils.py +++ b/mininterface/gui_interface/utils.py @@ -1,3 +1,4 @@ +from typing import TYPE_CHECKING from autocombobox import AutoCombobox from pathlib import Path, PosixPath from tkinter import Button, Entry, TclError, Variable, Widget @@ -11,6 +12,9 @@ from ..form_dict import TagDict from ..tag import Tag +if TYPE_CHECKING: + from tk_window import TkWindow + def recursive_set_focus(widget: Widget): for child in widget.winfo_children(): @@ -80,7 +84,7 @@ def _(*_): return _ -def replace_widgets(nested_widgets, form: TagDict): +def replace_widgets(tk_app: "TkWindow", nested_widgets, form: TagDict): def _fetch(variable): return ready_to_replace(widget, var_name, tag, variable) @@ -98,7 +102,8 @@ def _fetch(variable): # Replace with radio buttons if tag.choices: - variable = Variable(value=tag._get_ui_val()) + chosen_val = tag._get_ui_val() + variable = Variable() grid_info = _fetch(variable) nested_frame = Frame(master) @@ -109,15 +114,20 @@ def _fetch(variable): widget['values'] = list(tag._get_choices()) widget.pack() widget.bind('', lambda _: "break") # override default enter that submits the form + variable.set(chosen_val) else: - for i, choice_label in enumerate(tag._get_choices()): + for i, (choice_label, choice_val) in enumerate(tag._get_choices().items()): widget2 = Radiobutton(nested_frame, text=choice_label, variable=variable, value=choice_label) widget2.grid(row=i, column=1, sticky="w") subwidgets.append(widget2) + if choice_val is chosen_val: + variable.set(choice_label) + # TODO does this works in textual too? # File dialog - if path_tag := tag._morph(PathTag, (PosixPath, Path)): + elif path_tag := tag._morph(PathTag, (PosixPath, Path)): + # TODO this probably happens at ._factoryTime, get rid of _morph. I do not know, touch-timestamp uses nested Tag. grid_info = widget.grid_info() widget2 = Button(master, text='…', command=choose_file_handler(variable, path_tag)) @@ -134,7 +144,10 @@ def _fetch(variable): # Replace with a callback button elif tag._is_a_callable(): - variable, widget = create_button(master, _fetch, tag, label1, tag.val) + def inner(tag: Tag): + tk_app._post_submit_action = tag._run_callable + tag.facet.submit() + variable, widget = create_button(master, _fetch, tag, label1, lambda tag=tag: inner(tag)) # Add event handler tag._last_ui_val = variable.get() diff --git a/mininterface/mininterface.py b/mininterface/mininterface.py index 171c80e..c171a58 100644 --- a/mininterface/mininterface.py +++ b/mininterface/mininterface.py @@ -1,12 +1,16 @@ from enum import Enum import logging +from dataclasses import is_dataclass from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, Generic +from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload, Type from .common import Cancelled from .facet import Facet -from .form_dict import EnvClass, FormDictOrEnv, dict_to_tagdict, formdict_resolve +from .form_dict import DataClass, EnvClass, FormDict, FormDictOrEnv, dict_to_tagdict, formdict_resolve from .tag import ChoicesType, Tag, TagValue +from .form_dict import DataClass, FormDict, dataclass_to_tagdict, dict_to_tagdict, formdict_resolve +from .cli_parser import run_tyro_parser + if TYPE_CHECKING: # remove the line as of Python3.11 and make `"Self" -> Self` from typing import Self @@ -28,8 +32,7 @@ class Mininterface(Generic[EnvClass]): # This base interface does not require any user input and hence is suitable for headless testing. def __init__(self, title: str = "", - _env: EnvClass | SimpleNamespace | None = None, - _descriptions: dict | None = None, + _env: EnvClass | SimpleNamespace | None = None ): self.title = title or "Mininterface" # Why `or SimpleNamespace()`? @@ -75,9 +78,6 @@ class Env: ![Facet back-end](asset/facet_backend.avif) """ - self._descriptions = _descriptions or {} - """ Field descriptions """ - def __enter__(self) -> "Self": """ When used in the with statement, the GUI window does not vanish between dialogs and it redirects the stdout to a text area. """ @@ -176,7 +176,7 @@ class Color(Enum): ``` ![Default choice](asset/choices_default.avif) skippable: If there is a single option, choose it directly, without a dialog. - launch: If the chosen value is a callback, we directly call it. Then, the function returns None. + launch: If the chosen value is a callback, we directly call it and return its return value. Returns: The chosen value. @@ -198,19 +198,38 @@ class Color(Enum): if skippable and len(choices) == 1: if isinstance(choices, type) and issubclass(choices, Enum): out = list(choices)[0] + elif isinstance(choices, dict): + out = next(iter(choices.values())) else: out = choices[0] + tag = Tag(out) else: tag = Tag(val=default, choices=choices) key = title or "Choose" - out = self.form({key: tag})[key] - - if launch and Tag._is_a_callable_val(out): - return out() - return out + self.form({key: tag})[key] + + if launch: + if tag._is_a_callable(): + return tag._run_callable() + if isinstance(tag.val, Tag) and tag.val._is_a_callable(): + # Nested Tag: `m.choice([CallbackTag(callback_tag)])` -> `Tag(val=CallbackTag)` + return tag.val._run_callable() + return tag.val + + @overload + def form(self, form: None = None, title: str = "") -> EnvClass: ... + @overload + def form(self, form: FormDict, title: str = "") -> FormDict: ... + @overload + def form(self, form: Type[DataClass], title: str = "") -> DataClass: ... + @overload + def form(self, form: DataClass, title: str = "") -> DataClass: ... # NOTE: parameter submit_button = str (button text) or False to do not display the button - def form(self, form: FormDictOrEnv | None = None, title: str = "") -> FormDictOrEnv | EnvClass: + def form(self, + form: DataClass | Type[DataClass] | FormDict | None = None, + title: str = "" + ) -> FormDict | DataClass | EnvClass: """ Prompt the user to fill up an arbitrary form. Use scalars, enums, enum instances, objects like datetime, Paths or their list. @@ -289,19 +308,25 @@ class Color(Enum): print("The result", original["my label"].val) ``` """ - # NOTE in the future, support form=arbitrary dataclass too - if form is None: - print(f"Asking the form {title}".strip(), self.env) - # NOTE for testing, this might be converted to a tag_dict, see below - return self.env - f = form - print(f"Asking the form {title}".strip(), f) - - tag_dict = dict_to_tagdict(f, self.facet) - if True: # NOTE for testing, this might validate the fields with Tag._submit(ddd, ddd) - return formdict_resolve(tag_dict, extract_main=True) - else: - raise ValueError + print(f"Asking the form {title}".strip(), self.env if form is None else form) + return self._form(form, title, lambda tag_dict, title=None: tag_dict) + + def _form(self, + form: DataClass | Type[DataClass] | FormDict | None = None, + title: str = "", + launch_callback=None) -> FormDict | DataClass | EnvClass: + _form = self.env if form is None else form + if isinstance(_form, dict): + # TODO integrate to TextualMininterface and others, test and docs + # TODO After launching a callback, a TextualInterface stays, the form re-appears. + return formdict_resolve(launch_callback(dict_to_tagdict(_form, self.facet), title=title), extract_main=True) + if isinstance(_form, type): # form is a class, not an instance + _form, wf = run_tyro_parser(_form, {}, False, False, args=[]) # TODO what to do with wf + if is_dataclass(_form): # -> dataclass or its instance + # the original dataclass is updated, hence we do not need to catch the output from launch_callback + launch_callback(dataclass_to_tagdict(_form, self.facet)) + return _form + raise ValueError(f"Unknown form input {_form}") def is_yes(self, text: str) -> bool: """ Display confirm box, focusing yes. diff --git a/mininterface/redirectable.py b/mininterface/redirectable.py index 12430cd..232a421 100644 --- a/mininterface/redirectable.py +++ b/mininterface/redirectable.py @@ -5,11 +5,6 @@ else: from typing import Type -try: - from tkinter import END, Text, Tk -except ImportError: - pass - class RedirectText: """ Helps to redirect text from stdout to a text widget. """ @@ -30,28 +25,6 @@ def join(self): return t -class RedirectTextTkinter(RedirectText): - """ Helps to redirect text from stdout to a text widget. """ - - def __init__(self, widget: Text, window: Tk) -> None: - super().__init__() - self.widget = widget - self.window = window - - def write(self, text): - self.widget.pack(expand=True, fill='both') - self.widget.insert(END, text) - self.widget.see(END) # scroll to the end - self.trim() - self.window.update_idletasks() - super().write(text) - - def trim(self): - lines = int(self.widget.index('end-1c').split('.')[0]) - if lines > self.max_lines: - self.widget.delete(1.0, f"{lines - self.max_lines}.0") - - class Redirectable: # NOTE When used in the with statement, the TUI window should not vanish between dialogues. # The same way the GUI does not vanish. @@ -62,7 +35,6 @@ class Redirectable: # print("Second") # m.is_yes("Was it shown continuously?") - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._always_shown = False @@ -78,4 +50,4 @@ def __exit__(self, *_): self._always_shown = False sys.stdout = self._original_stdout if t := self._redirected.join(): # display text sent to the window but not displayed - print(t, end="") \ No newline at end of file + print(t, end="") diff --git a/mininterface/tag.py b/mininterface/tag.py index 36de132..f0e8c3d 100644 --- a/mininterface/tag.py +++ b/mininterface/tag.py @@ -3,9 +3,11 @@ from datetime import datetime from enum import Enum from types import FunctionType, MethodType, NoneType, UnionType -from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Type, TypeVar, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Type, TypeVar, get_args, get_origin from warnings import warn +from .type_stubs import TagCallback + from .experimental import SubmitButton @@ -232,19 +234,18 @@ def check(tag.val): _attrs_field: AttrsFieldInfo = None def __post_init__(self): + # Fetch information from the nested tag: `Tag(Tag(...))` + # TODO docs, test + if isinstance(self.val, Tag): + if self._src_obj or self._src_key: + raise ValueError("Wrong Tag inheritance, submit a bug report.") + self._src_obj = self.val + self._src_key = "val" + self._fetch_from(self.val) + self.val = self.val.val + # Fetch information from the parent object - if self._src_obj and not self._src_class: - self._src_class = self._src_obj if self._src_class: - if not self.annotation: # when we have _src_class, we must have _src_key too - self.annotation = get_type_hints(self._src_class).get(self._src_key) - field_type = self._src_class.__annotations__.get(self._src_key) - if field_type and hasattr(field_type, '__metadata__'): - for metadata in field_type.__metadata__: - if isinstance(metadata, Tag): - # The type of the Tag is another Tag - # Ex: `my_field: Validation(...) = 4` - self._fetch_from(metadata) # NOTE might fetch from a pydantic model too if pydantic: # Pydantic integration self._pydantic_field: dict | None = getattr(self._src_class, "model_fields", {}).get(self._src_key) if attr: # Attrs integration @@ -268,8 +269,17 @@ def __post_init__(self): if self.annotation is SubmitButton: self.val = False - if not self.name and self._src_key: - self.name = self._src_key + if not self.name: + if self._src_key: + self.name = self._src_key + # It seems to be it is better to fetch the name from the dict or object key than to use the function name. + # We are using get_name() instead. + # if self._is_a_callable(): + # self.name = self.val.__name__ + if not self.description and self._is_a_callable(): + # TODO does not work, do a test, there is `(fixed to` instead + self.description = self.val.__doc__ + self._original_desc = self.description self._original_name = self.name self.original_val = self.val @@ -283,23 +293,30 @@ def __repr__(self): # clean-up protected members if field.name.startswith("_"): continue - if field.name not in ("val", "description", "annotation", "name"): + if field.name not in ("val", "description", "annotation", "name", "choices"): continue # Display 'validation=not_empty' instead of 'validation=' if field.name == 'validation' and (func_name := getattr(field_value, "__name__", "")): - v = f"{field.name}={func_name}" + v = func_name + elif field.name == "choices": + if not self._get_choices(): + continue + v = list(self._get_choices()) + elif field.name == "val" and self._is_a_callable(): + v = self.val.__name__ else: - v = f"{field.name}={field_value!r}" + v = repr(field_value) - field_strings.append(v) + field_strings.append(f"{field.name}={v}") return f"{self.__class__.__name__}({', '.join(field_strings)})" def _fetch_from(self, tag: "Self") -> "Self": - """ Fetches public attributes from another instance. + """ Fetches attributes from another instance. (Skips the attributes that are already set.) """ - for attr in ['val', 'annotation', 'name', 'validation', 'choices', 'on_change', "facet"]: + for attr in ('val', 'annotation', 'name', 'validation', 'choices', 'on_change', "facet", + "_src_obj", "_src_key", "_src_class"): if getattr(self, attr) is None: setattr(self, attr, getattr(tag, attr)) if self.description == "": @@ -315,6 +332,9 @@ def _is_a_callable(self) -> bool: """ return self._is_a_callable_val(self.val, self.annotation) + def _run_callable(self): + return self.val() + def _on_change_trigger(self, ui_val): """ Trigger on_change only if the value has changed and if the validation succeeds. """ if self._last_ui_val != ui_val: @@ -325,10 +345,11 @@ def _on_change_trigger(self, ui_val): @staticmethod def _is_a_callable_val(val: TagValue, annot: type = None) -> bool: + # Note _is_a_callable_val(CallableTag(...)) -> False, as CallableTag is not a FunctionType + detect = FunctionType, MethodType if annot is None: - return isinstance(val, (FunctionType, MethodType)) - return isinstance(annot, (FunctionType, MethodType)) \ - or isinstance(annot, Callable) and isinstance(val, (FunctionType, MethodType)) + return isinstance(val, detect) + return isinstance(annot, detect) or isinstance(annot, Callable) and isinstance(val, detect) def _is_right_instance(self, val) -> bool: """ Check if the value conforms self.annotation. @@ -419,6 +440,15 @@ def remove_error_text(self): self.name = self._original_name self._error_text = None + def _get_name(self, make_effort=False): + """ It is not always wanted to set the callable name to the name. + When used as a form button, we prefer to use the dict key. + However, when used as a choice, this might be the only way to get the name. + """ + if make_effort and not self.name and self._is_a_callable(): + return self.val.__name__ + return self.name + def _repr_annotation(self): if isinstance(self.annotation, UnionType) or get_origin(self.annotation): # ex: `list[str]` @@ -441,23 +471,25 @@ def _get_ui_val(self): return self.val.value return self.val + @classmethod + def _repr_val(cls, v): + if cls._is_a_callable_val(v): + return v.__name__ + if isinstance(v, Tag): + return v._get_name(True) + if isinstance(v, Enum): # enum instances collection, ex: list(ColorEnum.RED, ColorEnum.BLUE) + return str(v.value) + return str(v) + def _get_choices(self) -> dict[ChoiceLabel, TagValue]: """ Wherease self.choices might have different format, this returns a canonic dict. """ - def _edit(v): - if self._is_a_callable_val(v): - return v.__name__ - if isinstance(v, Tag): - return v.name - if isinstance(v, Enum): # enum instances collection, ex: list(ColorEnum.RED, ColorEnum.BLUE) - return str(v.value) - return str(v) if self.choices is None: return {} if isinstance(self.choices, dict): return self.choices if isinstance(self.choices, common_iterables): - return {_edit(v): v for v in self.choices} + return {self._repr_val(v): v for v in self.choices} if isinstance(self.choices, type) and issubclass(self.choices, Enum): # Enum type, ex: choices=ColorEnum return {str(v.value): v for v in list(self.choices)} @@ -514,6 +546,12 @@ def _validate(self, out_value) -> TagValue: return out_value + def set_val(self, val: TagValue) -> "Self": + """ Sets the value without any checks. """ + self.val = val + self._update_source(val) + return self + def update(self, ui_value: TagValue) -> bool: """ UI value → Tag value → original value. (With type conversion and checks.) @@ -544,6 +582,8 @@ 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 self.annotation == TagCallback: + return True # TODO 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. @@ -591,16 +631,23 @@ def update(self, ui_value: TagValue) -> bool: self.val = self._validate(out_value) # checks succeeded, confirm the value except ValueError: return False + self._update_source(out_value) + return True + def _update_source(self, out_value): # Store to the source user data if self._src_dict: self._src_dict[self._src_key] = out_value elif self._src_obj: - setattr(self._src_obj, self._src_key, out_value) + if isinstance(self._src_obj, Tag): + # this helps to propagate the modification to possible other nested tags + self._src_obj.set_val(out_value) + else: + setattr(self._src_obj, self._src_key, out_value) else: # This might be user-created object. There is no need to update anything as the user reads directly from self.val. pass - return True + # Fixing types: # This code would support tuple[int, int]: # diff --git a/mininterface/tag_factory.py b/mininterface/tag_factory.py new file mode 100644 index 0000000..eaa57ce --- /dev/null +++ b/mininterface/tag_factory.py @@ -0,0 +1,28 @@ +from .tag import Tag +from .type_stubs import TagCallback +from .types import CallbackTag + + +from typing import get_type_hints + + +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 + 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) + 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) + return Tag(val, description, annotation, *args, **kwargs) diff --git a/mininterface/textual_interface/__init__.py b/mininterface/textual_interface/__init__.py index 4662266..6959ffc 100644 --- a/mininterface/textual_interface/__init__.py +++ b/mininterface/textual_interface/__init__.py @@ -35,7 +35,7 @@ def ask(self, text: str = None): def _ask_env(self) -> EnvClass: """ Display a window form with all parameters. """ - form = dataclass_to_tagdict(self.env, self._descriptions, self.facet) + form = dataclass_to_tagdict(self.env, self.facet) # fetch the dict of dicts values from the form back to the namespace of the dataclasses return TextualApp.run_dialog(self._get_app(), form) diff --git a/mininterface/textual_interface/textual_app.py b/mininterface/textual_interface/textual_app.py index fb916f1..3a4c919 100644 --- a/mininterface/textual_interface/textual_app.py +++ b/mininterface/textual_interface/textual_app.py @@ -24,6 +24,7 @@ class TextualApp(App[bool | None]): # NOTE: For a metaclass conflict I was not able to inherit from BackendAdaptor. + # TODO split classes so that it can inherit it? BINDINGS = [ ("up", "go_up", "Go up"), @@ -108,7 +109,8 @@ def compose(self) -> ComposeResult: if isinstance(fieldt, Input): yield Label(fieldt.placeholder) yield fieldt - yield Label(fieldt._link.description) + if fieldt._link.description: + yield Label(fieldt._link.description) yield Label("") def on_mount(self): diff --git a/mininterface/type_stubs.py b/mininterface/type_stubs.py new file mode 100644 index 0000000..8b50167 --- /dev/null +++ b/mininterface/type_stubs.py @@ -0,0 +1,22 @@ +from typing import Callable + + +class TagCallback(Callable): + """ TODO docs submit button """ + pass + + +class TagType: + """ TODO a mere Tag should work for a type too but Tyro interpretes it as a nested conf + + @dataclass + class SpecificTime: + date: str = "" # Allow missing + time: str = "" + run: TagCallback = controller.specific_time + run2: TagType = CallbackTag(controller.specific_time) + + m.form(SpecificTime()) + + """ + pass diff --git a/mininterface/types.py b/mininterface/types.py index a791595..85de0e8 100644 --- a/mininterface/types.py +++ b/mininterface/types.py @@ -1,11 +1,14 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable +from typing import Any, Callable from typing_extensions import Self, override + from .auxiliary import common_iterables from .tag import Tag, ValidationResult, TagValue +from .type_stubs import TagCallback, TagType # Allow import from the module + def Validation(check: Callable[["Tag"], ValidationResult | tuple[ValidationResult, TagValue]]): """ Alias to [`Tag(validation=...)`][mininterface.Tag.validation] @@ -30,6 +33,79 @@ def Choices(*choices: list[str]): return Tag(choices=choices) +@dataclass +class CallbackTag(Tag): + ''' Callback function is guaranteed to receives the [Tag][mininterface.Tag] as a parameter. + + For the following examples, we will use these custom callback functions: + ```python + from mininterface import run + + def callback_raw(): + """ Dummy function """ + print("Priting text") + return 50 + + def callback_tag(tag: Tag): + """ Receives a tag """ + print("Printing", type(tag)) + return 100 + + m = run() + ``` + + Use as buttons in a form: + ``` + m.form({"Button": callback_raw}) + m.form({"Button": CallbackTag(callback_tag)}) + ``` + + ![Callback button](asset/callback_button.avif) + + Via form, we receive the function handler: + ```python + out = m.form({ + "My choice": Tag(choices=[callback_raw, CallbackTag(callback_tag)]) + }) + print(out) # {'My choice': } + ``` + + Via choice, we receive the function output: + + ```python + out = m.choice({ + "My choice1": callback_raw, + "My choice2": CallbackTag(callback_tag), + # Not supported: "My choice3": Tag(callback_tag, annotation=CallbackTag), + }) + print(out) # output of callback0 or callback_tag, ex: + # Printing + # 100 + ``` + + ![Callback choice](asset/callback_choice.avif) + + + You may use callback in a dataclass. + ```python + @dataclass + class Callbacks: + p1: Callable = callback0 + p2: Annotated[Callable, CallbackTag(description="Foo")] = callback_tag + # Not supported: p3: CallbackTag = callback_tag + # Not supported: p4: CallbackTag = field(default_factory=CallbackTag(callback_tag)) + # Not supported: p5: Annotated[Callable, Tag(description="Bar", annotation=CallbackTag)] = callback_tag + + m = run(Callbacks) + m.form() + ``` + ''' + val: Callable[[str], Any] + + def _run_callable(self): + return self.val(self) + + @dataclass class PathTag(Tag): """ diff --git a/tests/configs.py b/tests/configs.py index b0fae5c..2269972 100644 --- a/tests/configs.py +++ b/tests/configs.py @@ -1,10 +1,10 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Annotated +from typing import Annotated, Callable from mininterface import Tag -from mininterface.types import Choices, Validation +from mininterface.types import CallbackTag, Choices, Validation from mininterface.validators import not_empty @@ -14,6 +14,27 @@ class ColorEnum(Enum): BLUE = 3 +class ColorEnumSingle(Enum): + ORANGE = 4 + + +def callback_tag(tag: Tag): + """ Receives a tag """ + print("Printing", type(tag)) + return 100 + + +def callback_tag2(tag: Tag): + """ Receives a tag """ + print("Printing", type(tag)) + + +def callback_raw(): + """ Dummy function """ + print("Priting text") + return 50 + + @dataclass class SimpleEnv: """Set of options.""" @@ -54,6 +75,7 @@ class NestedMissingEnv: @dataclass class FurtherEnv4: flag: bool = False + """ This is a deep flag """ @dataclass @@ -79,7 +101,7 @@ class OptionalFlagEnv: class ConstrainedEnv: """Set of options.""" - test: Annotated[str, Tag(validation=not_empty)] = "hello" + test: Annotated[str, Tag(validation=not_empty, name="Better name")] = "hello" """My testing flag""" test2: Annotated[str, Validation(not_empty)] = "hello" @@ -90,3 +112,14 @@ class ConstrainedEnv: @dataclass class ParametrizedGeneric: paths: list[Path] + + +@dataclass +class ComplicatedTypes: + p1: Callable = callback_raw + p2: Annotated[Callable, CallbackTag(description="Foo")] = callback_tag + # Not supported: p3: CallbackTag = callback_tag + # Not supported: p4: CallbackTag = field(default_factory=CallbackTag(callback_tag)) + # Not supported: p5: Annotated[Callable, Tag(description="Bar", annotation=CallbackTag)] = callback_tag + # NOTE add PathTag + # NOTE not used yet diff --git a/tests/tests.py b/tests/tests.py index c220165..8ec2e7f 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,24 +1,29 @@ -from datetime import datetime +from contextlib import contextmanager import logging import os import sys +from datetime import datetime from io import StringIO from pathlib import Path, PosixPath from types import SimpleNamespace +from typing import get_type_hints from unittest import TestCase, main from unittest.mock import patch from attrs_configs import AttrsModel, AttrsNested, AttrsNestedRestraint -from configs import (ColorEnum, ConstrainedEnv, FurtherEnv2, MissingUnderscore, NestedDefaultedEnv, NestedMissingEnv, - OptionalFlagEnv, ParametrizedGeneric, SimpleEnv) +from configs import (ColorEnum, ColorEnumSingle, ConstrainedEnv, FurtherEnv2, + MissingUnderscore, NestedDefaultedEnv, NestedMissingEnv, + OptionalFlagEnv, ParametrizedGeneric, SimpleEnv, + callback_raw, callback_tag, callback_tag2) from pydantic_configs import PydModel, PydNested, PydNestedRestraint from mininterface import Mininterface, TextInterface, run -from mininterface.validators import not_empty, limit from mininterface.auxiliary import flatten -from mininterface.form_dict import dataclass_to_tagdict, formdict_resolve -from mininterface.tag import Tag from mininterface.common import Cancelled +from mininterface.form_dict import dataclass_to_tagdict, formdict_resolve, TagDict +from mininterface.tag import Tag +from mininterface.types import CallbackTag +from mininterface.validators import limit, not_empty SYS_ARGV = None # To be redirected @@ -37,6 +42,18 @@ def tearDown(self): def sys(cls, *args): sys.argv = ["running-tests", *args] + @contextmanager + def assertOutputs(self, expected_output): + original_stdout = sys.stdout + new_stdout = StringIO() + sys.stdout = new_stdout + try: + yield + actual_output = new_stdout.getvalue().strip() + self.assertEqual(expected_output, actual_output) + finally: + sys.stdout = original_stdout + class TestCli(TestAbstract): def test_basic(self): @@ -124,15 +141,55 @@ def test_form_output(self): m = run(SimpleEnv, interface=Mininterface) d1 = {"test1": "str", "test2": Tag(True)} r1 = m.form(d1) + self.assertEqual(dict, type(r1)) # the original dict is not changed in the form self.assertEqual(True, d1["test2"].val) - # and even, when it changes, the output dict is not altered + # and even, when it changes, the outputp dict is not altered d1["test2"].val = False self.assertEqual(True, r1["test2"]) # when having empty form, it returns the env object self.assertIs(m.env, m.form()) + # putting a dataclass type + self.assertIsInstance(m.form(SimpleEnv), SimpleEnv) + + # putting a dataclass instance + self.assertIsInstance(m.form(SimpleEnv()), SimpleEnv) + + def test_choice_single(self): + m = run(interface=Mininterface) + self.assertEqual(1, m.choice([1])) + self.assertEqual(1, m.choice({"label": 1})) + self.assertEqual(ColorEnumSingle.ORANGE, m.choice(ColorEnumSingle)) + + def test_choice_callback(self): + m = run(interface=Mininterface) + + form = """Asking the form {'My choice': Tag(val=None, description='', annotation=None, name=None, choices=['callback_raw', 'callback_tag', 'callback_tag2'])}""" + with self.assertOutputs(form): + m.form({"My choice": Tag(choices=[ + callback_raw, + CallbackTag(callback_tag), + # This case works here but is not supported as such form cannot be submit in GUI: + Tag(callback_tag2, annotation=CallbackTag) + ])}) + + choices = { + "My choice1": callback_raw, + "My choice2": CallbackTag(callback_tag), + # Not supported: "My choice3": Tag(callback_tag, annotation=CallbackTag), + } + + form = """Asking the form {'Choose': Tag(val=None, description='', annotation=None, name=None, choices=['My choice1', 'My choice2'])}""" + with self.assertOutputs(form): + m.choice(choices) + + self.assertEqual(50, m.choice(choices, default=callback_raw)) + + # TODO This test does not work + # self.assertEqual(100, m.choice(choices, default=choices["My choice2"])) + class TestConversion(TestAbstract): def test_tagdict_resolve(self): @@ -229,7 +286,7 @@ def test_env_instance_dict_conversion(self): self.assertIsNone(env1.severity) - fd = dataclass_to_tagdict(env1, m._descriptions) + fd = dataclass_to_tagdict(env1) ui = formdict_resolve(fd) self.assertEqual({'': {'severity': '', 'msg': '', 'msg2': 'Default text'}, 'further': {'deep': {'flag': False}, 'numb': 0}}, ui) @@ -256,7 +313,7 @@ def test_env_instance_dict_conversion(self): Tag._submit_values(zip(flatten(fd), flatten(ui))) self.assertIsNone(env1.severity) - def test_choice(self): + def test_choices_param(self): t = Tag("one", choices=["one", "two"]) t.update("two") self.assertEqual(t.val, "two") @@ -264,7 +321,7 @@ def test_choice(self): self.assertEqual(t.val, "two") m = run(ConstrainedEnv) - d = dataclass_to_tagdict(m.env, m._descriptions) + d = dataclass_to_tagdict(m.env) self.assertFalse(d[""]["choices"].update("")) self.assertTrue(d[""]["choices"].update("two")) @@ -314,25 +371,81 @@ def test_choice_enum(self): t4.update(str(ColorEnum.BLUE.value)) self.assertEqual(ColorEnum.BLUE, t4.val) + def test_tag_src_update(self): + m = run(ConstrainedEnv, interface=Mininterface) + d: TagDict = dataclass_to_tagdict(m.env)[""] + + # tagdict uses the correct reference to the original object + # sharing a static annotation Tag is not desired: + # self.assertIs(ConstrainedEnv.__annotations__.get("test").__metadata__[0], d["test"]) + + # name is correctly determined from the dataclass attribute name + self.assertEqual("test2", d["test2"].name) + # but the tag in the annotation stays intact + self.assertIsNone(ConstrainedEnv.__annotations__.get("test2").__metadata__[0].name) + # name is correctly fetched from the dataclass annotation + self.assertEqual("Better name", d["test"].name) + + # a change via set_val propagates + self.assertEqual("hello", d["test"].val) + self.assertEqual("hello", m.env.test) + d["test"].set_val("foo") + self.assertEqual("foo", d["test"].val) + self.assertEqual("foo", m.env.test) + + # direct val change does not propagate + d["test"].val = "bar" + self.assertEqual("bar", d["test"].val) + self.assertEqual("foo", m.env.test) + + # a change via update propagates + d["test"].update("moo") + self.assertEqual("moo", d["test"].val) + self.assertEqual("moo", m.env.test) + + def test_nested_tag(self): + # TODO docs nested tags + t0 = Tag(5) + t1 = Tag(t0, name="Used name") + t2 = Tag(t1, name="Another name") + t3 = Tag(t1, name="Unused name") + t4 = Tag()._fetch_from(t2) + t5 = Tag(name="My name")._fetch_from(t2) + + self.assertEqual("Used name", t1.name) + self.assertEqual("Another name", t2.name) + self.assertEqual("Another name", t4.name) + self.assertEqual("My name", t5.name) + + self.assertEqual(5, t1.val) + self.assertEqual(5, t2.val) + self.assertEqual(5, t3.val) + self.assertEqual(5, t4.val) + self.assertEqual(5, t5.val) + + t5.set_val(8) + self.assertEqual(8, t0.val) + self.assertEqual(8, t1.val) + self.assertEqual(5, t2.val) + self.assertEqual(5, t3.val) + self.assertEqual(5, t4.val) + self.assertEqual(8, t5.val) # from t2, we iherited the hook to t1 + class TestRun(TestAbstract): def test_run_ask_empty(self): - with patch('sys.stdout', new_callable=StringIO) as stdout: + with self.assertOutputs("Asking the form SimpleEnv(test=False, important_number=4)"): run(SimpleEnv, True, interface=Mininterface) - self.assertEqual("Asking the form SimpleEnv(test=False, important_number=4)", stdout.getvalue().strip()) - with patch('sys.stdout', new_callable=StringIO) as stdout: + with self.assertOutputs(""): run(SimpleEnv, interface=Mininterface) - self.assertEqual("", stdout.getvalue().strip()) def test_run_ask_for_missing(self): form = """Asking the form {'token': Tag(val='', description='', annotation=, name='token')}""" # Ask for missing, no interference with ask_on_empty_cli - with patch('sys.stdout', new_callable=StringIO) as stdout: + with self.assertOutputs(form): run(FurtherEnv2, True, interface=Mininterface) - self.assertEqual(form, stdout.getvalue().strip()) - with patch('sys.stdout', new_callable=StringIO) as stdout: + with self.assertOutputs(form): run(FurtherEnv2, False, interface=Mininterface) - self.assertEqual(form, stdout.getvalue().strip()) # Ask for missing does not happen, CLI fails with self.assertRaises(SystemExit): run(FurtherEnv2, True, ask_for_missing=False, interface=Mininterface) @@ -470,7 +583,7 @@ def test_nested_restraint(self): m = run(PydNestedRestraint, interface=Mininterface) self.assertEqual("hello", m.env.inner.name) - f = dataclass_to_tagdict(m.env, m._descriptions)["inner"]["name"] + f = dataclass_to_tagdict(m.env)["inner"]["name"] self.assertTrue(f.update("short")) self.assertEqual("Restrained name ", f.description) self.assertFalse(f.update("long words")) @@ -508,7 +621,7 @@ def test_nested_restraint(self): m = run(AttrsNestedRestraint, interface=Mininterface) self.assertEqual("hello", m.env.inner.name) - f = dataclass_to_tagdict(m.env, m._descriptions)["inner"]["name"] + f = dataclass_to_tagdict(m.env)["inner"]["name"] self.assertTrue(f.update("short")) self.assertEqual("Restrained name ", f.description) self.assertFalse(f.update("long words")) @@ -520,7 +633,7 @@ def test_nested_restraint(self): class TestAnnotated(TestAbstract): def test_annotated(self): m = run(ConstrainedEnv) - d = dataclass_to_tagdict(m.env, m._descriptions) + d = dataclass_to_tagdict(m.env) self.assertFalse(d[""]["test"].update("")) self.assertFalse(d[""]["test2"].update("")) self.assertTrue(d[""]["test"].update(" ")) @@ -620,13 +733,13 @@ def test_path_union(self): def test_path_cli(self): m = run(ParametrizedGeneric, interface=Mininterface) - f = dataclass_to_tagdict(m.env, m._descriptions)[""]["paths"] + f = dataclass_to_tagdict(m.env)[""]["paths"] self.assertEqual("", f.val) self.assertTrue(f.update("[]")) self.sys("--paths", "/usr", "/tmp") m = run(ParametrizedGeneric, interface=Mininterface) - f = dataclass_to_tagdict(m.env, m._descriptions)[""]["paths"] + f = dataclass_to_tagdict(m.env)[""]["paths"] self.assertEqual([Path("/usr"), Path("/tmp")], f.val) self.assertEqual(['/usr', '/tmp'], f._get_ui_val()) self.assertTrue(f.update("['/var']"))