diff --git a/brainrender_napari/__init__.py b/brainrender_napari/__init__.py index 5a57962..f1fdfce 100644 --- a/brainrender_napari/__init__.py +++ b/brainrender_napari/__init__.py @@ -1,4 +1,4 @@ __version__ = "0.0.1" -__all__ = "BrainrenderWidget" +__all__ = "BrainrenderWidget", "AtlasVersionManagerWidget" diff --git a/brainrender_napari/atlas_version_manager_widget.py b/brainrender_napari/atlas_version_manager_widget.py new file mode 100644 index 0000000..39bb68c --- /dev/null +++ b/brainrender_napari/atlas_version_manager_widget.py @@ -0,0 +1,24 @@ +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from brainrender_napari.widgets.atlas_manager_view import AtlasManagerView + + +class AtlasVersionManagerWidget(QWidget): + def __init__(self): + """Instantiates the version manager widget + and sets up coordinating connections""" + super().__init__() + + self.setLayout(QVBoxLayout()) + + # create widgets + self.atlas_manager_view = AtlasManagerView(parent=self) + self.layout().addWidget(self.atlas_manager_view) + + self.atlas_manager_view.download_atlas_confirmed.connect(self._refresh) + + self.atlas_manager_view.update_atlas_confirmed.connect(self._refresh) + + def _refresh(self) -> None: + # refresh view once an atlas has been downloaded + self.atlas_manager_view = AtlasManagerView(parent=self) diff --git a/brainrender_napari/napari.yaml b/brainrender_napari/napari.yaml index f4415b3..2b87da8 100644 --- a/brainrender_napari/napari.yaml +++ b/brainrender_napari/napari.yaml @@ -5,6 +5,11 @@ contributions: - id: brainrender-napari.make_brainrender_widget python_name: brainrender_napari.brainrender_widget:BrainrenderWidget title: Make Brainrender Napari + - id: brainrender-napari.make_atlas_version_manager_widget + python_name: brainrender_napari.atlas_version_manager_widget:AtlasVersionManagerWidget + title: Make Brainrender Napari widgets: - command: brainrender-napari.make_brainrender_widget display_name: Brainrender + - command: brainrender-napari.make_atlas_version_manager_widget + display_name: Atlas version manager diff --git a/brainrender_napari/widgets/atlas_download_dialog.py b/brainrender_napari/widgets/atlas_manager_dialog.py similarity index 79% rename from brainrender_napari/widgets/atlas_download_dialog.py rename to brainrender_napari/widgets/atlas_manager_dialog.py index d8c39a7..08782f3 100644 --- a/brainrender_napari/widgets/atlas_download_dialog.py +++ b/brainrender_napari/widgets/atlas_manager_dialog.py @@ -8,16 +8,16 @@ ) -class AtlasDownloadDialog(QDialog): - """A modal dialog to ask users to confirm they'd like to download +class AtlasManagerDialog(QDialog): + """A modal dialog to ask users to confirm they'd like to download/update the selected atlas, and warn them that it may be slow. """ - def __init__(self, atlas_name): + def __init__(self, atlas_name: str, action: str) -> None: if atlas_name in get_all_atlases_lastversions().keys(): super().__init__() - self.setWindowTitle(f"Download {atlas_name} Atlas") + self.setWindowTitle(f"{action} {atlas_name} Atlas") self.setModal(True) self.label = QLabel("Are you sure?\n(It may take a while)") @@ -36,5 +36,6 @@ def __init__(self, atlas_name): self.setLayout(layout) else: raise ValueError( - "Download Dialog constructor called with invalid atlas name." + "Atlas manager dialog constructor" + "called with invalid atlas name." ) diff --git a/brainrender_napari/widgets/atlas_manager_view.py b/brainrender_napari/widgets/atlas_manager_view.py new file mode 100644 index 0000000..d24bfa1 --- /dev/null +++ b/brainrender_napari/widgets/atlas_manager_view.py @@ -0,0 +1,90 @@ +"""The purpose of this file is to provide interactive model and view classes +for a table holding atlases. Users interacting with the table can request to +* download an atlas (double-click on row of a not-yet downloaded atlas) +* add annotation and reference images (double-click on row of local atlas) +* add additional references (right-click on a row and select from menu) + +It is designed to be agnostic from the viewer framework by emitting signals +that any interested observers can connect to. +""" +from typing import Callable + +from bg_atlasapi.list_atlases import ( + get_downloaded_atlases, +) +from bg_atlasapi.update_atlases import install_atlas, update_atlas +from napari.qt import thread_worker +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QTableView, QWidget + +from brainrender_napari.data_models.atlas_table_model import AtlasTableModel +from brainrender_napari.widgets.atlas_manager_dialog import AtlasManagerDialog + + +class AtlasManagerView(QTableView): + download_atlas_confirmed = Signal(str) + update_atlas_confirmed = Signal(str) + + def __init__(self, parent: QWidget = None): + """Initialises an atlas table view with latest atlas versions. + + Also responsible for appearance, behaviour on selection, and + setting up signal-slot connections. + """ + super().__init__(parent) + + self.setModel(AtlasTableModel()) + self.setEnabled(True) + self.verticalHeader().hide() + self.resizeColumnsToContents() + + self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableView.SelectionMode.SingleSelection) + + self.doubleClicked.connect(self._on_row_double_clicked) + self.hideColumn(0) # hide raw name + + def _on_row_double_clicked(self): + atlas_name = self.selected_atlas_name() + if atlas_name in get_downloaded_atlases(): + # check if update needed + update_dialog = AtlasManagerDialog(atlas_name, "Update") + update_dialog.ok_button.clicked.connect( + self._on_update_atlas_confirmed + ) + update_dialog.exec() + else: + download_dialog = AtlasManagerDialog(atlas_name, "Download") + download_dialog.ok_button.clicked.connect( + self._on_download_atlas_confirmed + ) + download_dialog.exec() + + def _on_download_atlas_confirmed(self): + """Downloads the currently selected atlas and signals this.""" + atlas_name = self.selected_atlas_name() + worker = self._apply_in_thread(install_atlas, atlas_name) + worker.returned.connect(self.download_atlas_confirmed.emit) + worker.start() + + def _on_update_atlas_confirmed(self): + """Updates the currently selected atlas and signals this.""" + atlas_name = self.selected_atlas_name() + worker = self._apply_in_thread(update_atlas, atlas_name) + worker.returned.connect(self.update_atlas_confirmed.emit) + worker.start() + + def selected_atlas_name(self) -> str: + """A single place to get a valid selected atlas name.""" + selected_index = self.selectionModel().currentIndex() + assert selected_index.isValid() + selected_atlas_name_index = selected_index.siblingAtColumn(0) + selected_atlas_name = self.model().data(selected_atlas_name_index) + return selected_atlas_name + + @thread_worker + def _apply_in_thread(self, apply: Callable, atlas_name: str): + """Calls `apply` on the given atlas in a separate thread.""" + apply(atlas_name) + self.model().refresh_data() + return atlas_name diff --git a/brainrender_napari/widgets/structure_view.py b/brainrender_napari/widgets/structure_view.py index 757d89b..3c422c6 100644 --- a/brainrender_napari/widgets/structure_view.py +++ b/brainrender_napari/widgets/structure_view.py @@ -2,158 +2,18 @@ model and view classes for the structures that form part of an atlas. The view is only visible if the atlas is downloaded.""" -from typing import Dict, List - from bg_atlasapi.list_atlases import get_downloaded_atlases -from bg_atlasapi.structure_tree_util import get_structures_tree -from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal -from qtpy.QtGui import QStandardItem +from qtpy.QtCore import QModelIndex, Signal from qtpy.QtWidgets import QTreeView, QWidget +from brainrender_napari.data_models.structure_tree_model import ( + StructureTreeModel, +) from brainrender_napari.utils.load_user_data import ( read_atlas_structures_from_file, ) -class StructureTreeItem(QStandardItem): - """A class to hold items in a tree model.""" - - def __init__(self, data, parent=None): - self.parent_item = parent - self.item_data = data - self.child_items = [] - - def appendChild(self, item): - self.child_items.append(item) - - def child(self, row): - return self.child_items[row] - - def childCount(self): - return len(self.child_items) - - def columnCount(self): - return len(self.item_data) - - def data(self, column): - try: - return self.item_data[column] - except IndexError: - return None - - def parent(self): - return self.parent_item - - def row(self): - if self.parent_item: - return self.parent_item.child_items.index(self) - return 0 - - -class StructureTreeModel(QAbstractItemModel): - """Implementation of a read-only QAbstractItemModel to hold - the structure tree information provided by the Atlas API in a Qt Model""" - - def __init__(self, data: List, parent=None): - super().__init__() - self.root_item = StructureTreeItem(data=("acronym", "name", "id")) - self.build_structure_tree(data, self.root_item) - - def build_structure_tree(self, structures: List, root: StructureTreeItem): - """Build the structure tree given a list of structures.""" - tree = get_structures_tree(structures) - structure_id_dict = {} - for structure in structures: - structure_id_dict[structure["id"]] = structure - - inserted_items: Dict[int, StructureTreeItem] = {} - for n_id in tree.expand_tree(): # sorts nodes by default, - # so parents will always be already in the QAbstractItemModel - # before their children - node = tree.get_node(n_id) - acronym = structure_id_dict[node.identifier]["acronym"] - name = structure_id_dict[node.identifier]["name"] - if ( - len(structure_id_dict[node.identifier]["structure_id_path"]) - == 1 - ): - parent_item = root - else: - parent_id = tree.parent(node.identifier).identifier - parent_item = inserted_items[parent_id] - - item = StructureTreeItem( - data=(acronym, name, node.identifier), parent=parent_item - ) - parent_item.appendChild(item) - inserted_items[node.identifier] = item - - def data(self, index: QModelIndex, role=Qt.DisplayRole): - """Provides read-only data for a given index if - intended for display, otherwise None.""" - if not index.isValid(): - return None - - if role != Qt.DisplayRole: - return None - - item = index.internalPointer() - - return item.data(index.column()) - - def rowCount(self, parent: StructureTreeItem): - """Returns the number of rows(i.e. children) of an item""" - if parent.column() > 0: - return 0 - - if not parent.isValid(): - parent_item = self.root_item - else: - parent_item = parent.internalPointer() - - return parent_item.childCount() - - def columnCount(self, parent: StructureTreeItem): - """The number of columns of an item.""" - if parent.isValid(): - return parent.internalPointer().columnCount() - else: - return self.root_item.columnCount() - - def parent(self, index: QModelIndex): - """The first-column index of parent of the item - at a given index. Returns an empty index if the root, - or an invalid index, is passed. - """ - if not index.isValid(): - return QModelIndex() - - child_item = index.internalPointer() - parent_item = child_item.parent() - - if parent_item == self.root_item: - return QModelIndex() - - return self.createIndex(parent_item.row(), 0, parent_item) - - def index(self, row, column, parent=QModelIndex()): - """The index of the item at (row, column) with a given parent. - By default, the given parent is assumed to be the root.""" - if not self.hasIndex(row, column, parent): - return QModelIndex() - - if not parent.isValid(): - parent_item = self.root_item - else: - parent_item = parent.internalPointer() - - child_item = parent_item.child(row) - if child_item: - return self.createIndex(row, column, child_item) - else: - return QModelIndex() - - class StructureView(QTreeView): add_structure_requested = Signal(str) diff --git a/tests/conftest.py b/tests/conftest.py index 19e7541..31de9c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ import os +import shutil from pathlib import Path import pytest -from bg_atlasapi import BrainGlobeAtlas, config +from bg_atlasapi import BrainGlobeAtlas, config, list_atlases from qtpy.QtCore import Qt @@ -88,3 +89,28 @@ def inner_double_click_on_view(view, index): ) return inner_double_click_on_view + + +@pytest.fixture +def mock_newer_atlas_version_available(): + current_version_path = Path.home() / ".brainglobe/example_mouse_100um_v1.2" + older_version_path = Path.home() / ".brainglobe/example_mouse_100um_v1.1" + assert current_version_path.exists() and not older_version_path.exists() + + current_version_path.rename(older_version_path) + assert older_version_path.exists() and not current_version_path.exists() + assert ( + list_atlases.get_atlases_lastversions()["example_mouse_100um"][ + "latest_version" + ] + == "1.2" + ) + assert list_atlases.get_local_atlas_version("example_mouse_100um") == "1.1" + + yield # run test with outdated version + + # cleanup: ensure version is up-to-date again + if older_version_path.exists(): + shutil.rmtree(path=older_version_path) + _ = BrainGlobeAtlas("example_mouse_100um") + assert current_version_path.exists() and not older_version_path.exists() diff --git a/tests/test_integration/test_atlas_version_manager_widget.py b/tests/test_integration/test_atlas_version_manager_widget.py new file mode 100644 index 0000000..4d703b0 --- /dev/null +++ b/tests/test_integration/test_atlas_version_manager_widget.py @@ -0,0 +1,63 @@ +import pytest + +from brainrender_napari.atlas_version_manager_widget import ( + AtlasVersionManagerWidget, +) + + +@pytest.fixture +def atlas_version_manager_widget(qtbot) -> AtlasVersionManagerWidget: + """A fixture to provide a version manager widget. + + Depends on qtbot so Qt event loop is started. + """ + return AtlasVersionManagerWidget() + + +def test_refresh_calls_view_constructor(atlas_version_manager_widget, mocker): + """Checks that refreshing the version manager widget + calls the view's constructor.""" + atlas_manager_view_mock = mocker.patch( + "brainrender_napari.atlas_version_manager_widget.AtlasManagerView" + ) + atlas_version_manager_widget._refresh() + atlas_manager_view_mock.assert_called_once_with( + parent=atlas_version_manager_widget + ) + + +def test_refresh_on_download(qtbot, mocker): + """Checks that when the view signals an atlas has been downloaded, + the version manager widget is refreshed.""" + refresh_mock = mocker.patch( + "brainrender_napari.atlas_version_manager_widget.AtlasVersionManagerWidget._refresh" + ) + # Don't use atlas_version_manager_widget fixture here, + # because otherwise mocking is ineffectual! + atlas_version_manager_widget = AtlasVersionManagerWidget() + with qtbot.waitSignal( + atlas_version_manager_widget.atlas_manager_view.download_atlas_confirmed + ): + atlas_version_manager_widget.atlas_manager_view.download_atlas_confirmed.emit( + "allen_mouse_100um" + ) + refresh_mock.assert_called_once_with("allen_mouse_100um") + + +def test_refresh_on_update(qtbot, mocker): + """Checks that when the view signals an atlas has been updated, + the version manager widget is updated.""" + refresh_mock = mocker.patch( + "brainrender_napari.atlas_version_manager_widget" + ".AtlasVersionManagerWidget._refresh" + ) + # Don't use atlas_version_manager_widget fixture here, + # because otherwise mocking is ineffectual! + atlas_version_manager_widget = AtlasVersionManagerWidget() + with qtbot.waitSignal( + atlas_version_manager_widget.atlas_manager_view.update_atlas_confirmed + ): + atlas_version_manager_widget.atlas_manager_view.update_atlas_confirmed.emit( + "allen_mouse_100um" + ) + refresh_mock.assert_called_once_with("allen_mouse_100um") diff --git a/tests/test_unit/test_atlas_manager_view.py b/tests/test_unit/test_atlas_manager_view.py new file mode 100644 index 0000000..1b1e421 --- /dev/null +++ b/tests/test_unit/test_atlas_manager_view.py @@ -0,0 +1,103 @@ +import shutil +from pathlib import Path + +import pytest + +from brainrender_napari.widgets.atlas_manager_view import AtlasManagerView + + +@pytest.fixture +def atlas_manager_view(qtbot): + return AtlasManagerView() + + +def test_update_atlas_confirmed( + qtbot, + mock_newer_atlas_version_available, + atlas_manager_view, +): + """The order of fixtures matters here: + call mock before view constructor!""" + outdated_atlas_directory = ( + Path.home() / ".brainglobe/example_mouse_100um_v1.1" + ) + updated_atlas_directory = ( + Path.home() / ".brainglobe/example_mouse_100um_v1.2" + ) + assert ( + outdated_atlas_directory.exists() + and not updated_atlas_directory.exists() + ) + local_version_index = atlas_manager_view.model().index(0, 2) + assert atlas_manager_view.model().data(local_version_index) == "1.1" + + with qtbot.waitSignal( + atlas_manager_view.update_atlas_confirmed, + timeout=150000, # assumes atlas can be updated in 2.5 minutes! + ) as update_atlas_confirmed_signal: + # replace with double-click on view? + model_index = atlas_manager_view.model().index(0, 0) + atlas_manager_view.setCurrentIndex(model_index) + atlas_manager_view._on_update_atlas_confirmed() + + assert atlas_manager_view.model().data(local_version_index) == "1.2" + assert update_atlas_confirmed_signal.args == ["example_mouse_100um"] + assert ( + updated_atlas_directory.exists() + and not outdated_atlas_directory.exists() + ) + + +@pytest.mark.parametrize( + "row", + [ + 1, # "allen_mouse_10um" + 6, # "allen_human_500um" + ], +) +def test_double_click_on_not_yet_downloaded_atlas_row( + atlas_manager_view, mocker, double_click_on_view, row +): + """Check for a few yet-to-be-downloaded atlases that double-clicking + them on the atlas table view executes the download dialog. + """ + + model_index = atlas_manager_view.model().index(row, 1) + atlas_manager_view.setCurrentIndex(model_index) + + dialog_exec_mock = mocker.patch( + "brainrender_napari.widgets.atlas_manager_view.AtlasManagerDialog.exec" + ) + double_click_on_view(atlas_manager_view, model_index) + dialog_exec_mock.assert_called_once() + + +def test_download_confirmed_callback(atlas_manager_view, qtbot): + """Checks that confirming atlas download creates local copy of + example atlas files and emits expected signal. + + Test setup consists of remembering the expected files and folders + of a preexisting atlas and then removing them. This allows checking + that the function triggers the creation of the same local copy + of the atlas as the `bg_atlasapi` itself. + """ + atlas_directory = Path.home() / ".brainglobe/example_mouse_100um_v1.2" + expected_filenames = atlas_directory.iterdir() + shutil.rmtree( + path=atlas_directory + ) # now remove local copy so button has to trigger download + assert not Path.exists( + atlas_directory + ) # sanity check that local copy is gone + + with qtbot.waitSignal( + atlas_manager_view.download_atlas_confirmed, + timeout=150000, # assumes atlas can be installed in 2.5 minutes! + ) as download_atlas_confirmed_signal: + model_index = atlas_manager_view.model().index(0, 0) + atlas_manager_view.setCurrentIndex(model_index) + atlas_manager_view._on_download_atlas_confirmed() + + assert download_atlas_confirmed_signal.args == ["example_mouse_100um"] + for file in expected_filenames: + assert Path.exists(file) diff --git a/tests/test_unit/test_download_dialog.py b/tests/test_unit/test_download_dialog.py deleted file mode 100644 index dd02ab4..0000000 --- a/tests/test_unit/test_download_dialog.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest - -from brainrender_napari.widgets.atlas_download_dialog import ( - AtlasDownloadDialog, -) - - -def test_download_dialog(qtbot): - """Check download dialog constructor and buttons connections""" - dialog = AtlasDownloadDialog("example_mouse_100um") - with qtbot.waitSignal(dialog.accepted): - dialog.ok_button.click() - - with qtbot.waitSignal(dialog.rejected): - dialog.cancel_button.click() - - -def test_download_dialog_raises(): - """Check download dialog constructor errors on invalid input""" - with pytest.raises(ValueError) as e: - _ = AtlasDownloadDialog("wrong_atlas_name") - assert "invalid atlas name" in e diff --git a/tests/test_unit/test_download_update_dialog.py b/tests/test_unit/test_download_update_dialog.py new file mode 100644 index 0000000..bcb5297 --- /dev/null +++ b/tests/test_unit/test_download_update_dialog.py @@ -0,0 +1,24 @@ +import pytest + +from brainrender_napari.widgets.atlas_manager_dialog import ( + AtlasManagerDialog, +) + + +@pytest.mark.parametrize("action", ["Download", "Update"]) +def test_download_update_dialog(action, qtbot): + """Check download dialog constructor and buttons connections""" + dialog = AtlasManagerDialog("example_mouse_100um", action) + with qtbot.waitSignal(dialog.accepted): + dialog.ok_button.click() + + with qtbot.waitSignal(dialog.rejected): + dialog.cancel_button.click() + + +@pytest.mark.parametrize("action", ["Download", "Update"]) +def test_download_update_dialog_raises(action): + """Check download dialog constructor errors on invalid input""" + with pytest.raises(ValueError) as e: + _ = AtlasManagerDialog("wrong_atlas_name", action) + assert [action, "invalid atlas name"] in e