From 21660034419d2ee37ae4a9dae9f951fa91c52645 Mon Sep 17 00:00:00 2001 From: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:02:58 +0100 Subject: [PATCH] fix(networks): lazily import onnx so a broken onnx cannot block `import monai` `monai/networks/utils.py` and `monai/bundle/scripts.py` imported `onnx`, `onnx.reference` and `onnxruntime` eagerly at module scope via `optional_import`, which calls `__import__` immediately. Because `import monai` auto-loads `monai.networks`, a broken or hanging onnx install (e.g. onnx 1.18 on Windows) takes down `import monai` with no error message. Defer these optional imports into the functions that use them (`convert_to_onnx`, `onnx_export`), matching the existing lazy pattern used for tensorrt. Importing MONAI no longer imports onnx, and the conversion paths are otherwise unchanged. Adds a regression test asserting `import monai` and `import monai.bundle` do not eagerly import onnx/onnxruntime. Fixes #8455. Signed-off-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com> --- monai/bundle/scripts.py | 2 +- monai/networks/utils.py | 7 +-- tests/networks/test_lazy_onnx_import.py | 63 +++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 tests/networks/test_lazy_onnx_import.py diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index ab02cd552e..301dd19e2c 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -57,7 +57,6 @@ ValidationError, _ = optional_import("jsonschema.exceptions", name="ValidationError") Checkpoint, has_ignite = optional_import("ignite.handlers", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Checkpoint") requests, has_requests = optional_import("requests") -onnx, _ = optional_import("onnx") huggingface_hub, _ = optional_import("huggingface_hub") logger = get_logger(module_name=__name__) @@ -1419,6 +1418,7 @@ def onnx_export( converter_kwargs_.update({"inputs": inputs_, "use_trace": use_trace_}) def save_onnx(onnx_obj: Any, filename_prefix_or_stream: str, **kwargs: Any) -> None: + onnx, _ = optional_import("onnx") onnx.save(onnx_obj, filename_prefix_or_stream) _export( diff --git a/monai/networks/utils.py b/monai/networks/utils.py index f56c39dcd1..01f48fcf74 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -34,9 +34,6 @@ from monai.utils.module import look_up_option, optional_import from monai.utils.type_conversion import convert_to_dst_type, convert_to_tensor -onnx, _ = optional_import("onnx") -onnxreference, _ = optional_import("onnx.reference") -onnxruntime, _ = optional_import("onnxruntime") polygraphy, polygraphy_imported = optional_import("polygraphy") torch_tensorrt, _ = optional_import("torch_tensorrt", "1.4.0") @@ -708,6 +705,8 @@ def convert_to_onnx( https://pytorch.org/docs/master/generated/torch.jit.script.html. """ + onnx, _ = optional_import("onnx") + model.eval() with torch.no_grad(): torch_versioned_kwargs = {} @@ -777,11 +776,13 @@ def convert_to_onnx( model_input_names = [i.name for i in onnx_model.graph.input] input_dict = dict(zip(model_input_names, [i.cpu().numpy() for i in inputs])) if use_ort: + onnxruntime, _ = optional_import("onnxruntime") ort_sess = onnxruntime.InferenceSession( onnx_model.SerializeToString(), providers=ort_provider if ort_provider else ["CPUExecutionProvider"] ) onnx_out = ort_sess.run(None, input_dict) else: + onnxreference, _ = optional_import("onnx.reference") sess = onnxreference.ReferenceEvaluator(onnx_model) onnx_out = sess.run(None, input_dict) set_determinism(seed=None) diff --git a/tests/networks/test_lazy_onnx_import.py b/tests/networks/test_lazy_onnx_import.py new file mode 100644 index 0000000000..d28a49edba --- /dev/null +++ b/tests/networks/test_lazy_onnx_import.py @@ -0,0 +1,63 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import subprocess +import sys +import unittest + +# Run in a subprocess with a meta-path finder that records any import *attempt* +# of the optional heavy backends (works whether or not they are installed, and +# does not shadow/replace them). After importing MONAI, fail if any were touched. +_PROBE = """ +import sys, importlib.abc +_WATCH = ("onnx", "onnxruntime") +_attempted = set() + + +class _Recorder(importlib.abc.MetaPathFinder): + def find_spec(self, name, path, target=None): + if name.split(".")[0] in _WATCH: + _attempted.add(name.split(".")[0]) + return None # delegate to the real finders + + +sys.meta_path.insert(0, _Recorder()) +{import_stmt} +sys.exit("eagerly imported: " + ", ".join(sorted(_attempted)) if _attempted else 0) +""" + + +class TestLazyOptionalImports(unittest.TestCase): + """Regression tests for #8455. + + Importing MONAI must not eagerly import onnx / onnxruntime. These were + previously imported at module scope via ``optional_import`` in + ``monai/networks/utils.py`` and ``monai/bundle/scripts.py``, so a + broken/hanging onnx (e.g. onnx 1.18 on Windows) would take down + ``import monai`` entirely. + """ + + def _assert_not_eagerly_imported(self, import_stmt: str) -> None: + code = _PROBE.format(import_stmt=import_stmt) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, msg=(result.stdout + result.stderr).strip()) + + def test_import_monai_does_not_import_onnx(self): + self._assert_not_eagerly_imported("import monai") + + def test_import_monai_bundle_does_not_import_onnx(self): + self._assert_not_eagerly_imported("import monai.bundle") + + +if __name__ == "__main__": + unittest.main()