diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 3239dc53519..495ea065304 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -24,6 +24,14 @@ Applications `Clara MMARs` ------------- +.. deprecated:: 1.7.0 + 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. + +.. 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..28605c0abd4 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,18 @@ __all__ = ["get_model_spec", "download_mmar", "load_from_mmar"] +@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 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.0 + NVIDIA Clara MMAR support will be removed in version 1.9.0. Use MONAI Bundles instead. + """ if isinstance(idx, int): return MODEL_DESC[idx] if isinstance(idx, str): @@ -99,16 +104,20 @@ 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.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: """ Download and extract Medical Model Archive (MMAR) from Nvidia Clara Train. + .. deprecated:: 1.7.0 + NVIDIA Clara MMAR support will be removed in version 1.9.0. Use MONAI Bundles instead. + See Also: - https://docs.nvidia.com/clara/ - Nvidia NGC Registry CLI @@ -185,6 +194,11 @@ def download_mmar( return model_dir +@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, @@ -198,117 +212,31 @@ 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 NVIDIA Clara Medical Model Archive (MMAR) models. + + .. deprecated:: 1.7.0 + 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. + 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..6da7e6485d1 100644 --- a/monai/apps/mmars/model_desc.py +++ b/monai/apps/mmars/model_desc.py @@ -20,19 +20,29 @@ 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 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..19cf5b8bf85 100644 --- a/tests/apps/test_mmar_download.py +++ b/tests/apps/test_mmar_download.py @@ -11,143 +11,206 @@ 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, 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): - # 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) + """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"}} + 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): + """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}" + 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( + [ + (name, version, expected_warnings) + 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. + 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) + + 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 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"): + action() + else: + 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.*removed in version 1\.9\.0.*MONAI Bundles", + ) + + 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): + """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}"))) + for target in ( + "monai.apps.mmars.mmars.download_mmar", + "monai.apps.mmars.mmars.download_and_extract", + "monai.apps.mmars.mmars._get_all_ngc_models", + "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)): + with self.assertRaisesRegex(DeprecatedError, "disabled.*security.*MONAI Bundles"): + loader(item, **kwargs) + for guard in guards: + 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__":