Skip to content

Commit

Permalink
Create skeleton for napari plugin with collapsible widgets (#218)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
niksirbi committed Dec 18, 2024
1 parent c53e498 commit 5e3947a
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
include LICENSE
include *.md
include CITATION.CFF
include movement/napari/napari.yaml
exclude .pre-commit-config.yaml
exclude .cruft.json

Expand Down
19 changes: 18 additions & 1 deletion docs/source/user_guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,24 @@ Create and activate an environment with movement installed:
conda create -n movement-env -c conda-forge movement
conda activate movement-env
```
This will install all dependencies, including [napari](napari:),
which provides the GUI for movement.
:::
:::{tab-item} Pip
Create and activate an environment with some prerequisites:
```sh
conda create -n movement-env -c conda-forge python=3.11 pytables
conda activate movement-env
```
Install the latest movement release from PyPI:
Install the core package from the latest release on PyPI:
```sh
pip install movement
```
If you wish to use the GUI, which additionally requires [napari](napari:),
you should instead run:
```sh
pip install movement[napari]
```
:::
::::

Expand All @@ -45,6 +52,16 @@ 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.


## Update the package
To update movement to the latest version, we recommend installing it in a new environment,
as this prevents potential compatibility issues caused by changes in dependency versions.
Expand Down
Empty file added movement/napari/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions movement/napari/_loader_widget.py
Original file line number Diff line number Diff line change
@@ -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!")
27 changes: 27 additions & 0 deletions movement/napari/_meta_widget.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions movement/napari/napari.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -58,6 +70,8 @@ dev = [
"check-manifest",
"types-PyYAML",
"types-requests",
"pytest-qt",
"movement[napari]",
]

[project.scripts]
Expand Down Expand Up @@ -156,6 +170,13 @@ conda_deps =
pytables
conda_channels =
conda-forge
passenv =
CI
GITHUB_ACTIONS
DISPLAY
XAUTHORITY
NUMPY_EXPERIMENTAL_ARRAY_FUNCTION
PYVISTA_OFF_SCREEN
extras =
dev
commands =
Expand Down
61 changes: 61 additions & 0 deletions tests/test_integration/test_napari_plugin.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 5e3947a

Please sign in to comment.