-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Expand file tree
/
Copy pathconfig_item.py
More file actions
412 lines (324 loc) · 15.8 KB
/
Copy pathconfig_item.py
File metadata and controls
412 lines (324 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# 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 ast
import inspect
import sys
import warnings
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from importlib import import_module
from pprint import pformat
from typing import Any
from monai.bundle.utils import EXPR_KEY
from monai.utils import CompInitMode, ensure_tuple, first, instantiate, optional_import, run_debug, run_eval
__all__ = ["ComponentLocator", "ConfigItem", "ConfigExpression", "ConfigComponent", "Instantiable"]
class Instantiable(ABC):
"""
Base class for an instantiable object.
"""
@abstractmethod
def is_disabled(self, *args: Any, **kwargs: Any) -> bool:
"""
Return a boolean flag to indicate whether the object should be instantiated.
"""
raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.")
@abstractmethod
def instantiate(self, *args: Any, **kwargs: Any) -> object:
"""
Instantiate the target component and return the instance.
"""
raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.")
class ComponentLocator:
"""
Scan all the available classes and functions in the MONAI package and map them with the module paths in a table.
It's used to locate the module path for provided component name.
Args:
excludes: if any string of the `excludes` exists in the full module name, don't import this module.
"""
MOD_START = "monai"
def __init__(self, excludes: Sequence[str] | str | None = None):
self.excludes = [] if excludes is None else ensure_tuple(excludes)
self._components_table: dict[str, list] | None = None
def _find_module_names(self) -> list[str]:
"""
Find all the modules start with MOD_START and don't contain any of `excludes`.
"""
return [m for m in sys.modules if m.startswith(self.MOD_START) and all(s not in m for s in self.excludes)]
def _find_classes_or_functions(self, modnames: Sequence[str] | str) -> dict[str, list]:
"""
Find all the classes and functions in the modules with specified `modnames`.
Args:
modnames: names of the target modules to find all the classes and functions.
"""
table: dict[str, list] = {}
# all the MONAI modules are already loaded by `load_submodules`
for modname in ensure_tuple(modnames):
try:
# scan all the classes and functions in the module
module = import_module(modname)
for name, obj in inspect.getmembers(module):
if (inspect.isclass(obj) or inspect.isfunction(obj)) and obj.__module__ == modname:
if name not in table:
table[name] = []
table[name].append(modname)
except ModuleNotFoundError:
pass
return table
def get_component_module_name(self, name: str) -> list[str] | str | None:
"""
Get the full module name of the class or function with specified ``name``.
If target component name exists in multiple packages or modules, return a list of full module names.
Args:
name: name of the expected class or function.
"""
if not isinstance(name, str):
raise ValueError(f"`name` must be a valid string, but got: {name}.")
if self._components_table is None:
# init component and module mapping table
self._components_table = self._find_classes_or_functions(self._find_module_names())
mods: list[str] | str | None = self._components_table.get(name)
if isinstance(mods, list) and len(mods) == 1:
mods = mods[0]
return mods
class ConfigItem:
"""
Basic data structure to represent a configuration item.
A `ConfigItem` instance can optionally have a string id, so that other items can refer to it.
It has a build-in `config` property to store the configuration object.
Args:
config: content of a config item, can be objects of any types,
a configuration resolver may interpret the content to generate a configuration object.
id: name of the current config item, defaults to empty string.
"""
def __init__(self, config: Any, id: str = "") -> None:
self.config = config
self.id = id
def get_id(self) -> str:
"""
Get the ID name of current config item, useful to identify config items during parsing.
"""
return self.id
def update_config(self, config: Any) -> None:
"""
Replace the content of `self.config` with new `config`.
A typical usage is to modify the initial config content at runtime.
Args:
config: content of a `ConfigItem`.
"""
self.config = config
def get_config(self):
"""
Get the config content of current config item.
"""
return self.config
def __repr__(self) -> str:
return f"{type(self).__name__}: \n{pformat(self.config)}"
class ConfigComponent(ConfigItem, Instantiable):
"""
Subclass of :py:class:`monai.bundle.ConfigItem`, this class uses a dictionary with string keys to
represent a component of `class` or `function` and supports instantiation.
Currently, three special keys (strings surrounded by ``_``) are defined and interpreted beyond the regular literals:
- class or function identifier of the python module, specified by ``"_target_"``,
indicating a monai built-in Python class or function such as ``"LoadImageDict"``,
or a full module name, e.g. ``"monai.transforms.LoadImageDict"``, or a callable, e.g. ``"$@model.forward"``.
- ``"_requires_"`` (optional): specifies reference IDs (string starts with ``"@"``) or ``ConfigExpression``
of the dependencies for this ``ConfigComponent`` object. These dependencies will be
evaluated/instantiated before this object is instantiated. It is useful when the
component doesn't explicitly depend on the other `ConfigItems` via its arguments,
but requires the dependencies to be instantiated/evaluated beforehand.
- ``"_disabled_"`` (optional): a flag to indicate whether to skip the instantiation.
- ``"_desc_"`` (optional): free text descriptions of the component for code readability.
- ``"_mode_"`` (optional): operating mode for invoking the callable ``component`` defined by ``"_target_"``:
- ``"default"``: returns ``component(**kwargs)``
- ``"callable"``: returns ``component`` or, if ``kwargs`` are provided, ``functools.partial(component, **kwargs)``
- ``"debug"``: returns ``pdb.runcall(component, **kwargs)``
Other fields in the config content are input arguments to the python module.
.. code-block:: python
from monai.bundle import ComponentLocator, ConfigComponent
locator = ComponentLocator(excludes=["modules_to_exclude"])
config = {
"_target_": "LoadImaged",
"keys": ["image", "label"]
}
configer = ConfigComponent(config, id="test", locator=locator)
image_loader = configer.instantiate()
print(image_loader) # <monai.transforms.io.dictionary.LoadImaged object at 0x7fba7ad1ee50>
Args:
config: content of a config item.
id: name of the current config item, defaults to empty string.
locator: a ``ComponentLocator`` to convert a module name string into the actual python module.
if `None`, a ``ComponentLocator(excludes=excludes)`` will be used.
excludes: if ``locator`` is None, create a new ``ComponentLocator`` with ``excludes``.
See also: :py:class:`monai.bundle.ComponentLocator`.
"""
non_arg_keys = {"_target_", "_disabled_", "_requires_", "_desc_", "_mode_"}
def __init__(
self,
config: Any,
id: str = "",
locator: ComponentLocator | None = None,
excludes: Sequence[str] | str | None = None,
) -> None:
super().__init__(config=config, id=id)
self.locator = ComponentLocator(excludes=excludes) if locator is None else locator
@staticmethod
def is_instantiable(config: Any) -> bool:
"""
Check whether this config represents a `class` or `function` that is to be instantiated.
Args:
config: input config content to check.
"""
return isinstance(config, Mapping) and "_target_" in config
def resolve_module_name(self):
"""
Resolve the target module name from current config content.
The config content must have ``"_target_"`` key.
"""
config = dict(self.get_config())
target = config.get("_target_")
if not isinstance(target, str):
return target # for feature discussed in project-monai/monai#5852
module = self.locator.get_component_module_name(target)
if module is None:
# target is the full module name, no need to parse
return target
if isinstance(module, list):
warnings.warn(
f"there are more than 1 component have name `{target}`: {module}, use the first one `{module[0]}."
f" if want to use others, please set its full module path in `_target_` directly."
)
module = module[0]
return f"{module}.{target}"
def resolve_args(self):
"""
Utility function used in `instantiate()` to resolve the arguments from current config content.
"""
return {k: v for k, v in self.get_config().items() if k not in self.non_arg_keys}
def is_disabled(self) -> bool:
"""
Utility function used in `instantiate()` to check whether to skip the instantiation.
"""
_is_disabled = self.get_config().get("_disabled_", False)
return _is_disabled.lower().strip() == "true" if isinstance(_is_disabled, str) else bool(_is_disabled)
def instantiate(self, **kwargs: Any) -> object:
"""
Instantiate component based on ``self.config`` content.
The target component must be a `class` or a `function`, otherwise, return `None`.
Args:
kwargs: args to override / add the config args when instantiation.
"""
if not self.is_instantiable(self.get_config()) or self.is_disabled():
# if not a class or function or marked as `disabled`, skip parsing and return `None`
return None
modname = self.resolve_module_name()
mode = self.get_config().get("_mode_", CompInitMode.DEFAULT)
args = self.resolve_args()
args.update(kwargs)
return instantiate(modname, mode, **args)
class ConfigExpression(ConfigItem):
"""
Subclass of :py:class:`monai.bundle.ConfigItem`, the `ConfigItem` represents an executable expression
(execute based on ``eval()``, or import the module to the `globals` if it's an import statement).
See also:
- https://docs.python.org/3/library/functions.html#eval.
For example:
.. code-block:: python
import monai
from monai.bundle import ConfigExpression
config = "$monai.__version__"
expression = ConfigExpression(config, id="test", globals={"monai": monai})
print(expression.evaluate())
Args:
config: content of a config item.
id: name of current config item, defaults to empty string.
globals: additional global context to evaluate the string.
"""
prefix = EXPR_KEY
run_eval = run_eval
def __init__(self, config: Any, id: str = "", globals: dict | None = None) -> None:
super().__init__(config=config, id=id)
self.globals = globals if globals is not None else {}
def _parse_import_string(self, import_string: str) -> Any | None:
"""parse single import statement such as "from monai.transforms import Resize"""
node = first(ast.iter_child_nodes(ast.parse(import_string)))
if not isinstance(node, (ast.Import, ast.ImportFrom)):
return None
if len(node.names) < 1:
return None
if len(node.names) > 1:
warnings.warn(f"ignoring multiple import alias '{import_string}'.")
name, asname = f"{node.names[0].name}", node.names[0].asname
asname = name if asname is None else f"{asname}"
if isinstance(node, ast.ImportFrom):
self.globals[asname], _ = optional_import(f"{node.module}", name=f"{name}")
return self.globals[asname]
if isinstance(node, ast.Import):
self.globals[asname], _ = optional_import(f"{name}")
return self.globals[asname]
return None
def evaluate(self, globals: dict | None = None, locals: dict | None = None) -> str | Any | None:
"""
Execute the current config content and return the result if it is expression, based on Python `eval()`.
For more details: https://docs.python.org/3/library/functions.html#eval.
Args:
globals: besides ``self.globals``, other global symbols used in the expression at runtime.
locals: besides ``globals``, may also have some local symbols used in the expression at runtime.
"""
value = self.get_config()
if not ConfigExpression.is_expression(value):
return None
optional_module = self._parse_import_string(value[len(self.prefix) :])
if optional_module is not None:
return optional_module
if not self.run_eval:
return f"{value[len(self.prefix) :]}"
globals_ = dict(self.globals)
if globals is not None:
for k, v in globals.items():
if k in globals_:
warnings.warn(f"the new global variable `{k}` conflicts with `self.globals`, override it.")
globals_[k] = v
if not run_debug:
try:
return eval(value[len(self.prefix) :], globals_, locals)
except Exception as e:
raise RuntimeError(f"Failed to evaluate {self}") from e
warnings.warn(
f"\n\npdb: value={value}\n"
f"See also Debugger commands documentation: https://docs.python.org/3/library/pdb.html\n"
)
import pdb
pdb.run(value[len(self.prefix) :], globals_, locals)
return None
@classmethod
def is_expression(cls, config: dict | list | str) -> bool:
"""
Check whether the config is an executable expression string.
Currently, a string starts with ``"$"`` character is interpreted as an expression.
Args:
config: input config content to check.
"""
return isinstance(config, str) and config.startswith(cls.prefix)
@classmethod
def is_import_statement(cls, config: dict | list | str) -> bool:
"""
Check whether the config is an import statement (a special case of expression).
Args:
config: input config content to check.
"""
if not cls.is_expression(config):
return False
if "import" not in config:
return False
return isinstance(
first(ast.iter_child_nodes(ast.parse(f"{config[len(cls.prefix) :]}"))), (ast.Import, ast.ImportFrom)
)