From 94f317ad9e8933cc4cc47c1da43a5197c94dbfaf Mon Sep 17 00:00:00 2001 From: NiketGirdhar22 Date: Mon, 13 Jul 2026 03:56:07 +0530 Subject: [PATCH] Improve missing image writer dependency hints Signed-off-by: NiketGirdhar22 --- monai/data/image_writer.py | 22 ++++++++++++++++-- monai/utils/module.py | 2 +- tests/data/test_image_rw.py | 40 ++++++++++++++++++++++++++++++++- tests/utils/test_require_pkg.py | 3 ++- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py index cc6cdcdead1..52f34abdc83 100644 --- a/monai/data/image_writer.py +++ b/monai/data/image_writer.py @@ -63,6 +63,9 @@ SUPPORTED_WRITERS: dict = {} +# Map import names to their corresponding Python distribution names where they differ. +_PACKAGE_INSTALL_NAMES = {"PIL": "pillow"} + def register_writer(ext_name, *im_writers): """ @@ -99,6 +102,10 @@ def resolve_writer(ext_name, error_if_not_found=True) -> Sequence: As an indexing key it will be converted to a lower case string. error_if_not_found: whether to raise an error if no suitable image writer is found. if True , raise an ``OptionalImportError``, otherwise return an empty tuple. Default is ``True``. + + Raises: + OptionalImportError: When no registered writer is available. If candidate writers require missing optional + dependencies, the error includes commands for installing them. """ if not SUPPORTED_WRITERS: init() @@ -106,17 +113,28 @@ def resolve_writer(ext_name, error_if_not_found=True) -> Sequence: if fmt.startswith("."): fmt = fmt[1:] avail_writers = [] + missing_packages: set[str] = set() default_writers = SUPPORTED_WRITERS.get(EXT_WILDCARD, ()) for _writer in look_up_option(fmt, SUPPORTED_WRITERS, default=default_writers): try: _writer() # this triggers `monai.utils.module.require_pkg` to check the system availability avail_writers.append(_writer) - except OptionalImportError: + except OptionalImportError as error: + if error.name: + missing_packages.add(_PACKAGE_INSTALL_NAMES.get(error.name, error.name)) continue except Exception: # other writer init errors indicating it exists avail_writers.append(_writer) if not avail_writers and error_if_not_found: - raise OptionalImportError(f"No ImageWriter backend found for {fmt}.") + message = f"No ImageWriter backend found for {fmt}." + packages = sorted(missing_packages) + if len(packages) == 1: + package = packages[0] + message += f" Install `{package}` with `pip install {package}`." + elif packages: + hints = ", ".join(f"`{package}` (`pip install {package}`)" for package in packages) + message += f" Install one of the following packages: {hints}." + raise OptionalImportError(message) writer_tuple = ensure_tuple(avail_writers) SUPPORTED_WRITERS[fmt] = writer_tuple return writer_tuple diff --git a/monai/utils/module.py b/monai/utils/module.py index a2569b19fed..df710a0db8e 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -476,7 +476,7 @@ def _wrapper(*args, **kwargs): if not has: err_msg = f"required package `{pkg_name}` is not installed or the version doesn't match requirement." if raise_error: - raise OptionalImportError(err_msg) + raise OptionalImportError(err_msg, name=pkg_name) else: warnings.warn(err_msg) diff --git a/tests/data/test_image_rw.py b/tests/data/test_image_rw.py index d90c1c85711..48504863e8a 100644 --- a/tests/data/test_image_rw.py +++ b/tests/data/test_image_rw.py @@ -16,13 +16,21 @@ import shutil import tempfile import unittest +from unittest.mock import patch import numpy as np import torch from parameterized import parameterized from monai.data.image_reader import ITKReader, NibabelReader, NrrdReader, PILReader -from monai.data.image_writer import ITKWriter, NibabelWriter, PILWriter, register_writer, resolve_writer +from monai.data.image_writer import ( + SUPPORTED_WRITERS, + ITKWriter, + NibabelWriter, + PILWriter, + register_writer, + resolve_writer, +) from monai.data.meta_tensor import MetaTensor from monai.transforms import LoadImage, SaveImage, moveaxis from monai.utils import MetaKeys, OptionalImportError, optional_import @@ -150,6 +158,36 @@ def test_1_new(self): register_writer("new2", lambda x: x + 1) self.assertEqual(resolve_writer("new")[0](0), 1) + def test_missing_writer_install_hint(self): + def missing_pillow(): + raise OptionalImportError("PIL is unavailable", name="PIL") + + with patch.dict(SUPPORTED_WRITERS, {"png": (missing_pillow,)}, clear=True): + with self.assertRaisesRegex(OptionalImportError, r"Install `pillow` with `pip install pillow`"): + resolve_writer(".PNG") + + def test_multiple_missing_writer_install_hints(self): + def missing_itk(): + raise OptionalImportError("ITK is unavailable", name="itk") + + def missing_nibabel(): + raise OptionalImportError("Nibabel is unavailable", name="nibabel") + + with patch.dict(SUPPORTED_WRITERS, {"nii": (missing_nibabel, missing_itk)}, clear=True): + with self.assertRaises(OptionalImportError) as context: + resolve_writer("nii") + self.assertIn("Install one of the following packages", str(context.exception)) + self.assertIn("`itk` (`pip install itk`)", str(context.exception)) + self.assertIn("`nibabel` (`pip install nibabel`)", str(context.exception)) + + def test_missing_writer_without_package_name(self): + def missing_unknown(): + raise OptionalImportError("Unknown dependency") + + with patch.dict(SUPPORTED_WRITERS, {"unknown": (missing_unknown,)}, clear=True): + with self.assertRaisesRegex(OptionalImportError, r"^No ImageWriter backend found for unknown\.$"): + resolve_writer("unknown") + @unittest.skipUnless(has_itk, "itk not installed") class TestLoadSaveNrrd(unittest.TestCase): diff --git a/tests/utils/test_require_pkg.py b/tests/utils/test_require_pkg.py index 065a7509a4a..71d082aeafa 100644 --- a/tests/utils/test_require_pkg.py +++ b/tests/utils/test_require_pkg.py @@ -43,13 +43,14 @@ def test_func(x): test_func(x=None) def test_class_exception(self): - with self.assertRaises(OptionalImportError): + with self.assertRaises(OptionalImportError) as context: @require_pkg(pkg_name="test123") class TestClass: pass TestClass() + self.assertEqual(context.exception.name, "test123") def test_class_version_exception(self): with self.assertRaises(OptionalImportError):