Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion monai/bundle/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,89 @@
_default_globals = {"monai": "monai", "torch": "torch", "np": "numpy", "numpy": "numpy"}


def _wrap_parsed(parser: ConfigParser, id: str, value: Any) -> Any:
"""Wrap a parsed dict/list in a ``_ConfigProxy`` so nested access keeps chaining; pass scalars through."""
if isinstance(value, (dict, list)):
return _ConfigProxy(parser, id, value)
return value


class _ConfigProxy:
"""
Proxy that enables dot-notation and bracket-notation access to nested config structures.

When ``ConfigParser.__getattr__`` resolves to a dict or list, the result is wrapped
in this proxy so that further attribute and index access chains through the config
hierarchy using ``get_parsed_content``.

For example, ``parser.training.trainer.max_epochs`` is equivalent to
``parser.get_parsed_content("training::trainer::max_epochs")``.

If a config key is not found, the proxy falls back to the underlying dict/list, so
that dict/list methods (``.keys()``, ``.items()``, etc.) and normal indexing
semantics (``IndexError``, negative indices) still work when there is no config key
of the same name. Config keys that collide with dict/list method names (e.g.
``"keys"``) should be accessed via bracket notation or ``get_parsed_content``, and
the underlying dict/list is available via ``._raw``.

"""

def __init__(self, parser: ConfigParser, id: str, value: Any):
self._parser = parser
self._id = id
self._value = value

def _chain(self, key: str) -> Any:
new_id = f"{self._id}{ID_SEP_KEY}{key}"
return _wrap_parsed(self._parser, new_id, self._parser.get_parsed_content(new_id))

def __getattr__(self, key: str) -> Any:
try:
return self._chain(key)
except KeyError:
return getattr(self._value, key)

def __getitem__(self, key: str | int) -> Any:
try:
return self._chain(str(key))
except KeyError:
# no config key of that name: defer to the underlying dict/list so normal
# indexing semantics apply (IndexError, negative indices, dict KeyError).
return self._value[key]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def __setitem__(self, key: str | int, value: Any) -> None:
# writes go to the underlying dict/list, preserving the pre-proxy behaviour
# where ``parser.foo`` returned the raw container.
self._value[key] = value

def __delitem__(self, key: str | int) -> None:
del self._value[key]

def __len__(self) -> int:
return len(self._value)

def __iter__(self) -> Any:
return iter(self._value)

def __contains__(self, item: object) -> bool:
return item in self._value

def __bool__(self) -> bool:
return bool(self._value)

def __repr__(self) -> str:
return repr(self._value)

def __eq__(self, other: object) -> bool:
if isinstance(other, _ConfigProxy):
other = other._value
return bool(self._value == other)

@property
def _raw(self) -> Any:
return self._value


class ConfigParser:
"""
The primary configuration parser. It traverses a structured config (in the form of nested Python dict or list),
Expand Down Expand Up @@ -127,14 +210,18 @@ def __getattr__(self, id):
"""
Get the parsed result of ``ConfigItem`` with the specified ``id``
with default arguments (e.g. ``lazy=True``, ``instantiate=True`` and ``eval_expr=True``).
When the result is a dict or list, it is wrapped in a ``_ConfigProxy`` so that
nested attributes and indices chain through the config hierarchy.
For example, ``parser.training.trainer.max_epochs`` is equivalent to
``parser.get_parsed_content("training::trainer::max_epochs")``.

Args:
id: id of the ``ConfigItem``.

See also:
:py:meth:`get_parsed_content`
"""
return self.get_parsed_content(id)
return _wrap_parsed(self, id, self.get_parsed_content(id))

def __getitem__(self, id: str | int) -> Any:
"""
Expand Down
50 changes: 50 additions & 0 deletions tests/bundle/test_config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,56 @@ def test_get_via_attributes(self):
result = trans(np.ones(64))
self.assertTupleEqual(result.shape, (1, 8, 8))

def test_nested_dot_notation(self):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be broken up into multiple tests focused on specific areas. Checking exception raises should be separate, for example. You can put config into a setUp method to avoid copying it.

config = {
"A": {"B": {"C": 1, "D": [10, 20]}},
"training": {"trainer": {"max_epochs": 100, "lr": 0.001}},
"transforms": [{"keys": "image"}, {"keys": "label"}],
"my_dims": 2,
"dims_1": "$@my_dims + 1",
}
parser = ConfigParser(config=config, globals={"monai": "monai"})

self.assertEqual(parser.A.B.C, 1)
self.assertEqual(parser.training.trainer.max_epochs, 100)
self.assertEqual(parser.training.trainer.lr, 0.001)
self.assertEqual(parser.dims_1, 3)

self.assertEqual(parser.A.B.D[0], 10)
self.assertEqual(parser.A.B.D[1], 20)

self.assertEqual(parser.transforms[0].keys, "image")
self.assertEqual(parser.transforms[1].keys, "label")

self.assertEqual(parser.A._raw, {"B": {"C": 1, "D": [10, 20]}})

# container protocol delegates to the underlying dict/list
self.assertEqual(len(parser.A.B.D), 2)
self.assertEqual(list(parser.A.B.D), [10, 20])
self.assertIn("B", parser.A)
self.assertTrue(parser.A.B.D)
self.assertFalse(ConfigParser(config={"e": []}, globals={"monai": "monai"}).e)

# bracket access falls back to native container semantics when there is no
# config key of that name: negative indexing, IndexError, and dict KeyError.
self.assertEqual(parser.A.B.D[-1], 20)
with self.assertRaises(IndexError):
_ = parser.A.B.D[5]
with self.assertRaises(KeyError):
_ = parser.A.B["nonexistent"]

with self.assertRaises(AttributeError):
_ = parser.A.nonexistent

# item assignment/deletion writes through to the underlying container,
# preserving the pre-proxy behaviour where ``parser.x`` was the raw dict/list.
parser.A.B["C"] = 99
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think _ConfigProxy.__setattr__ may need to be implemented or something else fixed. If I do parser.A.X=[2,3] this should create an item X in the dictionary A as defined in your test here, and I can see that dictionary changed in the return value for parser.get_parsed_content("A"). However, the expression parser.A.X raises AttributeError: 'dict' object has no attribute 'X'. I don't think the assignment is updating the underlying config structure correctly. You should add tests that attempt to add members this way then extract the value again, and also check against get_parsed_content.

self.assertEqual(parser.A.B._raw["C"], 99)
parser.A.B.D[0] = 11
self.assertEqual(parser.A.B.D._raw, [11, 20])
del parser.A.B["C"]
self.assertNotIn("C", parser.A.B._raw)

def test_builtin(self):
config = {"import statements": "$import math", "calc": {"_target_": "math.isclose", "a": 0.001, "b": 0.001}}
self.assertEqual(ConfigParser(config).calc, True)
Expand Down
Loading