Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 35 additions & 13 deletions src/ndevio/_bioio_plugin_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
116 changes: 18 additions & 98 deletions src/ndevio/_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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]:
Expand All @@ -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'}
Expand All @@ -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 []
Expand All @@ -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

Expand Down Expand Up @@ -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. '
Expand All @@ -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():
Expand All @@ -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
Expand All @@ -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 ''
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/ndevio/widgets/_plugin_install_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
23 changes: 23 additions & 0 deletions tests/test_bioio_plugin_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for _bioio_plugin_utils module."""

import logging


class TestSuggestPluginsForPath:
"""Test suggest_plugins_for_path function."""
Expand Down Expand Up @@ -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."""
Expand Down
8 changes: 4 additions & 4 deletions tests/test_plugin_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_plugin_installer_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down