Skip to content

Commit 27a03ef

Browse files
aymuos15ericspod
andauthored
Add nested dot-notation access to ConfigParser (#6837) (#8858)
Fixes #6837. ### Description Follow-up to #5813: extends dot/bracket notation to nested config. Dict/list results from `ConfigParser.__getattr__` are wrapped in a `_ConfigProxy` that chains via `get_parsed_content`, so nested `$@ref`/`_target_` still resolve: parser.training.trainer.max_epochs # = get_parsed_content("training::trainer::max_epochs") `._raw` exposes the raw container; `len`/iter/`in`/`bool` and missing-key access delegate to it. Config keys take precedence over dict/list methods on attribute access (consistent with #5813). ### Types of changes - [x] Non-breaking (caveat: `parser.x` dict/list is now `_ConfigProxy`; `isinstance(parser.x, dict)` no longer matches). - [x] New tests added. - [x] In-line docstrings updated. --------- Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent 286e905 commit 27a03ef

2 files changed

Lines changed: 323 additions & 1 deletion

File tree

monai/bundle/config_parser.py

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import json
1515
import re
1616
from collections.abc import Sequence
17+
from copy import copy as _copy
1718
from copy import deepcopy
1819
from pathlib import Path
1920
from typing import TYPE_CHECKING, Any
@@ -35,6 +36,204 @@
3536
_default_globals = {"monai": "monai", "torch": "torch", "np": "numpy", "numpy": "numpy"}
3637

3738

39+
def _identity(value: Any) -> Any:
40+
"""Module-level reconstructor used by ``_ConfigProxy.__reduce__`` so proxies pickle as their raw value."""
41+
return value
42+
43+
44+
def _wrap_parsed(parser: ConfigParser, id: str, value: Any) -> Any:
45+
"""
46+
Wrap a parsed dict/list in a :class:`_ConfigProxy` so nested access keeps chaining; pass scalars through.
47+
48+
Args:
49+
parser: the owning :class:`ConfigParser`, used to resolve chained ids.
50+
id: the ``::``-separated id that produced ``value``.
51+
value: the parsed content to wrap.
52+
53+
Returns:
54+
A :class:`_ConfigProxy` wrapping ``value`` if it is a ``dict`` or ``list``,
55+
otherwise ``value`` unchanged.
56+
"""
57+
if isinstance(value, (dict, list)):
58+
return _ConfigProxy(parser, id, value)
59+
return value
60+
61+
62+
class _ConfigProxy:
63+
"""
64+
Proxy that enables dot-notation and bracket-notation access to nested config structures.
65+
66+
When :meth:`ConfigParser.__getattr__` resolves to a ``dict`` or ``list``, the result is
67+
wrapped in this proxy so that further attribute and index access chains through the
68+
config hierarchy using :meth:`ConfigParser.get_parsed_content`. For example::
69+
70+
parser.training.trainer.max_epochs
71+
# equivalent to
72+
parser.get_parsed_content("training::trainer::max_epochs")
73+
74+
parser.transforms[0].keys # list indexing chains too
75+
parser.A.B["C"] = 99 # writes update the config source
76+
del parser.A.B["C"] # deletes update the config source
77+
78+
Type caveat:
79+
Accessing a ``dict``/``list`` member through a :class:`ConfigParser` now returns a
80+
``_ConfigProxy``, not the raw container, so ``type(parser.A)`` is ``_ConfigProxy``
81+
and ``isinstance(parser.A, dict)`` is ``False``. Code that needs the real container
82+
should use ``parser.A._raw`` (read-only view) or ``parser.get_parsed_content("A")``.
83+
84+
Precedence and fallback:
85+
Config keys take precedence over ``dict``/``list`` attributes and methods. If a
86+
config key is not found, the proxy falls back to the underlying ``dict``/``list``
87+
so that container methods (``.keys()``, ``.items()`` ...) and native indexing
88+
semantics (``IndexError``, negative indices, dict ``KeyError``) still work. A
89+
config key that collides with a container method name (e.g. ``"keys"``) shadows
90+
that method on attribute access; access it via bracket notation,
91+
:meth:`ConfigParser.get_parsed_content`, or ``._raw``.
92+
93+
Writes:
94+
``__setitem__``/``__setattr__``/``__delitem__``/``__delattr__`` write through to
95+
the config *source* (via :class:`ConfigParser`) and reset the reference resolver,
96+
so the change is visible from both ``parser.<id>`` and
97+
``parser.get_parsed_content("<id>")``.
98+
"""
99+
100+
_INTERNAL = ("_parser", "_id", "_value")
101+
102+
def __init__(self, parser: ConfigParser, id: str, value: Any):
103+
"""
104+
Args:
105+
parser: the owning :class:`ConfigParser`.
106+
id: the ``::``-separated id this proxy represents.
107+
value: the parsed ``dict``/``list`` content this proxy wraps.
108+
"""
109+
self._parser = parser
110+
self._id = id
111+
self._value = value
112+
113+
def _child_id(self, key: str | int) -> str:
114+
return f"{self._id}{ID_SEP_KEY}{key}"
115+
116+
def _backing_id(self) -> str:
117+
"""Return the real config id this proxy writes to, resolving all ``$@ref`` hops transitively."""
118+
current = self._id
119+
seen: set[str] = set()
120+
while True:
121+
if current in seen:
122+
break
123+
seen.add(current)
124+
raw = self._parser[current]
125+
if not isinstance(raw, str):
126+
break
127+
refs = ReferenceResolver.match_refs_pattern(raw)
128+
if not refs:
129+
break
130+
current = next(iter(refs))
131+
return current
132+
133+
def _chain(self, key: str) -> Any:
134+
"""
135+
Resolve ``key`` as a nested config id.
136+
137+
Args:
138+
key: the child key/index.
139+
140+
Returns:
141+
The parsed child content, wrapped via :func:`_wrap_parsed`.
142+
143+
Raises:
144+
KeyError: if there is no config item at the chained id.
145+
"""
146+
new_id = self._child_id(key)
147+
return _wrap_parsed(self._parser, new_id, self._parser.get_parsed_content(new_id))
148+
149+
def __getattr__(self, key: str) -> Any:
150+
"""
151+
Resolve ``key`` as a nested config attribute, falling back to the underlying container.
152+
153+
Dunder names are never treated as config keys, so the proxy stays well-behaved
154+
with ``copy``/``pickle``/``hasattr`` and other stdlib introspection.
155+
156+
Raises:
157+
AttributeError: if ``key`` is neither a config key nor an attribute of the
158+
underlying ``dict``/``list``.
159+
"""
160+
if key.startswith("__") and key.endswith("__"):
161+
raise AttributeError(key)
162+
try:
163+
return self._chain(key)
164+
except KeyError:
165+
return getattr(self._value, key)
166+
167+
def __getitem__(self, key: str | int) -> Any:
168+
try:
169+
return self._chain(str(key))
170+
except KeyError:
171+
# no config key of that name: defer to the underlying dict/list so normal
172+
# indexing semantics apply (IndexError, negative indices, dict KeyError).
173+
return self._value[key]
174+
175+
def __setitem__(self, key: str | int, value: Any) -> None:
176+
# Write directly to the backing container so literal dict keys are preserved,
177+
# matching the semantics of __delitem__ and __getitem__.
178+
backing = self._backing_id()
179+
node = self._parser[backing]
180+
node[key if isinstance(node, dict) else int(key)] = value
181+
self._parser.ref_resolver.reset()
182+
183+
def __delitem__(self, key: str | int) -> None:
184+
backing = self._backing_id()
185+
node = self._parser[backing]
186+
del node[key if isinstance(node, dict) else int(key)]
187+
self._parser.ref_resolver.reset()
188+
189+
def __setattr__(self, key: str, value: Any) -> None:
190+
if key in _ConfigProxy._INTERNAL:
191+
object.__setattr__(self, key, value)
192+
return
193+
if key == "_raw":
194+
raise AttributeError("_raw is read-only")
195+
self[key] = value
196+
197+
def __delattr__(self, key: str) -> None:
198+
if key == "_raw":
199+
raise AttributeError("_raw is read-only")
200+
del self[key]
201+
202+
def __len__(self) -> int:
203+
return len(self._value)
204+
205+
def __iter__(self) -> Any:
206+
return iter(self._value)
207+
208+
def __contains__(self, item: object) -> bool:
209+
return item in self._value
210+
211+
def __bool__(self) -> bool:
212+
return bool(self._value)
213+
214+
def __repr__(self) -> str:
215+
return repr(self._value)
216+
217+
def __eq__(self, other: object) -> Any:
218+
if isinstance(other, _ConfigProxy):
219+
other = other._value
220+
return self._value == other
221+
222+
def __copy__(self) -> Any:
223+
return _copy(self._value)
224+
225+
def __deepcopy__(self, memo: Any) -> Any:
226+
return deepcopy(self._value, memo)
227+
228+
def __reduce__(self) -> Any:
229+
return (_identity, (self._value,))
230+
231+
@property
232+
def _raw(self) -> Any:
233+
"""The underlying ``dict``/``list`` container (the reference is read-only; the container contents are not copied)."""
234+
return self._value
235+
236+
38237
class ConfigParser:
39238
"""
40239
The primary configuration parser. It traverses a structured config (in the form of nested Python dict or list),
@@ -127,14 +326,23 @@ def __getattr__(self, id):
127326
"""
128327
Get the parsed result of ``ConfigItem`` with the specified ``id``
129328
with default arguments (e.g. ``lazy=True``, ``instantiate=True`` and ``eval_expr=True``).
329+
When the result is a dict or list, it is wrapped in a ``_ConfigProxy`` so that
330+
nested attributes and indices chain through the config hierarchy.
331+
For example, ``parser.training.trainer.max_epochs`` is equivalent to
332+
``parser.get_parsed_content("training::trainer::max_epochs")``.
130333
131334
Args:
132335
id: id of the ``ConfigItem``.
133336
337+
Returns:
338+
The parsed content (instance, evaluated expression, or config value). When it
339+
is a ``dict`` or ``list`` it is wrapped in a :class:`_ConfigProxy` so nested
340+
attributes/indices chain through the config hierarchy.
341+
134342
See also:
135343
:py:meth:`get_parsed_content`
136344
"""
137-
return self.get_parsed_content(id)
345+
return _wrap_parsed(self, id, self.get_parsed_content(id))
138346

139347
def __getitem__(self, id: str | int) -> Any:
140348
"""

tests/bundle/test_config_parser.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
from __future__ import annotations
1313

14+
import copy
1415
import os
16+
import pickle
1517
import tempfile
1618
import unittest
1719
import warnings
@@ -388,5 +390,117 @@ def test_load_configs(
388390
self.assertEqual(parser["key2"], expected_merged_vals)
389391

390392

393+
class TestConfigProxy(unittest.TestCase):
394+
"""Nested dot-/bracket-notation access on ConfigParser (issue #6837)."""
395+
396+
def setUp(self):
397+
self.config = {
398+
"A": {"B": {"C": 1, "D": [10, 20]}},
399+
"training": {"trainer": {"max_epochs": 100, "lr": 0.001}},
400+
"transforms": [{"keys": "image"}, {"keys": "label"}],
401+
"my_dims": 2,
402+
"dims_1": "$@my_dims + 1",
403+
}
404+
self.parser = ConfigParser(config=self.config, globals={"monai": "monai"})
405+
406+
def test_nested_attribute_access(self):
407+
self.assertEqual(self.parser.A.B.C, 1)
408+
self.assertEqual(self.parser.training.trainer.max_epochs, 100)
409+
self.assertEqual(self.parser.training.trainer.lr, 0.001)
410+
self.assertEqual(self.parser.dims_1, 3)
411+
412+
def test_nested_index_access(self):
413+
self.assertEqual(self.parser.A.B.D[0], 10)
414+
self.assertEqual(self.parser.A.B.D[1], 20)
415+
self.assertEqual(self.parser.transforms[0].keys, "image")
416+
self.assertEqual(self.parser.transforms[1].keys, "label")
417+
418+
def test_raw_and_container_protocol(self):
419+
self.assertEqual(self.parser.A._raw, {"B": {"C": 1, "D": [10, 20]}})
420+
self.assertEqual(len(self.parser.A.B.D), 2)
421+
self.assertEqual(list(self.parser.A.B.D), [10, 20])
422+
self.assertIn("B", self.parser.A)
423+
self.assertTrue(self.parser.A.B.D)
424+
self.assertFalse(ConfigParser(config={"e": []}, globals={"monai": "monai"}).e)
425+
426+
def test_native_index_fallback(self):
427+
# bracket access falls back to native container semantics when there is no
428+
# config key of that name: negative indexing still works.
429+
self.assertEqual(self.parser.A.B.D[-1], 20)
430+
431+
def test_attribute_write_through(self):
432+
# attribute assignment updates the config source and is visible from both
433+
# ``parser.<id>`` and ``get_parsed_content``.
434+
self.parser.A.X = [2, 3]
435+
self.assertEqual(self.parser.A.X, [2, 3])
436+
self.assertIn("X", self.parser.get_parsed_content("A"))
437+
self.assertEqual(self.parser.get_parsed_content("A::X"), [2, 3])
438+
439+
def test_item_write_through(self):
440+
self.parser.A.B["C"] = 99
441+
self.assertEqual(self.parser.A.B.C, 99)
442+
self.assertEqual(self.parser.get_parsed_content("A::B::C"), 99)
443+
self.parser.A.B.D[0] = 11
444+
self.assertEqual(self.parser.A.B.D._raw, [11, 20])
445+
self.assertEqual(self.parser.get_parsed_content("A::B::D"), [11, 20])
446+
447+
def test_delete_write_through(self):
448+
del self.parser.A.B["C"]
449+
self.assertNotIn("C", self.parser.get_parsed_content("A::B"))
450+
del self.parser.training.trainer
451+
self.assertNotIn("trainer", self.parser.get_parsed_content("training"))
452+
453+
def test_copy_and_pickle_yield_raw_container(self):
454+
# proxies copy/pickle as their underlying container (pre-proxy behaviour).
455+
a = self.parser.A
456+
self.assertEqual(copy.copy(a), {"B": {"C": 1, "D": [10, 20]}})
457+
self.assertEqual(copy.deepcopy(a), {"B": {"C": 1, "D": [10, 20]}})
458+
self.assertEqual(pickle.loads(pickle.dumps(a)), {"B": {"C": 1, "D": [10, 20]}}) # trusted in-process roundtrip
459+
460+
def test_config_key_shadows_container_method(self):
461+
# a config key named like a dict method shadows it on attribute access;
462+
# use bracket notation / ._raw to reach the real container.
463+
parser = ConfigParser(config={"sec": {"keys": "image"}}, globals={"monai": "monai"})
464+
self.assertEqual(parser.sec.keys, "image")
465+
self.assertEqual(parser.sec["keys"], "image")
466+
self.assertEqual(list(parser.sec._raw.keys()), ["keys"])
467+
468+
def test_ref_backed_proxy_write_through(self):
469+
# Writes/deletes on a proxy reached via $@ref must update the real backing config
470+
# node (i.e. "target"), not crash on the raw ref string (regression for the @ref
471+
# write crash: parser.alias["x"] = ... raised ValueError before this fix).
472+
parser = ConfigParser(config={"target": {"x": 1, "y": 2}, "alias": "$@target"}, globals={"monai": "monai"})
473+
parser.alias["x"] = 99
474+
# The change must be visible via both the backing id and a fresh alias proxy.
475+
self.assertEqual(parser.get_parsed_content("target::x"), 99)
476+
self.assertEqual(parser.alias["x"], 99)
477+
del parser.alias["y"]
478+
self.assertNotIn("y", parser.get_parsed_content("target"))
479+
480+
def test_chained_ref_backed_proxy_write_through(self):
481+
# _backing_id() must follow the full ref chain, not just one hop.
482+
parser = ConfigParser(
483+
config={"target": {"x": 1, "y": 2}, "mid": "$@target", "alias": "$@mid"}, globals={"monai": "monai"}
484+
)
485+
parser.alias["x"] = 99
486+
self.assertEqual(parser.get_parsed_content("target::x"), 99)
487+
del parser.alias["y"]
488+
self.assertNotIn("y", parser.get_parsed_content("target"))
489+
490+
def test_raw_is_read_only(self):
491+
with self.assertRaises(AttributeError):
492+
self.parser.A._raw = {"something": "else"}
493+
with self.assertRaises(AttributeError):
494+
del self.parser.A._raw
495+
496+
def test_missing_raises(self):
497+
with self.assertRaises(IndexError):
498+
_ = self.parser.A.B.D[5]
499+
with self.assertRaises(KeyError):
500+
_ = self.parser.A.B["nonexistent"]
501+
with self.assertRaises(AttributeError):
502+
_ = self.parser.A.nonexistent
503+
504+
391505
if __name__ == "__main__":
392506
unittest.main()

0 commit comments

Comments
 (0)