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 Jul 30, 2024
1 parent 4d63e9b commit f4fe567
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 5 deletions.
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
31 changes: 27 additions & 4 deletions docs/source/getting_started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
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 @@ -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 =
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 f4fe567

Please sign in to comment.