diff --git a/examples/magicgui_jupyter.ipynb b/examples/magicgui_jupyter.ipynb new file mode 100644 index 000000000..6cf244a15 --- /dev/null +++ b/examples/magicgui_jupyter.ipynb @@ -0,0 +1,80 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "1df96bcb5361429faddb252e3acfa608", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(HBox(children=(Label(value='aoi', layout=Layout(min_width='40px')), FloatText(value=1.0, step=1…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import math\n", + "from enum import Enum\n", + "\n", + "from magicgui import magicgui, use_app\n", + "use_app(\"ipynb\")\n", + "\n", + "class Medium(Enum):\n", + " \"\"\"Enum for various media and their refractive indices.\"\"\"\n", + "\n", + " Glass = 1.520\n", + " Oil = 1.515\n", + " Water = 1.333\n", + " Air = 1.0003\n", + "\n", + "\n", + "@magicgui(\n", + " call_button=\"calculate\", result_widget=True, layout='vertical', auto_call=True\n", + ")\n", + "def snells_law(aoi=1.0, n1=Medium.Glass, n2=Medium.Water, degrees=True):\n", + " \"\"\"Calculate the angle of refraction given two media and an AOI.\"\"\"\n", + " if degrees:\n", + " aoi = math.radians(aoi)\n", + " try:\n", + " n1 = n1.value\n", + " n2 = n2.value\n", + " result = math.asin(n1 * math.sin(aoi) / n2)\n", + " return round(math.degrees(result) if degrees else result, 2)\n", + " except ValueError: # math domain error\n", + " return \"TIR!\"\n", + "\n", + "\n", + "snells_law" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/magicgui/backends/__init__.py b/magicgui/backends/__init__.py index fed1f9dca..5790b82b4 100644 --- a/magicgui/backends/__init__.py +++ b/magicgui/backends/__init__.py @@ -1,6 +1,6 @@ """Backend modules implementing applications and widgets.""" -BACKENDS = {"Qt": ("_qtpy", "qtpy")} +BACKENDS = {"Qt": ("_qtpy", "qtpy"), "ipynb": ("_ipynb", "ipynb")} for key, value in list(BACKENDS.items()): if not key.islower(): diff --git a/magicgui/backends/_ipynb/__init__.py b/magicgui/backends/_ipynb/__init__.py new file mode 100644 index 000000000..aadac7932 --- /dev/null +++ b/magicgui/backends/_ipynb/__init__.py @@ -0,0 +1,47 @@ +from .application import ApplicationBackend +from .widgets import ( + CheckBox, + ComboBox, + Container, + DateEdit, + DateTimeEdit, + EmptyWidget, + FloatSlider, + FloatSpinBox, + Label, + LineEdit, + LiteralEvalLineEdit, + PushButton, + RadioButton, + Slider, + SpinBox, + TextEdit, + TimeEdit, + get_text_width, +) + +# DateTimeEdit +# show_file_dialog, + +__all__ = [ + "ApplicationBackend", + "CheckBox", + "ComboBox", + "Container", + "DateEdit", + "TimeEdit", + "DateTimeEdit", + "EmptyWidget", + "FloatSlider", + "FloatSpinBox", + "Label", + "LineEdit", + "LiteralEvalLineEdit", + "PushButton", + "RadioButton", + "Slider", + "SpinBox", + "TextEdit", + "get_text_width", + "show_file_dialog", +] diff --git a/magicgui/backends/_ipynb/application.py b/magicgui/backends/_ipynb/application.py new file mode 100644 index 000000000..51db5340c --- /dev/null +++ b/magicgui/backends/_ipynb/application.py @@ -0,0 +1,24 @@ +from magicgui.widgets._protocols import BaseApplicationBackend + + +class ApplicationBackend(BaseApplicationBackend): + def _mgui_get_backend_name(self): + return "ipynb" + + def _mgui_process_events(self): + raise NotImplementedError() + + def _mgui_run(self): + pass # We run in IPython, so we don't run! + + def _mgui_quit(self): + pass # We don't run so we don't quit! + + def _mgui_get_native_app(self): + return self + + def _mgui_start_timer(self, interval=0, on_timeout=None, single=False): + raise NotImplementedError() + + def _mgui_stop_timer(self): + raise NotImplementedError() diff --git a/magicgui/backends/_ipynb/widgets.py b/magicgui/backends/_ipynb/widgets.py new file mode 100644 index 000000000..25cee1541 --- /dev/null +++ b/magicgui/backends/_ipynb/widgets.py @@ -0,0 +1,427 @@ +from typing import Any, Callable, Iterable, Optional, Tuple, Type, Union + +try: + import ipywidgets + from ipywidgets import widgets as ipywdg +except ImportError as e: + raise ImportError( + "magicgui requires ipywidgets to be installed to use the 'ipynb' backend. " + "Please run `pip install ipywidgets`" + ) from e + +from magicgui.widgets import _protocols +from magicgui.widgets._bases import Widget + + +def _pxstr2int(pxstr: Union[int, str]) -> int: + if isinstance(pxstr, int): + return pxstr + if isinstance(pxstr, str) and pxstr.endswith("px"): + return int(pxstr[:-2]) + return int(pxstr) + + +def _int2pxstr(pxint: Union[int, str]) -> str: + return f"{pxint}px" if isinstance(pxint, int) else pxint + + +class _IPyWidget(_protocols.WidgetProtocol): + _ipywidget: ipywdg.Widget + + def __init__( + self, + wdg_class: Type[ipywdg.Widget] = None, + parent: Optional[ipywdg.Widget] = None, + ): + if wdg_class is None: + wdg_class = type(self).__annotations__.get("_ipywidget") + if wdg_class is None: + raise TypeError("Must provide a valid ipywidget type") + self._ipywidget = wdg_class() + # TODO: assign parent + + def _mgui_close_widget(self): + self._ipywidget.close() + + # `layout.display` will hide and unhide the widget and collapse the space + # `layout.visibility` will make the widget (in)visible without changing layout + def _mgui_get_visible(self): + return self._ipywidget.layout.display != "none" + + def _mgui_set_visible(self, value: bool): + self._ipywidget.layout.display = "block" if value else "none" + + def _mgui_get_enabled(self) -> bool: + return not self._ipywidget.disabled + + def _mgui_set_enabled(self, enabled: bool): + self._ipywidget.disabled = not enabled + + def _mgui_get_parent(self): + # TODO: how does ipywidgets handle this? + return getattr(self._ipywidget, "parent", None) + + def _mgui_set_parent(self, widget): + # TODO: how does ipywidgets handle this? + self._ipywidget.parent = widget + + def _mgui_get_native_widget(self) -> ipywdg.Widget: + return self._ipywidget + + def _mgui_get_root_native_widget(self) -> ipywdg.Widget: + return self._ipywidget + + def _mgui_get_width(self) -> int: + # TODO: ipywidgets deals in CSS ... by default width is `None` + # will this always work with our base Widget assumptions? + return _pxstr2int(self._ipywidget.layout.width) + + def _mgui_set_width(self, value: Union[int, str]) -> None: + """Set the current width of the widget.""" + self._ipywidget.layout.width = _int2pxstr(value) + + def _mgui_get_min_width(self) -> int: + return _pxstr2int(self._ipywidget.layout.min_width) + + def _mgui_set_min_width(self, value: Union[int, str]): + self._ipywidget.layout.min_width = _int2pxstr(value) + + def _mgui_get_max_width(self) -> int: + return _pxstr2int(self._ipywidget.layout.max_width) + + def _mgui_set_max_width(self, value: Union[int, str]): + self._ipywidget.layout.max_width = _int2pxstr(value) + + def _mgui_get_height(self) -> int: + """Return the current height of the widget.""" + return _pxstr2int(self._ipywidget.layout.height) + + def _mgui_set_height(self, value: int) -> None: + """Set the current height of the widget.""" + self._ipywidget.layout.height = _int2pxstr(value) + + def _mgui_get_min_height(self) -> int: + """Get the minimum allowable height of the widget.""" + return _pxstr2int(self._ipywidget.layout.min_height) + + def _mgui_set_min_height(self, value: int) -> None: + """Set the minimum allowable height of the widget.""" + self._ipywidget.layout.min_height = _int2pxstr(value) + + def _mgui_get_max_height(self) -> int: + """Get the maximum allowable height of the widget.""" + return _pxstr2int(self._ipywidget.layout.max_height) + + def _mgui_set_max_height(self, value: int) -> None: + """Set the maximum allowable height of the widget.""" + self._ipywidget.layout.max_height = _int2pxstr(value) + + def _mgui_get_tooltip(self) -> str: + return self._ipywidget.tooltip + + def _mgui_set_tooltip(self, value: Optional[str]) -> None: + self._ipywidget.tooltip = value + + def _ipython_display_(self, **kwargs): + return self._ipywidget._ipython_display_(**kwargs) + + def _mgui_bind_parent_change_callback(self, callback): + pass + + def _mgui_render(self): + pass + + +class EmptyWidget(_IPyWidget): + _ipywidget: ipywdg.Widget + + def _mgui_get_value(self) -> Any: + raise NotImplementedError() + + def _mgui_set_value(self, value: Any) -> None: + raise NotImplementedError() + + def _mgui_bind_change_callback(self, callback: Callable): + pass + + +class _IPyValueWidget(_IPyWidget, _protocols.ValueWidgetProtocol): + def _mgui_get_value(self) -> float: + return self._ipywidget.value + + def _mgui_set_value(self, value: Any) -> None: + self._ipywidget.value = value + + def _mgui_bind_change_callback(self, callback): + def _inner(change_dict): + callback(change_dict.get("new")) + + self._ipywidget.observe(_inner, names=["value"]) + + +class _IPyStringWidget(_IPyValueWidget): + def _mgui_set_value(self, value) -> None: + super()._mgui_set_value(str(value)) + + +class _IPyRangedWidget(_IPyValueWidget, _protocols.RangedWidgetProtocol): + def _mgui_get_min(self) -> float: + return self._ipywidget.min + + def _mgui_set_min(self, value: float) -> None: + self._ipywidget.min = value + + def _mgui_get_max(self) -> float: + return self._ipywidget.max + + def _mgui_set_max(self, value: float) -> None: + self._ipywidget.max = value + + def _mgui_get_step(self) -> float: + return self._ipywidget.step + + def _mgui_set_step(self, value: float) -> None: + self._ipywidget.step = value + + def _mgui_get_adaptive_step(self) -> bool: + return False + + def _mgui_set_adaptive_step(self, value: bool): + # TODO: + ... + # raise NotImplementedError('adaptive step not implemented for ipywidgets') + + +class _IPySupportsOrientation(_protocols.SupportsOrientation): + _ipywidget: ipywdg.Widget + + def _mgui_set_orientation(self, value) -> None: + self._ipywidget.orientation = value + + def _mgui_get_orientation(self) -> str: + return self._ipywidget.orientation + + +class _IPySupportsChoices(_protocols.SupportsChoices): + _ipywidget: ipywdg.Widget + + def _mgui_get_choices(self) -> Tuple[Tuple[str, Any]]: + """Get available choices.""" + return self._ipywidget.options + + def _mgui_set_choices(self, choices: Iterable[Tuple[str, Any]]) -> None: + """Set available choices.""" + self._ipywidget.options = choices + + def _mgui_del_choice(self, choice_name: str) -> None: + """Delete a choice.""" + options = [ + item + for item in self._ipywidget.options + if (not isinstance(item, tuple) or item[0] != choice_name) + and item != choice_name # noqa: W503 + ] + self._ipywidget.options = options + + def _mgui_get_choice(self, choice_name: str) -> Any: + """Get the data associated with a choice.""" + for item in self._ipywidget.options: + if isinstance(item, tuple) and item[0] == choice_name: + return item[1] + elif item == choice_name: + return item + return None + + def _mgui_get_count(self) -> int: + return len(self._ipywidget.options) + + def _mgui_get_current_choice(self) -> str: + return self._ipywidget.label + + def _mgui_set_choice(self, choice_name: str, data: Any) -> None: + """Set the data associated with a choice.""" + self._ipywidget.options = self._ipywidget.options + ((choice_name, data),) + + +class _IPySupportsText(_protocols.SupportsText): + """Widget that have text (in addition to value)... like buttons.""" + + _ipywidget: ipywdg.Widget + + def _mgui_set_text(self, value: str) -> None: + """Set text.""" + self._ipywidget.description = value + + def _mgui_get_text(self) -> str: + """Get text.""" + return self._ipywidget.description + + +class _IPyCategoricalWidget(_IPyValueWidget, _IPySupportsChoices): + pass + + +class _IPyButtonWidget(_IPyValueWidget, _IPySupportsText): + pass + + +class _IPySliderWidget(_IPyRangedWidget, _IPySupportsOrientation): + """Protocol for implementing a slider widget.""" + + def __init__(self, readout: bool = True, orientation: str = "horizontal", **kwargs): + super().__init__(**kwargs) + + def _mgui_set_readout_visibility(self, visible: bool) -> None: + """Set visibility of readout widget.""" + # TODO + + def _mgui_get_tracking(self) -> bool: + """If tracking is False, changed is only emitted when released.""" + # TODO + + def _mgui_set_tracking(self, tracking: bool) -> None: + """If tracking is False, changed is only emitted when released.""" + # TODO + + +class Label(_IPyStringWidget): + _ipywidget: ipywdg.Label + + +class LineEdit(_IPyStringWidget): + _ipywidget: ipywdg.Text + + +class LiteralEvalLineEdit(_IPyStringWidget): + _ipywidget: ipywdg.Text + + def _mgui_get_value(self) -> Any: + from ast import literal_eval + + value = super()._mgui_get_value() + return literal_eval(value) # type: ignore + + +class TextEdit(_IPyStringWidget): + _ipywidget: ipywdg.Textarea + + +class DateEdit(_IPyValueWidget): + _ipywidget: ipywdg.DatePicker + + +class DateTimeEdit(_IPyValueWidget): + _ipywidget: ipywdg.DatetimePicker + + +class TimeEdit(_IPyValueWidget): + _ipywidget: ipywdg.TimePicker + + +class PushButton(_IPyButtonWidget): + _ipywidget: ipywdg.Button + + def _mgui_bind_change_callback(self, callback): + self._ipywidget.on_click(lambda e: callback()) + + +class CheckBox(_IPyButtonWidget): + _ipywidget: ipywdg.Checkbox + + +class RadioButton(_IPyButtonWidget): + _ipywidget: ipywidgets.RadioButtons + + +class SpinBox(_IPyRangedWidget): + _ipywidget: ipywidgets.IntText + + +class FloatSpinBox(_IPyRangedWidget): + _ipywidget: ipywidgets.FloatText + + +class Slider(_IPySliderWidget): + _ipywidget: ipywidgets.IntSlider + + +class FloatSlider(_IPySliderWidget): + _ipywidget: ipywidgets.FloatSlider + + +class ComboBox(_IPyCategoricalWidget): + _ipywidget: ipywidgets.Dropdown + + +# CONTAINER ---------------------------------------------------------------------- + + +class Container( + _IPyWidget, _protocols.ContainerProtocol, _protocols.SupportsOrientation +): + def __init__(self, layout="horizontal", scrollable: bool = False, **kwargs): + wdg_class = ipywidgets.VBox if layout == "vertical" else ipywidgets.HBox + super().__init__(wdg_class, **kwargs) + + def _mgui_add_widget(self, widget: "Widget") -> None: + children = list(self._ipywidget.children) + children.append(widget.native) + self._ipywidget.children = children + widget.parent = self._ipywidget + + def _mgui_insert_widget(self, position: int, widget: "Widget") -> None: + children = list(self._ipywidget.children) + children.insert(position, widget.native) + self._ipywidget.children = children + widget.parent = self._ipywidget + + def _mgui_remove_widget(self, widget: "Widget") -> None: + children = list(self._ipywidget.children) + children.remove(widget.native) + self._ipywidget.children = children + + def _mgui_remove_index(self, position: int) -> None: + children = list(self._ipywidget.children) + children.pop(position) + self._ipywidget.children = children + + def _mgui_count(self) -> int: + return len(self._ipywidget.children) + + def _mgui_index(self, widget: "Widget") -> int: + return self._ipywidget.children.index(widget.native) + + def _mgui_get_index(self, index: int) -> Optional[Widget]: + """(return None instead of index error).""" + return self._ipywidget.children[index]._magic_widget + + def _mgui_get_native_layout(self) -> Any: + raise self._ipywidget + + def _mgui_get_margins(self) -> Tuple[int, int, int, int]: + margin = self._ipywidget.layout.margin + if margin: + try: + top, rgt, bot, lft = (int(x.replace("px", "")) for x in margin.split()) + return lft, top, rgt, bot + except ValueError: + return margin + return (0, 0, 0, 0) + + def _mgui_set_margins(self, margins: Tuple[int, int, int, int]) -> None: + lft, top, rgt, bot = margins + self._ipywidget.layout.margin = f"{top}px {rgt}px {bot}px {lft}px" + + def _mgui_set_orientation(self, value) -> None: + raise NotImplementedError( + "Sorry, changing orientation after instantiation " + "is not yet implemented for ipywidgets." + ) + + def _mgui_get_orientation(self) -> str: + return "vertical" if isinstance(self._ipywidget, ipywdg.VBox) else "horizontal" + + +def get_text_width(text): + # FIXME: how to do this in ipywidgets? + return 40 diff --git a/magicgui/backends/_qtpy/widgets.py b/magicgui/backends/_qtpy/widgets.py index 607b715f1..140519a77 100644 --- a/magicgui/backends/_qtpy/widgets.py +++ b/magicgui/backends/_qtpy/widgets.py @@ -144,7 +144,7 @@ def _mgui_set_max_height(self, value: int) -> None: def _mgui_get_tooltip(self) -> str: return self._qwidget.toolTip() - def _mgui_set_tooltip(self, value: str | None) -> None: + def _mgui_set_tooltip(self, value: Optional[str]) -> None: self._qwidget.setToolTip(str(value) if value else None) def _mgui_bind_parent_change_callback(self, callback): diff --git a/magicgui/widgets/__init__.py b/magicgui/widgets/__init__.py index f414e741c..da85fc650 100644 --- a/magicgui/widgets/__init__.py +++ b/magicgui/widgets/__init__.py @@ -64,6 +64,7 @@ Box = Container HBox = partial(Container, layout="horizontal") VBox = partial(Container, layout="vertical") +Button = PushButton __all__ = [ "CheckBox", diff --git a/magicgui/widgets/_bases/button_widget.py b/magicgui/widgets/_bases/button_widget.py index 18a5937f9..46484740f 100644 --- a/magicgui/widgets/_bases/button_widget.py +++ b/magicgui/widgets/_bases/button_widget.py @@ -28,6 +28,9 @@ def __init__(self, text: Optional[str] = None, **kwargs): " warning, only provide one of the two kwargs." ) text = text or kwargs.get("label") + # TODO: make a backend hook that lets backends inject their optional API + # ipywidgets button texts are called descriptions + text = text or kwargs.pop("description", None) super().__init__(**kwargs) self.text = (text or self.name).replace("_", " ") diff --git a/magicgui/widgets/_bases/widget.py b/magicgui/widgets/_bases/widget.py index 9767587db..b555e68df 100644 --- a/magicgui/widgets/_bases/widget.py +++ b/magicgui/widgets/_bases/widget.py @@ -4,7 +4,7 @@ import sys import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Optional from psygnal import Signal @@ -370,7 +370,7 @@ def __repr__(self) -> str: """Return representation of widget of instsance.""" return f"{self.widget_type}(annotation={self.annotation!r}, name={self.name!r})" - def _repr_png_(self): + def _repr_png_(self) -> Optional[bytes]: """Return PNG representation of the widget for QtConsole.""" from io import BytesIO @@ -383,10 +383,23 @@ def _repr_png_(self): ) return None - with BytesIO() as file_obj: - imsave(file_obj, self.render(), format="png") - file_obj.seek(0) - return file_obj.read() + rendered = self.render() + if rendered is not None: + with BytesIO() as file_obj: + imsave(file_obj, rendered, format="png") + file_obj.seek(0) + return file_obj.read() + return None def _emit_parent(self, *_): self.parent_changed.emit(self.parent) + + def _ipython_display_(self, *args, **kwargs): + if hasattr(self.native, "_ipython_display_"): + return self.native._ipython_display_(*args, **kwargs) + raise NotImplementedError() + + def _repr_mimebundle_(self, *args, **kwargs): + if hasattr(self.native, "_repr_mimebundle_"): + return self.native._repr_mimebundle_(*args, **kwargs) + raise NotImplementedError() diff --git a/magicgui/widgets/_protocols.py b/magicgui/widgets/_protocols.py index 06c4ebb1b..713b9f9b7 100644 --- a/magicgui/widgets/_protocols.py +++ b/magicgui/widgets/_protocols.py @@ -9,7 +9,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Sequence from typing_extensions import Protocol, runtime_checkable @@ -177,7 +177,7 @@ def _mgui_get_tooltip(self) -> str: raise NotImplementedError() @abstractmethod - def _mgui_set_tooltip(self, value: str | None) -> None: + def _mgui_set_tooltip(self, value: Optional[str]) -> None: """Set a tooltip for this widget.""" raise NotImplementedError() diff --git a/setup.cfg b/setup.cfg index f0886bdd2..3f5f65a2c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,6 +65,8 @@ pyqt5 = pyqt5>=5.12.0 tqdm = tqdm>=4.30.0 +jupyter = + ipywidgets>=7.5.0 image = pillow>=4.0 testing = @@ -79,6 +81,7 @@ testing = %(image)s matplotlib toolz + ipywidgets dev = ipython black @@ -104,7 +107,7 @@ ignore = D100, D213, D401, D413, D107, W503 per-file-ignores = magicgui/events.py:D tests/*.py:D - magicgui/backends/_qtpy/*.py:D + magicgui/backends/*/*.py:D magicgui/_type_wrapper.py:D [aliases] @@ -139,13 +142,12 @@ ignore_missing_imports = True [mypy-.examples/,magicgui._mpl_image.*,magicgui.backends._qtpy.widgets.*] ignore_errors = True -[mypy-.examples,numpy.*,_pytest.*,packaging.*,pyparsing.*,importlib_metadata.*,docstring_parser.*,psygnal.*,qtpy.QtCore.*] +[mypy-.examples,numpy.*,_pytest.*,packaging.*,pyparsing.*,importlib_metadata.*,docstring_parser.*,psygnal.*,qtpy.*,imageio.*,ipywidgets.*] ignore_errors = True [mypy-tomli.*] warn_unused_ignores = False - [isort] profile = black src_paths=magicgui diff --git a/tests/test_widgets.py b/tests/test_widgets.py index dfcda815e..27f0e0697 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -11,6 +11,39 @@ from magicgui.widgets._bases import ValueWidget +# it's important that "qt" be last here, so that it's used for +# the rest of the tests +@pytest.fixture(scope="module", params=["ipynb", "qt"]) +def backend(request): + return request.param + + +# FIXME: this test needs to come before we start swapping backends between qt and ipynb +# in other tests, otherwise it causes a stack overflow in windows... +# I'm not sure why that is, but it likely means that switching apps mid-process is +# not a good idea. This should be explored further and perhaps prevented... and +# testing might need to be reorganized to avoid this problem. +def test_bound_callable_catches_recursion(): + """Test that accessing widget.value raises an informative error message. + + (... rather than a recursion error) + """ + + # this should NOT raise here. the function should not be called greedily + @magicgui(x={"bind": lambda x: x.value * 2}) + def f(x: int = 5): + return x + + with pytest.raises(RuntimeError): + assert f() == 10 + f.x.unbind() + assert f() == 5 + + # use `get_value` within the callback if you need to access widget.value + f.x.bind(lambda x: x.get_value() * 4) + assert f() == 20 + + @pytest.mark.parametrize( "WidgetClass", [ @@ -24,11 +57,15 @@ "MainFunctionGui", "show_file_dialog", "request_values", + "create_widget", ) ], ) -def test_widgets(WidgetClass): +def test_widgets(WidgetClass, backend): """Test that we can retrieve getters, setters, and signals for most Widgets.""" + app = use_app(backend) + if not hasattr(app.backend_module, WidgetClass.__name__): + pytest.skip(f"no {WidgetClass.__name__!r} in backend {backend!r}") wdg: widgets.Widget = WidgetClass() wdg.close() @@ -411,27 +448,6 @@ def test_bound_not_called(): mock.assert_called_once_with(f.a) -def test_bound_callable_catches_recursion(): - """Test that accessing widget.value raises an informative error message. - - (... rather than a recursion error) - """ - - # this should NOT raise here. the function should not be called greedily - @magicgui(x={"bind": lambda x: x.value * 2}) - def f(x: int = 5): - return x - - with pytest.raises(RuntimeError): - assert f() == 10 - f.x.unbind() - assert f() == 5 - - # use `get_value` within the callback if you need to access widget.value - f.x.bind(lambda x: x.get_value() * 4) - assert f() == 20 - - def test_reset_choice_recursion(): """Test that reset_choices recursion works for multiple types of widgets.""" x = 0