Skip to content

Commit 894e989

Browse files
Nic-Mawyli
andauthored
3482 Add ConfigComponent for config parsing (#3720)
* [DLMED] add ConfigComponent Signed-off-by: Nic Ma <[email protected]> * [DLMED] totally update according to comments Signed-off-by: Nic Ma <[email protected]> * [DLMED] add excludes Signed-off-by: Nic Ma <[email protected]> * [DLMED] update according to comments Signed-off-by: Nic Ma <[email protected]> * [DLMED] update ComponentScanner Signed-off-by: Nic Ma <[email protected]> * [DLMED] enhance doc Signed-off-by: Nic Ma <[email protected]> * [DLMED] use load_submodules Signed-off-by: Nic Ma <[email protected]> * [DLMED] remove locate Signed-off-by: Nic Ma <[email protected]> * [DLMED] add test to ensure all components support `locate` Signed-off-by: Nic Ma <[email protected]> * [DLMED] fix min_tests Signed-off-by: Nic Ma <[email protected]> * [DLMED] update according to comments Signed-off-by: Nic Ma <[email protected]> * [DLMED] update according to comments Signed-off-by: Nic Ma <[email protected]> * [DLMED] add more doc-strings Signed-off-by: Nic Ma <[email protected]> * [DLMED] fix flake8 Signed-off-by: Nic Ma <[email protected]> * [DLMED] extract ConfigItem base class Signed-off-by: Nic Ma <[email protected]> * [DLMED] update according to comments Signed-off-by: Nic Ma <[email protected]> * [DLMED] fix typo Signed-off-by: Nic Ma <[email protected]> * [DLMED] update according to comments Signed-off-by: Nic Ma <[email protected]> * update instantiate util Signed-off-by: Wenqi Li <[email protected]> * [DLMED] optimize design Signed-off-by: Nic Ma <[email protected]> * update docstring Signed-off-by: Wenqi Li <[email protected]> * updating ConfigComponent Signed-off-by: Wenqi Li <[email protected]> * revise confi* Signed-off-by: Wenqi Li <[email protected]> * [DLMED] fix unit tests Signed-off-by: Nic Ma <[email protected]> * [DLMED] update function name Signed-off-by: Nic Ma <[email protected]> Co-authored-by: Wenqi Li <[email protected]>
1 parent 046e625 commit 894e989

File tree

8 files changed

+552
-1
lines changed

8 files changed

+552
-1
lines changed

docs/source/apps.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ Clara MMARs
2929
:annotation:
3030

3131

32+
Model Manifest
33+
--------------
34+
35+
.. autoclass:: ComponentLocator
36+
:members:
37+
38+
.. autoclass:: ConfigComponent
39+
:members:
40+
41+
.. autoclass:: ConfigExpression
42+
:members:
43+
44+
.. autoclass:: ConfigItem
45+
:members:
46+
47+
3248
`Utilities`
3349
-----------
3450

monai/apps/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
# limitations under the License.
1111

1212
from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset
13+
from .manifest import ComponentLocator, ConfigComponent, ConfigExpression, ConfigItem
1314
from .mmars import MODEL_DESC, RemoteMMARKeys, download_mmar, get_model_spec, load_from_mmar
1415
from .utils import SUPPORTED_HASH_TYPES, check_hash, download_and_extract, download_url, extractall, get_logger, logger

monai/apps/manifest/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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+
from .config_item import ComponentLocator, ConfigComponent, ConfigExpression, ConfigItem

monai/apps/manifest/config_item.py

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
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

Comments
 (0)