Skip to content

Commit 8040aa2

Browse files
authored
6721 bundle syntax # as alias of :: (#6955)
fixes #6721 ### Description compatible syntax by normalising any `#` in ids to `::` ```py from monai.bundle import ConfigParser config = { "my_dims": 2, "dims_1": "$@my_dims + 1", "my_net": {"_target_": "BasicUNet", "spatial_dims": "@dims_1", "in_channels": 1, "out_channels": 4}, } # in the example $@my_dims + 1 is an expression, which adds 1 to the value of @my_dims parser = ConfigParser(config) print(parser.get_parsed_content("my_net::spatial_dims")) # returns 3 print(parser.get_parsed_content("my_net#spatial_dims")) # returns 3 ``` new test cases: https://github.com/Project-MONAI/MONAI/blob/66b50fb8384ae2dc3c76b39de673ce76908f94f2/tests/test_config_parser.py#L317-L321 ### Types of changes <!--- Put an `x` in all the boxes that apply, and remove the not applicable items --> - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. --------- Signed-off-by: Wenqi Li <wenqil@nvidia.com>
1 parent ef7debe commit 8040aa2

File tree

7 files changed

+101
-56
lines changed

7 files changed

+101
-56
lines changed

docs/source/config_syntax.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,15 @@ A few characters and keywords are interpreted beyond the plain texts, here are e
7676
### To reference Python objects in configurations
7777

7878
```json
79-
"@preprocessing#transforms#keys"
79+
"@preprocessing::transforms::keys"
8080
```
8181

82-
_Description:_ `@` character indicates a reference to another configuration value defined at `preprocessing#transforms#keys`.
83-
where `#` indicates a sub-structure of this configuration file.
82+
_Description:_ `@` character indicates a reference to another configuration value defined at `preprocessing::transforms::keys`.
83+
where `::` indicates a sub-structure of this configuration file. (`#` is a synonym for `::`, `preprocessing#transforms#keys`
84+
refers to the same object.)
8485

8586
```json
86-
"@preprocessing#1"
87+
"@preprocessing::1"
8788
```
8889

8990
_Description:_ `1` is referencing as an integer, which is used to index (zero-based indexing) the `preprocessing` sub-structure.
@@ -122,10 +123,10 @@ It's therefore possible to modify the Python objects within an expression, for e
122123
### To textually replace configuration elements
123124

124125
```json
125-
"%demo_config.json#demo_net#in_channels"
126+
"%demo_config.json::demo_net::in_channels"
126127
```
127128

128-
_Description:_ `%` character indicates a macro to replace the current configuration element with the texts at `demo_net#in_channels` in the
129+
_Description:_ `%` character indicates a macro to replace the current configuration element with the texts at `demo_net::in_channels` in the
129130
`demo_config.json` file. The replacement is done before instantiating or evaluating the components.
130131

