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

Issue:
`FunctionGuiTester` and error handling
`FunctionGuiTester` is super helpful. However what I was hoping would happen is that when I use `.call()`, it would throw an exception and thereby cause my tests to fail when it encountered an error. Instead, it seems to show the normal GUI error handler (not sure if this is QT or Napari's doing):
![image](https://github.com/hanjinliu/magic-class/assets/5019367/5d48c659-bca0-4099-bdd7-e9d37a598dce)

Is there a way to mock the error handler to make it throw an exception?


Discussion / hints:
That sounds like a bug...
Is it an exception raised inside a method?
Yes, this happens when an exception is raised inside a class method. Here's a simple reproducible example. Just run `pytest test.py`:

```python
from magicclass import magicclass
from magicclass.testing import FunctionGuiTester

@magicclass
class Test:
    def load(self):
        raise Exception("Bleh")

def test_load():
    ui = Test()
    tester = FunctionGuiTester(ui.load)
    tester.call()
```

What it will do is show this error message and wait forever for user input: 
![image](https://github.com/hanjinliu/magic-class/assets/5019367/eecc9ce0-636e-4ecf-bfd7-74dc60b8108d)

What I want it to do is propagate the exception so that it fails the test suite.
Ok, that definitely should be fixed at some points.
The dialog is a default behavior of magicclass to notify users when an error was raised in a method, only if the method is called by clicking the call button.
Since `FunctionGuiTester` emulates clicking buttons, popup dialog should be temporarily disabled.
Do you think raising an error without the dialog (as a stderr) will work in your case?
> Do you think raising an error without the dialog (as a stderr) will work in your case?

I'm not exactly sure what you mean. I hope that the error remains unhandled so that pytest can see it. Printing to stderr will happen automatically but isn't my goal here.
I mean, in my perspective, the new behavior should be "in `FunctionGuiTester.call()`, the dialog will not appear any more, but the error will be raised so that it is visible for pytest". I wonder if it should be "the dialog will still be popped up, and the error will also be visible for pytest" (I think the dialog is not needed during test but my thought may not cover some situations...).

Fix patch:
diff --git a/magicclass/_gui/_base.py b/magicclass/_gui/_base.py
index c33795e9..3bbf5ef2 100644
--- a/magicclass/_gui/_base.py
+++ b/magicclass/_gui/_base.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
+from contextlib import contextmanager
 import functools
 from typing import (
     Any,
+    ContextManager,
     Union,
     Callable,
     TYPE_CHECKING,
@@ -672,6 +674,49 @@ def vfield(
         fld.set_destination(cls)
         return fld
 
+    @contextmanager
+    def config_context(
+        self,
+        error_mode=None,
+        close_on_run=None,
+        recursive: bool = True,
+    ):
+        """
+        Context manager to temporarily change the configuration of the widget.
+
+        Parameters
+        ----------
+        error_mode : ErrorMode, optional
+            Error mode to use.
+        """
+        if error_mode is not None:
+            old_error_mode = self._error_mode
+            self._error_mode = ErrorMode(error_mode)
+        else:
+            old_error_mode = self._popup_mode
+        if close_on_run is not None:
+            old_close_on_run = self._close_on_run
+        else:
+            old_close_on_run = self._close_on_run
+
+        child_contexts: list[ContextManager] = []
+        try:
+            if recursive:
+                for child in self.__magicclass_children__:
+                    ctx = child.config_context(
+                        error_mode=error_mode,
+                        close_on_run=close_on_run,
+                        recursive=True,
+                    )
+                    ctx.__enter__()
+                    child_contexts.append(ctx)
+            yield
+        finally:
+            for ctx in child_contexts:
+                ctx.__exit__(None, None, None)
+            self._error_mode = old_error_mode
+            self._close_on_run = old_close_on_run
+
     def _convert_attributes_into_widgets(self):
         """
         This function is called in dynamically created __init__. Methods, fields and nested
diff --git a/magicclass/_gui/_gui_modes.py b/magicclass/_gui/_gui_modes.py
index 1c698737..4d5d3107 100644
--- a/magicclass/_gui/_gui_modes.py
+++ b/magicclass/_gui/_gui_modes.py
@@ -2,13 +2,16 @@
 
 from types import TracebackType
 from contextlib import contextmanager
-from typing import Callable
+from typing import Callable, TYPE_CHECKING
 from enum import Enum
 import functools
 from magicgui.widgets import Widget
 from .mgui_ext import FunctionGuiPlus
 from magicclass._exceptions import Canceled
 
+if TYPE_CHECKING:
+    from ._base import BaseGui
+
 
 class PopUpMode(Enum):
     """Define how to popup FunctionGui."""
@@ -65,11 +68,7 @@ def _msgbox_raising(e: Exception, parent: Widget):
 
 
 def _stderr_raising(e: Exception, parent: Widget):
-    pass
-
-
-def _stdout_raising(e: Exception, parent: Widget):
-    print(f"{e.__class__.__name__}: {e}")
+    raise e
 
 
 def _napari_notification_raising(e: Exception, parent: Widget):
@@ -94,17 +93,19 @@ class ErrorMode(Enum):
     stdout = "stdout"
     napari = "napari"
     debug = "debug"
+    ignore = "ignore"
 
     def get_handler(self):
         """Get error handler."""
         return ErrorModeHandlers[self]
 
-    def wrap_handler(self, func: Callable, parent):
+    @classmethod
+    def wrap_handler(cls, func: Callable, parent: BaseGui):
         """Wrap function with the error handler."""
-        handler = self.get_handler()
 
         @functools.wraps(func)
         def wrapped_func(*args, **kwargs):
+            handler = parent._error_mode.get_handler()
             try:
                 out = func(*args, **kwargs)
             except Canceled as e:
@@ -117,21 +118,23 @@ def wrapped_func(*args, **kwargs):
 
         return wrapped_func
 
+    @classmethod
     @contextmanager
-    def raise_with_handler(self, parent):
+    def raise_with_handler(cls, parent: BaseGui):
         """Raise error with the error handler in this context."""
         try:
             yield
         except Exception as e:
-            self.get_handler()(e, parent=parent)
+            parent._error_mode.get_handler()(e, parent=parent)
 
 
 ErrorModeHandlers = {
     ErrorMode.msgbox: _msgbox_raising,
     ErrorMode.stderr: _stderr_raising,
-    ErrorMode.stdout: _stdout_raising,
+    ErrorMode.stdout: lambda e, parent: print(f"{e.__class__.__name__}: {e}"),
     ErrorMode.napari: _napari_notification_raising,
     ErrorMode.debug: _debug_raising,
+    ErrorMode.ignore: lambda e, parent: None,
 }
 
 


Test patch:
diff --git a/magicclass/testing/_function_gui_test.py b/magicclass/testing/_function_gui_test.py
index 617c592f..042714be 100644
--- a/magicclass/testing/_function_gui_test.py
+++ b/magicclass/testing/_function_gui_test.py
@@ -50,6 +50,7 @@ def __init__(self, method: Callable[_P, _R]):
         self._fgui = get_function_gui(method)
         # NOTE: if the widget is in napari etc., choices depend on the parent.
         ui: BaseGui = method.__self__
+        self._magicclass_gui = ui
         self._fgui.native.setParent(ui.native, self._fgui.native.windowFlags())
         self._fgui.reset_choices()
         self._method = method
@@ -66,13 +67,16 @@ def __init__(self, method: Callable[_P, _R]):
 
     @property
     def has_preview(self) -> bool:
+        """True if the method has preview function."""
         return self._prev_func is not None
 
     @property
     def has_confirmation(self) -> bool:
+        """True if the method has confirmation function."""
         return self._conf_dict is not None
 
     def click_preview(self):
+        """Emulate the preview button click."""
         if not self.has_preview:
             raise RuntimeError("No preview function found.")
         if self._fgui._auto_call:
@@ -88,25 +92,30 @@ def click_preview(self):
             prev_widget.changed.emit(True)
 
     def update_parameters(self, **kwargs):
+        """Update the parameters of the function GUI."""
         self._fgui.update(**kwargs)
 
     def call(self, *args: _P.args, **kwargs: _P.kwargs):
-        if not self.has_confirmation:
-            return self._fgui(*args, **kwargs)
-        cb = self._conf_dict["callback"]
-        self._conf_dict["callback"] = self._mock_confirmation
-        try:
-            out = self._fgui(*args, **kwargs)
-        finally:
-            self._conf_dict["callback"] = cb
-        return out
+        """Call the method as if it is called from the GUI."""
+        with self._magicclass_gui.config_context(error_mode="stderr"):
+            if not self.has_confirmation:
+                return self._fgui(*args, **kwargs)
+            cb = self._conf_dict["callback"]
+            self._conf_dict["callback"] = self._mock_confirmation
+            try:
+                out = self._fgui(*args, **kwargs)
+            finally:
+                self._conf_dict["callback"] = cb
+            return out
 
     @property
     def confirm_count(self) -> int:
+        """Number of times the confirmation is called."""
         return self._n_confirm
 
     @property
     def call_count(self) -> int:
+        """Number of times the method is called."""
         return self._fgui.call_count
 
     def _mock_confirmation(self, *_, **__):
diff --git a/tests/test_testing.py b/tests/test_testing.py
index b11e74aa..9ba1f5e9 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -278,3 +278,15 @@ def _preview(self, x: int):
     assert hist == [0, 1, 0, 1]
     testf.update_parameters(x=4)
     assert hist == [0, 1, 0, 1]
+
+def test_error_detectable():
+    @magicclass
+    class A:
+        def f(self, raises=True):
+            if raises:
+                raise ValueError("error")
+
+    ui = A()
+    testf = FunctionGuiTester(ui.f)
+    with pytest.raises(ValueError):
+        testf.call()