Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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.12.5
hooks:
Expand All @@ -20,7 +22,7 @@ repos:
hooks:
- id: napari-plugin-checks
- repo: https://github.com/ndev-kit/ndev-settings
rev: v0.3.0
rev: v0.4.0
hooks:
- id: reset-settings-values
# https://mypy.readthedocs.io/en/stable/
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ This package is a **spiritual successor to napari-aicsimageio** but must be inde
The reader selection follows a priority hierarchy:

1. **Explicit reader parameter** - If passed directly to function
2. **User preference from settings** - `ndev_settings.ndevio_Reader.preferred_reader`
2. **User preference from settings** - `ndev_settings.ndevio_reader.preferred_reader`
3. **bioio feasibility check** - Verify preferred reader supports the file
4. **bioio auto-detection** - Fallback to bioio's plugin determination

Expand Down Expand Up @@ -282,7 +282,7 @@ ndevio/
Settings defined in `ndev_settings.yaml`:

```yaml
ndevio_Reader:
ndevio_reader:
preferred_reader:
default: bioio-ome-tiff
dynamic_choices:
Expand All @@ -308,7 +308,7 @@ ndevio_Reader:
from ndev_settings import get_settings

settings = get_settings()
preferred = settings.ndevio_Reader.preferred_reader
preferred = settings.ndevio_reader.preferred_reader
```

---
Expand Down Expand Up @@ -562,7 +562,7 @@ def get_reader(path):
# CORRECT
def get_preferred_reader(image, preferred_reader=None):
settings = get_settings()
preferred = preferred_reader or settings.ndevio_Reader.preferred_reader
preferred = preferred_reader or settings.ndevio_reader.preferred_reader

# Use bioio's feasibility check
fr = bioio.plugin_feasibility_report(image)
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ requires-python = ">=3.11"
# or any other Qt bindings directly (e.g. PyQt5, PySide2).
# See best practices: https://napari.org/stable/plugins/building_a_plugin/best_practices.html
dependencies = [
"ndev-settings",
"napari",
"ndev-settings>=0.4.0",
"nbatch>=0.0.4",
"natsort",
"magicgui",
"magic-class",
"xarray",
"bioio-base",
"bioio>=2", # migrates writers to plugins
Expand Down
34 changes: 30 additions & 4 deletions src/ndevio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,34 @@
except ImportError:
__version__ = "unknown"

from ._napari_reader import napari_get_reader
from ._plugin_manager import ReaderPluginManager
from .nimage import nImage
# Lazy imports for performance - heavy modules loaded on first access
_lazy_imports = {
"helpers": ".helpers",
"nImage": ".nimage",
"napari_get_reader": "._napari_reader",
"ReaderPluginManager": "._plugin_manager",
}

__all__ = ["__version__", "nImage", "napari_get_reader", "ReaderPluginManager"]

def __getattr__(name: str):
"""Lazily import modules/objects to speed up package import."""
if name in _lazy_imports:
import importlib

module_path = _lazy_imports[name]
module = importlib.import_module(module_path, __name__)
# For module imports (helpers), return the module
if name == "helpers":
return module
# For class/function imports, get the attribute from the module
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


__all__ = [
"__version__",
"helpers",
"nImage",
"napari_get_reader",
"ReaderPluginManager",
]
6 changes: 3 additions & 3 deletions src/ndevio/_napari_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ def napari_get_reader(
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.ndevio_reader.scene_handling == "View First Scene Only" # type: ignore
) 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.ndevio_reader.scene_handling == "View All Scenes" # type: ignore
) or False

