From f4fe5675cfa07bc0fb9245cf6934081785f24c0c Mon Sep 17 00:00:00 2001 From: Niko Sirmpilatze Date: Wed, 17 Jul 2024 11:53:19 +0100 Subject: [PATCH] Create skeleton for napari plugin with collapsible widgets (#218) * initialise napari plugin development * initialise napari plugin development * create skeleton for napari plugin with collapsible widgets * add basic widget smoke tests and allow headless testing * do not depend on napari from pip * include napari option in install instructions * make meta_widget module private * pin atlasapi version to avoid unnecessary dependencies * pin napari >= 0.4.19 from conda-forge * switched to pip install of napari[all] * seperation of concerns in widget tests * add pytest-mock dev dependency --- .github/workflows/test_and_deploy.yml | 4 ++ MANIFEST.in | 1 + README.md | 9 ++- docs/source/getting_started/installation.md | 31 ++++++++-- movement/napari/__init__.py | 0 movement/napari/_loader_widget.py | 32 ++++++++++ movement/napari/_meta_widget.py | 27 +++++++++ movement/napari/napari.yaml | 10 ++++ pyproject.toml | 21 +++++++ tests/test_integration/test_napari_plugin.py | 61 ++++++++++++++++++++ 10 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 movement/napari/__init__.py create mode 100644 movement/napari/_loader_widget.py create mode 100644 movement/napari/_meta_widget.py create mode 100644 movement/napari/napari.yaml create mode 100644 tests/test_integration/test_napari_plugin.py diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 009baa63..f501fca3 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -40,6 +40,10 @@ jobs: python-version: "3.11" steps: + # these libraries enable testing on Qt on linux + - uses: pyvista/setup-headless-display-action@v2 + with: + qt: true - name: Cache Test Data uses: actions/cache@v4 with: diff --git a/MANIFEST.in b/MANIFEST.in index 0fbedce8..d4a32d4d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include LICENSE include *.md include CITATION.CFF +include movement/napari/napari.yaml exclude .pre-commit-config.yaml exclude .cruft.json diff --git a/README.md b/README.md index fd7aaba1..fcaf1769 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,18 @@ conda create -n movement-env -c conda-forge python=3.11 pytables conda activate movement-env ``` -Then install the `movement` package: +Then install the core `movement` package: ``` pip install movement ``` +If you want to use the graphical user interface (GUI), which is provided via +a [`napari`](https://napari.org/) plugin, run this command instead: + +``` +pip install "movement[napari]" +``` + > [!Note] > Read the [documentation](https://movement.neuroinformatics.dev) for more information, including [full installation instructions](https://movement.neuroinformatics.dev/getting_started/installation.html) and [examples](https://movement.neuroinformatics.dev/examples/index.html). diff --git a/docs/source/getting_started/installation.md b/docs/source/getting_started/installation.md index d1937944..33db1d53 100644 --- a/docs/source/getting_started/installation.md +++ b/docs/source/getting_started/installation.md @@ -25,8 +25,8 @@ Then install the `movement` package as described below. ::::{tab-set} -:::{tab-item} Users -To get the latest release from PyPI: +:::{tab-item} Without the GUI +To install the core package from PyPI: ```sh pip install movement @@ -39,14 +39,28 @@ pip install --upgrade movement ``` ::: -:::{tab-item} Developers +:::{tab-item} With the GUI +To install the package including the GUI (napari plugin) from PyPI: + +```sh +pip install "movement[napari]" +``` +If you have an older version of `movement` installed in the same environment, +you can update to the latest version with: + +```sh +pip install --upgrade "movement[napari]" +``` +::: + +:::{tab-item} For developers To get the latest development version, clone the [GitHub repository](movement-github:) and then run from inside the repository: ```sh pip install -e .[dev] # works on most shells -pip install -e '.[dev]' # works on zsh (the default shell on macOS) +pip install -e ".[dev]" # works on zsh (the default shell on macOS) ``` This will install the package in editable mode, including all `dev` dependencies. @@ -66,3 +80,12 @@ movement info You should see a printout including the version numbers of `movement` and some of its dependencies. + +To test the GUI installation, you can run: + +```sh +napari -w movement +``` + +This should open a new `napari` window with the `movement` plugin loaded +on the right side. diff --git a/movement/napari/__init__.py b/movement/napari/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/movement/napari/_loader_widget.py b/movement/napari/_loader_widget.py new file mode 100644 index 00000000..7da5c3ce --- /dev/null +++ b/movement/napari/_loader_widget.py @@ -0,0 +1,32 @@ +from napari.utils.notifications import show_info +from napari.viewer import Viewer +from qtpy.QtWidgets import ( + QFormLayout, + QPushButton, + QWidget, +) + + +class Loader(QWidget): + """Widget for loading data from files.""" + + def __init__(self, napari_viewer: Viewer, parent=None): + """Initialize the loader widget.""" + super().__init__(parent=parent) + self.viewer = napari_viewer + self.setLayout(QFormLayout()) + # Create widgets + self._create_hello_widget() + + def _create_hello_widget(self): + """Create the hello widget. + + This widget contains a button that, when clicked, shows a greeting. + """ + hello_button = QPushButton("Say hello") + hello_button.clicked.connect(self._on_hello_clicked) + self.layout().addRow("Greeting", hello_button) + + def _on_hello_clicked(self): + """Show a greeting.""" + show_info("Hello, world!") diff --git a/movement/napari/_meta_widget.py b/movement/napari/_meta_widget.py new file mode 100644 index 00000000..3ed09575 --- /dev/null +++ b/movement/napari/_meta_widget.py @@ -0,0 +1,27 @@ +"""The main napari widget for the ``movement`` package.""" + +from brainglobe_utils.qtpy.collapsible_widget import CollapsibleWidgetContainer +from napari.viewer import Viewer + +from movement.napari._loader_widget import Loader + + +class MovementMetaWidget(CollapsibleWidgetContainer): + """The widget to rule all ``movement`` napari widgets. + + This is a container of collapsible widgets, each responsible + for handing specific tasks in the movement napari workflow. + """ + + def __init__(self, napari_viewer: Viewer, parent=None): + """Initialize the meta-widget.""" + super().__init__() + + self.add_widget( + Loader(napari_viewer, parent=self), + collapsible=True, + widget_title="Load data", + ) + + self.loader = self.collapsible_widgets[0] + self.loader.expand() # expand the loader widget by default diff --git a/movement/napari/napari.yaml b/movement/napari/napari.yaml new file mode 100644 index 00000000..15d956fd --- /dev/null +++ b/movement/napari/napari.yaml @@ -0,0 +1,10 @@ +name: movement +display_name: movement +contributions: + commands: + - id: movement.make_widget + python_name: movement.napari._meta_widget:MovementMetaWidget + title: movement + widgets: + - command: movement.make_widget + display_name: movement diff --git a/pyproject.toml b/pyproject.toml index 130268bd..327ba5a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ classifiers = [ "License :: OSI Approved :: BSD License", ] +# Entry point for napari plugin +entry-points."napari.manifest".movement = "movement.napari:napari.yaml" + [project.urls] "Homepage" = "https://github.com/neuroinformatics-unit/movement" "Bug Tracker" = "https://github.com/neuroinformatics-unit/movement/issues" @@ -43,9 +46,18 @@ classifiers = [ "User Support" = "https://neuroinformatics.zulipchat.com/#narrow/stream/406001-Movement" [project.optional-dependencies] +napari = [ + "napari[all]>=0.4.19", + # the rest will be replaced by brainglobe-utils[qt]>=0.6 after release + "brainglobe-atlasapi>=2.0.7", + "brainglobe-utils>=0.5", + "qtpy", + "superqt", +] dev = [ "pytest", "pytest-cov", + "pytest-mock", "coverage", "tox", "mypy", @@ -58,6 +70,8 @@ dev = [ "check-manifest", "types-PyYAML", "types-requests", + "pytest-qt", + "movement[napari]", ] [project.scripts] @@ -154,6 +168,13 @@ conda_deps = pytables conda_channels = conda-forge +passenv = + CI + GITHUB_ACTIONS + DISPLAY + XAUTHORITY + NUMPY_EXPERIMENTAL_ARRAY_FUNCTION + PYVISTA_OFF_SCREEN extras = dev commands = diff --git a/tests/test_integration/test_napari_plugin.py b/tests/test_integration/test_napari_plugin.py new file mode 100644 index 00000000..e7225dc5 --- /dev/null +++ b/tests/test_integration/test_napari_plugin.py @@ -0,0 +1,61 @@ +import pytest +from qtpy.QtWidgets import QPushButton, QWidget + +from movement.napari._loader_widget import Loader +from movement.napari._meta_widget import MovementMetaWidget + + +@pytest.fixture +def meta_widget(make_napari_viewer_proxy) -> MovementMetaWidget: + """Fixture to expose the MovementMetaWidget for testing. + Simultaneously acts as a smoke test that the widget + can be instantiated without crashing. + """ + viewer = make_napari_viewer_proxy() + return MovementMetaWidget(viewer) + + +@pytest.fixture +def loader_widget(meta_widget) -> QWidget: + """Fixture to expose the Loader widget for testing.""" + loader = meta_widget.loader.content() + return loader + + +def test_meta_widget(meta_widget): + """Test that the meta widget is properly instantiated.""" + assert meta_widget is not None + assert len(meta_widget.collapsible_widgets) == 1 + + first_widget = meta_widget.collapsible_widgets[0] + assert first_widget._text == "Load data" + assert first_widget.isExpanded() + + +def test_loader_widget(loader_widget): + """Test that the loader widget is properly instantiated.""" + assert loader_widget is not None + assert loader_widget.layout().rowCount() == 1 + + +def test_hello_button_calls_on_hello_clicked(make_napari_viewer_proxy, mocker): + """Test that clicking the hello button calls _on_hello_clicked. + + Here we have to create a new Loader widget after mocking the method. + We cannot reuse the existing widget fixture because then it would be too + late to mock (the widget has already "decided" which method to call). + """ + mock_method = mocker.patch( + "movement.napari._loader_widget.Loader._on_hello_clicked" + ) + loader = Loader(make_napari_viewer_proxy) + hello_button = loader.findChildren(QPushButton)[0] + hello_button.click() + mock_method.assert_called_once() + + +def test_on_hello_clicked_outputs_message(loader_widget, capsys): + """Test that _on_hello_clicked outputs the expected message.""" + loader_widget._on_hello_clicked() + captured = capsys.readouterr() + assert "INFO: Hello, world!" in captured.out