Repository: Project-MONAI/MONAI

Issue:
make 'meta_file' optional for `monai.bundle run`
**Is your feature request related to a problem? Please describe.**
make it possible to instantiate components via CLI without providing a metadata file.


Discussion:

Patch:
diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py
--- a/monai/bundle/scripts.py
+++ b/monai/bundle/scripts.py
@@ -133,9 +133,8 @@ def run(
         python -m monai.bundle run --args_file "/workspace/data/args.json" --config_file <config path>
 
     Args:
-        runner_id: ID name of the runner component or workflow, it must have a `run` method.
-        meta_file: filepath of the metadata file, if `None`, must be provided in `args_file`.
-            if it is a list of file paths, the content of them will be merged.
+        runner_id: ID name of the runner component or workflow, it must have a `run` method. Defaults to ``""``.
+        meta_file: filepath of the metadata file, if it is a list of file paths, the content of them will be merged.
         config_file: filepath of the config file, if `None`, must be provided in `args_file`.
             if it is a list of file paths, the content of them will be merged.
         args_file: a JSON or YAML file to provide default values for `runner_id`, `meta_file`,
@@ -146,14 +145,14 @@ def run(
     """
 
     _args = _update_args(args=args_file, runner_id=runner_id, meta_file=meta_file, config_file=config_file, **override)
-    for k in ("meta_file", "config_file"):
-        if k not in _args:
-            raise ValueError(f"{k} is required for 'monai.bundle run'.\n{run.__doc__}")
+    if "config_file" not in _args:
+        raise ValueError(f"`config_file` is required for 'monai.bundle run'.\n{run.__doc__}")
     _log_input_summary(tag="run", args=_args)
 
     parser = ConfigParser()
     parser.read_config(f=_args.pop("config_file"))
-    parser.read_meta(f=_args.pop("meta_file"))
+    if "meta_file" in _args:
+        parser.read_meta(f=_args.pop("meta_file"))
     id = _args.pop("runner_id", "")
 
     # the rest key-values in the _args are to override config content
@@ -162,8 +161,8 @@ def run(
 
     workflow = parser.get_parsed_content(id=id)
     if not hasattr(workflow, "run"):
-        raise ValueError(f"The parsed workflow {type(workflow)} does not have a `run` method.\n{run.__doc__}")
-    workflow.run()
+        raise ValueError(f"The parsed workflow {type(workflow)} (id={id}) does not have a `run` method.\n{run.__doc__}")
+    return workflow.run()
 
 
 def verify_metadata(
@@ -171,6 +170,7 @@ def verify_metadata(
     filepath: Optional[PathLike] = None,
     create_dir: Optional[bool] = None,
     hash_val: Optional[str] = None,
+    hash_type: Optional[str] = None,
     args_file: Optional[str] = None,
     **kwargs,
 ):
@@ -185,6 +185,7 @@ def verify_metadata(
         filepath: file path to store the downloaded schema.
         create_dir: whether to create directories if not existing, default to `True`.
         hash_val: if not None, define the hash value to verify the downloaded schema file.
+        hash_type: if not None, define the hash type to verify the downloaded schema file. Defaults to "md5".
         args_file: a JSON or YAML file to provide default values for all the args in this function.
             so that the command line inputs can be simplified.
         kwargs: other arguments for `jsonschema.validate()`. for more details:
@@ -193,7 +194,13 @@ def verify_metadata(
     """
 
     _args = _update_args(
-        args=args_file, meta_file=meta_file, filepath=filepath, create_dir=create_dir, hash_val=hash_val, **kwargs
+        args=args_file,
+        meta_file=meta_file,
+        filepath=filepath,
+        create_dir=create_dir,
+        hash_val=hash_val,
+        hash_type=hash_type,
+        **kwargs,
     )
     _log_input_summary(tag="verify_metadata", args=_args)
 
@@ -205,7 +212,13 @@ def verify_metadata(
     url = metadata.get("schema")
     if url is None:
         raise ValueError("must provide the `schema` field in the metadata for the URL of schema file.")
-    download_url(url=url, filepath=filepath_, hash_val=_args.pop("hash_val", None), hash_type="md5", progress=True)
+    download_url(
+        url=url,
+        filepath=filepath_,
+        hash_val=_args.pop("hash_val", None),
+        hash_type=_args.pop("hash_type", "md5"),
+        progress=True,
+    )
     schema = ConfigParser.load_config_file(filepath=filepath_)
 
     try:


Test patch:
diff --git a/tests/test_bundle_verify_metadata.py b/tests/test_bundle_verify_metadata.py
--- a/tests/test_bundle_verify_metadata.py
+++ b/tests/test_bundle_verify_metadata.py
@@ -20,16 +20,24 @@
 from parameterized import parameterized
 
 from monai.bundle import ConfigParser
-from tests.utils import skip_if_windows
+from tests.utils import download_url_or_skip_test, skip_if_windows, testing_data_config
 
-TEST_CASE_1 = [
-    os.path.join(os.path.dirname(__file__), "testing_data", "metadata.json"),
-    os.path.join(os.path.dirname(__file__), "testing_data", "schema.json"),
-]
+SCHEMA_FILE = os.path.join(os.path.dirname(__file__), "testing_data", "schema.json")
+
+TEST_CASE_1 = [os.path.join(os.path.dirname(__file__), "testing_data", "metadata.json"), SCHEMA_FILE]
 
 
 @skip_if_windows
 class TestVerifyMetaData(unittest.TestCase):
+    def setUp(self):
+        self.config = testing_data_config("configs", "test_meta_file")
+        download_url_or_skip_test(
+            url=self.config["url"],
+            filepath=SCHEMA_FILE,
+            hash_val=self.config.get("hash_val"),
+            hash_type=self.config.get("hash_type", "sha256"),
+        )
+
     @parameterized.expand([TEST_CASE_1])
     def test_verify(self, meta_file, schema_file):
         logging.basicConfig(stream=sys.stdout, level=logging.INFO)
@@ -38,10 +46,8 @@ def test_verify(self, meta_file, schema_file):
             def_args_file = os.path.join(tempdir, "def_args.json")
             ConfigParser.export_config_file(config=def_args, filepath=def_args_file)
 
-            hash_val = "e3a7e23d1113a1f3e6c69f09b6f9ce2c"
-
             cmd = [sys.executable, "-m", "monai.bundle", "verify_metadata", "--meta_file", meta_file]
-            cmd += ["--filepath", schema_file, "--hash_val", hash_val, "--args_file", def_args_file]
+            cmd += ["--filepath", schema_file, "--hash_val", self.config["hash_val"], "--args_file", def_args_file]
             ret = subprocess.check_call(cmd)
             self.assertEqual(ret, 0)
 
@@ -50,15 +56,9 @@ def test_verify_error(self):
         with tempfile.TemporaryDirectory() as tempdir:
             filepath = os.path.join(tempdir, "schema.json")
             metafile = os.path.join(tempdir, "metadata.json")
+            meta_dict = {"schema": self.config["url"], "wrong_meta": "wrong content"}
             with open(metafile, "w") as f:
-                json.dump(
-                    {
-                        "schema": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/"
-                        "download/0.8.1/meta_schema_202203171008.json",
-                        "wrong_meta": "wrong content",
-                    },
-                    f,
-                )
+                json.dump(meta_dict, f)
 
             cmd = [sys.executable, "-m", "monai.bundle", "verify_metadata", metafile, "--filepath", filepath]
             ret = subprocess.check_call(cmd)
diff --git a/tests/test_integration_bundle_run.py b/tests/test_integration_bundle_run.py
--- a/tests/test_integration_bundle_run.py
+++ b/tests/test_integration_bundle_run.py
@@ -11,6 +11,7 @@
 
 import json
 import os
+import shutil
 import subprocess
 import sys
 import tempfile
@@ -28,57 +29,80 @@
 TEST_CASE_2 = [os.path.join(os.path.dirname(__file__), "testing_data", "inference.yaml"), (128, 128, 128)]
 
 
+class _Runnable42:
+    def __init__(self, val=1):
+        self.val = val
+
+    def run(self):
+        assert self.val == 42  # defined in `TestBundleRun.test_tiny``
+        return self.val
+
+
 class TestBundleRun(unittest.TestCase):
+    def setUp(self):
+        self.data_dir = tempfile.mkdtemp()
+
+    def tearDown(self):
+        shutil.rmtree(self.data_dir)
+
+    def test_tiny(self):
+        config_file = os.path.join(self.data_dir, "tiny_config.json")
+        with open(config_file, "w") as f:
+            json.dump({"": {"_target_": "tests.test_integration_bundle_run._Runnable42", "val": 42}}, f)
+        cmd = [sys.executable, "-m", "monai.bundle", "run", "--config_file", config_file]
+        ret = subprocess.check_call(cmd)
+        self.assertEqual(ret, 0)
+
     @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
     def test_shape(self, config_file, expected_shape):
         test_image = np.random.rand(*expected_shape)
-        with tempfile.TemporaryDirectory() as tempdir:
-            filename = os.path.join(tempdir, "image.nii")
-            nib.save(nib.Nifti1Image(test_image, np.eye(4)), filename)
-
-            # generate default args in a JSON file
-            def_args = {"config_file": "will be replaced by `config_file` arg"}
-            def_args_file = os.path.join(tempdir, "def_args.json")
-            ConfigParser.export_config_file(config=def_args, filepath=def_args_file)
-
-            meta = {"datalist": [{"image": filename}], "output_dir": tempdir, "window": (96, 96, 96)}
-            # test YAML file
-            meta_file = os.path.join(tempdir, "meta.yaml")
-            ConfigParser.export_config_file(config=meta, filepath=meta_file, fmt="yaml")
-
-            # test override with file, up case postfix
-            overridefile1 = os.path.join(tempdir, "override1.JSON")
-            with open(overridefile1, "w") as f:
-                # test override with part of the overriding file
-                json.dump({"move_net": "$@network_def.to(@device)"}, f)
-            os.makedirs(os.path.join(tempdir, "jsons"), exist_ok=True)
-            overridefile2 = os.path.join(tempdir, "jsons/override2.JSON")
-            with open(overridefile2, "w") as f:
-                # test override with the whole overriding file
-                json.dump("Dataset", f)
-
-            saver = LoadImage(image_only=True)
-
-            if sys.platform == "win32":
-                override = "--network $@network_def.to(@device) --dataset#_target_ Dataset"
-            else:
-                override = f"--network %{overridefile1}#move_net --dataset#_target_ %{overridefile2}"
-            # test with `monai.bundle` as CLI entry directly
-            cmd = f"-m monai.bundle run evaluator --postprocessing#transforms#2#output_postfix seg {override}"
-            la = [f"{sys.executable}"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file]
-            test_env = os.environ.copy()
-            print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES"))
-            ret = subprocess.check_call(la + ["--args_file", def_args_file], env=test_env)
-            self.assertEqual(ret, 0)
-            self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape)
-
-            # here test the script with `google fire` tool as CLI
-            cmd = "-m fire monai.bundle.scripts run --runner_id evaluator"
-            cmd += f" --evaluator#amp False {override}"
-            la = [f"{sys.executable}"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file]
-            ret = subprocess.check_call(la, env=test_env)
-            self.assertEqual(ret, 0)
-            self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_trans.nii.gz")).shape, expected_shape)
+        tempdir = self.data_dir
+        filename = os.path.join(tempdir, "image.nii")
+        nib.save(nib.Nifti1Image(test_image, np.eye(4)), filename)
+
+        # generate default args in a JSON file
+        def_args = {"config_file": "will be replaced by `config_file` arg"}
+        def_args_file = os.path.join(tempdir, "def_args.json")
+        ConfigParser.export_config_file(config=def_args, filepath=def_args_file)
+
+        meta = {"datalist": [{"image": filename}], "output_dir": tempdir, "window": (96, 96, 96)}
+        # test YAML file
+        meta_file = os.path.join(tempdir, "meta.yaml")
+        ConfigParser.export_config_file(config=meta, filepath=meta_file, fmt="yaml")
+
+        # test override with file, up case postfix
+        overridefile1 = os.path.join(tempdir, "override1.JSON")
+        with open(overridefile1, "w") as f:
+            # test override with part of the overriding file
+            json.dump({"move_net": "$@network_def.to(@device)"}, f)
+        os.makedirs(os.path.join(tempdir, "jsons"), exist_ok=True)
+        overridefile2 = os.path.join(tempdir, "jsons/override2.JSON")
+        with open(overridefile2, "w") as f:
+            # test override with the whole overriding file
+            json.dump("Dataset", f)
+
+        saver = LoadImage(image_only=True)
+
+        if sys.platform == "win32":
+            override = "--network $@network_def.to(@device) --dataset#_target_ Dataset"
+        else:
+            override = f"--network %{overridefile1}#move_net --dataset#_target_ %{overridefile2}"
+        # test with `monai.bundle` as CLI entry directly
+        cmd = f"-m monai.bundle run evaluator --postprocessing#transforms#2#output_postfix seg {override}"
+        la = [f"{sys.executable}"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file]
+        test_env = os.environ.copy()
+        print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES"))
+        ret = subprocess.check_call(la + ["--args_file", def_args_file], env=test_env)
+        self.assertEqual(ret, 0)
+        self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape)
+
+        # here test the script with `google fire` tool as CLI
+        cmd = "-m fire monai.bundle.scripts run --runner_id evaluator"
+        cmd += f" --evaluator#amp False {override}"
+        la = [f"{sys.executable}"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file]
+        ret = subprocess.check_call(la, env=test_env)
+        self.assertEqual(ret, 0)
+        self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_trans.nii.gz")).shape, expected_shape)
 
 
 if __name__ == "__main__":
diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json
--- a/tests/testing_data/data_config.json
+++ b/tests/testing_data/data_config.json
@@ -67,5 +67,12 @@
             "hash_type": "sha256",
             "hash_val": "a260b3a40f82dfe37c58d26a612bcf7bef0d27c6fed096226b0e4e9fb364168e"
         }
+    },
+    "configs": {
+        "test_meta_file": {
+            "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/meta_schema_202203171008.json",
+            "hash_type": "md5",
+            "hash_val": "e3a7e23d1113a1f3e6c69f09b6f9ce2c"
+        }
     }
 }