diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 0628a7fbc4..7118262a6d 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -11,6 +11,7 @@ """ A collection of "vanilla" transforms for IO functions. """ + from __future__ import annotations import inspect @@ -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 @@ -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): diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 4170412207..18c6627cd1 100644 --- a/tests/data/test_init_reader.py +++ b/tests/data/test_init_reader.py @@ -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") diff --git a/tests/transforms/test_load_image.py b/tests/transforms/test_load_image.py index 031e38272e..b8d99ca0c2 100644 --- a/tests/transforms/test_load_image.py +++ b/tests/transforms/test_load_image.py @@ -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 @@ -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) @@ -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()