diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e098778..5d89c4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,8 +7,6 @@ repos: - id: trailing-whitespace exclude: ^\.napari-hub/.* - id: check-yaml # checks for correct yaml syntax for github actions ex. - exclude: - (?x)(^src/ndevio/ndev_settings\.yaml$) - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.8 hooks: @@ -18,7 +16,3 @@ repos: rev: v0.3.0 hooks: - id: napari-plugin-checks - - repo: https://github.com/ndev-kit/ndev-settings - rev: v0.4.0 - hooks: - - id: reset-settings-values diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43579e6..343b2ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ The napari reader plugin ([_napari_reader.py](src/ndevio/_napari_reader.py)) imp - **Plugin discovery**: Registers file extensions and provides reader selection - **Scene handling**: Integrates with the Scene Widget for multi-scene files -- **Settings integration**: Respects user preferences from ndev-settings +- **Settings integration**: Respects user preferences from napari's plugin settings (`contributions.configuration` in `napari.yaml`) - **Error handling**: Provides helpful suggestions for missing bioio plugins This module is the **bridge between napari and nImage**, translating drag-and-drop file operations into proper layer creation. @@ -71,7 +71,7 @@ This module is the **bridge between napari and nImage**, translating drag-and-dr 1. **Core logic separate from UI**: Business logic in `nimage.py`, `_layer_utils.py`, etc. should have **no Qt or napari imports** (except type hints) 2. **Lazy imports**: Expensive imports (Qt, napari widgets) should be lazy-loaded to keep startup fast -3. **Settings-driven behavior**: Use [ndev-settings] for user-configurable behavior rather than hardcoding choices +3. **Settings-driven behavior**: Use napari plugin settings (declared in `napari.yaml` under `contributions.configuration`) for user-configurable behavior rather than hardcoding choices 4. **bioio compatibility**: Stay aligned with bioio's data model and avoid reinventing functionality ## Testing diff --git a/README.md b/README.md index b0f616e..195bd8a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Originally developed as part of napari-ndev (as a spiritual successor to [napari - **Thorough metadata extraction** — extract and apply scale, units, axis labels, metadata (inc. OME) to napari layers - **Remote file support** — compatible Bioio readers,such as [bioio-ome-zarr], can read from remote filesystems (HTTP, S3, etc.) with dask-backed loading - **Native multiscale support** — automatically read and display multiscale images when supported by the reader. For best experience, turn on the asynchronous rendering experimental setting in napari. -- **Configurable behavior** via [ndev-settings] — customize reader priority, multi-scene handling, and more +- **Configurable behavior** via napari Preferences — customize multi-scene handling, memory policy, plugin suggestions, and more - **Smart plugin installation** — automatic suggestions to install missing bioio reader plugins - **Programmatic API** — `nImage` class for napari-ready metadata extraction - **Batch utilities** — legacy widget for batch concatenation (with [nbatch]) and metadata management, with features being superseded by [napari-metadata] @@ -87,17 +87,18 @@ If you open a file that requires a bioio reader not currently installed, ndevio This widget taps into the `napari-plugin-manager` to install the bioio reader plugin from PyPI via a GUI. You may invoke this widget manually at any time via `Plugins > ndevio > Install BioIO Reader Plugins` to install any additional bioio reader plugin *and* update any currently installed plugins. -#### Settings Widget +#### Settings -Access **ndevio settings** via `Plugins > ndev-settings > Settings` to customize: +Configure **ndevio settings** in napari's **Preferences** dialog (under the +Plugins section, select `ndevio`) to customize: -- **Preferred reader**: Override bioio's default plugin selection priority (useful for formats with multiple compatible readers) -- **Multi-scene handling**: Choose whether to show the scene widget, view all scenes as a stack, or view only the first scene +- **Multi-scene handling**: Choose whether to show the scene widget, view all scenes as layers, or view only the first scene - **Plugin suggestions**: Enable/disable automatic plugin installation prompts for unsupported formats +- **Memory policy**: Set the maximum uncompressed image size (GB) to load eagerly; larger images use dask +- **Export**: Set the canvas scale and (optionally) override the screenshot canvas size -![ndevio settings via the ndev-settings widget in napari](https://github.com/ndev-kit/ndevio/blob/main/resources/ndev-settings.png?raw=true) - -These settings are managed by [ndev-settings] and persist across napari sessions. +These settings are declared in ndevio's plugin manifest (`contributions.configuration`), +managed by napari, and persist across napari sessions. #### Utilities Widget @@ -165,10 +166,13 @@ Image metadata is extracted from bioio and converted to napari layer metadata ba Images are loaded **in-memory** or **lazily** (via dask) automatically based on: -- File size < 4 GB **AND** +- Uncompressed file size below the configured in-memory limit (default 8 GB) **AND** - File size < 30% of available RAM - Remote files (e.g., S3, HTTP) and multiscale are always loaded lazily +The in-memory limit is configurable via the `ndevio` plugin settings in napari's +Preferences dialog. + ### Multi-channel Images Multi-channel images are **always split** into individual layers (one per channel), using channel names from metadata when available. Images are added with colorblind-friendly colormaps. @@ -216,9 +220,8 @@ If you encounter any problems, please [file an issue] along with a detailed desc [tox]: https://tox.readthedocs.io/en/latest/ [bioio]: https://github.com/bioio-devs/bioio [napari-aicsimageio]: https://github.com/AllenCellModeling/napari-aicsimageio -[ndev-settings]: https://github.com/ndev-kit/ndev-settings -[napari-metadata]: https://github.com/napari/napari-metadata [nbatch]: https://github.com/ndev-kit/nbatch +[napari-metadata]: https://github.com/napari/napari-metadata [uv]: https://docs.astral.sh/uv/ [ndev-kit]: https://github.com/ndev-kit [bioio-ome-zarr]: https://github.com/bioio-devs/bioio-ome-zarr diff --git a/pyproject.toml b/pyproject.toml index a415afb..d749d41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ requires-python = ">=3.11" # See best practices: https://napari.org/stable/plugins/building_a_plugin/best_practices.html dependencies = [ "napari", - "ndev-settings>=0.4.2", "nbatch>=0.0.4", "natsort", "magicgui", @@ -56,17 +55,14 @@ dev = [ "pytest", # https://docs.pytest.org/en/latest/contents.html "pytest-cov", # https://pytest-cov.readthedocs.io/en/latest/ "pytest-qt", # https://pytest-qt.readthedocs.io/en/latest/ - "napari", - "pyqt6", # Explicitly use PyQt6 (napari's future default, has ARM64 macOS support) + "napari[all]>=0.9.0rc0", # for testing plugin settings + "npe2>=0.9.0rc0", # for testing plugin settings "bioio-czi", # Include an additional priority reader for development/testing ] [project.entry-points."napari.manifest"] ndevio = "ndevio:napari.yaml" -[project.entry-points."ndev_settings.manifest"] -ndevio = "ndevio:ndev_settings.yaml" - [project.urls] "Bug Tracker" = "https://github.com/ndev-kit/ndevio/issues" "Documentation" = "https://github.com/ndev-kit/ndevio#README.md" diff --git a/src/ndevio/_napari_reader.py b/src/ndevio/_napari_reader.py index 783cf2f..92b6d03 100644 --- a/src/ndevio/_napari_reader.py +++ b/src/ndevio/_napari_reader.py @@ -38,20 +38,20 @@ def napari_get_reader( The reader function for the given path """ - from ndev_settings import get_settings + from ._settings import get_ndevio_settings - settings = get_settings() + settings = get_ndevio_settings() open_first_scene_only = ( open_first_scene_only if open_first_scene_only is not None - else settings.ndevio_reader.scene_handling == 'View First Scene Only' # type: ignore + else settings.reader.scene_handling == 'View First Scene Only' ) or False open_all_scenes = ( open_all_scenes if open_all_scenes is not None - else settings.ndevio_reader.scene_handling == 'View All Scenes' # type: ignore + else settings.reader.scene_handling == 'View All Scenes' ) or False # Return reader function; actual format validation happens in @@ -71,8 +71,8 @@ def napari_reader_function( """ Read a file using bioio. - nImage handles reader selection: if a preferred_reader is set in settings, - it's tried first with automatic fallback to bioio's default plugin ordering. + nImage handles reader selection, relying on bioio's default plugin + ordering (an explicit ``reader`` may be passed to ``nImage`` directly). Parameters ---------- diff --git a/src/ndevio/_settings.py b/src/ndevio/_settings.py new file mode 100644 index 0000000..5c48f4f --- /dev/null +++ b/src/ndevio/_settings.py @@ -0,0 +1,57 @@ +"""Access to ndevio's plugin settings. + +ndevio's user-configurable behavior is declared in ``napari.yaml`` under +``contributions.configurations`` (two categories: ``reader`` and ``export``) +and surfaces in napari's **Preferences** dialog. At runtime the values are +read through ``napari.settings.get_plugin_settings('ndevio')``. + +On napari versions too old to expose plugin settings (i.e. it predates the +``get_plugin_settings`` API, released in 0.9.0), :func:`get_ndevio_settings` +falls back to the defaults declared in ``napari.yaml`` so ndevio keeps working +everywhere. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from napari.settings import PluginPreferences + +_PLUGIN_NAME = 'ndevio' + +# Defaults mirroring the ``contributions.configuration`` block in +# ``napari.yaml`` — only the values ndevio's code reads. Used as a fallback +# when napari is too old to expose plugin settings; the settings are not +# user-configurable in that case. +_DEFAULTS = SimpleNamespace( + reader=SimpleNamespace( + suggest_reader_plugins=True, + scene_handling='Open Scene Widget', + clear_layers_on_new_scene=False, + max_in_mem_gb=8.0, + ), + export=SimpleNamespace( + canvas_scale=1.0, + override_canvas_size=False, + canvas_width=1024, + canvas_height=1024, + ), +) + + +def get_ndevio_settings() -> PluginPreferences | SimpleNamespace: + """Return ndevio's plugin settings, falling back to manifest defaults. + + Returns the napari-managed plugin preferences for ``ndevio`` when + available; otherwise a ``SimpleNamespace`` carrying the manifest defaults + (used when napari is too old for ``get_plugin_settings``). Both expose + the same ``.reader`` and ``.export`` attributes. + """ + try: + from napari.settings import get_plugin_settings + + return get_plugin_settings(_PLUGIN_NAME) + except ImportError: # pragma: no cover - napari < 0.9.0 + return _DEFAULTS diff --git a/src/ndevio/bioio_plugins/_manager.py b/src/ndevio/bioio_plugins/_manager.py index a7d7c98..1ae8420 100644 --- a/src/ndevio/bioio_plugins/_manager.py +++ b/src/ndevio/bioio_plugins/_manager.py @@ -159,13 +159,14 @@ def raise_unsupported_with_suggestions(path: PathLike) -> None: """ from bioio_base.exceptions import UnsupportedFileFormatError - from ndev_settings import get_settings - settings = get_settings() + from ndevio._settings import get_ndevio_settings + + settings = get_ndevio_settings() manager = ReaderPluginManager(path) msg_extra = ( manager.get_installation_message() - if settings.ndevio_reader.suggest_reader_plugins # type: ignore + if settings.reader.suggest_reader_plugins else None ) diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index caad56c..f6c4da4 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -59,6 +59,77 @@ contributions: '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', ] + configurations: + reader: + title: Reader + properties: + suggest_reader_plugins: + type: boolean + default: true + title: Suggest reader plugins + description: >- + Whether to suggest plugins to install when no reader can be found + for a file. + scene_handling: + type: string + default: Open Scene Widget + title: Scene handling + description: >- + How to handle files with multiple scenes. Choose between showing + a scene-selection widget, viewing all scenes as layers, or viewing + only the first scene. + enum: + - Open Scene Widget + - View All Scenes + - View First Scene Only + clear_layers_on_new_scene: + type: boolean + default: false + title: Clear layers on new scene + description: Whether to clear the viewer when selecting a new scene. + max_in_mem_gb: + type: number + default: 8.0 + title: Max in-memory size (GB) + description: >- + Maximum uncompressed image size in GB to load eagerly. Larger + images use dask. + minimum: 0.5 + maximum: 128.0 + export: + title: Export + properties: + canvas_scale: + type: number + default: 1.0 + title: Canvas scale + description: Scale factor for exported figures and screenshots. + minimum: 0.01 + maximum: 100.0 + override_canvas_size: + type: boolean + default: false + title: Override canvas size + description: >- + Whether to override the canvas size when exporting screenshots. + canvas_width: + type: integer + default: 1024 + title: Canvas width + description: >- + Canvas width in pixels when exporting screenshots (if override + is enabled). + minimum: 1 + maximum: 100000 + canvas_height: + type: integer + default: 1024 + title: Canvas height + description: >- + Canvas height in pixels when exporting screenshots (if override + is enabled). + minimum: 1 + maximum: 100000 widgets: - command: ndevio.make_plugin_installer_widget display_name: Install BioIO Reader Plugins diff --git a/src/ndevio/ndev_settings.yaml b/src/ndevio/ndev_settings.yaml deleted file mode 100644 index ae77bfc..0000000 --- a/src/ndevio/ndev_settings.yaml +++ /dev/null @@ -1,51 +0,0 @@ -ndevio_reader: - suggest_reader_plugins: - default: true - tooltip: Whether to suggest plugins to install when no reader can be found. - value: true - preferred_reader: - default: null - dynamic_choices: - fallback_message: No readers found - provider: bioio.readers - tooltip: Preferred reader to try first when opening images. - value: null - scene_handling: - choices: - - Open Scene Widget - - View All Scenes - - View First Scene Only - default: Open Scene Widget - tooltip: How to handle files with multiple scenes. - value: Open Scene Widget - clear_layers_on_new_scene: - default: false - tooltip: Whether to clear the viewer when selecting a new scene. - value: false - max_in_mem_gb: - default: 8.0 - min: 0.5 - max: 128.0 - step: 0.5 - tooltip: Maximum uncompressed image size in GB to load eagerly. Larger images use dask. - value: 8.0 - -ndevio_export: - canvas_scale: - default: 1.0 - min: 0.01 - max: 100.0 - step: 1.0 - tooltip: Scale factor for exported figures and screenshots - value: 1.0 - override_canvas_size: - default: false - tooltip: Whether to override canvas size when exporting screenshots - value: false - canvas_size: - default: !!python/tuple [1024, 1024] - options: - min: 1 - max: 100000 - tooltip: Height x width of canvas when exporting (if override enabled) - value: !!python/tuple [1024, 1024] diff --git a/src/ndevio/nimage.py b/src/ndevio/nimage.py index fdc9567..ae42f25 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -38,9 +38,8 @@ class nImage(BioImage): Image to be loaded. Can be a path to an image file, a numpy array, or an xarray DataArray. reader : type[Reader] | Sequence[type[Reader]], optional - Reader class or priority list of readers. If not provided, checks - settings for preferred_reader and tries that first, then falls back - to bioio's default deterministic priority. + Reader class or priority list of readers. If not provided, bioio's + default deterministic reader priority is used. **kwargs Additional arguments passed to BioImage. @@ -171,13 +170,11 @@ def _fits_in_memory(self) -> bool: if self.path is None: return True - from ndev_settings import get_settings from psutil import virtual_memory - max_bytes = ( - float(getattr(get_settings().ndevio_reader, 'max_in_mem_gb', 8.0)) # type: ignore[attr-defined] - * 1e9 - ) + from ._settings import get_ndevio_settings + + max_bytes = float(get_ndevio_settings().reader.max_in_mem_gb) * 1e9 available = int(virtual_memory().available) # xr.DataArray.nbytes = shape × dtype.itemsize — no IO, dask-safe uncompressed = self.xarray_dask_data.nbytes @@ -661,10 +658,14 @@ def _resolve_reader( ) -> type[Reader] | Sequence[type[Reader]] | None: """Resolve the reader to use for an image. - Priority: - 1. Explicit reader (passed to __init__) - 2. Preferred reader from settings (if file path and installed) - 3. None (let bioio determine) + An explicit reader (passed to ``nImage.__init__``) is honored as-is; + otherwise ``None`` is returned and bioio selects the reader using its + default plugin priority. + + Note that a settings-driven "preferred reader" was previously consulted + here, but that dynamic setting cannot be expressed as an npe2 + ``ConfigurationContribution`` and was removed — bioio handles reader + priority and fallback internally (see bioio#162). Parameters ---------- @@ -679,26 +680,4 @@ def _resolve_reader( The reader to use, or None to let bioio choose. """ - if explicit_reader is not None: - return explicit_reader - - # Only check preferred reader for file paths - if not isinstance(image, str | Path): - return None - - # Get preferred reader from settings - from ndev_settings import get_settings - - from .bioio_plugins._utils import get_installed_plugins, get_reader_by_name - - settings = get_settings() - preferred = settings.ndevio_reader.preferred_reader # type: ignore - - if not preferred: - return None - - if preferred not in get_installed_plugins(): - logger.debug('Preferred reader %s not installed', preferred) - return None - - return get_reader_by_name(preferred) + return explicit_reader diff --git a/src/ndevio/widgets/_scene_widget.py b/src/ndevio/widgets/_scene_widget.py index 88e68ae..c078814 100644 --- a/src/ndevio/widgets/_scene_widget.py +++ b/src/ndevio/widgets/_scene_widget.py @@ -10,7 +10,8 @@ from typing import TYPE_CHECKING from magicgui.widgets import Container, Select -from ndev_settings import get_settings + +from ndevio._settings import get_ndevio_settings if TYPE_CHECKING: import napari @@ -82,7 +83,7 @@ def __init__( self.viewer = viewer self.path = path self.img = img - self.settings = get_settings() + self.settings = get_ndevio_settings() self.scenes = [ f'{idx}{DELIMITER}{scene}' for idx, scene in enumerate(self.img.scenes) @@ -104,7 +105,7 @@ def _connect_events(self): def open_scene(self) -> None: """Open the selected scene(s) in the viewer.""" - if self.settings.ndevio_reader.clear_layers_on_new_scene: + if self.settings.reader.clear_layers_on_new_scene: self.viewer.layers.clear() for scene in self._scene_list_widget.value: diff --git a/src/ndevio/widgets/_utilities_container.py b/src/ndevio/widgets/_utilities_container.py index ffd5016..ef0e9f8 100644 --- a/src/ndevio/widgets/_utilities_container.py +++ b/src/ndevio/widgets/_utilities_container.py @@ -25,8 +25,8 @@ TextEdit, TupleEdit, ) -from ndev_settings import get_settings +from ndevio._settings import get_ndevio_settings from ndevio.utils import helpers if TYPE_CHECKING: @@ -270,7 +270,7 @@ def __init__(self, viewer: napari.viewer.Viewer = None): self._viewer = viewer if viewer is not None else None self._squeezed_dims_order: str | None = None self._squeezed_dims: tuple[int, ...] | None = None - self._settings = get_settings() + self._settings = get_ndevio_settings() self._init_widgets() self._init_save_name_container() @@ -1005,7 +1005,7 @@ def canvas_export_figure(self) -> None: save_name, ) - scale = self._settings.ndevio_export.canvas_scale + scale = self._settings.export.canvas_scale self._viewer.export_figure( path=str(save_path), @@ -1027,9 +1027,12 @@ def canvas_screenshot(self) -> None: self._save_directory.value, 'Figures', save_name ) - scale = self._settings.ndevio_export.canvas_scale - if self._settings.ndevio_export.override_canvas_size: - canvas_size = self._settings.ndevio_export.canvas_size + scale = self._settings.export.canvas_scale + if self._settings.export.override_canvas_size: + canvas_size = ( + self._settings.export.canvas_width, + self._settings.export.canvas_height, + ) else: canvas_size = self._viewer.window._qt_viewer.canvas.size diff --git a/tests/test_bioio_plugins/test_manager.py b/tests/test_bioio_plugins/test_manager.py index c0f3bb6..7344bac 100644 --- a/tests/test_bioio_plugins/test_manager.py +++ b/tests/test_bioio_plugins/test_manager.py @@ -4,6 +4,7 @@ via TestSuggestPluginsForPath. We trust those unit tests and don't duplicate here. """ +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -19,11 +20,12 @@ def test_raises_with_suggestions_enabled(self): raise_unsupported_with_suggestions, ) - with patch('ndev_settings.get_settings') as mock_settings: - mock_settings.return_value.ndevio_reader.suggest_reader_plugins = ( - True - ) - + with patch( + 'ndevio._settings.get_ndevio_settings', + return_value=SimpleNamespace( + reader=SimpleNamespace(suggest_reader_plugins=True), + ), + ): with pytest.raises(UnsupportedFileFormatError) as exc_info: raise_unsupported_with_suggestions('test.czi') @@ -36,11 +38,12 @@ def test_raises_without_suggestions_when_disabled(self): raise_unsupported_with_suggestions, ) - with patch('ndev_settings.get_settings') as mock_settings: - mock_settings.return_value.ndevio_reader.suggest_reader_plugins = ( - False - ) - + with patch( + 'ndevio._settings.get_ndevio_settings', + return_value=SimpleNamespace( + reader=SimpleNamespace(suggest_reader_plugins=False), + ), + ): with pytest.raises(UnsupportedFileFormatError) as exc_info: raise_unsupported_with_suggestions('test.czi') diff --git a/tests/test_nimage.py b/tests/test_nimage.py index 9b2a084..7655705 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -131,7 +131,6 @@ def test_nImage_ome_reader(resources_dir: Path): img_path = resources_dir / CELLS3D2CH_OME_TIFF nimg = nImage(img_path) - # assert nimg.settings.ndevio_reader.preferred_reader == 'bioio-ome-tiff' # this was the old methodology before bioio#162 assert nimg.reader.name == 'bioio_ome_tiff' # the below only exists if 'bioio-ome-tiff' is used assert hasattr(nimg, 'ome_metadata') @@ -671,13 +670,12 @@ def test_channel_kwargs_override_metadata(self, resources_dir: Path): ) # default for 2-channel -class TestPreferredReaderFallback: - """Tests for preferred reader fallback logic in nImage.__init__.""" +class TestReaderFallback: + """Tests for reader selection/fallback logic in nImage.__init__.""" - def test_preferred_reader_success(self, resources_dir: Path): - """Test that preferred reader is used when it works.""" + def test_resolved_reader_used_when_it_works(self, resources_dir: Path): + """Test that the resolved reader is used when it works.""" with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Mock returning a valid reader from bioio_tifffile import Reader mock_resolve.return_value = Reader @@ -689,10 +687,9 @@ def test_preferred_reader_success(self, resources_dir: Path): assert img is not None assert img.reader.name == 'bioio_tifffile' - def test_preferred_reader_fallback(self, resources_dir: Path): - """Test that failed preferred reader will fallback""" + def test_failed_reader_falls_back(self, resources_dir: Path): + """Test that a reader which can't read the file falls back.""" with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Mock returning a reader that won't work for this file from bioio_czi import Reader mock_resolve.return_value = Reader @@ -705,10 +702,10 @@ def test_preferred_reader_fallback(self, resources_dir: Path): # Should have fallen back to bioio's default (ome-tiff) assert img.reader.name == 'bioio_ome_tiff' - def test_no_preferred_reader_uses_default(self, resources_dir: Path): - """Test that no preferred reader uses bioio's default priority.""" + def test_no_reader_uses_default(self, resources_dir: Path): + """Test that no resolved reader uses bioio's default priority.""" with patch('ndevio.nimage._resolve_reader') as mock_resolve: - mock_resolve.return_value = None # No preferred reader + mock_resolve.return_value = None # No explicit reader img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) assert img is not None @@ -719,94 +716,38 @@ def test_no_preferred_reader_uses_default(self, resources_dir: Path): class TestResolveReaderFunction: """Tests for _resolve_reader function.""" - def test_returns_none_when_no_preferred_reader(self): - """Test returns None when preferred_reader is not set.""" + def test_returns_none_without_explicit_reader(self): + """Returns None (bioio picks the reader) when no reader is given.""" from ndevio.nimage import _resolve_reader - with patch('ndev_settings.get_settings') as mock_get_settings: - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - None - ) - - result = _resolve_reader('test.tiff', None) - assert result is None - - def test_returns_none_when_preferred_not_installed(self): - """Test returns None when preferred reader is not installed.""" - from ndevio.nimage import _resolve_reader - - with ( - patch('ndev_settings.get_settings') as mock_get_settings, - patch( - 'ndevio.bioio_plugins._utils.get_installed_plugins', - return_value={'bioio-ome-tiff', 'bioio-tifffile'}, - ), - ): - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - 'bioio-czi' - ) - - result = _resolve_reader('test.tiff', None) - assert result is None - - def test_returns_reader_when_preferred_installed(self): - """Test returns reader class when preferred reader is installed.""" - from ndevio.nimage import _resolve_reader - - with ( - patch('ndev_settings.get_settings') as mock_get_settings, - patch( - 'ndevio.bioio_plugins._utils.get_installed_plugins', - return_value={'bioio-ome-tiff'}, - ), - patch( - 'ndevio.bioio_plugins._utils.get_reader_by_name' - ) as mock_get_reader, - ): - from bioio_ome_tiff import Reader as OmeTiffReader + result = _resolve_reader('test.tiff', None) + assert result is None - mock_get_reader.return_value = OmeTiffReader - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - 'bioio-ome-tiff' - ) - - result = _resolve_reader('test.tiff', None) - assert result == OmeTiffReader - mock_get_reader.assert_called_once_with('bioio-ome-tiff') - - def test_explicit_reader_bypasses_settings(self): - """Test that explicit reader bypasses settings lookup.""" + def test_returns_explicit_reader(self): + """An explicit reader is returned as-is.""" from bioio_tifffile import Reader as TifffileReader from ndevio.nimage import _resolve_reader - with patch('ndev_settings.get_settings') as mock_get_settings: - result = _resolve_reader('test.tiff', TifffileReader) - - # Should return explicit reader without checking settings - assert result == TifffileReader - mock_get_settings.assert_not_called() + result = _resolve_reader('test.tiff', TifffileReader) + assert result == TifffileReader def test_array_input_returns_none(self): - """Test that array inputs don't trigger preferred reader lookup.""" + """Array inputs don't need reader resolution.""" import numpy as np from ndevio.nimage import _resolve_reader - with patch('ndev_settings.get_settings') as mock_get_settings: - arr = np.zeros((10, 10), dtype=np.uint8) - result = _resolve_reader(arr, None) - - # Should return None without checking settings for arrays - assert result is None - mock_get_settings.assert_not_called() + arr = np.zeros((10, 10), dtype=np.uint8) + result = _resolve_reader(arr, None) + assert result is None class TestNonPathImageHandling: """Tests for handling non-path inputs (arrays).""" - def test_array_input_no_preferred_reader_check(self): - """Test that arrays don't trigger preferred reader logic.""" + def test_array_input_no_reader_resolution(self): + """Test that arrays don't trigger reader resolution.""" import numpy as np with patch('ndevio.nimage._resolve_reader') as mock_resolve: @@ -840,8 +781,8 @@ def test_unsupported_array_raises_without_suggestions(self): class TestExplicitReaderParameter: """Tests for when reader is explicitly provided.""" - def test_explicit_reader_bypasses_preferred(self, resources_dir: Path): - """Test that explicit reader parameter bypasses preferred reader.""" + def test_explicit_reader_used_directly(self, resources_dir: Path): + """Test that an explicit reader parameter is used directly.""" from bioio_tifffile import Reader as TifffileReader with patch('ndevio.nimage._resolve_reader') as mock_resolve: @@ -916,7 +857,7 @@ def test_exceeds_memory_percentage_returns_false(self, tmp_path): assert img._fits_in_memory() is False def test_missing_max_in_mem_setting_falls_back_to_default(self, tmp_path): - """Older persisted settings missing max_in_mem_gb should use 8 GB.""" + """Missing napari settings should fall back to the 8 GB default.""" from types import SimpleNamespace import numpy as np @@ -927,9 +868,9 @@ def test_missing_max_in_mem_setting_falls_back_to_default(self, tmp_path): with ( mock.patch( - 'ndev_settings.get_settings', + 'ndevio._settings.get_ndevio_settings', return_value=SimpleNamespace( - ndevio_reader=SimpleNamespace(preferred_reader=None), + reader=SimpleNamespace(max_in_mem_gb=8.0), ), ), mock.patch( diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..b7fed7a --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,186 @@ +"""Tests for ndevio's plugin settings. + +ndevio's user-configurable settings are declared in ``napari.yaml`` under +``contributions.configurations`` and exposed to napari through +``napari.settings.get_plugin_settings('ndevio')``. These tests check that: + +* the manifest declares the expected configuration contributions, +* ``ndevio._settings.get_ndevio_settings`` returns napari's settings when + available and falls back to the manifest defaults otherwise, +* napari's ``plugin_settings`` pytest fixture drives the real manifest + end-to-end (napari >= 0.9.0). +""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from ndevio._settings import get_ndevio_settings + +MANIFEST = Path(__file__).parent.parent / 'src' / 'ndevio' / 'napari.yaml' + + +@pytest.fixture +def manifest(): + """Parse the ndevio npe2 manifest once per test.""" + from npe2 import PluginManifest + + return PluginManifest.from_file(MANIFEST) + + +def test_manifest_declares_configuration_categories(manifest): + """The manifest contributes reader / pre-processing / export categories.""" + configs = manifest.contributions.configurations + assert list(configs) == ['reader', 'pre_processing_widget', 'export'] + assert [c.title for c in configs.values()] == [ + 'Reader', + 'Export', + ] + + reader = configs['reader'] + assert set(reader.properties) == { + 'suggest_reader_plugins', + 'scene_handling', + 'clear_layers_on_new_scene', + 'max_in_mem_gb', + } + + export = configs['export'] + assert set(export.properties) == { + 'canvas_scale', + 'override_canvas_size', + 'canvas_width', + 'canvas_height', + } + + +def test_manifest_reader_property_defaults(manifest): + """Reader properties carry the same defaults as the old ndev-settings.""" + reader = manifest.contributions.configurations['reader'] + props = reader.properties + + assert props['suggest_reader_plugins'].default is True + assert props['scene_handling'].default == 'Open Scene Widget' + assert props['scene_handling'].enum == [ + 'Open Scene Widget', + 'View All Scenes', + 'View First Scene Only', + ] + assert props['clear_layers_on_new_scene'].default is False + assert props['max_in_mem_gb'].default == 8.0 + assert props['max_in_mem_gb'].minimum == 0.5 + assert props['max_in_mem_gb'].maximum == 128.0 + + +def test_manifest_export_property_defaults(manifest): + """Export properties carry the same defaults as the old ndev-settings.""" + export = manifest.contributions.configurations['export'] + props = export.properties + + assert props['canvas_scale'].default == 1.0 + assert props['canvas_scale'].minimum == 0.01 + assert props['canvas_scale'].maximum == 100.0 + assert props['override_canvas_size'].default is False + assert props['canvas_width'].default == 1024 + assert props['canvas_height'].default == 1024 + + +def test_manifest_has_no_dynamic_preferred_reader(manifest): + """The dynamic 'preferred_reader' setting is not representable and dropped.""" + for config in manifest.contributions.configurations.values(): + for key in config.properties: + assert 'preferred' not in key.lower() + + +def test_fallback_defaults_shape(): + """The fallback defaults mirror the manifest defaults.""" + from ndevio._settings import _DEFAULTS + + assert _DEFAULTS.reader.suggest_reader_plugins is True + assert _DEFAULTS.reader.scene_handling == 'Open Scene Widget' + assert _DEFAULTS.reader.clear_layers_on_new_scene is False + assert _DEFAULTS.reader.max_in_mem_gb == 8.0 + + assert _DEFAULTS.export.canvas_scale == 1.0 + assert _DEFAULTS.export.override_canvas_size is False + assert _DEFAULTS.export.canvas_width == 1024 + assert _DEFAULTS.export.canvas_height == 1024 + + +def test_get_ndevio_settings_uses_napari(monkeypatch): + """When napari provides plugin settings, they are returned as-is.""" + import napari.settings as napari_settings + + mock = SimpleNamespace( + reader=SimpleNamespace(scene_handling='View All Scenes'), + ) + monkeypatch.setattr( + napari_settings, + 'get_plugin_settings', + lambda plugin: mock, + raising=False, + ) + + assert get_ndevio_settings() is mock + + +def test_get_ndevio_settings_falls_back_when_feature_missing(monkeypatch): + """Older napari without get_plugin_settings -> defaults.""" + import napari.settings as napari_settings + + monkeypatch.delattr(napari_settings, 'get_plugin_settings', raising=False) + + settings = get_ndevio_settings() + assert isinstance(settings, SimpleNamespace) + assert settings.reader.max_in_mem_gb == 8.0 + + +def test_plugin_settings_fixture_end_to_end(plugin_settings, npe2pm): + """End-to-end via napari's `plugin_settings` fixture + the real manifest. + + Requires napari >= 0.9.0, which ships the `plugin_settings` pytest + fixture; on older napari the fixture simply doesn't exist and the test + errors at setup (it is not silently skipped). + """ + # register ndevio's *real* manifest, loaded from its file, scoped to this + # test by the npe2pm fixture + with npe2pm.tmp_plugin(manifest=MANIFEST): + from napari.settings import get_plugin_settings + + settings = get_plugin_settings('ndevio') + + assert settings.reader.suggest_reader_plugins is True + assert settings.reader.scene_handling == 'Open Scene Widget' + assert settings.reader.clear_layers_on_new_scene is False + assert settings.reader.max_in_mem_gb == 8.0 + + assert settings.export.canvas_scale == 1.0 + assert settings.export.override_canvas_size is False + assert settings.export.canvas_width == 1024 + assert settings.export.canvas_height == 1024 + + # changes auto-save under the fixture's per-test tmp_path + settings.reader.max_in_mem_gb = 4.0 + assert 'max_in_mem_gb: 4.0' in settings.config_path.read_text() + + # the accessor used by ndevio's code paths returns this same model + assert get_ndevio_settings() is settings + assert not isinstance(get_ndevio_settings(), SimpleNamespace) + + +def test_plugin_settings_from_installed_package(plugin_settings, npe2pm): + """Same end-to-end, but register ndevio from its installed distribution. + + ``npe2pm.tmp_plugin(package='ndevio')`` loads the manifest via + ``PluginManifest.from_distribution`` — the natural mode when the plugin + under test is installed in the test environment. + """ + with npe2pm.tmp_plugin(package='ndevio'): + from napari.settings import get_plugin_settings + + settings = get_plugin_settings('ndevio') + assert settings.reader.max_in_mem_gb == 8.0 + + settings.reader.max_in_mem_gb = 4.0 + assert 'max_in_mem_gb: 4.0' in settings.config_path.read_text()