From 51f03d04d19f19a62a4a789bc5cb362b71fc7693 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:37:04 -0500 Subject: [PATCH 01/12] remove precommit ndev-settings stuff --- .pre-commit-config.yaml | 6 ------ 1 file changed, 6 deletions(-) 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 From d8282b1fd541aaacea15b61ea88c97227cfe9050 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:37:20 -0500 Subject: [PATCH 02/12] remove ndev-settings from pyproject --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a415afb..569c4bd 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", @@ -64,9 +63,6 @@ dev = [ [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" From d8736880aa36ae37787f639c676039af7d5e10c7 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:38:07 -0500 Subject: [PATCH 03/12] move ndev-settings manifest to napari.yaml manifest --- src/ndevio/napari.yaml | 69 +++++++++++++++++++++++++++++++++++ src/ndevio/ndev_settings.yaml | 51 -------------------------- 2 files changed, 69 insertions(+), 51 deletions(-) delete mode 100644 src/ndevio/ndev_settings.yaml diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index caad56c..558bf89 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -59,6 +59,75 @@ contributions: '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', ] + configuration: + - title: Reader + properties: + ndevio.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. + ndevio.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 + ndevio.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. + ndevio.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 + - title: Export + properties: + ndevio.canvas_scale: + type: number + default: 1.0 + title: Canvas scale + description: Scale factor for exported figures and screenshots. + minimum: 0.01 + maximum: 100.0 + ndevio.override_canvas_size: + type: boolean + default: false + title: Override canvas size + description: >- + Whether to override the canvas size when exporting screenshots. + ndevio.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 + ndevio.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] From 18f4d9fc0f375b6d10f8880ebad95859b62a5013 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:43:13 -0500 Subject: [PATCH 04/12] backwards compat settings for no get_plugin_settings --- src/ndevio/_settings.py | 82 ++++++++++++++++ tests/test_settings.py | 211 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/ndevio/_settings.py create mode 100644 tests/test_settings.py diff --git a/src/ndevio/_settings.py b/src/ndevio/_settings.py new file mode 100644 index 0000000..796794d --- /dev/null +++ b/src/ndevio/_settings.py @@ -0,0 +1,82 @@ +"""Access to ndevio's plugin settings. + +ndevio's user-configurable behavior is declared in ``napari.yaml`` under +``contributions.configuration`` (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')``. + +When napari is too old to expose plugin settings (i.e. it predates the +``get_plugin_settings`` API), or when the ndevio plugin has not been +discovered yet, :func:`get_ndevio_settings` falls back to the same defaults +declared in the manifest so ndevio keeps working everywhere. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from napari.settings import PluginPreferences + +_PLUGIN_NAME = 'ndevio' + +# The defaults below mirror the ``contributions.configuration`` block in +# ``napari.yaml``. They are only used as a fallback when napari's plugin +# settings are unavailable (old napari, or plugin not discovered yet). + + +@dataclass(frozen=True) +class ReaderDefaults: + """Default values for the ``reader`` configuration category.""" + + suggest_reader_plugins: bool = True + scene_handling: str = 'Open Scene Widget' + clear_layers_on_new_scene: bool = False + max_in_mem_gb: float = 8.0 + + +@dataclass(frozen=True) +class ExportDefaults: + """Default values for the ``export`` configuration category.""" + + canvas_scale: float = 1.0 + override_canvas_size: bool = False + canvas_width: int = 1024 + canvas_height: int = 1024 + + +@dataclass(frozen=True) +class _DefaultSettings: + """Fallback settings object exposing the same shape as napari's model. + + Attributes are named to match the generated napari plugin preferences + (``.reader`` and ``.export``), so callers can use the fallback + interchangeably with the napari-managed model. + """ + + reader: ReaderDefaults = field(default_factory=ReaderDefaults) + export: ExportDefaults = field(default_factory=ExportDefaults) + + +def get_ndevio_settings() -> PluginPreferences | _DefaultSettings: + """Return ndevio's plugin settings, falling back to defaults. + + Returns + ------- + PluginPreferences | _DefaultSettings + The napari-managed plugin preferences for ``ndevio`` when available, + otherwise a frozen dataclass carrying the manifest defaults. Both + expose the same ``.reader`` and ``.export`` attributes. + + """ + try: + from napari.settings import get_plugin_settings + except ImportError: # pragma: no cover - pre-plugin-settings napari + return _DefaultSettings() + + try: + return get_plugin_settings(_PLUGIN_NAME) + except KeyError: + # ndevio is not registered/discovered (yet) -> manifest defaults + return _DefaultSettings() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..beb15d2 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,211 @@ +"""Tests for ndevio's plugin settings. + +ndevio's user-configurable settings are declared in ``napari.yaml`` under +``contributions.configuration`` 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. +""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from ndevio._settings import ( + ExportDefaults, + ReaderDefaults, + _DefaultSettings, + 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 a 'Reader' and an 'Export' category.""" + configs = manifest.contributions.configuration + assert [c.title for c in configs] == ['Reader', 'Export'] + + reader = configs[0] + assert set(reader.properties) == { + 'ndevio.suggest_reader_plugins', + 'ndevio.scene_handling', + 'ndevio.clear_layers_on_new_scene', + 'ndevio.max_in_mem_gb', + } + + export = configs[1] + assert set(export.properties) == { + 'ndevio.canvas_scale', + 'ndevio.override_canvas_size', + 'ndevio.canvas_width', + 'ndevio.canvas_height', + } + + +def test_manifest_reader_property_defaults(manifest): + """Reader properties carry the same defaults as the old ndev-settings.""" + reader = manifest.contributions.configuration[0] + props = reader.properties + + assert props['ndevio.suggest_reader_plugins'].default is True + assert props['ndevio.scene_handling'].default == 'Open Scene Widget' + assert props['ndevio.scene_handling'].enum == [ + 'Open Scene Widget', + 'View All Scenes', + 'View First Scene Only', + ] + assert props['ndevio.clear_layers_on_new_scene'].default is False + assert props['ndevio.max_in_mem_gb'].default == 8.0 + assert props['ndevio.max_in_mem_gb'].minimum == 0.5 + assert props['ndevio.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.configuration[1] + props = export.properties + + assert props['ndevio.canvas_scale'].default == 1.0 + assert props['ndevio.canvas_scale'].minimum == 0.01 + assert props['ndevio.canvas_scale'].maximum == 100.0 + assert props['ndevio.override_canvas_size'].default is False + assert props['ndevio.canvas_width'].default == 1024 + assert props['ndevio.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.configuration: + for key in config.properties: + assert 'preferred' not in key.lower() + + +def test_default_settings_shape(): + """The fallback settings object mirrors the manifest defaults.""" + defaults = _DefaultSettings() + assert isinstance(defaults.reader, ReaderDefaults) + assert isinstance(defaults.export, ExportDefaults) + + 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_plugin_missing(monkeypatch): + """napari raising KeyError for an undiscovered plugin -> defaults.""" + import napari.settings as napari_settings + + def _raise(_plugin: str): + raise KeyError('ndevio') + + monkeypatch.setattr( + napari_settings, 'get_plugin_settings', _raise, raising=False + ) + + settings = get_ndevio_settings() + assert isinstance(settings, _DefaultSettings) + assert settings.reader.scene_handling == 'Open Scene Widget' + + +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, _DefaultSettings) + assert settings.reader.max_in_mem_gb == 8.0 + + +def test_real_get_plugin_settings(tmp_path): + """End-to-end: napari builds ndevio's preferences from the manifest. + + This exercises the real ``napari.settings.get_plugin_settings('ndevio')`` + path, which requires a napari that supports plugin settings. + """ + import napari.settings as napari_settings + + if not hasattr(napari_settings, 'get_plugin_settings'): + pytest.skip('installed napari lacks plugin settings support') + + from npe2 import PluginManager + + # Make sure ndevio's manifest is known to the npe2 plugin manager. + pm = PluginManager.instance() + if 'ndevio' not in pm: + from npe2 import PluginManifest + + pm.register(PluginManifest.from_file(MANIFEST)) + + napari_settings._PLUGIN_PREFERENCES.clear() + try: + settings = napari_settings.get_plugin_settings( + 'ndevio', path_dir=tmp_path + ) + + 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 + + # Preferences persist to a per-plugin yaml under the given path dir. + assert settings.config_path == tmp_path / 'ndevio.yaml' + + # The accessor used by ndevio's code paths returns this same model + # (the napari cache is still populated with the tmp_path config above). + from ndevio._settings import get_ndevio_settings + + accessed = get_ndevio_settings() + assert accessed is settings + assert accessed.reader.scene_handling == 'Open Scene Widget' + assert accessed.export.canvas_width == 1024 + assert not isinstance(accessed, _DefaultSettings) + + # Changing a value and saving writes the per-plugin yaml file. + settings.reader.max_in_mem_gb = 4.0 + settings.save() + assert (tmp_path / 'ndevio.yaml').exists() + assert 'max_in_mem_gb: 4.0' in (tmp_path / 'ndevio.yaml').read_text() + finally: + napari_settings._PLUGIN_PREFERENCES.clear() From e83b6492738b18f4476a97811e7c70effac11523 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:44:34 -0500 Subject: [PATCH 05/12] use new settings in all places --- src/ndevio/_napari_reader.py | 12 +++--- src/ndevio/bioio_plugins/_manager.py | 7 ++-- src/ndevio/nimage.py | 49 +++++++--------------- src/ndevio/widgets/_scene_widget.py | 7 ++-- src/ndevio/widgets/_utilities_container.py | 15 ++++--- 5 files changed, 37 insertions(+), 53 deletions(-) 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/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/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 From 12b5f2abce07d5a88fc7c9e060ca4964e289311f Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:44:59 -0500 Subject: [PATCH 06/12] update test_manager.py --- tests/test_bioio_plugins/test_manager.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) 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') From 6221826bb433e42567ce91ec709d3518ed15828a Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:45:09 -0500 Subject: [PATCH 07/12] update test_image.py --- tests/test_nimage.py | 115 +++++++++++-------------------------------- 1 file changed, 28 insertions(+), 87 deletions(-) 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( From be6192c6e5eb1f1e8785c768925a24d999da6344 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 16:45:38 -0500 Subject: [PATCH 08/12] update both contributing and readme's to explain settings --- CONTRIBUTING.md | 4 ++-- README.md | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) 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 From 72f66f57498af8e9ee55da5801457c0cc740ddd1 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 22:21:34 -0500 Subject: [PATCH 09/12] clean up the testing --- tests/test_settings.py | 75 ++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 46 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index beb15d2..ce70036 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -154,58 +154,41 @@ def test_get_ndevio_settings_falls_back_when_feature_missing(monkeypatch): def test_real_get_plugin_settings(tmp_path): - """End-to-end: napari builds ndevio's preferences from the manifest. - - This exercises the real ``napari.settings.get_plugin_settings('ndevio')`` - path, which requires a napari that supports plugin settings. - """ - import napari.settings as napari_settings + """End-to-end: napari builds ndevio's preferences from the manifest.""" + from napari import settings as napari_settings if not hasattr(napari_settings, 'get_plugin_settings'): - pytest.skip('installed napari lacks plugin settings support') + pytest.skip( + 'installed napari lacks plugin settings (need napari>=0.9.0)' + ) - from npe2 import PluginManager + from npe2 import PluginManager, PluginManifest - # Make sure ndevio's manifest is known to the npe2 plugin manager. + # pytest blocks discovery, so register the (always installed) manifest. pm = PluginManager.instance() if 'ndevio' not in pm: - from npe2 import PluginManifest + pm.register(PluginManifest.from_distribution('ndevio')) + # Reset the in-memory cache so the registered plugin is included (saved + # values are re-read from disk; this also allows path_dir=tmp_path). + napari_settings._PLUGIN_PREFERENCES.clear() - pm.register(PluginManifest.from_file(MANIFEST)) + settings = napari_settings.get_plugin_settings('ndevio', path_dir=tmp_path) - napari_settings._PLUGIN_PREFERENCES.clear() - try: - settings = napari_settings.get_plugin_settings( - 'ndevio', path_dir=tmp_path - ) + 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 + + # Settings are persisted to a per-plugin yaml, and changes auto-save. + assert settings.config_path == tmp_path / 'ndevio.yaml' + settings.reader.max_in_mem_gb = 4.0 + assert 'max_in_mem_gb: 4.0' in (tmp_path / 'ndevio.yaml').read_text() - 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 - - # Preferences persist to a per-plugin yaml under the given path dir. - assert settings.config_path == tmp_path / 'ndevio.yaml' - - # The accessor used by ndevio's code paths returns this same model - # (the napari cache is still populated with the tmp_path config above). - from ndevio._settings import get_ndevio_settings - - accessed = get_ndevio_settings() - assert accessed is settings - assert accessed.reader.scene_handling == 'Open Scene Widget' - assert accessed.export.canvas_width == 1024 - assert not isinstance(accessed, _DefaultSettings) - - # Changing a value and saving writes the per-plugin yaml file. - settings.reader.max_in_mem_gb = 4.0 - settings.save() - assert (tmp_path / 'ndevio.yaml').exists() - assert 'max_in_mem_gb: 4.0' in (tmp_path / 'ndevio.yaml').read_text() - finally: - napari_settings._PLUGIN_PREFERENCES.clear() + # The accessor used by ndevio's code paths returns this same model. + assert get_ndevio_settings() is settings + assert not isinstance(get_ndevio_settings(), _DefaultSettings) From c6ac993e571528ad87daa33e12908e078d59d8ba Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 6 Aug 2026 23:04:17 -0500 Subject: [PATCH 10/12] simplify teh backup settings --- src/ndevio/_settings.py | 95 +++++++++++++++-------------------------- tests/test_settings.py | 51 ++++++---------------- 2 files changed, 49 insertions(+), 97 deletions(-) diff --git a/src/ndevio/_settings.py b/src/ndevio/_settings.py index 796794d..03923db 100644 --- a/src/ndevio/_settings.py +++ b/src/ndevio/_settings.py @@ -1,19 +1,19 @@ """Access to ndevio's plugin settings. ndevio's user-configurable behavior is declared in ``napari.yaml`` under -``contributions.configuration`` (two categories: ``reader`` and ``export``) +``contributions.configuration`` (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')``. -When napari is too old to expose plugin settings (i.e. it predates the -``get_plugin_settings`` API), or when the ndevio plugin has not been -discovered yet, :func:`get_ndevio_settings` falls back to the same defaults -declared in the manifest so ndevio keeps working everywhere. +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 dataclasses import dataclass, field +from types import SimpleNamespace from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -21,62 +21,37 @@ _PLUGIN_NAME = 'ndevio' -# The defaults below mirror the ``contributions.configuration`` block in -# ``napari.yaml``. They are only used as a fallback when napari's plugin -# settings are unavailable (old napari, or plugin not discovered yet). - - -@dataclass(frozen=True) -class ReaderDefaults: - """Default values for the ``reader`` configuration category.""" - - suggest_reader_plugins: bool = True - scene_handling: str = 'Open Scene Widget' - clear_layers_on_new_scene: bool = False - max_in_mem_gb: float = 8.0 - - -@dataclass(frozen=True) -class ExportDefaults: - """Default values for the ``export`` configuration category.""" - - canvas_scale: float = 1.0 - override_canvas_size: bool = False - canvas_width: int = 1024 - canvas_height: int = 1024 - - -@dataclass(frozen=True) -class _DefaultSettings: - """Fallback settings object exposing the same shape as napari's model. - - Attributes are named to match the generated napari plugin preferences - (``.reader`` and ``.export``), so callers can use the fallback - interchangeably with the napari-managed model. - """ - - reader: ReaderDefaults = field(default_factory=ReaderDefaults) - export: ExportDefaults = field(default_factory=ExportDefaults) - - -def get_ndevio_settings() -> PluginPreferences | _DefaultSettings: - """Return ndevio's plugin settings, falling back to defaults. - - Returns - ------- - PluginPreferences | _DefaultSettings - The napari-managed plugin preferences for ``ndevio`` when available, - otherwise a frozen dataclass carrying the manifest defaults. Both - expose the same ``.reader`` and ``.export`` attributes. - +# 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 - except ImportError: # pragma: no cover - pre-plugin-settings napari - return _DefaultSettings() - try: return get_plugin_settings(_PLUGIN_NAME) - except KeyError: - # ndevio is not registered/discovered (yet) -> manifest defaults - return _DefaultSettings() + except ImportError: # pragma: no cover - napari < 0.9.0 + return _DEFAULTS diff --git a/tests/test_settings.py b/tests/test_settings.py index ce70036..5f3dd4d 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -14,12 +14,7 @@ import pytest -from ndevio._settings import ( - ExportDefaults, - ReaderDefaults, - _DefaultSettings, - get_ndevio_settings, -) +from ndevio._settings import get_ndevio_settings MANIFEST = Path(__file__).parent.parent / 'src' / 'ndevio' / 'napari.yaml' @@ -92,21 +87,19 @@ def test_manifest_has_no_dynamic_preferred_reader(manifest): assert 'preferred' not in key.lower() -def test_default_settings_shape(): - """The fallback settings object mirrors the manifest defaults.""" - defaults = _DefaultSettings() - assert isinstance(defaults.reader, ReaderDefaults) - assert isinstance(defaults.export, ExportDefaults) +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.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 + 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): @@ -126,22 +119,6 @@ def test_get_ndevio_settings_uses_napari(monkeypatch): assert get_ndevio_settings() is mock -def test_get_ndevio_settings_falls_back_when_plugin_missing(monkeypatch): - """napari raising KeyError for an undiscovered plugin -> defaults.""" - import napari.settings as napari_settings - - def _raise(_plugin: str): - raise KeyError('ndevio') - - monkeypatch.setattr( - napari_settings, 'get_plugin_settings', _raise, raising=False - ) - - settings = get_ndevio_settings() - assert isinstance(settings, _DefaultSettings) - assert settings.reader.scene_handling == 'Open Scene Widget' - - def test_get_ndevio_settings_falls_back_when_feature_missing(monkeypatch): """Older napari without get_plugin_settings -> defaults.""" import napari.settings as napari_settings @@ -149,7 +126,7 @@ def test_get_ndevio_settings_falls_back_when_feature_missing(monkeypatch): monkeypatch.delattr(napari_settings, 'get_plugin_settings', raising=False) settings = get_ndevio_settings() - assert isinstance(settings, _DefaultSettings) + assert isinstance(settings, SimpleNamespace) assert settings.reader.max_in_mem_gb == 8.0 @@ -191,4 +168,4 @@ def test_real_get_plugin_settings(tmp_path): # The accessor used by ndevio's code paths returns this same model. assert get_ndevio_settings() is settings - assert not isinstance(get_ndevio_settings(), _DefaultSettings) + assert not isinstance(get_ndevio_settings(), SimpleNamespace) From 2524b006e27586e8f9028a37d4cd5a6b31ae5479 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 13 Aug 2026 00:39:26 -0500 Subject: [PATCH 11/12] update for recent changes to config --- src/ndevio/_settings.py | 2 +- src/ndevio/napari.yaml | 24 +++---- tests/test_settings.py | 139 ++++++++++++++++++++++------------------ 3 files changed, 91 insertions(+), 74 deletions(-) diff --git a/src/ndevio/_settings.py b/src/ndevio/_settings.py index 03923db..5c48f4f 100644 --- a/src/ndevio/_settings.py +++ b/src/ndevio/_settings.py @@ -1,7 +1,7 @@ """Access to ndevio's plugin settings. ndevio's user-configurable behavior is declared in ``napari.yaml`` under -``contributions.configuration`` (two categories: ``Reader`` and ``Export``) +``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')``. diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index 558bf89..f6c4da4 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -59,17 +59,18 @@ contributions: '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', ] - configuration: - - title: Reader + configurations: + reader: + title: Reader properties: - ndevio.suggest_reader_plugins: + 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. - ndevio.scene_handling: + scene_handling: type: string default: Open Scene Widget title: Scene handling @@ -81,12 +82,12 @@ contributions: - Open Scene Widget - View All Scenes - View First Scene Only - ndevio.clear_layers_on_new_scene: + 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. - ndevio.max_in_mem_gb: + max_in_mem_gb: type: number default: 8.0 title: Max in-memory size (GB) @@ -95,22 +96,23 @@ contributions: images use dask. minimum: 0.5 maximum: 128.0 - - title: Export + export: + title: Export properties: - ndevio.canvas_scale: + canvas_scale: type: number default: 1.0 title: Canvas scale description: Scale factor for exported figures and screenshots. minimum: 0.01 maximum: 100.0 - ndevio.override_canvas_size: + override_canvas_size: type: boolean default: false title: Override canvas size description: >- Whether to override the canvas size when exporting screenshots. - ndevio.canvas_width: + canvas_width: type: integer default: 1024 title: Canvas width @@ -119,7 +121,7 @@ contributions: is enabled). minimum: 1 maximum: 100000 - ndevio.canvas_height: + canvas_height: type: integer default: 1024 title: Canvas height diff --git a/tests/test_settings.py b/tests/test_settings.py index 5f3dd4d..b7fed7a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,12 +1,14 @@ """Tests for ndevio's plugin settings. ndevio's user-configurable settings are declared in ``napari.yaml`` under -``contributions.configuration`` and exposed to napari through +``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. + 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 @@ -28,61 +30,65 @@ def manifest(): def test_manifest_declares_configuration_categories(manifest): - """The manifest contributes a 'Reader' and an 'Export' category.""" - configs = manifest.contributions.configuration - assert [c.title for c in configs] == ['Reader', 'Export'] + """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[0] + reader = configs['reader'] assert set(reader.properties) == { - 'ndevio.suggest_reader_plugins', - 'ndevio.scene_handling', - 'ndevio.clear_layers_on_new_scene', - 'ndevio.max_in_mem_gb', + 'suggest_reader_plugins', + 'scene_handling', + 'clear_layers_on_new_scene', + 'max_in_mem_gb', } - export = configs[1] + export = configs['export'] assert set(export.properties) == { - 'ndevio.canvas_scale', - 'ndevio.override_canvas_size', - 'ndevio.canvas_width', - 'ndevio.canvas_height', + '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.configuration[0] + reader = manifest.contributions.configurations['reader'] props = reader.properties - assert props['ndevio.suggest_reader_plugins'].default is True - assert props['ndevio.scene_handling'].default == 'Open Scene Widget' - assert props['ndevio.scene_handling'].enum == [ + 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['ndevio.clear_layers_on_new_scene'].default is False - assert props['ndevio.max_in_mem_gb'].default == 8.0 - assert props['ndevio.max_in_mem_gb'].minimum == 0.5 - assert props['ndevio.max_in_mem_gb'].maximum == 128.0 + 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.configuration[1] + export = manifest.contributions.configurations['export'] props = export.properties - assert props['ndevio.canvas_scale'].default == 1.0 - assert props['ndevio.canvas_scale'].minimum == 0.01 - assert props['ndevio.canvas_scale'].maximum == 100.0 - assert props['ndevio.override_canvas_size'].default is False - assert props['ndevio.canvas_width'].default == 1024 - assert props['ndevio.canvas_height'].default == 1024 + 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.configuration: + for config in manifest.contributions.configurations.values(): for key in config.properties: assert 'preferred' not in key.lower() @@ -130,42 +136,51 @@ def test_get_ndevio_settings_falls_back_when_feature_missing(monkeypatch): assert settings.reader.max_in_mem_gb == 8.0 -def test_real_get_plugin_settings(tmp_path): - """End-to-end: napari builds ndevio's preferences from the manifest.""" - from napari import settings as napari_settings +def test_plugin_settings_fixture_end_to_end(plugin_settings, npe2pm): + """End-to-end via napari's `plugin_settings` fixture + the real manifest. - if not hasattr(napari_settings, 'get_plugin_settings'): - pytest.skip( - 'installed napari lacks plugin settings (need napari>=0.9.0)' - ) + 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 - from npe2 import PluginManager, PluginManifest + settings = get_plugin_settings('ndevio') - # pytest blocks discovery, so register the (always installed) manifest. - pm = PluginManager.instance() - if 'ndevio' not in pm: - pm.register(PluginManifest.from_distribution('ndevio')) - # Reset the in-memory cache so the registered plugin is included (saved - # values are re-read from disk; this also allows path_dir=tmp_path). - napari_settings._PLUGIN_PREFERENCES.clear() + 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 - settings = napari_settings.get_plugin_settings('ndevio', path_dir=tmp_path) + 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 - 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 + # 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. - 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 + ``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 are persisted to a per-plugin yaml, and changes auto-save. - assert settings.config_path == tmp_path / 'ndevio.yaml' - settings.reader.max_in_mem_gb = 4.0 - assert 'max_in_mem_gb: 4.0' in (tmp_path / 'ndevio.yaml').read_text() + settings = get_plugin_settings('ndevio') + assert settings.reader.max_in_mem_gb == 8.0 - # 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) + settings.reader.max_in_mem_gb = 4.0 + assert 'max_in_mem_gb: 4.0' in settings.config_path.read_text() From 943da5f473b006c29dd96dee2c9e2392d0cc3a36 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Thu, 13 Aug 2026 10:21:48 -0500 Subject: [PATCH 12/12] use napari pre-release for testing (needs released) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 569c4bd..d749d41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,8 +55,8 @@ 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 ]