From 892355245d50679cf9e1bce8222bb0bafb9e1843 Mon Sep 17 00:00:00 2001 From: Muhammad Talha Date: Thu, 15 Oct 2026 18:49:46 +0500 Subject: [PATCH 1/3] Deprecate MMAR APIs and disable model loading Disable load_from_mmar before any download or model loading, retain the public signature, and deprecate the MMAR APIs for 1.7. Replace skipped network tests with offline regression coverage and update the API docs. Signed-off-by: Muhammad Talha Assisted-by: OpenAI Codex --- docs/source/apps.rst | 7 + monai/apps/mmars/mmars.py | 143 ++++------------ monai/apps/mmars/model_desc.py | 2 +- tests/apps/test_mmar_download.py | 272 ++++++++++++++++--------------- 4 files changed, 179 insertions(+), 245 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 3239dc53519..37d4b6fc76e 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -24,6 +24,13 @@ Applications `Clara MMARs` ------------- +.. deprecated:: 1.7 + NVIDIA Clara MMAR support is deprecated. Use :doc:`MONAI Bundles ` instead. + ``load_from_mmar`` is disabled for security reasons and raises + :class:`monai.utils.DeprecatedError` without downloading or loading a model. + +.. autofunction:: get_model_spec + .. autofunction:: download_mmar .. autofunction:: load_from_mmar diff --git a/monai/apps/mmars/mmars.py b/monai/apps/mmars/mmars.py index 1fc0690cc9d..e2e6116986b 100644 --- a/monai/apps/mmars/mmars.py +++ b/monai/apps/mmars/mmars.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -Utilities for accessing Nvidia MMARs +Deprecated utilities for accessing Nvidia MMARs. Use MONAI Bundles instead. See Also: - https://docs.nvidia.com/clara/clara-train-sdk/pt/mmar.html @@ -18,18 +18,13 @@ from __future__ import annotations import json -import os -import warnings from collections.abc import Mapping from pathlib import Path from typing import Any -import torch - -import monai.networks.nets as monai_nets -from monai.apps.utils import download_and_extract, logger +from monai.apps.utils import download_and_extract from monai.config.type_definitions import PathLike -from monai.networks.utils import copy_model_state +from monai.utils.deprecate_utils import DeprecatedError, deprecated from monai.utils.module import optional_import from .model_desc import MODEL_DESC @@ -38,8 +33,14 @@ __all__ = ["get_model_spec", "download_mmar", "load_from_mmar"] +@deprecated(since="1.7", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") def get_model_spec(idx: int | str) -> dict | Any: - """get model specification by `idx`. `idx` could be index of the constant tuple of dict or the actual model ID.""" + """ + Get a model specification by index or model ID. + + .. deprecated:: 1.7 + NVIDIA Clara MMAR support is deprecated. Use MONAI Bundles instead. + """ if isinstance(idx, int): return MODEL_DESC[idx] if isinstance(idx, str): @@ -99,16 +100,16 @@ def _get_ngc_url(model_name: str, version: str, model_prefix: str = "") -> str: return f"https://api.ngc.nvidia.com/v2/models/{model_prefix}{model_name}/versions/{version}/zip" -def _get_ngc_doc_url(model_name: str, model_prefix: str = "") -> str: - return f"https://ngc.nvidia.com/catalog/models/{model_prefix}{model_name}" - - +@deprecated(since="1.7", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") def download_mmar( item: str | Mapping, mmar_dir: PathLike | None = None, progress: bool = True, api: bool = True, version: int = -1 ) -> Path: """ Download and extract Medical Model Archive (MMAR) from Nvidia Clara Train. + .. deprecated:: 1.7 + NVIDIA Clara MMAR support is deprecated. Use MONAI Bundles instead. + See Also: - https://docs.nvidia.com/clara/ - Nvidia NGC Registry CLI @@ -185,6 +186,7 @@ def download_mmar( return model_dir +@deprecated(since="1.7", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") def load_from_mmar( item: Mapping | str | int, mmar_dir: PathLike | None = None, @@ -198,117 +200,30 @@ def load_from_mmar( model_file: PathLike | None = None, ) -> Any: """ - Download and extract Medical Model Archive (MMAR) model weights from Nvidia Clara Train. + Disabled loader for Medical Model Archive (MMAR) models from Nvidia Clara Train. + + .. deprecated:: 1.7 + MMAR loading is disabled for security reasons. Use MONAI Bundles instead. + + This function retains its signature for compatibility but always raises + :class:`monai.utils.DeprecatedError` without downloading or loading a model. + All arguments are ignored; their former meanings are listed below. Args: item: the corresponding model item from `MODEL_DESC`. - mmar_dir: : target directory to store the MMAR, default is mmars subfolder under `torch.hub get_dir()`. + mmar_dir: target directory to store the MMAR, default is mmars subfolder under `torch.hub get_dir()`. progress: whether to display a progress bar when downloading the content. version: version number of the MMAR. Set it to `-1` to use `item[Keys.VERSION]`. map_location: pytorch API parameter for `torch.load` or `torch.jit.load`. pretrained: whether to load the pretrained weights after initializing a network module. weights_only: whether to load only the weights instead of initializing the network module and assign weights. model_key: a key to search in the model file or config file for the model dictionary. - Currently this function assumes that the model dictionary has + The model dictionary was expected to have `{"[name|path]": "test.module", "args": {'kw': 'test'}}`. - api: whether to query NGC API to get model infomation. + api: whether to query NGC API to get model information. model_file: the relative path to the model file within an MMAR. - Examples:: - >>> from monai.apps import load_from_mmar - >>> unet_model = load_from_mmar("clara_pt_prostate_mri_segmentation_1", mmar_dir=".", map_location="cpu") - >>> print(unet_model) - - See Also: - https://docs.nvidia.com/clara/ - """ - if api: - item = {Keys.NAME: get_model_spec(item)[Keys.NAME] if isinstance(item, int) else f"{item}"} - if not isinstance(item, Mapping): - item = get_model_spec(item) - model_dir = download_mmar(item=item, mmar_dir=mmar_dir, progress=progress, version=version, api=api) - if model_file is None: - model_file = os.path.join("models", "model.pt") - _model_file = model_dir / item.get(Keys.MODEL_FILE, model_file) - logger.info(f'\n*** "{item.get(Keys.NAME)}" available at {model_dir}.') - - # loading with `torch.jit.load` - if _model_file.name.endswith(".ts"): - if not pretrained: - warnings.warn("Loading a ScriptModule, 'pretrained' option ignored.") - if weights_only: - warnings.warn("Loading a ScriptModule, 'weights_only' option ignored.") - return torch.jit.load(_model_file, map_location=map_location) - - # loading with `torch.load` - model_dict = torch.load(_model_file, map_location=map_location, weights_only=True) - if weights_only: - return model_dict.get(model_key, model_dict) # model_dict[model_key] or model_dict directly - - # 1. search `model_dict['train_config]` for model config spec. - model_config = _get_val(dict(model_dict).get("train_conf", {}), key=model_key, default={}) - if not model_config or not isinstance(model_config, Mapping): - # 2. search json CONFIG_FILE for model config spec. - json_path = model_dir / item.get(Keys.CONFIG_FILE, os.path.join("config", "config_train.json")) - with open(json_path) as f: - conf_dict = json.load(f) - conf_dict = dict(conf_dict) - model_config = _get_val(conf_dict, key=model_key, default={}) - if not model_config: - # 3. search `model_dict` for model config spec. - model_config = _get_val(dict(model_dict), key=model_key, default={}) - - if not (model_config and isinstance(model_config, Mapping)): - raise ValueError( - f"Could not load model config dictionary from config: {item.get(Keys.CONFIG_FILE)}, " - f"or from model file: {item.get(Keys.MODEL_FILE)}." - ) - - # parse `model_config` for model class and model parameters - if model_config.get("name"): # model config section is a "name" - model_name = model_config["name"] - model_cls = monai_nets.__dict__[model_name] - elif model_config.get("path"): # model config section is a "path" - # https://docs.nvidia.com/clara/clara-train-sdk/pt/byom.html - model_module, model_name = model_config.get("path", ".").rsplit(".", 1) - model_cls, has_cls = optional_import(module=model_module, name=model_name) - if not has_cls: - raise ValueError( - f"Could not load MMAR model config {model_config.get('path', '')}, " - f"Please make sure MMAR's sub-folders in '{model_dir}' is on the PYTHONPATH." - "See also: https://docs.nvidia.com/clara/clara-train-sdk/pt/byom.html" - ) - else: - raise ValueError(f"Could not load model config {model_config}.") - - logger.info(f"*** Model: {model_cls}") - model_kwargs = model_config.get("args", None) - if model_kwargs: - model_inst = model_cls(**model_kwargs) - logger.info(f"*** Model params: {model_kwargs}") - else: - model_inst = model_cls() - if pretrained: - _, changed, unchanged = copy_model_state(model_inst, model_dict.get(model_key, model_dict), inplace=True) - if not (changed and not unchanged): # not all model_inst variables are changed - logger.warning(f"*** Loading model state -- unchanged: {len(unchanged)}, changed: {len(changed)}.") - logger.info("\n---") - doc_url = item.get(Keys.DOC) or _get_ngc_doc_url(item[Keys.NAME], model_prefix="nvidia:med:") - logger.info(f"For more information, please visit {doc_url}\n") - return model_inst - - -def _get_val(input_dict: Mapping, key: str = "model", default: Any | None = None) -> Any | None: - """ - Search for the item with `key` in `config_dict`. - Returns: the first occurrence of `key` in a breadth first search. + Raises: + DeprecatedError: always, because MMAR loading is disabled for security reasons. """ - if key in input_dict: - return input_dict[key] - for sub_dict in input_dict: - val = input_dict[sub_dict] - if isinstance(val, Mapping): - found_val = _get_val(val, key=key, default=None) - if found_val is not None: - return found_val - return default + raise DeprecatedError("load_from_mmar is disabled for security reasons. Please use MONAI Bundles instead.") diff --git a/monai/apps/mmars/model_desc.py b/monai/apps/mmars/model_desc.py index a3963689fb5..7121e453775 100644 --- a/monai/apps/mmars/model_desc.py +++ b/monai/apps/mmars/model_desc.py @@ -32,7 +32,7 @@ class RemoteMMARKeys: ID = "id" # unique MMAR NAME = "name" # MMAR name for readability URL = "url" # remote location of the MMAR, see also: `monai.apps.mmars.mmars._get_ngc_url` - DOC = "doc" # documentation page of the remote model, see also: `monai.apps.mmars.mmars._get_ngc_doc_url` + DOC = "doc" # documentation page of the remote model FILE_TYPE = "file_type" # type of the compressed MMAR HASH_TYPE = "hash_type" # hashing method for the compressed MMAR HASH_VAL = "hash_val" # hashing value for the compressed MMAR diff --git a/tests/apps/test_mmar_download.py b/tests/apps/test_mmar_download.py index bca3fa2a30f..5a43b78b96f 100644 --- a/tests/apps/test_mmar_download.py +++ b/tests/apps/test_mmar_download.py @@ -11,143 +11,155 @@ from __future__ import annotations -import os -import tempfile +import inspect import unittest +import warnings +from contextlib import ExitStack +from functools import partial +from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +from unittest.mock import patch -import numpy as np -import torch from parameterized import parameterized -from monai import __version__ -from monai.apps import download_mmar, load_from_mmar -from monai.apps.mmars import MODEL_DESC -from monai.apps.mmars.mmars import _get_val -from monai.utils import version_leq -from tests.test_utils import skip_if_downloading_fails, skip_if_quick - -TEST_CASES = [["clara_pt_prostate_mri_segmentation"], ["clara_pt_covid19_ct_lesion_segmentation"]] -TEST_EXTRACT_CASES = [ - ( - {"item": "clara_pt_prostate_mri_segmentation", "map_location": "cuda" if torch.cuda.is_available() else "cpu"}, - "UNet", - np.array( - [ - [[-0.0838, 0.0116, -0.0861], [-0.0792, 0.2216, -0.0301], [-0.0379, 0.0006, -0.0399]], - [[-0.0347, 0.0979, 0.0754], [0.1689, 0.3759, 0.2584], [-0.0698, 0.2740, 0.1414]], - [[-0.0772, 0.1046, -0.0103], [0.0917, 0.1942, 0.0284], [-0.0165, -0.0181, 0.0247]], - ] - ), - ), - ( - { - "item": "clara_pt_covid19_ct_lesion_segmentation", - "map_location": "cuda" if torch.cuda.is_available() else "cpu", - }, - "SegResNet", - np.array( - [ - [ - [0.01671106, 0.08502351, -0.1766469], - [-0.13039736, -0.06137804, 0.03924942], - [0.02268324, 0.159056, -0.03485069], - ], - [ - [0.04788467, -0.09365353, -0.05802464], - [-0.19500689, -0.13514304, -0.08191573], - [0.0238207, 0.08029253, 0.10818923], - ], - [ - [-0.11541673, -0.10622888, 0.039689], - [0.18462701, -0.0499289, 0.14309818], - [0.00528282, 0.02152331, 0.1698219], - ], - ] - ), - ), - ( - { - "item": "clara_pt_fed_learning_brain_tumor_mri_segmentation", - "map_location": "cuda" if torch.cuda.is_available() else "cpu", - "model_file": os.path.join("models", "server", "best_FL_global_model.pt"), - }, - "SegResNet", - np.array( - [ - [ - [0.01874463, 0.12237817, 0.09269974], - [0.07691482, 0.00621202, -0.06682577], - [-0.07718472, 0.08637864, -0.03222707], - ], - [ - [0.05117761, 0.07428649, -0.03053505], - [0.11045473, 0.07083791, 0.06547518], - [0.09555705, -0.03950734, -0.00819483], - ], - [ - [0.03704128, 0.062543, 0.0380853], - [-0.02814676, -0.03078287, -0.01383446], - [-0.08137762, 0.01385882, 0.01229484], - ], - ] - ), - ), - ( - { - "item": "clara_pt_pathology_metastasis_detection", - "map_location": "cuda" if torch.cuda.is_available() else "cpu", - }, - "TorchVisionFCModel", - np.array( - [ - [-0.00540746, -0.00274996, -0.00837622, 0.05415914, 0.03555066, -0.00071636, -0.02325751], - [0.00564625, 0.00674562, -0.1098334, -0.2936509, -0.28384757, -0.13580588, -0.00737865], - [-0.02159783, 0.04615543, 0.29717407, 0.6001161, 0.53496915, 0.2528417, 0.04530451], - [0.0225903, -0.07556137, -0.3070122, -0.43984795, -0.26286602, -0.00172576, 0.05003437], - [-0.0320133, 0.00855468, 0.06824744, -0.04786247, -0.30358723, -0.3960023, -0.24895012], - [0.02412516, 0.03411723, 0.06513759, 0.24332047, 0.41664436, 0.38999054, 0.15957521], - [-0.01303542, -0.00166874, -0.01965466, -0.06620175, -0.15635538, -0.10023144, -0.01698002], - ] - ), - ), -] - - -@unittest.skip("deprecating mmar tests") -class TestMMMARDownload(unittest.TestCase): - @parameterized.expand(TEST_CASES) - @skip_if_quick - def test_download(self, idx): - with skip_if_downloading_fails(): - with self.assertLogs(level="INFO", logger="monai.apps"): - download_mmar(idx) - download_mmar(idx, progress=False) # repeated to check caching - with tempfile.TemporaryDirectory() as tmp_dir: - download_mmar(idx, mmar_dir=tmp_dir, progress=False) - download_mmar(idx, mmar_dir=Path(tmp_dir), progress=False, version=1) # repeated to check caching - self.assertTrue(os.path.exists(os.path.join(tmp_dir, idx))) - - @parameterized.expand(TEST_EXTRACT_CASES) - @skip_if_quick - @unittest.skipIf(version_leq(__version__, "0.6"), "requires newer monai") - def test_load_ckpt(self, input_args, expected_name, expected_val): - with skip_if_downloading_fails(): - output = load_from_mmar(**input_args) - self.assertEqual(output.__class__.__name__, expected_name) - x = next(output.parameters()) # verify the first element - np.testing.assert_allclose(x[0][0].detach().cpu().numpy(), expected_val, rtol=1e-3, atol=1e-3) +import monai.apps +from monai.apps import download_mmar, get_model_spec, load_from_mmar +from monai.apps.mmars import MODEL_DESC, mmars +from monai.utils import DeprecatedError, deprecated + + +class TestMMARDownload(unittest.TestCase): + @parameterized.expand([(0,), (f" {MODEL_DESC[0]['id'].upper()} ",)]) + def test_get_model_spec(self, item): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + self.assertIs(get_model_spec(item), MODEL_DESC[0]) + + def test_get_model_spec_unknown(self): + item = "unknown_model" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + self.assertEqual(get_model_spec(item), item) def test_unique(self): - # model ids are unique - keys = sorted(m["id"] for m in MODEL_DESC) - self.assertEqual(keys, sorted(set(keys))) - - def test_search(self): - self.assertEqual(_get_val({"a": 1, "b": 2}, key="b"), 2) - self.assertEqual(_get_val({"a": {"c": {"c": 4}}, "b": {"c": 2}}, key="b"), {"c": 2}) - self.assertEqual(_get_val({"a": {"c": 4}, "b": {"c": 2}}, key="c"), 4) - self.assertEqual(_get_val({"a": {"c": None}, "b": {"c": 2}}, key="c"), 2) + keys = [model["id"] for model in MODEL_DESC] + self.assertEqual(len(keys), len(set(keys))) + + @parameterized.expand([(-1, "3"), (2, "2")]) + def test_download_api(self, version, expected_version): + mmar_dir = Path("mmars") + model = "test_model" + model_dict = {f"nvidia/med/{model}": {"name": model, "latest": "3"}} + with ( + patch.object(mmars, "_get_all_ngc_models", return_value=model_dict) as query, + patch.object(mmars, "download_and_extract") as download, + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", FutureWarning) + result = download_mmar(model, mmar_dir=mmar_dir, progress=False, version=version) + + self.assertEqual(result, mmar_dir / model) + query.assert_called_once_with(model) + download.assert_called_once_with( + url=f"https://api.ngc.nvidia.com/v2/models/nvidia/med/{model}/versions/{expected_version}/zip", + filepath=mmar_dir / f"{model}_{expected_version}.zip", + output_dir=mmar_dir / model, + hash_val=None, + hash_type="md5", + file_type="zip", + has_base=False, + progress=False, + ) + + @parameterized.expand([(-1, "1"), (2, "2")]) + def test_download_model_spec(self, version, expected_version): + mmar_dir = Path("mmars") + model = MODEL_DESC[0] + model_fullname = f"{model['name']}_{expected_version}" + with ( + patch.object(mmars, "_get_all_ngc_models") as query, + patch.object(mmars, "download_and_extract") as download, + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore", FutureWarning) + result = download_mmar(model, mmar_dir=mmar_dir, progress=False, api=False, version=version) + + self.assertEqual(result, mmar_dir / model_fullname) + query.assert_not_called() + download.assert_called_once_with( + url=f"https://api.ngc.nvidia.com/v2/models/nvidia/med/{model['name']}/versions/{expected_version}/zip", + filepath=mmar_dir / f"{model_fullname}.zip", + output_dir=mmar_dir / model_fullname, + hash_val=model["hash_val"], + hash_type=model["hash_type"], + file_type=model["file_type"], + has_base=False, + progress=False, + ) + + @parameterized.expand([("get_model_spec",), ("download_mmar",), ("load_from_mmar",)]) + def test_deprecation_warning(self, name): + # Development checkouts may still report a version earlier than 1.7. + # Import an isolated copy to exercise the decorators at their release version. + spec = spec_from_file_location("monai.apps.mmars._mmars_deprecation_test", mmars.__file__) + assert spec is not None and spec.loader is not None + module = module_from_spec(spec) + with patch("monai.utils.deprecate_utils.deprecated", partial(deprecated, version_val="1.7")): + spec.loader.exec_module(module) + + with ( + patch.object(module, "download_and_extract"), + self.assertWarnsRegex(FutureWarning, f"{name}.*deprecated.*MONAI Bundles"), + ): + if name == "load_from_mmar": + with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): + module.load_from_mmar("test_model") + elif name == "download_mmar": + module.download_mmar(MODEL_DESC[0], mmar_dir="mmars", api=False) + else: + module.get_model_spec(0) + + if name == "load_from_mmar": + with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): + inspect.unwrap(module.load_from_mmar)("test_model") + + @parameterized.expand( + [ + ("default", "test_model", {}), + ("index", 0, {}), + ("model_spec", MODEL_DESC[0], {"api": False}), + ("weights_only", "test_model", {"weights_only": True}), + ("untrained", "test_model", {"pretrained": False}), + ("torchscript", "test_model", {"model_file": "models/model.ts"}), + ("custom_directory", "test_model", {"mmar_dir": Path("mmars"), "map_location": "cpu"}), + ] + ) + def test_load_disabled_without_side_effects(self, _, item, kwargs): + with ExitStack() as stack: + guards = [ + stack.enter_context(patch(target, side_effect=AssertionError(f"Unexpected call to {target}"))) + for target in ( + "monai.apps.mmars.mmars.download_mmar", + "monai.apps.mmars.mmars.download_and_extract", + "monai.apps.mmars.mmars._get_all_ngc_models", + "monai.apps.mmars.mmars.Path", + "builtins.open", + "torch.load", + "torch.jit.load", + ) + ] + stack.enter_context(warnings.catch_warnings()) + warnings.simplefilter("ignore", FutureWarning) + for loader in (load_from_mmar, inspect.unwrap(load_from_mmar)): + with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): + loader(item, **kwargs) + for guard in guards: + guard.assert_not_called() + + def test_public_exports(self): + for name in ("get_model_spec", "download_mmar", "load_from_mmar"): + self.assertIs(getattr(monai.apps, name), getattr(mmars, name)) + self.assertIs(getattr(monai.apps.mmars, name), getattr(mmars, name)) if __name__ == "__main__": From 3754d3b52cbef4868adb55bee0219c9b82f2be2e Mon Sep 17 00:00:00 2001 From: Muhammad Talha Date: Thu, 15 Oct 2026 23:37:53 +0500 Subject: [PATCH 2/3] Fix MMAR deprecation at the 1.7.0 release boundary Use the full release version in the MMAR deprecation decorators and test the actual APIs before and after that boundary. Keep loader rejection unconditional and scope the file-access guard to the MMAR module. Signed-off-by: Muhammad Talha Assisted-by: OpenAI Codex Assisted-by: Claude --- docs/source/apps.rst | 6 +++--- monai/apps/mmars/mmars.py | 22 +++++++++---------- tests/apps/test_mmar_download.py | 36 ++++++++++++++++++++++---------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 37d4b6fc76e..bd9ed184b9a 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -24,10 +24,10 @@ Applications `Clara MMARs` ------------- -.. deprecated:: 1.7 +.. deprecated:: 1.7.0 NVIDIA Clara MMAR support is deprecated. Use :doc:`MONAI Bundles ` instead. - ``load_from_mmar`` is disabled for security reasons and raises - :class:`monai.utils.DeprecatedError` without downloading or loading a model. + For security reasons, ``load_from_mmar`` now raises + :class:`monai.utils.DeprecatedError` before downloading or loading a model. .. autofunction:: get_model_spec diff --git a/monai/apps/mmars/mmars.py b/monai/apps/mmars/mmars.py index e2e6116986b..a980e31c0cc 100644 --- a/monai/apps/mmars/mmars.py +++ b/monai/apps/mmars/mmars.py @@ -33,12 +33,12 @@ __all__ = ["get_model_spec", "download_mmar", "load_from_mmar"] -@deprecated(since="1.7", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") +@deprecated(since="1.7.0", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") def get_model_spec(idx: int | str) -> dict | Any: """ Get a model specification by index or model ID. - .. deprecated:: 1.7 + .. deprecated:: 1.7.0 NVIDIA Clara MMAR support is deprecated. Use MONAI Bundles instead. """ if isinstance(idx, int): @@ -100,14 +100,14 @@ def _get_ngc_url(model_name: str, version: str, model_prefix: str = "") -> str: return f"https://api.ngc.nvidia.com/v2/models/{model_prefix}{model_name}/versions/{version}/zip" -@deprecated(since="1.7", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") +@deprecated(since="1.7.0", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") def download_mmar( item: str | Mapping, mmar_dir: PathLike | None = None, progress: bool = True, api: bool = True, version: int = -1 ) -> Path: """ Download and extract Medical Model Archive (MMAR) from Nvidia Clara Train. - .. deprecated:: 1.7 + .. deprecated:: 1.7.0 NVIDIA Clara MMAR support is deprecated. Use MONAI Bundles instead. See Also: @@ -186,7 +186,7 @@ def download_mmar( return model_dir -@deprecated(since="1.7", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") +@deprecated(since="1.7.0", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") def load_from_mmar( item: Mapping | str | int, mmar_dir: PathLike | None = None, @@ -200,14 +200,14 @@ def load_from_mmar( model_file: PathLike | None = None, ) -> Any: """ - Disabled loader for Medical Model Archive (MMAR) models from Nvidia Clara Train. + Disabled loader for NVIDIA Clara Medical Model Archive (MMAR) models. - .. deprecated:: 1.7 - MMAR loading is disabled for security reasons. Use MONAI Bundles instead. + .. deprecated:: 1.7.0 + Loading MMAR models is disabled for security reasons. Use MONAI Bundles instead. - This function retains its signature for compatibility but always raises - :class:`monai.utils.DeprecatedError` without downloading or loading a model. - All arguments are ignored; their former meanings are listed below. + Every call raises :class:`monai.utils.DeprecatedError` before downloading or + loading a model. The arguments are kept for compatibility and are ignored. + Their former meanings are listed below. Args: item: the corresponding model item from `MODEL_DESC`. diff --git a/tests/apps/test_mmar_download.py b/tests/apps/test_mmar_download.py index 5a43b78b96f..2551b7d5265 100644 --- a/tests/apps/test_mmar_download.py +++ b/tests/apps/test_mmar_download.py @@ -97,20 +97,24 @@ def test_download_model_spec(self, version, expected_version): progress=False, ) - @parameterized.expand([("get_model_spec",), ("download_mmar",), ("load_from_mmar",)]) - def test_deprecation_warning(self, name): - # Development checkouts may still report a version earlier than 1.7. - # Import an isolated copy to exercise the decorators at their release version. + @parameterized.expand( + [ + (name, version, expected_warnings) + for name in ("get_model_spec", "download_mmar", "load_from_mmar") + for version, expected_warnings in (("1.6.0", 0), ("1.7.0rc1", 0), ("1.7.0", 1), ("1.7.1", 1)) + ] + ) + def test_deprecation_warning(self, name, version, expected_warnings): + # Import an isolated copy to test the actual decorators across the release + # boundary without changing the functions imported by other tests. spec = spec_from_file_location("monai.apps.mmars._mmars_deprecation_test", mmars.__file__) assert spec is not None and spec.loader is not None module = module_from_spec(spec) - with patch("monai.utils.deprecate_utils.deprecated", partial(deprecated, version_val="1.7")): + with patch("monai.utils.deprecate_utils.deprecated", partial(deprecated, version_val=version)): spec.loader.exec_module(module) - with ( - patch.object(module, "download_and_extract"), - self.assertWarnsRegex(FutureWarning, f"{name}.*deprecated.*MONAI Bundles"), - ): + with patch.object(module, "download_and_extract"), warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", FutureWarning) if name == "load_from_mmar": with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): module.load_from_mmar("test_model") @@ -119,6 +123,13 @@ def test_deprecation_warning(self, name): else: module.get_model_spec(0) + future_warnings = [warning for warning in captured if issubclass(warning.category, FutureWarning)] + self.assertEqual(len(future_warnings), expected_warnings) + if expected_warnings: + self.assertRegex( + str(future_warnings[0].message), rf"{name}.*deprecated since version 1\.7\.0.*MONAI Bundles" + ) + if name == "load_from_mmar": with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): inspect.unwrap(module.load_from_mmar)("test_model") @@ -142,12 +153,15 @@ def test_load_disabled_without_side_effects(self, _, item, kwargs): "monai.apps.mmars.mmars.download_mmar", "monai.apps.mmars.mmars.download_and_extract", "monai.apps.mmars.mmars._get_all_ngc_models", - "monai.apps.mmars.mmars.Path", - "builtins.open", "torch.load", "torch.jit.load", ) ] + guards.append( + stack.enter_context( + patch.object(mmars, "open", create=True, side_effect=AssertionError("Unexpected file access")) + ) + ) stack.enter_context(warnings.catch_warnings()) warnings.simplefilter("ignore", FutureWarning) for loader in (load_from_mmar, inspect.unwrap(load_from_mmar)): From e344df1959a10874f251536cdc8304737682e8b7 Mon Sep 17 00:00:00 2001 From: Muhammad Talha Date: Fri, 16 Oct 2026 12:14:13 +0500 Subject: [PATCH 3/3] Complete MMAR deprecation schedule after review Deprecate RemoteMMARKeys and schedule the class and all three MMAR functions for removal in 1.9.0. Keep model loading disabled immediately. Extend release-boundary tests and document the test cases and removal schedule. Signed-off-by: Muhammad Talha Assisted-by: OpenAI Codex --- docs/source/apps.rst | 3 +- monai/apps/mmars/mmars.py | 25 ++++++++++---- monai/apps/mmars/model_desc.py | 10 ++++++ tests/apps/test_mmar_download.py | 59 ++++++++++++++++++++++++++------ 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index bd9ed184b9a..495ea065304 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -25,7 +25,8 @@ Applications `Clara MMARs` ------------- .. deprecated:: 1.7.0 - NVIDIA Clara MMAR support is deprecated. Use :doc:`MONAI Bundles ` instead. + NVIDIA Clara MMAR support is deprecated and will be removed in version 1.9.0. + Use :doc:`MONAI Bundles ` instead. For security reasons, ``load_from_mmar`` now raises :class:`monai.utils.DeprecatedError` before downloading or loading a model. diff --git a/monai/apps/mmars/mmars.py b/monai/apps/mmars/mmars.py index a980e31c0cc..28605c0abd4 100644 --- a/monai/apps/mmars/mmars.py +++ b/monai/apps/mmars/mmars.py @@ -33,13 +33,17 @@ __all__ = ["get_model_spec", "download_mmar", "load_from_mmar"] -@deprecated(since="1.7.0", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") +@deprecated( + since="1.7.0", + removed="1.9.0", + msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.", +) def get_model_spec(idx: int | str) -> dict | Any: """ Get a model specification by index or model ID. .. deprecated:: 1.7.0 - NVIDIA Clara MMAR support is deprecated. Use MONAI Bundles instead. + NVIDIA Clara MMAR support will be removed in version 1.9.0. Use MONAI Bundles instead. """ if isinstance(idx, int): return MODEL_DESC[idx] @@ -100,7 +104,11 @@ def _get_ngc_url(model_name: str, version: str, model_prefix: str = "") -> str: return f"https://api.ngc.nvidia.com/v2/models/{model_prefix}{model_name}/versions/{version}/zip" -@deprecated(since="1.7.0", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") +@deprecated( + since="1.7.0", + removed="1.9.0", + msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.", +) def download_mmar( item: str | Mapping, mmar_dir: PathLike | None = None, progress: bool = True, api: bool = True, version: int = -1 ) -> Path: @@ -108,7 +116,7 @@ def download_mmar( Download and extract Medical Model Archive (MMAR) from Nvidia Clara Train. .. deprecated:: 1.7.0 - NVIDIA Clara MMAR support is deprecated. Use MONAI Bundles instead. + NVIDIA Clara MMAR support will be removed in version 1.9.0. Use MONAI Bundles instead. See Also: - https://docs.nvidia.com/clara/ @@ -186,7 +194,11 @@ def download_mmar( return model_dir -@deprecated(since="1.7.0", msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.") +@deprecated( + since="1.7.0", + removed="1.9.0", + msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.", +) def load_from_mmar( item: Mapping | str | int, mmar_dir: PathLike | None = None, @@ -203,7 +215,8 @@ def load_from_mmar( Disabled loader for NVIDIA Clara Medical Model Archive (MMAR) models. .. deprecated:: 1.7.0 - Loading MMAR models is disabled for security reasons. Use MONAI Bundles instead. + Loading MMAR models is disabled for security reasons. This function will be removed + in version 1.9.0. Use MONAI Bundles instead. Every call raises :class:`monai.utils.DeprecatedError` before downloading or loading a model. The arguments are kept for compatibility and are ignored. diff --git a/monai/apps/mmars/model_desc.py b/monai/apps/mmars/model_desc.py index 7121e453775..6da7e6485d1 100644 --- a/monai/apps/mmars/model_desc.py +++ b/monai/apps/mmars/model_desc.py @@ -20,13 +20,23 @@ import os from typing import Any +from monai.utils.deprecate_utils import deprecated + __all__ = ["MODEL_DESC", "RemoteMMARKeys"] +@deprecated( + since="1.7.0", + removed="1.9.0", + msg_suffix="NVIDIA Clara MMAR support is deprecated. Please use MONAI Bundles instead.", +) class RemoteMMARKeys: """ Data keys used for loading MMAR. ID must uniquely define an MMAR. + + .. deprecated:: 1.7.0 + NVIDIA Clara MMAR support will be removed in version 1.9.0. Use MONAI Bundles instead. """ ID = "id" # unique MMAR diff --git a/tests/apps/test_mmar_download.py b/tests/apps/test_mmar_download.py index 2551b7d5265..19cf5b8bf85 100644 --- a/tests/apps/test_mmar_download.py +++ b/tests/apps/test_mmar_download.py @@ -24,29 +24,35 @@ import monai.apps from monai.apps import download_mmar, get_model_spec, load_from_mmar -from monai.apps.mmars import MODEL_DESC, mmars +from monai.apps.mmars import MODEL_DESC, mmars, model_desc from monai.utils import DeprecatedError, deprecated class TestMMARDownload(unittest.TestCase): + """Check MMAR compatibility, deprecation, and disabled model loading offline.""" + @parameterized.expand([(0,), (f" {MODEL_DESC[0]['id'].upper()} ",)]) def test_get_model_spec(self, item): + """Resolve numeric indices and normalized model IDs to the original descriptor.""" with warnings.catch_warnings(): warnings.simplefilter("ignore", FutureWarning) self.assertIs(get_model_spec(item), MODEL_DESC[0]) def test_get_model_spec_unknown(self): + """Leave unrecognized model IDs unchanged.""" item = "unknown_model" with warnings.catch_warnings(): warnings.simplefilter("ignore", FutureWarning) self.assertEqual(get_model_spec(item), item) def test_unique(self): + """Keep every model descriptor addressable by a unique ID.""" keys = [model["id"] for model in MODEL_DESC] self.assertEqual(len(keys), len(set(keys))) @parameterized.expand([(-1, "3"), (2, "2")]) def test_download_api(self, version, expected_version): + """Select the requested NGC version and pass its archive details to the downloader.""" mmar_dir = Path("mmars") model = "test_model" model_dict = {f"nvidia/med/{model}": {"name": model, "latest": "3"}} @@ -73,6 +79,7 @@ def test_download_api(self, version, expected_version): @parameterized.expand([(-1, "1"), (2, "2")]) def test_download_model_spec(self, version, expected_version): + """Download from a descriptor without querying NGC, honoring version overrides.""" mmar_dir = Path("mmars") model = MODEL_DESC[0] model_fullname = f"{model['name']}_{expected_version}" @@ -100,34 +107,60 @@ def test_download_model_spec(self, version, expected_version): @parameterized.expand( [ (name, version, expected_warnings) - for name in ("get_model_spec", "download_mmar", "load_from_mmar") - for version, expected_warnings in (("1.6.0", 0), ("1.7.0rc1", 0), ("1.7.0", 1), ("1.7.1", 1)) + for name in ("get_model_spec", "download_mmar", "load_from_mmar", "RemoteMMARKeys") + for version, expected_warnings in ( + ("1.6.0", 0), + ("1.7.0rc1", 0), + ("1.7.0", 1), + ("1.7.1", 1), + ("1.9.0rc1", 1), + ("1.9.0", 0), + ) ] ) def test_deprecation_warning(self, name, version, expected_warnings): + """Warn from 1.7.0, remove callable APIs in 1.9.0, and preserve MMAR constants.""" # Import an isolated copy to test the actual decorators across the release # boundary without changing the functions imported by other tests. - spec = spec_from_file_location("monai.apps.mmars._mmars_deprecation_test", mmars.__file__) + source = model_desc if name == "RemoteMMARKeys" else mmars + spec = spec_from_file_location("monai.apps.mmars._mmars_deprecation_test", source.__file__) assert spec is not None and spec.loader is not None module = module_from_spec(spec) with patch("monai.utils.deprecate_utils.deprecated", partial(deprecated, version_val=version)): spec.loader.exec_module(module) - with patch.object(module, "download_and_extract"), warnings.catch_warnings(record=True) as captured: + if name == "RemoteMMARKeys": + action = module.RemoteMMARKeys + self.assertEqual(module.MODEL_DESC, MODEL_DESC) + for key, value in vars(model_desc.RemoteMMARKeys).items(): + if key.isupper(): + self.assertEqual(getattr(module.RemoteMMARKeys, key), value) + elif name == "download_mmar": + action = partial(module.download_mmar, MODEL_DESC[0], mmar_dir="mmars", api=False) + elif name == "load_from_mmar": + action = partial(module.load_from_mmar, "test_model") + else: + action = partial(module.get_model_spec, 0) + + with ExitStack() as stack, warnings.catch_warnings(record=True) as captured: + if name != "RemoteMMARKeys": + stack.enter_context(patch.object(module, "download_and_extract")) warnings.simplefilter("always", FutureWarning) - if name == "load_from_mmar": + if version == "1.9.0": + with self.assertRaisesRegex(DeprecatedError, rf"{name}.*removed in version 1\.9\.0.*MONAI Bundles"): + action() + elif name == "load_from_mmar": with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): - module.load_from_mmar("test_model") - elif name == "download_mmar": - module.download_mmar(MODEL_DESC[0], mmar_dir="mmars", api=False) + action() else: - module.get_model_spec(0) + action() future_warnings = [warning for warning in captured if issubclass(warning.category, FutureWarning)] self.assertEqual(len(future_warnings), expected_warnings) if expected_warnings: self.assertRegex( - str(future_warnings[0].message), rf"{name}.*deprecated since version 1\.7\.0.*MONAI Bundles" + str(future_warnings[0].message), + rf"{name}.*deprecated since version 1\.7\.0.*removed in version 1\.9\.0.*MONAI Bundles", ) if name == "load_from_mmar": @@ -146,6 +179,7 @@ def test_deprecation_warning(self, name, version, expected_warnings): ] ) def test_load_disabled_without_side_effects(self, _, item, kwargs): + """Reject every loading mode without downloads, file reads, or deserialization.""" with ExitStack() as stack: guards = [ stack.enter_context(patch(target, side_effect=AssertionError(f"Unexpected call to {target}"))) @@ -171,9 +205,12 @@ def test_load_disabled_without_side_effects(self, _, item, kwargs): guard.assert_not_called() def test_public_exports(self): + """Retain the public function and descriptor-class aliases for existing imports.""" for name in ("get_model_spec", "download_mmar", "load_from_mmar"): self.assertIs(getattr(monai.apps, name), getattr(mmars, name)) self.assertIs(getattr(monai.apps.mmars, name), getattr(mmars, name)) + self.assertIs(monai.apps.RemoteMMARKeys, model_desc.RemoteMMARKeys) + self.assertIs(monai.apps.mmars.RemoteMMARKeys, model_desc.RemoteMMARKeys) if __name__ == "__main__":