diff --git a/README.md b/README.md index 6074325..544d9d7 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ $ python -m oneface.wrap_cli run add.yaml qt_gui # run Qt GUI app + Generate CLI, Qt GUI, Dash Web app from a python function or a command line. + Automatically check the type and range of input parameters and pretty print them. + Easy extension of parameter types and GUI widgets. -+ Support for embedding the generated interface into a parent application ++ Support for embedding the generated interface into a parent application. Detail usage see the [documentation](https://oneface.readthedocs.io/en/latest/). diff --git a/docs/index.md b/docs/index.md index 1b955c5..1943081 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ oneFace is a Python library for automatically generating multiple interfaces(CLI + Generate CLI, Qt GUI, Dash Web app from a python function or a command line. + Automatically check the type and range of input parameters and pretty print them. + Easy extension of parameter types and GUI widgets. -+ Support for embedding the generated interface into a parent application ++ Support for embedding the generated interface into a parent application. ## Installation diff --git a/oneface/__init__.py b/oneface/__init__.py index 83b05c0..63e29e2 100644 --- a/oneface/__init__.py +++ b/oneface/__init__.py @@ -1,5 +1,6 @@ -from .core import one, Arg +from .core import one +from .arg import Arg -__version__ = '0.1.8' +__version__ = '0.1.9' __all__ = [one, Arg] diff --git a/oneface/arg.py b/oneface/arg.py index e974cb3..b9d1a8e 100644 --- a/oneface/arg.py +++ b/oneface/arg.py @@ -69,7 +69,7 @@ def _check_number_in_range(v, range): Arg.register_type_check(bool) -def get_func_argobjs(func: T.Callable) -> T.OrderedDict[str, Arg]: +def parse_func_args(func: T.Callable) -> T.OrderedDict[str, Arg]: args = OrderedDict() sig = inspect.signature(func) for n, p in sig.parameters.items(): diff --git a/oneface/check.py b/oneface/check.py new file mode 100644 index 0000000..5644dfa --- /dev/null +++ b/oneface/check.py @@ -0,0 +1,116 @@ +import typing as T +from copy import copy +from ctypes import ArgumentError +import functools + +from rich.console import Console +from rich.table import Table + +from .arg import Empty, Arg, parse_func_args +from .utils import AllowWrapInstanceMethod + + +def check_args(func=None, **kwargs): + if func is None: + return functools.partial(check_args, **kwargs) + return CallWithCheck(func, **kwargs) + + +console = Console() + + +class ArgsCheckError(Exception): + pass + + +def parse_pass_in( + args: tuple, kwargs: dict, + arg_objs: T.OrderedDict[str, "Arg"]): + """Get the pass in value of the func + arguments according to it's signature.""" + args_ = list(args) + kwargs = copy(kwargs) + res = {} + for n, a in arg_objs.items(): + has_default = a.default is not Empty + if len(args_) > 0: + res[n] = args_.pop(0) + elif (len(kwargs) > 0) and (n in kwargs): + res[n] = kwargs.pop(n) + else: + if has_default: + res[n] = a.default + else: + raise ArgumentError( + f"{n} is not provided and has no default value.") + return res + + +class CallWithCheck(AllowWrapInstanceMethod): + def __init__(self, func, print_args=True, name=None): + self.func = func + functools.update_wrapper(self, func) + if name is not None: + self.name = name + elif hasattr(func, "name"): + self.name = func.name + else: + self.name = func.__name__ + self.is_print_args = print_args + self.table = None + + def __call__(self, *args, **kwargs): + arg_objs = parse_func_args(self.func) + if self.is_print_args: + self.table = self.get_argument_table() + # check args + vals = parse_pass_in(args, kwargs, arg_objs) + errors = [] + for n, arg in arg_objs.items(): + self._check_arg(n, arg, vals[n], errors) + if self.is_print_args: + self.print_args() + if len(errors) > 0: + raise ArgsCheckError(errors) + return self.func(*args, **kwargs) + + def print_args(self): + if self.name: + console.print(f"Run: [bold purple]{self.name}") + console.print("Arguments table:\n") + console.print(self.table) + console.print() + + def _check_arg( + self, name: str, arg: Arg, + val: T.Any, errors: T.List[Exception]): + val_str = str(val) + range_str = str(arg.range) + tp_str = str(type(val)) + ann_tp_str = str(arg.type) + try: + arg.check(val) + except Exception as e: + errors.append(e) + if isinstance(e, ValueError): + val_str = f"[red]{val_str}[/red]" + range_str = f"[red]{range_str}[/red]" + elif isinstance(e, TypeError): + ann_tp_str = f"[red]{ann_tp_str}[/red]" + tp_str = f"[red]{tp_str}[/red]" + else: + raise e + if self.is_print_args: + self.table.add_row(name, ann_tp_str, range_str, val_str, tp_str) + + @staticmethod + def get_argument_table(): + table = Table( + show_header=True, header_style="bold magenta", + box=None) + table.add_column("Argument") + table.add_column("Type") + table.add_column("Range") + table.add_column("InputVal") + table.add_column("InputType") + return table diff --git a/oneface/core.py b/oneface/core.py index 1f845c0..b95fd17 100644 --- a/oneface/core.py +++ b/oneface/core.py @@ -1,43 +1,6 @@ -import typing as T -from copy import copy -from ctypes import ArgumentError import functools -from rich.console import Console -from rich.table import Table - - -from .arg import Empty, Arg, get_func_argobjs - - -console = Console() - - -class ArgsCheckError(Exception): - pass - - -def parse_pass_in( - args: tuple, kwargs: dict, - arg_objs: T.OrderedDict[str, "Arg"]): - """Get the pass in value of the func - arguments according to it's signature.""" - args_ = list(args) - kwargs = copy(kwargs) - res = {} - for n, a in arg_objs.items(): - has_default = a.default is not Empty - if len(args_) > 0: - res[n] = args_.pop(0) - elif (len(kwargs) > 0) and (n in kwargs): - res[n] = kwargs.pop(n) - else: - if has_default: - res[n] = a.default - else: - raise ArgumentError( - f"{n} is not provided and has no default value.") - return res +from .check import CallWithCheck def one(func=None, **kwargs): @@ -46,79 +9,7 @@ def one(func=None, **kwargs): return One(func, **kwargs) -class One(object): - def __init__(self, func, print_args=True, name=None): - self.func = func - functools.update_wrapper(self, func) - self.arg_objs = get_func_argobjs(func) - if name is not None: - self.name = name - elif hasattr(func, "name"): - self.name = func.name - else: - self.name = func.__name__ - self.is_print_args = print_args - self.table = None - - def __call__(self, *args, **kwargs): - if self.is_print_args: - self.table = self.get_argument_table() - # check args - vals = parse_pass_in(args, kwargs, self.arg_objs) - errors = [] - for n, arg in self.arg_objs.items(): - self._check_arg(n, arg, vals[n], errors) - if self.is_print_args: - self.print_args() - if len(errors) > 0: - raise ArgsCheckError(errors) - return self.func(*args, **kwargs) - - def print_args(self): - if self.name: - console.print(f"Run: [bold purple]{self.name}") - console.print("Arguments table:\n") - console.print(self.table) - console.print() - - def __get__(self, obj, objtype): - """Support instance method - see https://stackoverflow.com/a/3296318/8500469""" - return functools.partial(self.__call__, obj) - - def _check_arg( - self, name: str, arg: Arg, - val: T.Any, errors: T.List[Exception]): - val_str = str(val) - range_str = str(arg.range) - tp_str = str(type(val)) - ann_tp_str = str(arg.type) - try: - arg.check(val) - except Exception as e: - errors.append(e) - if isinstance(e, ValueError): - val_str = f"[red]{val_str}[/red]" - range_str = f"[red]{range_str}[/red]" - elif isinstance(e, TypeError): - ann_tp_str = f"[red]{ann_tp_str}[/red]" - tp_str = f"[red]{tp_str}[/red]" - else: - raise e - if self.is_print_args: - self.table.add_row(name, ann_tp_str, range_str, val_str, tp_str) - - @staticmethod - def get_argument_table(): - table = Table( - show_header=True, header_style="bold magenta", - box=None) - table.add_column("Argument") - table.add_column("Type") - table.add_column("Range") - table.add_column("InputVal") - table.add_column("InputType") - return table +class One(CallWithCheck): def cli(self): from fire import Fire diff --git a/oneface/dash_app/app.py b/oneface/dash_app/app.py index 389cf63..1e779b5 100644 --- a/oneface/dash_app/app.py +++ b/oneface/dash_app/app.py @@ -8,14 +8,15 @@ import visdcc from ..types import (Selection, SubSet, InputPath, OutputPath) -from ..arg import Empty, get_func_argobjs +from ..arg import Empty, parse_func_args from .input_item import ( InputItem, IntInputItem, FloatInputItem, StrInputItem, BoolInputItem, DropdownInputItem, MultiDropdownInputItem, ) +from ..utils import AllowWrapInstanceMethod -class App(object): +class App(AllowWrapInstanceMethod): type_to_widget_constructor: T.Dict[str, "InputItem"] = {} convert_types: T.Dict[str, T.Callable] = {} @@ -113,7 +114,7 @@ def parse_args(self) -> T.List["html.Div"]: """Parse target function's arguments, return a list of input widgets.""" widgets, names, types, attrs = [], [], [], [] - arg_objs = get_func_argobjs(self.func) + arg_objs = parse_func_args(self.func) for n, a in arg_objs.items(): if a.type is Empty: continue diff --git a/oneface/qt.py b/oneface/qt.py index 3ba0c3e..c57a3ec 100644 --- a/oneface/qt.py +++ b/oneface/qt.py @@ -4,7 +4,8 @@ from qtpy import QtCore from .types import (InputPath, OutputPath, Selection, SubSet) -from .arg import Empty, get_func_argobjs +from .arg import Empty, parse_func_args +from .utils import AllowWrapInstanceMethod class Worker(QtCore.QObject): @@ -34,7 +35,7 @@ def get_app(): return app -class GUI(): +class GUI(AllowWrapInstanceMethod): type_to_widget_constructor = {} @@ -71,7 +72,7 @@ def compose_ui(self): self.window.setLayout(self.layout) def compose_arg_widgets(self, layout: QtWidgets.QVBoxLayout): - arg_objs = get_func_argobjs(self.func) + arg_objs = parse_func_args(self.func) for n, a in arg_objs.items(): if a.type is Empty: continue diff --git a/oneface/types.py b/oneface/types.py index 1f4ba8b..25ba313 100644 --- a/oneface/types.py +++ b/oneface/types.py @@ -1,6 +1,6 @@ from pathlib import Path -from oneface.core import Arg +from oneface.arg import Arg class ArgType(object): diff --git a/oneface/utils.py b/oneface/utils.py new file mode 100644 index 0000000..54d97f9 --- /dev/null +++ b/oneface/utils.py @@ -0,0 +1,11 @@ +import functools + + +class AllowWrapInstanceMethod(object): + def __get__(self, obj, objtype): + if not hasattr(self, "_bounded"): # bound only once + target_func = self.func + bound_mth = functools.partial(target_func, obj) + self.func = bound_mth + self._bounded = True + return self diff --git a/oneface/wrap_cli/wrap.py b/oneface/wrap_cli/wrap.py index 8a66977..52f402b 100644 --- a/oneface/wrap_cli/wrap.py +++ b/oneface/wrap_cli/wrap.py @@ -12,7 +12,7 @@ import yaml from ..arg import Arg, Empty -from ..core import parse_pass_in +from ..check import parse_pass_in def load_config(path: str) -> dict: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b60add..17bde8e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ sys.path.insert(0, "./") import subprocess from oneface.core import * +from oneface.arg import Arg import pytest diff --git a/tests/test_core.py b/tests/test_core.py index db88b89..67f3d2b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,10 +1,11 @@ from oneface.core import * +from oneface.check import * import pytest def test_arg_check(): - @one(print_args=False) + @check_args(print_args=False) def func(a: Arg(int, [0, 10]), b: Arg(float, [0, 1]), k=10): return a assert func(10, 0.3) == 10 @@ -25,12 +26,12 @@ def func(a: Arg(int, [0, 10]), b: Arg(float, [0, 1]), k=10): assert isinstance(e.value.args[0][0], ValueError) assert isinstance(e.value.args[0][1], TypeError) func(2, 0.5) - @one(print_args=False) + @check_args(print_args=False) def func(a: Arg(bool)): pass with pytest.raises(ArgsCheckError) as e: func(1) - @one(print_args=False) + @check_args(print_args=False) def func(a: Arg(int)): return a with pytest.raises(ArgsCheckError) as e: @@ -67,7 +68,7 @@ class A(): def __init__(self, a): self.a = a - @one(print_args=False) + @check_args(print_args=False) def mth1(self, b: Arg(float, [0, 1])): return self.a + b @@ -85,7 +86,7 @@ def mth1(self, b: Arg(float, [0, 1])): def test_parse_pass_in(): def f1(a, b, c=1, d=2): pass - arg_objs = get_func_argobjs(f1) + arg_objs = parse_func_args(f1) vals = parse_pass_in((1, 2), {'d': 10}, arg_objs) assert (vals['a'] == 1) and (vals['b'] == 2) and (vals['c'] == 1) and (vals['d'] == 10) vals = parse_pass_in((1, 2, 3), {}, arg_objs) diff --git a/tests/test_dash.py b/tests/test_dash.py index 7d0d0b3..2aef69e 100644 --- a/tests/test_dash.py +++ b/tests/test_dash.py @@ -1,5 +1,6 @@ from oneface.dash_app import * -from oneface.core import one, Arg +from oneface.core import one +from oneface.arg import Arg from oneface.dash_app.embed import flask_route from dash import dcc @@ -68,4 +69,23 @@ def test_embed(): @one def func(name: str): return name - \ No newline at end of file + + +def test_on_native_func(): + @app + def func(a: int, b: float): + return a + b + + assert func.get_dash_app() is not None + assert func.input_names == ['a', 'b'] + assert func.input_types == [int, float] + + class A(): + @app + def mth1(self, name: str, weight: float): + return name, weight + + a = A() + assert a.mth1.get_dash_app() is not None + assert a.mth1.input_names == ['name', 'weight'] + assert a.mth1.input_types == [str, float] diff --git a/tests/test_qt.py b/tests/test_qt.py index fc7b6eb..582ca0a 100644 --- a/tests/test_qt.py +++ b/tests/test_qt.py @@ -1,5 +1,6 @@ import sys; sys.path.insert(0, "./") from oneface.core import * +from oneface.check import * from oneface.qt import * import pytest @@ -23,7 +24,7 @@ def test_int_with_default(): @one def func(a: Arg(int, [0, 10]) = 1): return a - + assert isinstance(func, GUI) kwargs = func.get_args() assert kwargs['a'] == 1 @@ -120,5 +121,21 @@ def func(a: Arg(InputPath) = "a"): assert isinstance(e.value.args[0][0], ValueError) +def test_on_native_func(): + @gui + def func(a: int, b: float): + return a + b + + assert isinstance(func, GUI) + + class A(): + @gui + def mth1(self, name: str, weight: float): + return name, weight + + a = A() + assert isinstance(a.mth1, GUI) + + if __name__ == "__main__": test_set_text() diff --git a/tests/test_types.py b/tests/test_types.py index f5db24c..a552b67 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,5 +1,6 @@ from oneface.types import * -from oneface.core import one, Arg, ArgsCheckError +from oneface.core import one +from oneface.check import Arg, ArgsCheckError import pytest