if isinstance(path, list):
Expand All @@ -78,7 +78,7 @@ def napari_get_reader(
# determine_reader_plugin() already enhanced the error message
logger.error("ndevio: Unsupported file format: %s", path)
# Show plugin installer widget if enabled in settings
if settings.ndevio_Reader.suggest_reader_plugins: # type: ignore
if settings.ndevio_reader.suggest_reader_plugins: # type: ignore
_open_plugin_installer(path, e)

# Return None per napari reader spec - don't raise exception
Expand Down
259 changes: 259 additions & 0 deletions src/ndevio/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
"""
Helper functions for file handling and image processing.

Functions
---------
get_directory_and_files : Get the directory and files in the specified directory.
get_channel_names : Get the channel names from a BioImage object.
get_squeezed_dim_order : Return a string containing the squeezed dimensions of the given BioImage object.
create_id_string : Create an ID string for the given image.
check_for_missing_files : Check if the given files are missing in the specified directories.
elide_string : Elide a string if it exceeds the specified length.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from bioio import BioImage

from ndevio import nImage

__all__ = [
"check_for_missing_files",
"create_id_string",
"elide_string",
"get_channel_names",
"get_directory_and_files",
"get_squeezed_dim_order",
]


def get_directory_and_files(
dir_path: str | Path | None = None,
pattern: list[str] | str | None = None,
) -> tuple[Path | None, list[Path]]:
"""
Get the directory and files in the specified directory.

Parameters
----------
dir_path : str or Path or None, optional
The directory path.
pattern : list of str or str or None, optional
The file pattern(s) to match. If a string is provided, it will be
treated as a single pattern. If a list is provided, each element
will be treated as a separate pattern.
Defaults to common image formats: tif, tiff, nd2, czi, lif, oib, png,
jpg, jpeg, bmp, gif.

Returns
-------
tuple of (Path or None, list of Path)
A tuple containing the directory path and a list of file paths.

"""
if pattern is None:
pattern = [
"tif",
"tiff",
"nd2",
"czi",
"lif",
"oib",
"png",
"jpg",
"jpeg",
"bmp",
"gif",
]
if dir_path is None:
return None, []

directory = Path(dir_path)

if dir_path is not None and not directory.exists():
raise FileNotFoundError(f"Directory {dir_path} does not exist.")

pattern = [pattern] if isinstance(pattern, str) else pattern
# add *. to each pattern if it doesn't already have either
pattern_glob = []
for pat in pattern:
if "." not in pat:
pat = f"*.{pat}"
if "*" not in pat:
pat = f"*{pat}"
pattern_glob.append(pat)

files = []
for p_glob in pattern_glob:
for file in directory.glob(p_glob):
files.append(file)
return directory, files


def get_channel_names(img: nImage | BioImage) -> list[str]:
"""
Get the channel names from a BioImage object.

If the image has a dimension order that includes "S" (it is RGB),
return the default channel names ["red", "green", "blue"].
Otherwise, return the channel names from the image.

Parameters
----------
img : nImage or BioImage
The image object.

Returns
-------
list of str
The channel names.

"""
if "S" in img.dims.order:
return ["red", "green", "blue"]
# Ensure we have plain Python strings, not numpy string types
return [str(c) for c in img.channel_names]


def get_squeezed_dim_order(
img: nImage | BioImage,
skip_dims: tuple[str, ...] | list[str] | str = ("C", "S"),
) -> str:
"""
Return a string containing the squeezed dimensions of the given BioImage.

Parameters
----------
img : nImage or BioImage
The image object.
skip_dims : tuple of str or list of str or str
Dimensions to skip. Defaults to ("C", "S").

Returns
-------
str
A string containing the squeezed dimensions.

"""
if isinstance(skip_dims, str):
skip_dims = (skip_dims,)
return "".join(
{k: v for k, v in img.dims.items() if v > 1 and k not in skip_dims}
)


def create_id_string(img: nImage | BioImage, identifier: str) -> str:
"""
Create an ID string for the given image.

Parameters
----------
img : nImage or BioImage
The image object.
identifier : str
The identifier string.

Returns
-------
str
The ID string in format: '{identifier}__{scene_idx}__{scene_name}'.

Examples
--------
>>> create_id_string(img, 'test')
'test__0__Scene:0'

"""
scene_idx = img.current_scene_index
# Use ome_metadata.name because this gets saved with OmeTiffWriter
try:
if img.ome_metadata.images[scene_idx].name is None:
scene = img.current_scene
else:
scene = img.ome_metadata.images[scene_idx].name
except NotImplementedError:
scene = img.current_scene # not useful with OmeTiffReader, atm
id_string = f"{identifier}__{scene_idx}__{scene}"
return id_string


def check_for_missing_files(
files: list[Path] | list[str], *directories: Path | str
) -> list[tuple[str, str]]:
"""
Check if the given files are missing in the specified directories.

Parameters
----------
files : list of Path or list of str
List of files to check.
directories : Path or str
Directories to search for the files.

Returns
-------
list of tuple
List of tuples containing (file_name, directory_name) for missing files.

"""
missing_files = []
for file in files:
for directory in directories:
if isinstance(directory, str):
directory = Path(directory)
if isinstance(file, str):
file = Path(file)

file_loc = directory / file.name
if not file_loc.exists():
missing_files.append((file.name, directory.name))

return missing_files


def elide_string(
input_string: str, max_length: int = 15, location: str = "middle"
) -> str:
"""
Elide a string if it exceeds the specified length.

Parameters
----------
input_string : str
The input string.
max_length : int, optional
The maximum length of the string. Defaults to 15.
location : str, optional
The location to elide the string. Can be 'start', 'middle', or 'end'.
Defaults to 'middle'.

Returns
-------
str
The elided string.

Raises
------
ValueError
If location is not 'start', 'middle', or 'end'.

"""
# If the string is already shorter than the max length, return it
if len(input_string) <= max_length:
return input_string
# If max_length is too small, just truncate
if max_length <= 5:
return input_string[:max_length]
# Elide the string based on the location
if location == "start":
return "..." + input_string[-(max_length - 3) :]
if location == "end":
return input_string[: max_length - 3] + "..."
if location == "middle":
half_length = (max_length - 3) // 2
return input_string[:half_length] + "..." + input_string[-half_length:]
raise ValueError('Invalid location. Must be "start", "middle", or "end".')
Loading