131132
### Instantiate a Python object
@@ -203,6 +204,6 @@ Details on the CLI argument parsing is provided in the
203204
simple structures with sparse uses of expressions or references are preferred.
204205
- For `$import <module>` in the configuration, please make sure there are instructions for the users to install
205206
the `<module>` if it is not a (optional) dependency of MONAI.
206-
- As "#" and "$" might be interpreted differently by the `shell` or `CLI` tools, may need to add escape characters
207+
- As `#`, `::`, and `$` might be interpreted differently by the `shell` or `CLI` tools, may need to add escape characters
207208
or quotes for them in the command line, like: `"\$torch.device('cuda:1')"`, `"'train_part#trainer'"`.
208209
- For more details and examples, please see [the tutorials](https://github.com/Project-MONAI/tutorials/tree/main/bundle).

monai/bundle/config_parser.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,16 @@ def __getitem__(self, id: str | int) -> Any:
141141
Get the config by id.
142142
143143
Args:
144-
id: id of the ``ConfigItem``, ``"#"`` in id are interpreted as special characters to
144+
id: id of the ``ConfigItem``, ``"::"`` (or ``"#"``) in id are interpreted as special characters to
145145
go one level further into the nested structures.
146146
Use digits indexing from "0" for list or other strings for dict.
147-
For example: ``"xform#5"``, ``"net#channels"``. ``""`` indicates the entire ``self.config``.
147+
For example: ``"xform::5"``, ``"net::channels"``. ``""`` indicates the entire ``self.config``.
148148
149149
"""
150150
if id == "":
151151
return self.config
152152
config = self.config
153-
for k in str(id).split(ID_SEP_KEY):
153+
for k in ReferenceResolver.split_id(id):
154154
if not isinstance(config, (dict, list)):
155155
raise ValueError(f"config must be dict or list for key `{k}`, but got {type(config)}: {config}.")
156156
try:
@@ -167,23 +167,22 @@ def __setitem__(self, id: str | int, config: Any) -> None:
167167
to ensure the updates are included in the parsed content.
168168
169169
Args:
170-
id: id of the ``ConfigItem``, ``"#"`` in id are interpreted as special characters to
170+
id: id of the ``ConfigItem``, ``"::"`` (or ``"#"``) in id are interpreted as special characters to
171171
go one level further into the nested structures.
172172
Use digits indexing from "0" for list or other strings for dict.
173-
For example: ``"xform#5"``, ``"net#channels"``. ``""`` indicates the entire ``self.config``.
173+
For example: ``"xform::5"``, ``"net::channels"``. ``""`` indicates the entire ``self.config``.
174174
config: config to set at location ``id``.
175175
176176
"""
177177
if id == "":
178178
self.config = config
179179
self.ref_resolver.reset()
180180
return
181-
keys = str(id).split(ID_SEP_KEY)
181+
last_id, base_id = ReferenceResolver.split_id(id, last=True)
182182
# get the last parent level config item and replace it
183-
last_id = ID_SEP_KEY.join(keys[:-1])
184183
conf_ = self[last_id]
185184

186-
indexing = keys[-1] if isinstance(conf_, dict) else int(keys[-1])
185+
indexing = base_id if isinstance(conf_, dict) else int(base_id)
187186
conf_[indexing] = config
188187
self.ref_resolver.reset()
189188
return
@@ -213,7 +212,7 @@ def set(self, config: Any, id: str = "", recursive: bool = True) -> None:
213212
default to `True`. for the nested id, only support `dict` for the missing section.
214213
215214
"""
216-
keys = str(id).split(ID_SEP_KEY)
215+
keys = ReferenceResolver.split_id(id)
217216
conf_ = self.get()
218217
if recursive:
219218
if conf_ is None:
@@ -222,12 +221,12 @@ def set(self, config: Any, id: str = "", recursive: bool = True) -> None:
222221
if isinstance(conf_, dict) and k not in conf_:
223222
conf_[k] = {}
224223
conf_ = conf_[k if isinstance(conf_, dict) else int(k)]
225-
self[id] = config
224+
self[ReferenceResolver.normalize_id(id)] = config
226225

227226
def update(self, pairs: dict[str, Any]) -> None:
228227
"""
229228
Set the ``id`` and the corresponding config content in pairs, see also :py:meth:`__setitem__`.
230-
For example, ``parser.update({"train#epoch": 100, "train#lr": 0.02})``
229+
For example, ``parser.update({"train::epoch": 100, "train::lr": 0.02})``
231230
232231
Args:
233232
pairs: dictionary of `id` and config pairs.
@@ -272,10 +271,10 @@ def get_parsed_content(self, id: str = "", **kwargs: Any) -> Any:
272271
- Else, the result is the configuration content of `ConfigItem`.
273272
274273
Args:
275-
id: id of the ``ConfigItem``, ``"#"`` in id are interpreted as special characters to
274+
id: id of the ``ConfigItem``, ``"::"`` (or ``"#"``) in id are interpreted as special characters to
276275
go one level further into the nested structures.
277276
Use digits indexing from "0" for list or other strings for dict.
278-
For example: ``"xform#5"``, ``"net#channels"``. ``""`` indicates the entire ``self.config``.
277+
For example: ``"xform::5"``, ``"net::channels"``. ``""`` indicates the entire ``self.config``.
279278
kwargs: additional keyword arguments to be passed to ``_resolve_one_item``.
280279
Currently support ``lazy`` (whether to retain the current config cache, default to `True`),
281280
``instantiate`` (whether to instantiate the `ConfigComponent`, default to `True`) and
@@ -330,16 +329,15 @@ def _do_resolve(self, config: Any, id: str = "") -> Any:
330329
331330
Args:
332331
config: input config file to resolve.
333-
id: id of the ``ConfigItem``, ``"#"`` in id are interpreted as special characters to
332+
id: id of the ``ConfigItem``, ``"::"`` (or ``"#"``) in id are interpreted as special characters to
334333
go one level further into the nested structures.
335334
Use digits indexing from "0" for list or other strings for dict.
336-
For example: ``"xform#5"``, ``"net#channels"``. ``""`` indicates the entire ``self.config``.
335+
For example: ``"xform::5"``, ``"net::channels"``. ``""`` indicates the entire ``self.config``.
337336
338337
"""
339338
if isinstance(config, (dict, list)):
340-
for k, v in enumerate(config) if isinstance(config, list) else config.items():
341-
sub_id = f"{id}{ID_SEP_KEY}{k}" if id != "" else k
342-
config[k] = self._do_resolve(v, sub_id)
339+
for k, sub_id, v in self.ref_resolver.iter_subconfigs(id=id, config=config):
340+
config[k] = self._do_resolve(v, sub_id) # type: ignore
343341
if isinstance(config, str):
344342
config = self.resolve_relative_ids(id, config)
345343
if config.startswith(MACRO_KEY):
@@ -354,7 +352,7 @@ def resolve_macro_and_relative_ids(self):
354352
Recursively resolve `self.config` to replace the relative ids with absolute ids, for example,
355353
`@##A` means `A` in the upper level. and replace the macro tokens with target content,
356354
The macro tokens are marked as starting with "%", can be from another structured file, like:
357-
``"%default_net"``, ``"%/data/config.json#net"``.
355+
``"%default_net"``, ``"%/data/config.json::net"``.
358356
359357
"""
360358
self.set(self._do_resolve(config=self.get()))
@@ -365,15 +363,14 @@ def _do_parse(self, config: Any, id: str = "") -> None:
365363
366364
Args:
367365
config: config source to parse.
368-
id: id of the ``ConfigItem``, ``"#"`` in id are interpreted as special characters to
366+
id: id of the ``ConfigItem``, ``"::"`` (or ``"#"``) in id are interpreted as special characters to
369367
go one level further into the nested structures.
370368
Use digits indexing from "0" for list or other strings for dict.
371-
For example: ``"xform#5"``, ``"net#channels"``. ``""`` indicates the entire ``self.config``.
369+
For example: ``"xform::5"``, ``"net::channels"``. ``""`` indicates the entire ``self.config``.
372370
373371
"""
374372
if isinstance(config, (dict, list)):
375-
for k, v in enumerate(config) if isinstance(config, list) else config.items():
376-
sub_id = f"{id}{ID_SEP_KEY}{k}" if id != "" else k
373+
for _, sub_id, v in self.ref_resolver.iter_subconfigs(id=id, config=config):
377374
self._do_parse(config=v, id=sub_id)
378375

379376
if ConfigComponent.is_instantiable(config):
@@ -410,7 +407,7 @@ def load_config_files(cls, files: PathLike | Sequence[PathLike] | dict, **kwargs
410407
"""
411408
Load config files into a single config dict.
412409
The latter config file in the list will override or add the former config file.
413-
``"#"`` in the config keys are interpreted as special characters to go one level
410+
``"::"`` (or ``"#"``) in the config keys are interpreted as special characters to go one level
414411
further into the nested structures.
415412
416413
Args:
@@ -451,13 +448,14 @@ def export_config_file(cls, config: dict, filepath: PathLike, fmt: str = "json",
451448
def split_path_id(cls, src: str) -> tuple[str, str]:
452449
"""
453450
Split `src` string into two parts: a config file path and component id.
454-
The file path should end with `(json|yaml|yml)`. The component id should be separated by `#` if it exists.
451+
The file path should end with `(json|yaml|yml)`. The component id should be separated by `::` if it exists.
455452
If no path or no id, return "".
456453
457454
Args:
458455
src: source string to split.
459456
460457
"""
458+
src = ReferenceResolver.normalize_id(src)
461459
result = re.compile(rf"({cls.suffix_match}(?=(?:{ID_SEP_KEY}.*)|$))", re.IGNORECASE).findall(src)
462460
if not result:
463461
return "", src # the src is a pure id
@@ -488,6 +486,7 @@ def resolve_relative_ids(cls, id: str, value: str) -> str:
488486
489487
"""
490488
# get the prefixes like: "@####", "%###", "@#"
489+
value = ReferenceResolver.normalize_id(value)
491490
prefixes = sorted(set().union(cls.relative_id_prefix.findall(value)), reverse=True)
492491
current_id = id.split(ID_SEP_KEY)
493492

monai/bundle/reference_resolver.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import re
1515
import warnings
1616
from collections.abc import Sequence
17-
from typing import Any
17+
from typing import Any, Iterator
1818

1919
from monai.bundle.config_item import ConfigComponent, ConfigExpression, ConfigItem
2020
from monai.bundle.utils import ID_REF_KEY, ID_SEP_KEY
@@ -31,7 +31,7 @@ class ReferenceResolver:
3131
The IDs must be unique within this set. A string in ``ConfigItem``
3232
starting with ``@`` will be treated as a reference to other ``ConfigItem`` objects by ID.
3333
Since ``ConfigItem`` may have a nested dictionary or list structure,
34-
the reference string may also contain a ``#`` character to refer to a substructure by
34+
the reference string may also contain the separator ``::`` to refer to a substructure by
3535
key indexing for a dictionary or integer indexing for a list.
3636
3737
In this class, resolving references is essentially substitution of the reference strings with the
@@ -52,7 +52,7 @@ class ReferenceResolver:
5252
_vars = "__local_refs"
5353
sep = ID_SEP_KEY # separator for key indexing
5454
ref = ID_REF_KEY # reference prefix
55-
# match a reference string, e.g. "@id#key", "@id#key#0", "@_target_#key"
55+
# match a reference string, e.g. "@id::key", "@id::key::0", "@_target_::key"
5656
id_matcher = re.compile(rf"{ref}(?:\w*)(?:{sep}\w*)*")
5757
# if `allow_missing_reference` and can't find a reference ID, will just raise a warning and don't update the config
5858
allow_missing_reference = allow_missing_reference
@@ -99,6 +99,7 @@ def get_item(self, id: str, resolve: bool = False, **kwargs: Any) -> ConfigItem
9999
kwargs: keyword arguments to pass to ``_resolve_one_item()``.
100100
Currently support ``instantiate`` and ``eval_expr``. Both are defaulting to True.
101101
"""
102+
id = self.normalize_id(id)
102103
if resolve and id not in self.resolved_content:
103104
self._resolve_one_item(id=id, **kwargs)
104105
return self.items.get(id)
@@ -121,6 +122,7 @@ def _resolve_one_item(
121122
if the `id` is not in the config content, must be a `ConfigItem` object.
122123
123124
"""
125+
id = self.normalize_id(id)
124126
if id in self.resolved_content:
125127
return self.resolved_content[id]
126128
try:
@@ -190,18 +192,56 @@ def get_resolved_content(self, id: str, **kwargs: Any) -> ConfigExpression | str
190192
"""
191193
return self._resolve_one_item(id=id, **kwargs)
192194

195+
@classmethod
196+
def normalize_id(cls, id: str | int) -> str:
197+
"""
198+
Normalize the id string to consistently use `cls.sep`.
199+
200+
Args:
201+
id: id string to be normalized.
202+
"""
203+
return str(id).replace("#", cls.sep) # backward compatibility `#` is the old separator
204+
205+
@classmethod
206+
def split_id(cls, id: str | int, last: bool = False) -> list[str]:
207+
"""
208+
Split the id string into a list of strings by `cls.sep`.
209+
210+
Args:
211+
id: id string to be split.
212+
last: whether to split the rightmost part of the id. default is False (split all parts).
213+
"""
214+
if not last:
215+
return cls.normalize_id(id).split(cls.sep)
216+
res = cls.normalize_id(id).rsplit(cls.sep, 1)
217+
return ["".join(res[:-1]), res[-1]]
218+
219+
@classmethod
220+
def iter_subconfigs(cls, id: str, config: Any) -> Iterator[tuple[str, str, Any]]:
221+
"""
222+
Iterate over the sub-configs of the input config, the output `sub_id` uses `cls.sep` to denote substructure.
223+
224+
Args:
225+
id: id string of the current input config.
226+
config: input config to be iterated.
227+
"""
228+
for k, v in config.items() if isinstance(config, dict) else enumerate(config):
229+
sub_id = f"{id}{cls.sep}{k}" if id != "" else f"{k}"
230+
yield k, sub_id, v
231+
193232
@classmethod
194233
def match_refs_pattern(cls, value: str) -> dict[str, int]:
195234
"""
196235
Match regular expression for the input string to find the references.
197-
The reference string starts with ``"@"``, like: ``"@XXX#YYY#ZZZ"``.
236+
The reference string starts with ``"@"``, like: ``"@XXX::YYY::ZZZ"``.
198237
199238
Args:
200239
value: input value to match regular expression.
201240
202241
"""
203242
refs: dict[str, int] = {}
204-
# regular expression pattern to match "@XXX" or "@XXX#YYY"
243+
# regular expression pattern to match "@XXX" or "@XXX::YYY"
244+
value = cls.normalize_id(value)
205245
result = cls.id_matcher.findall(value)
206246
value_is_expr = ConfigExpression.is_expression(value)
207247
for item in result:
@@ -215,15 +255,16 @@ def match_refs_pattern(cls, value: str) -> dict[str, int]:
215255
def update_refs_pattern(cls, value: str, refs: dict) -> str:
216256
"""
217257
Match regular expression for the input string to update content with the references.
218-
The reference part starts with ``"@"``, like: ``"@XXX#YYY#ZZZ"``.
258+
The reference part starts with ``"@"``, like: ``"@XXX::YYY::ZZZ"``.
219259
References dictionary must contain the referring IDs as keys.
220260
221261
Args:
222262
value: input value to match regular expression.
223263
refs: all the referring components with ids as keys, default to `None`.
224264
225265
"""
226-
# regular expression pattern to match "@XXX" or "@XXX#YYY"
266+
# regular expression pattern to match "@XXX" or "@XXX::YYY"
267+
value = cls.normalize_id(value)
227268
result = cls.id_matcher.findall(value)
228269
# reversely sort the matched references by length
229270
# and handle the longer first in case a reference item is substring of another longer item
@@ -235,11 +276,10 @@ def update_refs_pattern(cls, value: str, refs: dict) -> str:
235276
ref_id = item[len(cls.ref) :] # remove the ref prefix "@"
236277
if ref_id not in refs:
237278
msg = f"can not find expected ID '{ref_id}' in the references."
238-
if cls.allow_missing_reference:
239-
warnings.warn(msg)
240-
continue
241-
else:
279+
if not cls.allow_missing_reference:
242280
raise KeyError(msg)
281+
warnings.warn(msg)
282+
continue
243283
if value_is_expr:
244284
# replace with local code, `{"__local_refs": self.resolved_content}` will be added to
245285
# the `globals` argument of python `eval` in the `evaluate`
@@ -265,12 +305,11 @@ def find_refs_in_config(cls, config: Any, id: str, refs: dict[str, int] | None =
265305
"""
266306
refs_: dict[str, int] = refs or {}
267307
if isinstance(config, str):
268-
for id, count in cls.match_refs_pattern(value=config).items():
308+
for id, count in cls.match_refs_pattern(value=config).items(): # ref count is not currently used
269309
refs_[id] = refs_.get(id, 0) + count
270310
if not isinstance(config, (list, dict)):
271311
return refs_
272-
for k, v in config.items() if isinstance(config, dict) else enumerate(config):
273-
sub_id = f"{id}{cls.sep}{k}" if id != "" else f"{k}"
312+
for _, sub_id, v in cls.iter_subconfigs(id, config):
274313
if ConfigComponent.is_instantiable(v) or ConfigExpression.is_expression(v) and sub_id not in refs_:
275314
refs_[sub_id] = 1
276315
refs_ = cls.find_refs_in_config(v, sub_id, refs_)
@@ -294,8 +333,7 @@ def update_config_with_refs(cls, config: Any, id: str, refs: dict | None = None)
294333
if not isinstance(config, (list, dict)):
295334
return config
296335
ret = type(config)()
297-
for idx, v in config.items() if isinstance(config, dict) else enumerate(config):
298-
sub_id = f"{id}{cls.sep}{idx}" if id != "" else f"{idx}"
336+
for idx, sub_id, v in cls.iter_subconfigs(id, config):
299337
if ConfigComponent.is_instantiable(v) or ConfigExpression.is_expression(v):
300338
updated = refs_[sub_id]
301339
if ConfigComponent.is_instantiable(v) and updated is None:

monai/bundle/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
__all__ = ["ID_REF_KEY", "ID_SEP_KEY", "EXPR_KEY", "MACRO_KEY", "DEFAULT_MLFLOW_SETTINGS", "DEFAULT_EXP_MGMT_SETTINGS"]
2525

2626
ID_REF_KEY = "@" # start of a reference to a ConfigItem
27-
ID_SEP_KEY = "#" # separator for the ID of a ConfigItem
27+
ID_SEP_KEY = "::" # separator for the ID of a ConfigItem
2828
EXPR_KEY = "$" # start of a ConfigExpression
2929
MACRO_KEY = "%" # start of a macro of a config
3030

0 commit comments

Comments
 (0)