Repository: hanjinliu/magic-class (language: python)

Issue:
Return annotation is not supported
Currently `magic-class` does not support return annotation callback, such as:

```python
from magicclass import magicclass
from napari.types import ImageData

@magicclass
class A:
    def f(self, shape = (128, 128)) -> ImageData:
        return np.random.random(shape)
```

The biggest problem is macro recording.


Discussion / hints:

Fix patch:
diff --git a/magicclass/__init__.py b/magicclass/__init__.py
index a2fad01a..0f809bee 100644
--- a/magicclass/__init__.py
+++ b/magicclass/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.6.7.dev1"
+__version__ = "0.6.7.dev2"
 
 from .core import (
     magicclass,
@@ -38,7 +38,7 @@
     "build_help",
     "get_function_gui",
     "redo",
-    "update_widget",
+    "update_widget_state",
     "set_options",
     "set_design",
     "do_not_record",
diff --git a/magicclass/_gui/_base.py b/magicclass/_gui/_base.py
index c20dcb92..baa6c911 100644
--- a/magicclass/_gui/_base.py
+++ b/magicclass/_gui/_base.py
@@ -717,6 +717,13 @@ def _iter_child_magicclasses(self) -> Iterable[MagicTemplate]:
             yield child
             yield from child._iter_child_magicclasses()
 
+    def _call_with_return_callback(self, fname: str, *args, **kwargs) -> None:
+        from ..core import get_function_gui
+
+        fgui = get_function_gui(self, fname)
+        fgui(*args, **kwargs)
+        return None
+
 
 class BaseGui(MagicTemplate):
     def __init__(self, close_on_run, popup_mode, error_mode):
@@ -1039,7 +1046,7 @@ def wrapper(f: _C) -> _C:
                 new_params.append(v)
 
         # update empty return annotation
-        if old_signature.return_annotation is inspect._empty:
+        if old_signature.return_annotation is inspect.Parameter.empty:
             return_annotation = template_signature.return_annotation
         else:
             return_annotation = old_signature.return_annotation
@@ -1213,34 +1220,60 @@ def _recordable(bgui: MagicTemplate, *args, **kwargs):
         return _func
 
 
-def _define_macro_recorder(sig, func):
+def _define_macro_recorder(sig: inspect.Signature, func: Callable):
     if isinstance(sig, MagicMethodSignature):
         opt = sig.additional_options
         _auto_call = opt.get("auto_call", False)
     else:
         _auto_call = False
 
-    # TODO: if function has a return_annotation, macro should be recorded like ui["f"](...)
-    def _record_macro(bgui: MagicTemplate, *args, **kwargs):
-        bound = sig.bind(*args, **kwargs)
-        kwargs = dict(bound.arguments.items())
-        expr = Expr.parse_method(bgui, func, (), kwargs)
-        if _auto_call:
-            # Auto-call will cause many redundant macros. To avoid this, only the last
-            # input will be recorded in magic-class.
-            last_expr = bgui.macro[-1]
-            if (
-                last_expr.head == Head.call
-                and last_expr.args[0].head == Head.getattr
-                and last_expr.at(0, 1) == expr.at(0, 1)
-                and len(bgui.macro) > 0
-            ):
-                bgui.macro.pop()
-                bgui.macro._erase_last()
+    if sig.return_annotation is inspect.Parameter.empty:
+
+        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
+            bound = sig.bind(*args, **kwargs)
+            kwargs = dict(bound.arguments.items())
+            expr = Expr.parse_method(bgui, func, (), kwargs)
+            if _auto_call:
+                # Auto-call will cause many redundant macros. To avoid this, only the last
+                # input will be recorded in magic-class.
+                last_expr = bgui.macro[-1]
+                if (
+                    last_expr.head == Head.call
+                    and last_expr.args[0].head == Head.getattr
+                    and last_expr.at(0, 1) == expr.at(0, 1)
+                    and len(bgui.macro) > 0
+                ):
+                    bgui.macro.pop()
+                    bgui.macro._erase_last()
 
-        bgui.macro.append(expr)
-        bgui.macro._last_setval = None
-        return None
+            bgui.macro.append(expr)
+            bgui.macro._last_setval = None
+            return None
+
+    else:
+        _cname_ = "_call_with_return_callback"
+
+        def _record_macro(bgui: MagicTemplate, *args, **kwargs):
+            bound = sig.bind(*args, **kwargs)
+            kwargs = dict(bound.arguments.items())
+            expr = Expr.parse_method(bgui, _cname_, (func.__name__,), kwargs)
+            if _auto_call:
+                # Auto-call will cause many redundant macros. To avoid this, only the last
+                # input will be recorded in magic-class.
+                last_expr = bgui.macro[-1]
+                if (
+                    last_expr.head == Head.call
+                    and last_expr.args[0].head == Head.getattr
+                    and last_expr.at(0, 1) == expr.at(0, 1)
+                    and last_expr.args[1] == expr.args[1]
+                    and len(bgui.macro) > 0
+                ):
+                    bgui.macro.pop()
+                    bgui.macro._erase_last()
+
+            bgui.macro.append(expr)
+            bgui.macro._last_setval = None
+            return None
 
     return _record_macro
 
diff --git a/magicclass/core.py b/magicclass/core.py
index 325ecedb..45c72878 100644
--- a/magicclass/core.py
+++ b/magicclass/core.py
@@ -516,6 +516,9 @@ def get_function_gui(ui: MagicTemplate, name: str) -> FunctionGuiPlus:
     if not hasattr(widget, "mgui"):
         raise TypeError(f"Widget {widget} does not have FunctionGui inside it.")
 
+    if widget.mgui is not None:
+        return widget.mgui
+
     from ._gui._base import _build_mgui, _create_gui_method
 
     func = _create_gui_method(ui, func)
@@ -551,7 +554,7 @@ def update_widget_state(ui: MagicTemplate, macro: Macro | str | None = None) ->
 
     This helper function works similar to the ``update_widget`` method of ``FunctionGui``.
     In most cases, this function will be used for restoring a state from a macro recorded
-    before.
+    before. Value changed signal will not be emitted within this operation.
 
     Parameters
     ----------
@@ -578,16 +581,20 @@ def update_widget_state(ui: MagicTemplate, macro: Macro | str | None = None) ->
         if expr.head == Head.call:
             # ui.func(...)
             ui_f, *arguments = expr.args
-            f = ui_f.args[1]
-            for i, arg in enumerate(arguments):
-                if isinstance(arg, Expr) and arg.head == Head.kw:
-                    break
-            args = arguments[:i]
-            kwargs: list[Expr] = arguments[i:]
-            args = Expr(Head.call, [tuple] + args).eval()
-            kwargs = Expr(Head.call, [dict] + kwargs).eval()
-            fgui = get_function_gui(ui, str(f))
-            fgui.update(kwargs)
+            fname = str(ui_f.args[1])
+            if fname.startswith("_"):
+                if fname != "_call_with_return_callback":
+                    continue
+                args, kwargs = _arguments_to_values(arguments)
+                fgui = get_function_gui(ui, args[0])
+
+            else:
+                args, kwargs = _arguments_to_values(arguments)
+                fgui = get_function_gui(ui, fname)
+
+            with fgui.changed.blocked():
+                for key, value in kwargs.items():
+                    getattr(fgui, key).value = value
 
         elif expr.head == Head.assign:
             # ui.field.value = ...
@@ -597,6 +604,23 @@ def update_widget_state(ui: MagicTemplate, macro: Macro | str | None = None) ->
     return None
 
 
+def _tuple(*args) -> tuple:
+    return args
+
+
+def _arguments_to_values(arguments) -> tuple[tuple, dict[str, Any]]:
+    from macrokit import Head, Expr, symbol
+
+    for i, arg in enumerate(arguments):
+        if isinstance(arg, Expr) and arg.head == Head.kw:
+            break
+    args = arguments[:i]
+    kwargs: list[Expr] = arguments[i:]
+    args = Expr(Head.call, [_tuple] + args).eval({symbol(_tuple): _tuple})
+    kwargs = Expr(Head.call, [dict] + kwargs).eval()
+    return args, kwargs
+
+
 class Parameters:
     def __init__(self):
         self.__name__ = self.__class__.__name__


Test patch:
diff --git a/tests/test_helper_functions.py b/tests/test_helper_functions.py
index dad3cca1..66bae410 100644
--- a/tests/test_helper_functions.py
+++ b/tests/test_helper_functions.py
@@ -17,6 +17,7 @@ def f(self): ...
 
 def test_update_widget_state():
     from magicclass import update_widget_state
+
     @magicclass
     class A:
         x = field(0)
diff --git a/tests/test_return_annotation.py b/tests/test_return_annotation.py
new file mode 100644
index 00000000..0b9b559e
--- /dev/null
+++ b/tests/test_return_annotation.py
@@ -0,0 +1,60 @@
+from magicclass import magicclass, get_function_gui, set_options
+from magicgui import register_type
+from unittest.mock import MagicMock
+
+mock = MagicMock()
+
+def _dummy(*args, **kwargs):
+    pass
+
+register_type(int, return_callback=mock)
+register_type(str, return_callback=mock)
+register_type(float, return_callback=_dummy)
+
+def test_return_annotation():
+    @magicclass
+    class A:
+        def f(self, x: int) -> int:
+            return x + 1
+
+        def g(self, s: str = "s") -> str:
+            return s + "-0"
+
+    ui = A()
+    fgui_f = get_function_gui(ui, "f")
+    fgui_g = get_function_gui(ui, "g")
+
+    assert len(ui.macro) == 1
+    mock.assert_not_called()
+    fgui_f.call_button.changed()
+    mock.assert_called_with(fgui_f, 1, int)
+    fgui_g.call_button.changed()
+    mock.assert_called_with(fgui_g, "s-0", str)
+    assert len(ui.macro) == 3
+    assert str(ui.macro[1]) == "ui._call_with_return_callback('f', x=0)"
+    assert str(ui.macro[2]) == "ui._call_with_return_callback('g', s='s')"
+
+def test_return_annotation_auto_call():
+    @magicclass
+    class A:
+        @set_options(auto_call=True)
+        def f(self, x: int = 1) -> float:
+            return float(x)
+
+        def g(self):
+            pass
+
+    ui = A()
+    fgui_f = get_function_gui(ui, "f")
+
+    assert len(ui.macro) == 1
+    fgui_f.x.value = 2
+    assert len(ui.macro) == 2
+    assert str(ui.macro[-1]) == "ui._call_with_return_callback('f', x=2)"
+    fgui_f.x.value = 3
+    assert len(ui.macro) == 2
+    assert str(ui.macro[-1]) == "ui._call_with_return_callback('f', x=3)"
+    ui.g()
+    fgui_f.x.value = 2
+    assert len(ui.macro) == 4
+    assert str(ui.macro[-1]) == "ui._call_with_return_callback('f', x=2)"