Skip to content
Open
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
31 changes: 24 additions & 7 deletions monai/transforms/io/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""
A collection of "vanilla" transforms for IO functions.
"""

from __future__ import annotations

import inspect
Expand Down Expand Up @@ -209,15 +210,30 @@ def __init__(
the_reader = look_up_option(_r.lower(), SUPPORTED_READERS)
try:
self.register(the_reader(*args, **kwargs))
except OptionalImportError:
warnings.warn(
f"required package for reader {_r} is not installed, or the version doesn't match requirement."
)
except OptionalImportError as e:
raise RuntimeError(
f"The required package for reader '{_r}' is not installed, or the version doesn't match "
f"the requirement. If you want to use '{_r}', please install the required package. "
f"If you want to use an alternative reader, do not specify the `reader` argument."
) from e
except TypeError: # the reader doesn't have the corresponding args/kwargs
warnings.warn(f"{_r} is not supported with the given parameters {args} {kwargs}.")
warn_msg = f"{_r} is not supported with the given parameters {args} {kwargs}."
warnings.warn(warn_msg, stacklevel=2)
self.register(the_reader())
elif inspect.isclass(_r):
self.register(_r(*args, **kwargs))
try:
self.register(_r(*args, **kwargs))
except OptionalImportError as e:
reader_name = getattr(_r, "__name__", str(_r))
raise RuntimeError(
f"The required package for reader '{reader_name}' is not installed, or the version doesn't match "
f"the requirement. If you want to use '{reader_name}', please install the required package. "
f"If you want to use an alternative reader, do not specify the `reader` argument."
) from e
except TypeError: # the reader doesn't have the corresponding args/kwargs
warn_msg = f"{_r.__name__} is not supported with the given parameters {args} {kwargs}."
warnings.warn(warn_msg, stacklevel=2)
self.register(_r())
else:
self.register(_r) # reader instance, ignoring the constructor args/kwargs
return
Expand All @@ -231,7 +247,8 @@ def register(self, reader: ImageReader):

"""
if not isinstance(reader, ImageReader):
warnings.warn(f"Preferably the reader should inherit ImageReader, but got {type(reader)}.")
warn_msg = f"Preferably the reader should inherit ImageReader, but got {type(reader)}."
warnings.warn(warn_msg, stacklevel=3)
self.readers.append(reader)

def __call__(self, filename: Sequence[PathLike] | PathLike, reader: ImageReader | None = None):
Expand Down
6 changes: 3 additions & 3 deletions tests/data/test_init_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ def test_load_image(self):
self.assertIsInstance(instance1, LoadImage)
self.assertIsInstance(instance2, LoadImage)

for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", None]:
inst = LoadImaged("image", reader=r)
self.assertIsInstance(inst, LoadImaged)
# Test with None (auto-select) - should always work
inst = LoadImaged("image", reader=None)
self.assertIsInstance(inst, LoadImaged)

@SkipIfNoModule("nibabel")
@SkipIfNoModule("cupy")
Expand Down
67 changes: 66 additions & 1 deletion tests/transforms/test_load_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

import nibabel as nib
import numpy as np
Expand All @@ -28,7 +29,7 @@
from monai.data.meta_obj import set_track_meta
from monai.data.meta_tensor import MetaTensor
from monai.transforms import LoadImage
from monai.utils import optional_import
from monai.utils import OptionalImportError, optional_import
from tests.test_utils import SkipIfNoModule, assert_allclose, skip_if_downloading_fails, testing_data_config

itk, has_itk = optional_import("itk", allow_namespace_pkg=True)
Expand Down Expand Up @@ -498,5 +499,69 @@ def test_correct(self, input_param, expected_shape, track_meta):
self.assertFalse(hasattr(r, "affine"))


class TestLoadImageMissingReader(unittest.TestCase):
"""Test that LoadImage raises RuntimeError when a user-specified reader is not installed."""

def test_explicit_reader_not_installed_raises_runtime_error(self):
"""When the user explicitly names a reader whose package is missing, a RuntimeError must be raised."""
# Patch the reader class so that instantiation raises OptionalImportError,
# simulating a missing optional dependency (e.g. itk not installed).
with patch("monai.data.ITKReader.__init__", side_effect=OptionalImportError("itk")):
with self.assertRaises(RuntimeError) as ctx:
LoadImage(reader="ITKReader")
self.assertIn("ITKReader", str(ctx.exception))
self.assertIn("not installed", str(ctx.exception))

def test_explicit_class_reader_not_installed_raises_runtime_error(self):
"""Explicit class reader raises RuntimeError when package is missing."""
# This tests the class path (not string path) to ensure consistent behavior
with patch("monai.data.ITKReader.__init__", side_effect=OptionalImportError("itk")):
with self.assertRaises(RuntimeError) as ctx:
LoadImage(reader=ITKReader)
self.assertIn("ITKReader", str(ctx.exception))
self.assertIn("not installed", str(ctx.exception))

def test_unspecified_reader_falls_back_silently(self):
"""When no reader is specified, missing optional readers should be silently skipped (no exception)."""
# Force the fallback path by simulating missing optional dependencies.
# Patch the constructor to raise OptionalImportError for some readers,
# then verify LoadImage still instantiates and logs warnings.
from monai.transforms.io.array import SUPPORTED_READERS

# Patch a few readers to fail (e.g., ITKReader)
try:
original_itk = SUPPORTED_READERS.get("itkreader")

def failing_reader(*args, **kwargs):
raise OptionalImportError("itk not installed")

# Temporarily replace ITKReader with a failing version
SUPPORTED_READERS["itkreader"] = failing_reader

# Capture log output to verify warn-and-skip was invoked
with self.assertLogs("LoadImage", level="DEBUG") as cm:
loader = LoadImage()
self.assertIsInstance(loader, LoadImage)

# Verify we got the expected debug log about skipping the missing reader
self.assertTrue(
any("not installed" in msg for msg in cm.output),
f"Expected 'not installed' in debug logs, got: {cm.output}",
)
finally:
# Restore or remove the reader depending on whether it existed originally
if original_itk is not None:
SUPPORTED_READERS["itkreader"] = original_itk
else:
# Remove the entry if it didn't exist originally
SUPPORTED_READERS.pop("itkreader", None)

def test_explicit_reader_available_succeeds(self):
"""When the user explicitly names a reader whose package IS installed, no exception is raised."""
# NibabelReader is always available (nibabel is a core dep)
loader = LoadImage(reader="NibabelReader")
self.assertIsInstance(loader, LoadImage)


if __name__ == "__main__":
unittest.main()
Loading