diff --git a/src/ndevio/_bioio_plugin_utils.py b/src/ndevio/_bioio_plugin_utils.py index 9ce480b..bc7dba7 100644 --- a/src/ndevio/_bioio_plugin_utils.py +++ b/src/ndevio/_bioio_plugin_utils.py @@ -1,12 +1,12 @@ """Bioio plugin metadata and extension mapping. -This module contains the BIOIO_PLUGINS registry and low-level utilities for +This module contains the BIOIO_PLUGINS registry and utilities for plugin discovery. The ReaderPluginManager uses these utilities internally. Public API: BIOIO_PLUGINS - Dict of all bioio plugins and their file extensions suggest_plugins_for_path() - Get list of suggested plugins by file extension - get_reader_priority() - Get reader priority list from BIOIO_PLUGINS order + get_reader_by_name() - Import and return Reader class from plugin name Internal API (used by ReaderPluginManager): format_plugin_installation_message() - Generate installation message @@ -22,16 +22,23 @@ >>> manager = ReaderPluginManager("image.czi") >>> print(manager.installable_plugins) >>> print(manager.get_installation_message()) + >>> + >>> # Direct reader import + >>> from ndevio._bioio_plugin_utils import get_reader_by_name + >>> reader = get_reader_by_name("bioio-ome-tiff") """ from __future__ import annotations +import importlib import logging from typing import TYPE_CHECKING if TYPE_CHECKING: from pathlib import Path + from bioio_base.reader import Reader + logger = logging.getLogger(__name__) # Bioio plugins and their supported extensions @@ -124,25 +131,40 @@ _EXTENSION_TO_PLUGIN[ext].append(plugin_name) -def get_reader_priority() -> list[str]: - """Get reader priority list from BIOIO_PLUGINS dictionary order. +def get_reader_by_name(reader_name: str) -> Reader: + """Import and return Reader class from plugin name. + + Converts plugin name (e.g., 'bioio-czi') to module name (e.g., 'bioio_czi') + and imports the Reader class. - Returns plugin names in priority order (highest priority first). - This order is used by ReaderPluginManager when selecting readers. + Parameters + ---------- + reader_name : str + Name of the reader plugin (e.g., 'bioio-czi', 'bioio-ome-tiff') Returns ------- - list of str - Plugin names in priority order + Reader + The Reader class from the plugin module + + Raises + ------ + ImportError + If the reader module cannot be imported (plugin not installed) + AttributeError + If the module doesn't have a Reader attribute Examples -------- - >>> from ndevio._bioio_plugin_utils import get_reader_priority - >>> priority = get_reader_priority() - >>> print(priority[0]) # Highest priority reader - 'bioio-ome-zarr' + >>> from ndevio._bioio_plugin_utils import get_reader_by_name + >>> reader = get_reader_by_name('bioio-ome-tiff') + >>> from ndevio import nImage + >>> img = nImage('image.tif', reader=reader) """ - return list(BIOIO_PLUGINS.keys()) + # Convert plugin name to module name (bioio-czi -> bioio_czi) + module_name = reader_name.replace('-', '_') + module = importlib.import_module(module_name) + return module.Reader def format_plugin_installation_message( diff --git a/src/ndevio/_plugin_manager.py b/src/ndevio/_plugin_manager.py index ac1fc9d..f663b4f 100644 --- a/src/ndevio/_plugin_manager.py +++ b/src/ndevio/_plugin_manager.py @@ -32,8 +32,8 @@ from __future__ import annotations -import importlib import logging +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING @@ -91,31 +91,21 @@ def __init__(self, path: PathLike | None = None): standalone mode. """ self.path = Path(path) if path is not None else None - self._feasibility_report = None # Cached - self._available_plugins = None # Cached @property - def available_plugins(self) -> list[str]: + def known_plugins(self) -> list[str]: """Get all known bioio plugin names from BIOIO_PLUGINS. Returns ------- list of str List of plugin names (e.g., ['bioio-czi', 'bioio-ome-tiff', ...]). - - Examples - -------- - >>> manager = ReaderPluginManager() - >>> print(manager.available_plugins) - ['bioio-czi', 'bioio-dv', 'bioio-imageio', ...] """ - if self._available_plugins is None: - from ._bioio_plugin_utils import BIOIO_PLUGINS + from ._bioio_plugin_utils import BIOIO_PLUGINS - self._available_plugins = list(BIOIO_PLUGINS.keys()) - return self._available_plugins + return list(BIOIO_PLUGINS.keys()) - @property + @cached_property def feasibility_report(self) -> dict[str, PluginSupport]: """Get cached feasibility report for current path. @@ -128,18 +118,13 @@ def feasibility_report(self) -> dict[str, PluginSupport]: dict Mapping of plugin names to PluginSupport objects. Empty dict if no path is set. - - Notes - ----- - The report is only generated once per manager instance. Create a new - manager to refresh the report. """ - if self._feasibility_report is None and self.path: - from bioio import plugin_feasibility_report + if not self.path: + return {} - logger.debug('Generating feasibility report for: %s', self.path) - self._feasibility_report = plugin_feasibility_report(self.path) - return self._feasibility_report or {} + from bioio import plugin_feasibility_report + + return plugin_feasibility_report(self.path) @property def installed_plugins(self) -> set[str]: @@ -149,19 +134,6 @@ def installed_plugins(self) -> set[str]: ------- set of str Set of installed plugin names (excludes "ArrayLike"). - Empty set if no path is set. - - Notes - ----- - A plugin appearing in the feasibility report indicates it's installed, - regardless of whether it can read the specific file (the 'supported' - field indicates that). - - Examples - -------- - >>> manager = ReaderPluginManager("image.tif") - >>> if "bioio-ome-tiff" in manager.installed_plugins: - ... print("OME-TIFF reader is available") """ report = self.feasibility_report return {name for name in report if name != 'ArrayLike'} @@ -176,13 +148,7 @@ def suggested_plugins(self) -> list[str]: Returns ------- list of str - List of plugin names (e.g., ['bioio-czi']). Empty list if no path. - - Examples - -------- - >>> manager = ReaderPluginManager("image.czi") - >>> print(manager.suggested_plugins) - ['bioio-czi'] + List of plugin names (e.g., ['bioio-czi']). """ if not self.path: return [] @@ -203,12 +169,6 @@ def installable_plugins(self) -> list[str]: list of str List of plugin names that should be installed. Empty list if no path is set or all suitable plugins are installed. - - Examples - -------- - >>> manager = ReaderPluginManager("image.czi") - >>> if manager.installable_plugins: - ... print(f"Install: pip install {manager.installable_plugins[0]}") """ from ._bioio_plugin_utils import BIOIO_PLUGINS @@ -250,15 +210,9 @@ def get_working_reader( The priority order is determined by the ordering of BIOIO_PLUGINS in _bioio_plugin_utils.py, which prioritizes readers based on metadata preservation quality, reliability, and known issues. - - Examples - -------- - >>> manager = ReaderPluginManager("image.tif") - >>> reader = manager.get_working_reader(preferred_reader="bioio-ome-tiff") - >>> if reader: - ... from bioio import BioImage - ... img = BioImage("image.tif", reader=reader) """ + from ._bioio_plugin_utils import get_reader_by_name + if not self.path: logger.warning( 'Cannot get working reader without a path. ' @@ -279,19 +233,17 @@ def get_working_reader( preferred_reader, self.path, ) - return self._get_reader_module(preferred_reader) + return get_reader_by_name(preferred_reader) # Try readers in priority order from BIOIO_PLUGINS - from ._bioio_plugin_utils import get_reader_priority - - for reader_name in get_reader_priority(): + for reader_name in self.known_plugins: if reader_name in report and report[reader_name].supported: logger.info( 'Using reader: %s for %s (from priority list)', reader_name, self.path, ) - return self._get_reader_module(reader_name) + return get_reader_by_name(reader_name) # Try any other installed reader that supports the file for name, support in report.items(): @@ -301,7 +253,7 @@ def get_working_reader( name, self.path, ) - return self._get_reader_module(name) + return get_reader_by_name(name) logger.warning('No working reader found for: %s', self.path) return None @@ -315,14 +267,7 @@ def get_installation_message(self) -> str: Returns ------- str - Formatted message with installation suggestions. Empty string if - no path is set. - - Examples - -------- - >>> manager = ReaderPluginManager("image.czi") - >>> if not manager.get_working_reader(): - ... print(manager.get_installation_message()) + Formatted message with installation suggestions. """ if not self.path: return '' @@ -335,28 +280,3 @@ def get_installation_message(self) -> str: installed_plugins=self.installed_plugins, installable_plugins=self.installable_plugins, ) - - @staticmethod - def _get_reader_module(reader_name: str) -> Reader: - """Import and return reader class. - - Parameters - ---------- - reader_name : str - Name of the reader plugin (e.g., "bioio-czi") - - Returns - ------- - Reader - The Reader class from the plugin module - - Raises - ------ - ImportError - If the reader module cannot be imported - """ - # Convert plugin name to module name (bioio-czi -> bioio_czi) - module_name = reader_name.replace('-', '_') - logger.debug('Importing reader module: %s', module_name) - module = importlib.import_module(module_name) - return module.Reader diff --git a/src/ndevio/widgets/_plugin_install_widget.py b/src/ndevio/widgets/_plugin_install_widget.py index 84c91a2..7443cc9 100644 --- a/src/ndevio/widgets/_plugin_install_widget.py +++ b/src/ndevio/widgets/_plugin_install_widget.py @@ -94,7 +94,7 @@ def _init_widgets(self): self.append(self._info_label) # Get all available plugin names from manager - plugin_names = self.manager.available_plugins + plugin_names = self.manager.known_plugins self._plugin_select = ComboBox( label='Plugin', diff --git a/tests/test_bioio_plugin_utils.py b/tests/test_bioio_plugin_utils.py index 61bb072..c797351 100644 --- a/tests/test_bioio_plugin_utils.py +++ b/tests/test_bioio_plugin_utils.py @@ -1,5 +1,7 @@ """Tests for _bioio_plugin_utils module.""" +import logging + class TestSuggestPluginsForPath: """Test suggest_plugins_for_path function.""" @@ -117,6 +119,27 @@ def test_manager_excludes_core_plugins_from_installable(self): # bioio-tiff-glob is not core, should be installable assert 'bioio-tiff-glob' in installable_plugins + def test_get_working_reader_no_path_(self, caplog): + from ndevio._plugin_manager import ReaderPluginManager + + manager = ReaderPluginManager() # No path + + with caplog.at_level(logging.WARNING): + result = manager.get_working_reader() + + assert result is None + assert 'Cannot get working reader without a path' in caplog.text + + def test_get_installation_message_no_path_returns_empty(self): + """Test that get_installation_message returns empty string without path.""" + from ndevio._plugin_manager import ReaderPluginManager + + manager = ReaderPluginManager() # No path + + install_msg = manager.get_installation_message() + + assert install_msg == '' + class TestFormatPluginInstallationMessage: """Test format_plugin_installation_message function.""" diff --git a/tests/test_plugin_installer.py b/tests/test_plugin_installer.py index 6a31ad4..2af509c 100644 --- a/tests/test_plugin_installer.py +++ b/tests/test_plugin_installer.py @@ -88,7 +88,7 @@ def test_standalone_mode(self, make_napari_viewer): widget = PluginInstallerWidget() # Should have ALL plugins available via manager - assert len(widget.manager.available_plugins) > 0 + assert len(widget.manager.known_plugins) > 0 # Should not have path assert widget.manager.path is None @@ -115,7 +115,7 @@ def test_error_mode_with_installable_plugins(self, make_napari_viewer): widget = PluginInstallerWidget(plugin_manager=manager) # Should have ALL plugins available - assert len(widget.manager.available_plugins) > 0 + assert len(widget.manager.known_plugins) > 0 # Should have installable plugins installable = widget.manager.installable_plugins @@ -148,7 +148,7 @@ def test_error_mode_no_installable_plugins(self, make_napari_viewer): widget = PluginInstallerWidget(plugin_manager=manager) # Should still have ALL plugins available - assert len(widget.manager.available_plugins) > 0 + assert len(widget.manager.known_plugins) > 0 # No installable plugins (core already installed or unsupported format) # So no pre-selection or pre-select first available @@ -162,7 +162,7 @@ def test_widget_without_viewer(self): widget = PluginInstallerWidget() # Widget should have all plugins via manager - assert len(widget.manager.available_plugins) > 0 + assert len(widget.manager.known_plugins) > 0 class TestInstallPlugin: diff --git a/tests/test_plugin_installer_integration.py b/tests/test_plugin_installer_integration.py index daec073..b7268c5 100644 --- a/tests/test_plugin_installer_integration.py +++ b/tests/test_plugin_installer_integration.py @@ -209,7 +209,7 @@ def test_widget_shows_all_plugins(self, make_napari_viewer): widget = PluginInstallerWidget() # Should have all plugins from manager's available_plugins - plugin_names = widget.manager.available_plugins + plugin_names = widget.manager.known_plugins expected_names = list(BIOIO_PLUGINS.keys()) assert set(plugin_names) == set(expected_names)