|
| 1 | +# Copyright (c) MONAI Consortium |
| 2 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 3 | +# you may not use this file except in compliance with the License. |
| 4 | +# You may obtain a copy of the License at |
| 5 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 6 | +# Unless required by applicable law or agreed to in writing, software |
| 7 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 8 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 9 | +# See the License for the specific language governing permissions and |
| 10 | +# limitations under the License. |
| 11 | + |
| 12 | +import inspect |
| 13 | +import sys |
| 14 | +import warnings |
| 15 | +from abc import ABC, abstractmethod |
| 16 | +from importlib import import_module |
| 17 | +from typing import Any, Dict, List, Mapping, Optional, Sequence, Union |
| 18 | + |
| 19 | +from monai.utils import ensure_tuple, instantiate |
| 20 | + |
| 21 | +__all__ = ["ComponentLocator", "ConfigItem", "ConfigExpression", "ConfigComponent"] |
| 22 | + |
| 23 | + |
| 24 | +class Instantiable(ABC): |
| 25 | + """ |
| 26 | + Base class for instantiable object with module name and arguments. |
| 27 | +
|
| 28 | + .. code-block:: python |
| 29 | +
|
| 30 | + if not is_disabled(): |
| 31 | + instantiate(module_name=resolve_module_name(), args=resolve_args()) |
| 32 | +
|
| 33 | + """ |
| 34 | + |
| 35 | + @abstractmethod |
| 36 | + def resolve_module_name(self, *args: Any, **kwargs: Any): |
| 37 | + """ |
| 38 | + Resolve the target module name, it should return an object class (or function) to be instantiated. |
| 39 | + """ |
| 40 | + raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.") |
| 41 | + |
| 42 | + @abstractmethod |
| 43 | + def resolve_args(self, *args: Any, **kwargs: Any): |
| 44 | + """ |
| 45 | + Resolve the arguments, it should return arguments to be passed to the object when instantiating. |
| 46 | + """ |
| 47 | + raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.") |
| 48 | + |
| 49 | + @abstractmethod |
| 50 | + def is_disabled(self, *args: Any, **kwargs: Any) -> bool: |
| 51 | + """ |
| 52 | + Return a boolean flag to indicate whether the object should be instantiated. |
| 53 | + """ |
| 54 | + raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.") |
| 55 | + |
| 56 | + @abstractmethod |
| 57 | + def instantiate(self, *args: Any, **kwargs: Any): |
| 58 | + """ |
| 59 | + Instantiate the target component. |
| 60 | + """ |
| 61 | + raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.") |
| 62 | + |
| 63 | + |
| 64 | +class ComponentLocator: |
| 65 | + """ |
| 66 | + Scan all the available classes and functions in the MONAI package and map them with the module paths in a table. |
| 67 | + It's used to locate the module path for provided component name. |
| 68 | +
|
| 69 | + Args: |
| 70 | + excludes: if any string of the `excludes` exists in the full module name, don't import this module. |
| 71 | +
|
| 72 | + """ |
| 73 | + |
| 74 | + MOD_START = "monai" |
| 75 | + |
| 76 | + def __init__(self, excludes: Optional[Union[Sequence[str], str]] = None): |
| 77 | + self.excludes = [] if excludes is None else ensure_tuple(excludes) |
| 78 | + self._components_table: Optional[Dict[str, List]] = None |
| 79 | + |
| 80 | + def _find_module_names(self) -> List[str]: |
| 81 | + """ |
| 82 | + Find all the modules start with MOD_START and don't contain any of `excludes`. |
| 83 | +
|
| 84 | + """ |
| 85 | + return [ |
| 86 | + m for m in sys.modules.keys() if m.startswith(self.MOD_START) and all(s not in m for s in self.excludes) |
| 87 | + ] |
| 88 | + |
| 89 | + def _find_classes_or_functions(self, modnames: Union[Sequence[str], str]) -> Dict[str, List]: |
| 90 | + """ |
| 91 | + Find all the classes and functions in the modules with specified `modnames`. |
| 92 | +
|
| 93 | + Args: |
| 94 | + modnames: names of the target modules to find all the classes and functions. |
| 95 | +
|
| 96 | + """ |
| 97 | + table: Dict[str, List] = {} |
| 98 | + # all the MONAI modules are already loaded by `load_submodules` |
| 99 | + for modname in ensure_tuple(modnames): |
| 100 | + try: |
| 101 | + # scan all the classes and functions in the module |
| 102 | + module = import_module(modname) |
| 103 | + for name, obj in inspect.getmembers(module): |
| 104 | + if (inspect.isclass(obj) or inspect.isfunction(obj)) and obj.__module__ == modname: |
| 105 | + if name not in table: |
| 106 | + table[name] = [] |
| 107 | + table[name].append(modname) |
| 108 | + except ModuleNotFoundError: |
| 109 | + pass |
| 110 | + return table |
| 111 | + |
| 112 | + def get_component_module_name(self, name: str) -> Optional[Union[List[str], str]]: |
| 113 | + """ |
| 114 | + Get the full module name of the class or function with specified ``name``. |
| 115 | + If target component name exists in multiple packages or modules, return a list of full module names. |
| 116 | +
|
| 117 | + Args: |
| 118 | + name: name of the expected class or function. |
| 119 | +
|
| 120 | + """ |
| 121 | + if not isinstance(name, str): |
| 122 | + raise ValueError(f"`name` must be a valid string, but got: {name}.") |
| 123 | + if self._components_table is None: |
| 124 | + # init component and module mapping table |
| 125 | + self._components_table = self._find_classes_or_functions(self._find_module_names()) |
| 126 | + |
| 127 | + mods: Optional[Union[List[str], str]] = self._components_table.get(name, None) |
| 128 | + if isinstance(mods, list) and len(mods) == 1: |
| 129 | + mods = mods[0] |
| 130 | + return mods |
| 131 | + |
| 132 | + |
| 133 | +class ConfigItem: |
| 134 | + """ |
| 135 | + Basic data structure to represent a configuration item. |
| 136 | +
|
| 137 | + A `ConfigItem` instance can optionally have a string id, so that other items can refer to it. |
| 138 | + It has a build-in `config` property to store the configuration object. |
| 139 | +
|
| 140 | + Args: |
| 141 | + config: content of a config item, can be objects of any types, |
| 142 | + a configuration resolver may interpret the content to generate a configuration object. |
| 143 | + id: optional name of the current config item, defaults to `None`. |
| 144 | +
|
| 145 | + """ |
| 146 | + |
| 147 | + def __init__(self, config: Any, id: Optional[str] = None) -> None: |
| 148 | + self.config = config |
| 149 | + self.id = id |
| 150 | + |
| 151 | + def get_id(self) -> Optional[str]: |
| 152 | + """ |
| 153 | + Get the ID name of current config item, useful to identify config items during parsing. |
| 154 | +
|
| 155 | + """ |
| 156 | + return self.id |
| 157 | + |
| 158 | + def update_config(self, config: Any): |
| 159 | + """ |
| 160 | + Replace the content of `self.config` with new `config`. |
| 161 | + A typical usage is to modify the initial config content at runtime. |
| 162 | +
|
| 163 | + Args: |
| 164 | + config: content of a `ConfigItem`. |
| 165 | +
|
| 166 | + """ |
| 167 | + self.config = config |
| 168 | + |
| 169 | + def get_config(self): |
| 170 | + """ |
| 171 | + Get the config content of current config item. |
| 172 | +
|
| 173 | + """ |
| 174 | + return self.config |
| 175 | + |
| 176 | + |
| 177 | +class ConfigComponent(ConfigItem, Instantiable): |
| 178 | + """ |
| 179 | + Subclass of :py:class:`monai.apps.ConfigItem`, this class uses a dictionary with string keys to |
| 180 | + represent a component of `class` or `function` and supports instantiation. |
| 181 | +
|
| 182 | + Currently, four special keys (strings surrounded by ``<>``) are defined and interpreted beyond the regular literals: |
| 183 | +
|
| 184 | + - class or function identifier of the python module, specified by one of the two keys. |
| 185 | + - ``"<name>"``: indicates build-in python classes or functions such as "LoadImageDict". |
| 186 | + - ``"<path>"``: full module name, such as "monai.transforms.LoadImageDict". |
| 187 | + - ``"<args>"``: input arguments to the python module. |
| 188 | + - ``"<disabled>"``: a flag to indicate whether to skip the instantiation. |
| 189 | +
|
| 190 | + .. code-block:: python |
| 191 | +
|
| 192 | + locator = ComponentLocator(excludes=["modules_to_exclude"]) |
| 193 | + config = { |
| 194 | + "<name>": "LoadImaged", |
| 195 | + "<args>": { |
| 196 | + "keys": ["image", "label"] |
| 197 | + } |
| 198 | + } |
| 199 | +
|
| 200 | + configer = ConfigComponent(config, id="test", locator=locator) |
| 201 | + image_loader = configer.instantiate() |
| 202 | + print(image_loader) # <monai.transforms.io.dictionary.LoadImaged object at 0x7fba7ad1ee50> |
| 203 | +
|
| 204 | + Args: |
| 205 | + config: content of a config item. |
| 206 | + id: optional name of the current config item, defaults to `None`. |
| 207 | + locator: a ``ComponentLocator`` to convert a module name string into the actual python module. |
| 208 | + if `None`, a ``ComponentLocator(excludes=excludes)`` will be used. |
| 209 | + excludes: if ``locator`` is None, create a new ``ComponentLocator`` with ``excludes``. |
| 210 | + See also: :py:class:`monai.apps.manifest.ComponentLocator`. |
| 211 | +
|
| 212 | + """ |
| 213 | + |
| 214 | + def __init__( |
| 215 | + self, |
| 216 | + config: Any, |
| 217 | + id: Optional[str] = None, |
| 218 | + locator: Optional[ComponentLocator] = None, |
| 219 | + excludes: Optional[Union[Sequence[str], str]] = None, |
| 220 | + ) -> None: |
| 221 | + super().__init__(config=config, id=id) |
| 222 | + self.locator = ComponentLocator(excludes=excludes) if locator is None else locator |
| 223 | + |
| 224 | + @staticmethod |
| 225 | + def is_instantiable(config: Any) -> bool: |
| 226 | + """ |
| 227 | + Check whether this config represents a `class` or `function` that is to be instantiated. |
| 228 | +
|
| 229 | + Args: |
| 230 | + config: input config content to check. |
| 231 | +
|
| 232 | + """ |
| 233 | + return isinstance(config, Mapping) and ("<path>" in config or "<name>" in config) |
| 234 | + |
| 235 | + def resolve_module_name(self): |
| 236 | + """ |
| 237 | + Resolve the target module name from current config content. |
| 238 | + The config content must have ``"<path>"`` or ``"<name>"``. |
| 239 | + When both are specified, ``"<path>"`` will be used. |
| 240 | +
|
| 241 | + """ |
| 242 | + config = dict(self.get_config()) |
| 243 | + path = config.get("<path>") |
| 244 | + if path is not None: |
| 245 | + if not isinstance(path, str): |
| 246 | + raise ValueError(f"'<path>' must be a string, but got: {path}.") |
| 247 | + if "<name>" in config: |
| 248 | + warnings.warn(f"both '<path>' and '<name>', default to use '<path>': {path}.") |
| 249 | + return path |
| 250 | + |
| 251 | + name = config.get("<name>") |
| 252 | + if not isinstance(name, str): |
| 253 | + raise ValueError("must provide a string for `<path>` or `<name>` of target component to instantiate.") |
| 254 | + |
| 255 | + module = self.locator.get_component_module_name(name) |
| 256 | + if module is None: |
| 257 | + raise ModuleNotFoundError(f"can not find component '{name}' in {self.locator.MOD_START} modules.") |
| 258 | + if isinstance(module, list): |
| 259 | + warnings.warn( |
| 260 | + f"there are more than 1 component have name `{name}`: {module}, use the first one `{module[0]}." |
| 261 | + f" if want to use others, please set its module path in `<path>` directly." |
| 262 | + ) |
| 263 | + module = module[0] |
| 264 | + return f"{module}.{name}" |
| 265 | + |
| 266 | + def resolve_args(self): |
| 267 | + """ |
| 268 | + Utility function used in `instantiate()` to resolve the arguments from current config content. |
| 269 | +
|
| 270 | + """ |
| 271 | + return self.get_config().get("<args>", {}) |
| 272 | + |
| 273 | + def is_disabled(self) -> bool: # type: ignore |
| 274 | + """ |
| 275 | + Utility function used in `instantiate()` to check whether to skip the instantiation. |
| 276 | +
|
| 277 | + """ |
| 278 | + _is_disabled = self.get_config().get("<disabled>", False) |
| 279 | + return _is_disabled.lower().strip() == "true" if isinstance(_is_disabled, str) else bool(_is_disabled) |
| 280 | + |
| 281 | + def instantiate(self, **kwargs) -> object: # type: ignore |
| 282 | + """ |
| 283 | + Instantiate component based on ``self.config`` content. |
| 284 | + The target component must be a `class` or a `function`, otherwise, return `None`. |
| 285 | +
|
| 286 | + Args: |
| 287 | + kwargs: args to override / add the config args when instantiation. |
| 288 | +
|
| 289 | + """ |
| 290 | + if not self.is_instantiable(self.get_config()) or self.is_disabled(): |
| 291 | + # if not a class or function or marked as `disabled`, skip parsing and return `None` |
| 292 | + return None |
| 293 | + |
| 294 | + modname = self.resolve_module_name() |
| 295 | + args = self.resolve_args() |
| 296 | + args.update(kwargs) |
| 297 | + return instantiate(modname, **args) |
| 298 | + |
| 299 | + |
| 300 | +class ConfigExpression(ConfigItem): |
| 301 | + """ |
| 302 | + Subclass of :py:class:`monai.apps.ConfigItem`, the `ConfigItem` represents an executable expression |
| 303 | + (execute based on ``eval()``). |
| 304 | +
|
| 305 | + See also: |
| 306 | +
|
| 307 | + - https://docs.python.org/3/library/functions.html#eval. |
| 308 | +
|
| 309 | + For example: |
| 310 | +
|
| 311 | + .. code-block:: python |
| 312 | +
|
| 313 | + import monai |
| 314 | + from monai.apps.manifest import ConfigExpression |
| 315 | +
|
| 316 | + config = "$monai.__version__" |
| 317 | + expression = ConfigExpression(config, id="test", globals={"monai": monai}) |
| 318 | + print(expression.execute()) |
| 319 | +
|
| 320 | + Args: |
| 321 | + config: content of a config item. |
| 322 | + id: optional name of current config item, defaults to `None`. |
| 323 | + globals: additional global context to evaluate the string. |
| 324 | +
|
| 325 | + """ |
| 326 | + |
| 327 | + def __init__(self, config: Any, id: Optional[str] = None, globals: Optional[Dict] = None) -> None: |
| 328 | + super().__init__(config=config, id=id) |
| 329 | + self.globals = globals |
| 330 | + |
| 331 | + def evaluate(self, locals: Optional[Dict] = None): |
| 332 | + """ |
| 333 | + Excute current config content and return the result if it is expression, based on python `eval()`. |
| 334 | + For more details: https://docs.python.org/3/library/functions.html#eval. |
| 335 | +
|
| 336 | + Args: |
| 337 | + locals: besides ``globals``, may also have some local symbols used in the expression at runtime. |
| 338 | +
|
| 339 | + """ |
| 340 | + value = self.get_config() |
| 341 | + if not ConfigExpression.is_expression(value): |
| 342 | + return None |
| 343 | + return eval(value[1:], self.globals, locals) |
| 344 | + |
| 345 | + @staticmethod |
| 346 | + def is_expression(config: Union[Dict, List, str]) -> bool: |
| 347 | + """ |
| 348 | + Check whether the config is an executable expression string. |
| 349 | + Currently A string starts with ``"$"`` character is interpreted as an expression. |
| 350 | +
|
| 351 | + Args: |
| 352 | + config: input config content to check. |
| 353 | +
|
| 354 | + """ |
| 355 | + return isinstance(config, str) and config.startswith("$") |
0 commit comments