diff --git a/.gitattributes b/.gitattributes index 887a2c1..b5c1db7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +# Normalize all text files to LF on commit +* text=auto eol=lf + # SCM syntax highlighting & preventing 3-way merges pixi.lock merge=binary linguist-language=YAML linguist-generated=true diff --git a/.gitignore b/.gitignore index 75fb276..1f0dd19 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ target/ .idea/ venv/ .vscode/ +.venv*/ # IPython Notebook .ipynb_checkpoints diff --git a/src/ndevio/__init__.py b/src/ndevio/__init__.py index 0442a07..6449e16 100644 --- a/src/ndevio/__init__.py +++ b/src/ndevio/__init__.py @@ -5,19 +5,21 @@ except ImportError: __version__ = 'unknown' -from .utils import helpers - -# Type stub for lazy import - lets type checkers know nImage exists if TYPE_CHECKING: from .nimage import nImage as nImage + from .utils import helpers as helpers -def __getattr__(name: str): - """Lazily import nImage to speed up package import.""" +def __getattr__(name: str) -> object: + """Lazily import heavy submodules to speed up package import.""" if name == 'nImage': from .nimage import nImage return nImage + if name == 'helpers': + from .utils import helpers + + return helpers raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/src/ndevio/_napari_reader.py b/src/ndevio/_napari_reader.py index a19e0f8..783cf2f 100644 --- a/src/ndevio/_napari_reader.py +++ b/src/ndevio/_napari_reader.py @@ -4,13 +4,11 @@ from functools import partial from typing import TYPE_CHECKING -from ndev_settings import get_settings - -from .nimage import nImage - if TYPE_CHECKING: from napari.types import LayerDataTuple, PathLike, ReaderFunction + from .nimage import nImage + logger = logging.getLogger(__name__) @@ -40,6 +38,8 @@ def napari_get_reader( The reader function for the given path """ + from ndev_settings import get_settings + settings = get_settings() open_first_scene_only = ( @@ -93,6 +93,8 @@ def napari_reader_function( """ from bioio_base.exceptions import UnsupportedFileFormatError + from .nimage import nImage + try: img = nImage(path) # nImage handles preferred reader and fallback except UnsupportedFileFormatError: @@ -125,7 +127,7 @@ def _open_scene_container(path: PathLike, img: nImage) -> None: import napari - from .widgets import DELIMITER, nImageSceneWidget + from .widgets._scene_widget import DELIMITER, nImageSceneWidget viewer = napari.current_viewer() viewer.window.add_dock_widget( @@ -155,7 +157,7 @@ def _open_plugin_installer(path: PathLike) -> None: from bioio_base.exceptions import UnsupportedFileFormatError from .bioio_plugins._manager import ReaderPluginManager - from .widgets import PluginInstallerWidget + from .widgets._plugin_install_widget import PluginInstallerWidget # Get viewer, handle case where no viewer available viewer = napari.current_viewer() diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index da3ccdc..caad56c 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -10,28 +10,28 @@ contributions: python_name: ndevio._napari_reader:napari_get_reader title: Open file with ndevio - id: ndevio.make_plugin_installer_widget - python_name: ndevio.widgets:PluginInstallerWidget + python_name: ndevio.widgets._plugin_install_widget:PluginInstallerWidget title: Install BioIO Reader Plugins - id: ndevio.make_utilities_widget - python_name: ndevio.widgets:UtilitiesContainer + python_name: ndevio.widgets._utilities_container:UtilitiesContainer title: I/O Utilities - id: ndevio.make_ndev_logo - python_name: ndevio.sampledata:ndev_logo + python_name: ndevio.sampledata._sample_data:ndev_logo title: Load ndev logo - id: ndevio.make_scratch_assay - python_name: ndevio.sampledata:scratch_assay + python_name: ndevio.sampledata._sample_data:scratch_assay title: Load scratch assay data - id: ndevio.make_neocortex - python_name: ndevio.sampledata:neocortex + python_name: ndevio.sampledata._sample_data:neocortex title: Load neocortex data - id: ndevio.make_neuron_raw - python_name: ndevio.sampledata:neuron_raw + python_name: ndevio.sampledata._sample_data:neuron_raw title: Load raw neuron data - id: ndevio.make_neuron_labels - python_name: ndevio.sampledata:neuron_labels + python_name: ndevio.sampledata._sample_data:neuron_labels title: Load neuron labels data - id: ndevio.make_neuron_labels_processed - python_name: ndevio.sampledata:neuron_labels_processed + python_name: ndevio.sampledata._sample_data:neuron_labels_processed title: Load processed neuron labels data readers: - command: ndevio.get_reader diff --git a/src/ndevio/nimage.py b/src/ndevio/nimage.py index c1f54f6..38616d3 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -7,8 +7,6 @@ from typing import TYPE_CHECKING from bioio import BioImage -from bioio_base.reader import Reader -from bioio_base.types import ImageLike from .bioio_plugins._manager import raise_unsupported_with_suggestions from .utils._layer_utils import ( diff --git a/src/ndevio/sampledata/__init__.py b/src/ndevio/sampledata/__init__.py index 654872d..9746753 100644 --- a/src/ndevio/sampledata/__init__.py +++ b/src/ndevio/sampledata/__init__.py @@ -1,19 +1 @@ """Sample data for ndevio and the ndev-kit ecosystem.""" - -from ndevio.sampledata._sample_data import ( - ndev_logo, - neocortex, - neuron_labels, - neuron_labels_processed, - neuron_raw, - scratch_assay, -) - -__all__ = [ - 'ndev_logo', - 'neocortex', - 'neuron_labels', - 'neuron_labels_processed', - 'neuron_raw', - 'scratch_assay', -] diff --git a/src/ndevio/sampledata/_sample_data.py b/src/ndevio/sampledata/_sample_data.py index 433c4fd..74cd289 100644 --- a/src/ndevio/sampledata/_sample_data.py +++ b/src/ndevio/sampledata/_sample_data.py @@ -10,12 +10,6 @@ from pathlib import Path from typing import TYPE_CHECKING -import pooch -from bioio_imageio import Reader as ImageIOReader -from bioio_ome_tiff import Reader as OmeTiffReader - -from ndevio import nImage - if TYPE_CHECKING: from napari.types import LayerDataTuple @@ -24,6 +18,10 @@ def ndev_logo() -> list[LayerDataTuple]: """Load the ndev logo image.""" + from bioio_imageio import Reader as ImageIOReader + + from ..nimage import nImage + return nImage( SAMPLE_DIR / 'ndev-logo.png', reader=ImageIOReader, @@ -32,6 +30,11 @@ def ndev_logo() -> list[LayerDataTuple]: def scratch_assay() -> list[LayerDataTuple]: """Load scratch assay data with labeled nuclei and cytoplasm.""" + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + scratch_assay_raw_path = pooch.retrieve( url='doi:10.5281/zenodo.17845346/scratch-assay-labeled-10T-2Ch.tiff', known_hash='md5:2b98c4ea18cd741a1545e59855348a2f', @@ -58,6 +61,11 @@ def scratch_assay() -> list[LayerDataTuple]: def neocortex() -> list[LayerDataTuple]: """Load neocortex 3-channel image data.""" + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + neocortex_raw_path = pooch.retrieve( url='doi:10.5281/zenodo.17845346/neocortex-3Ch.tiff', known_hash='md5:eadc3fac751052461fb2e5f3c6716afa', @@ -75,6 +83,11 @@ def neuron_raw() -> list[LayerDataTuple]: This sample is downloaded from Zenodo if not present locally. """ + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + neuron_raw_path = pooch.retrieve( url='doi:10.5281/zenodo.17845346/neuron-4Ch_raw.tiff', known_hash='md5:5d3e42bca2085e8588b6f23cf89ba87c', @@ -94,6 +107,10 @@ def neuron_raw() -> list[LayerDataTuple]: def neuron_labels() -> list[LayerDataTuple]: """Load neuron labels data.""" + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + return nImage( SAMPLE_DIR / 'neuron-4Ch_labels.tiff', reader=OmeTiffReader, @@ -104,6 +121,10 @@ def neuron_labels() -> list[LayerDataTuple]: def neuron_labels_processed() -> list[LayerDataTuple]: """Load processed neuron labels data.""" + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + return nImage( SAMPLE_DIR / 'neuron-4Ch_labels_processed.tiff', reader=OmeTiffReader, diff --git a/src/ndevio/widgets/__init__.py b/src/ndevio/widgets/__init__.py index 0531491..56f8619 100644 --- a/src/ndevio/widgets/__init__.py +++ b/src/ndevio/widgets/__init__.py @@ -1,14 +1 @@ """Widgets for ndevio package.""" - -from ..bioio_plugins._manager import ReaderPluginManager -from ._plugin_install_widget import PluginInstallerWidget -from ._scene_widget import DELIMITER, nImageSceneWidget -from ._utilities_container import UtilitiesContainer - -__all__ = [ - 'PluginInstallerWidget', - 'nImageSceneWidget', - 'UtilitiesContainer', - 'DELIMITER', - 'ReaderPluginManager', -] diff --git a/tests/test_nimage.py b/tests/test_nimage.py index dd6817e..e2ea657 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -42,6 +42,40 @@ def test_nImage_zarr(resources_dir: Path): assert img.data.shape == (1, 1, 2, 4, 4) +def test_nImage_zarr_trailing_slash(resources_dir: Path): + """Test that a string path with a trailing slash is handled correctly. + + Regression test: bioio's extension-based reader detection fails when the + path ends with '/', e.g. 'store.zarr/'. nImage strips the slash on init. + See https://github.com/ndev-kit/ndevio/issues/XX + """ + path_with_slash = str(resources_dir / ZARR) + '/' + img = nImage(path_with_slash) + assert img.data is not None + # path stored without the trailing slash + assert not img.path.endswith('/') + assert img.path == str(resources_dir / ZARR) + assert img.data.shape == (1, 1, 2, 4, 4) + + +@pytest.mark.network +def test_nImage_remote_zarr_trailing_slash(): + """Test that a remote Zarr URL with a trailing slash is read correctly. + + Regression test: 'https://...9846152.zarr/' crashed with + 'Reader ndevio returned no data' because the trailing slash prevented + bioio from matching the '*.zarr' extension pattern. + """ + remote_zarr = ( + 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/' + ) + img = nImage(remote_zarr) + assert img._is_remote + assert not img.path.endswith('/') + assert img.path == remote_zarr.rstrip('/') + assert img.xarray_dask_data is not None + + @pytest.mark.network def test_nImage_remote_zarr(): """Test that nImage can read a remote Zarr file.""" diff --git a/tests/test_sampledata/test_sampledata.py b/tests/test_sampledata/test_sampledata.py index f90f149..41cb358 100644 --- a/tests/test_sampledata/test_sampledata.py +++ b/tests/test_sampledata/test_sampledata.py @@ -4,7 +4,7 @@ import pytest -from ndevio.sampledata import ( +from ndevio.sampledata._sample_data import ( ndev_logo, neocortex, neuron_labels, diff --git a/tests/test_widgets/test_plugin_installer_widget.py b/tests/test_widgets/test_plugin_installer_widget.py index 2087b4d..0889058 100644 --- a/tests/test_widgets/test_plugin_installer_widget.py +++ b/tests/test_widgets/test_plugin_installer_widget.py @@ -16,7 +16,7 @@ class TestPluginInstallerWidget: def test_standalone_mode(self): """Test widget in standalone mode - no path, shows generic title.""" - from ndevio.widgets import PluginInstallerWidget + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget widget = PluginInstallerWidget() @@ -27,7 +27,7 @@ def test_standalone_mode(self): def test_error_mode_with_path(self): """Test widget in error mode - has path, preselects suggested plugin.""" from ndevio.bioio_plugins._manager import ReaderPluginManager - from ndevio.widgets import PluginInstallerWidget + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget # Mock installed plugins to NOT include bioio-czi # This simulates the error case where file can't be read @@ -48,7 +48,7 @@ def test_error_mode_with_path(self): def test_install_button_behavior(self): """Test install button: queues installation and updates status.""" - from ndevio.widgets import PluginInstallerWidget + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget widget = PluginInstallerWidget() widget._plugin_select.value = 'bioio-imageio' @@ -64,7 +64,7 @@ def test_install_button_behavior(self): def test_install_without_selection_shows_error(self): """Test that clicking install with no selection shows error.""" - from ndevio.widgets import PluginInstallerWidget + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget widget = PluginInstallerWidget() widget._plugin_select.value = None