diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f7b62a0b..1475ab68c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: black - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.118 + rev: v0.0.125 hooks: - id: ruff args: ["--fix"] diff --git a/src/magicgui/application.py b/src/magicgui/application.py index 0c2f3ed7d..86ca04d22 100644 --- a/src/magicgui/application.py +++ b/src/magicgui/application.py @@ -33,8 +33,12 @@ class Application: _backend: BaseApplicationBackend _instance: Application | None = None - def __init__(self, backend_name: str | None = None): - self._use(backend_name) + def __init__(self, backend_name: str | BaseApplicationBackend | None = None): + if isinstance(backend_name, str) or not backend_name: + self._use(backend_name) + else: + self._backend = backend_name + self._backend_module = import_module(backend_name.__module__) @property def backend_name(self) -> str: @@ -140,16 +144,13 @@ def _use_app(backend_name: Optional[str] = None): # If we already have a default_app, raise error or return current = Application._instance if current is not None: - if backend_name: - names = current.backend_name.lower().replace("(", " ").strip(") ") - _nm = [n for n in names.split(" ") if n] - if backend_name.lower() not in _nm: - raise RuntimeError( - f"Can only select a backend once, already using {_nm}." - ) - else: + if not backend_name: return current # Current backend matches backend_name + names = current.backend_name.lower().replace("(", " ").strip(") ") + _nm = [n for n in names.split(" ") if n] + if backend_name.lower() not in _nm: + raise RuntimeError(f"Can only select a backend once, already using {_nm}.") # Create default app Application._instance = Application(backend_name) return Application._instance diff --git a/src/magicgui/tqdm.py b/src/magicgui/tqdm.py index c43def391..ea7af6645 100644 --- a/src/magicgui/tqdm.py +++ b/src/magicgui/tqdm.py @@ -82,7 +82,7 @@ def __init__(self, iterable: Iterable | None = None, *args, **kwargs) -> None: return # no-op status printer, required for older tqdm compat - self.sp = lambda x: None # noqa: E731 + self.sp = lambda x: None if self.disable: return diff --git a/src/magicgui/widgets/bases/_value_widget.py b/src/magicgui/widgets/bases/_value_widget.py index cb2a102d6..7ce907b1e 100644 --- a/src/magicgui/widgets/bases/_value_widget.py +++ b/src/magicgui/widgets/bases/_value_widget.py @@ -43,6 +43,7 @@ def __init__( def _post_init(self): super()._post_init() + # Note that it is the responsibility of the backend to emit the changed signal self._widget._mgui_bind_change_callback(self._on_value_change) def _on_value_change(self, value=None): @@ -79,6 +80,8 @@ def value(self): @value.setter def value(self, value): + # value_changed will be emitted indirectly by the backend calling our + # _on_value_change method, which we connected in _post_init return self._widget._mgui_set_value(value) def __repr__(self) -> str: diff --git a/src/magicgui/widgets/bases/_widget.py b/src/magicgui/widgets/bases/_widget.py index 043b7a94d..6d57294ba 100644 --- a/src/magicgui/widgets/bases/_widget.py +++ b/src/magicgui/widgets/bases/_widget.py @@ -9,7 +9,7 @@ from psygnal import Signal from magicgui._type_resolution import resolve_single_type -from magicgui.application import use_app +from magicgui.application import Application, use_app from magicgui.widgets import protocols BUILDING_DOCS = sys.argv[-2:] == ["build", "docs"] @@ -99,7 +99,7 @@ def __init__( _prot = _prot.__name__ prot = getattr(protocols, _prot.replace("protocols.", "")) protocols.assert_protocol(widget_type, prot) - self.__magicgui_app__ = use_app() + self.__magicgui_app__: Application = use_app() assert self.__magicgui_app__.native if isinstance(parent, Widget): parent = parent.native @@ -212,8 +212,15 @@ def parent(self) -> Widget: @parent.setter def parent(self, value: Widget): + # note that it's up to the backend to actually set the parent + # which should trigger a call to _emit_parent because of the + # self._widget._mgui_bind_parent_change_callback(self._emit_parent) + # in the constructor. self._widget._mgui_set_parent(value) + def _emit_parent(self, *_): + self.parent_changed.emit(self.parent) + @property def widget_type(self) -> str: """Return type of widget.""" @@ -391,9 +398,6 @@ def _repr_png_(self) -> Optional[bytes]: 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) diff --git a/tests/conftest.py b/tests/conftest.py index df4ad5936..32220e2d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ +from unittest.mock import MagicMock, PropertyMock, create_autospec, patch + import pytest -from magicgui.application import use_app +from magicgui.application import Application, use_app +from magicgui.widgets.protocols import BaseApplicationBackend @pytest.fixture(scope="session") @@ -11,9 +14,24 @@ def qapp(): # for now, the only backend is qt, and pytest-qt's qapp provides some nice pre-post # test cleanup that prevents some segfaults. Once we start testing multiple backends # this will need to change. -@pytest.fixture(autouse=True, scope="function") +@pytest.fixture(scope="function") def always_qapp(qapp): yield qapp for w in qapp.topLevelWidgets(): w.close() w.deleteLater() + + +@pytest.fixture +def mock_app(): + MockAppBackend: MagicMock = create_autospec(BaseApplicationBackend, spec_set=True) + mock_app = Application(MockAppBackend) + + backend_module = MagicMock() + p = PropertyMock() + setattr(type(backend_module), "some name", p) + setattr(mock_app, "_prop", p) + + mock_app._backend_module = backend_module + with patch.object(Application, "_instance", mock_app): + yield mock_app diff --git a/tests/test_application.py b/tests/test_application.py index 0f3fb8ffc..aad649dc0 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,7 +1,26 @@ -from magicgui import use_app -from magicgui.application import APPLICATION_NAME +from magicgui.application import use_app -def test_app_name(): - app = use_app("qt") - assert app.native.applicationName() == APPLICATION_NAME +def test_mock_app(mock_app): + app = use_app() + backend = mock_app._backend + + assert app is mock_app + + app.backend_name + backend._mgui_get_backend_name.assert_called_once() + + app.get_obj("some name") + mock_app._prop.assert_called_once() + + with app: + backend._mgui_get_native_app.assert_called_once() + backend._mgui_start_timer.assert_called_once() + backend._mgui_run.assert_called_once() + backend._mgui_stop_timer.assert_called_once() + + app.process_events() + backend._mgui_process_events.assert_called_once() + + app.quit() + backend._mgui_quit.assert_called_once() diff --git a/tests/test_types.py b/tests/test_types.py index ce82d2fd3..93445c323 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -111,7 +111,7 @@ def test_widget_options(): def test_nested_forward_refs(): - resolved = resolve_single_type(Optional['List["numpy.ndarray"]']) # noqa + resolved = resolve_single_type(Optional['List["numpy.ndarray"]']) from typing import List diff --git a/tests/test_widget_bases.py b/tests/test_widget_bases.py new file mode 100644 index 000000000..533bf8a6b --- /dev/null +++ b/tests/test_widget_bases.py @@ -0,0 +1,149 @@ +# type: ignore +from __future__ import annotations + +import enum +from typing import ForwardRef, TypeVar +from unittest.mock import Mock, create_autospec + +from magicgui import widgets + +W = TypeVar("W", bound=widgets.Widget) + + +def _mock_widget(WidgetType: type[W], **kwargs) -> W: + """Create a mock widget with the given spec.""" + from magicgui.widgets import protocols + + _proto = WidgetType.__annotations__.get("_widget", None) + if _proto is None: + raise TypeError(f"Cannot mock {WidgetType} without a _widget annotation") + elif isinstance(_proto, (ForwardRef, str)): + if isinstance(_proto, str): + _proto = ForwardRef(_proto) + try: + _proto = _proto._evaluate({"protocols": protocols}, None, frozenset()) + except TypeError: + _proto = _proto._evaluate({"protocols": protocols}, None) + backend_mock = create_autospec(_proto, spec_set=True) + widget = WidgetType(widget_type=backend_mock, **kwargs) + backend_mock.assert_called_once_with(parent=None) + return widget + + +def test_base_widgtet_protocol(mock_app): + widget = _mock_widget(widgets.Widget) + + assert widget.__magicgui_app__ is mock_app + mock = widget._widget + + mock._mgui_get_native_widget.assert_called_once() + assert widget.native._magic_widget is widget + + mock._mgui_set_tooltip.assert_called_once_with(None) + mock._mgui_set_enabled.assert_called_once_with(True) + mock._mgui_bind_parent_change_callback.assert_called_once() + + assert {"enabled", "visible"}.issubset(set(widget.options)) + mock._mgui_get_enabled.assert_called_once() + mock._mgui_get_visible.assert_called_once() + + for attr in ( + "width", + "height", + "min_width", + "min_height", + "max_width", + "max_height", + ): + getattr(widget, attr) + getattr(mock, f"_mgui_get_{attr}").assert_called_once() + setattr(widget, attr, 1) + getattr(mock, f"_mgui_set_{attr}").assert_called_once_with(1) + + widget.show(run=True) + mock._mgui_set_visible.assert_called_once_with(True) + mock_app._backend._mgui_run.assert_called_once() + + # shown context + mock._mgui_set_visible.reset_mock() + assert mock_app._backend._mgui_get_native_app.call_count == 1 + assert mock_app._backend._mgui_run.call_count == 1 + with widget.shown(): + mock._mgui_set_visible.assert_called_with(True) + assert mock_app._backend._mgui_get_native_app.call_count == 2 + assert mock_app._backend._mgui_run.call_count == 2 + + widget.hide() + mock._mgui_set_visible.assert_called_with(False) + + widget.close() + mock._mgui_close_widget.assert_called_once() + + widget.render() + mock._mgui_render.assert_called_once() + + +def test_base_widget_events(mock_app): + widget = _mock_widget(widgets.Widget) + widget._widget._mgui_set_parent.side_effect = widget._emit_parent + + mock = Mock() + widget.label_changed.connect(mock) + widget.label = "new_label" + mock.assert_called_once_with("new_label") + + mock.reset_mock() + widget.parent_changed.connect(mock) + widget.parent = "new_parent" + mock.assert_called_once() + + +def test_value_widget_protocol(mock_app): + widget = _mock_widget(widgets.bases.ValueWidget, value=1) + widget._widget._mgui_set_value.assert_called_once_with(1) + + widget.value + assert widget._widget._mgui_get_value.call_count == 1 + widget.get_value() + assert widget._widget._mgui_get_value.call_count == 2 + + widget.value = 2 + widget._widget._mgui_set_value.assert_called_with(2) + + +def test_value_widget_bind(mock_app): + widget = _mock_widget(widgets.bases.ValueWidget) + + mock = Mock() + mock.return_value = 3 + widget.bind(mock) + mock.assert_not_called() + assert widget.value == 3 + mock.assert_called_once_with(widget) + + +def test_value_widget_events(mock_app): + widget = _mock_widget(widgets.bases.ValueWidget, value=1) + widget._widget._mgui_set_value.side_effect = widget._on_value_change + + change_mock = Mock() + widget.changed.connect(change_mock) + + widget.value = 2 + change_mock.assert_called_with(2) + + +def test_categorical_widget_events(mock_app): + class E(enum.Enum): + a = 1 + b = 2 + + widget = _mock_widget(widgets.bases.CategoricalWidget, choices=E) + widget._widget._mgui_get_choices.return_value = (("a", E.a), ("b", E.b)) + widget._widget._mgui_set_value.side_effect = widget._on_value_change + + change_mock = Mock() + widget.changed.connect(change_mock) + + widget.value = E.b + change_mock.assert_called_with(E.b)