From b1ac7d6b99220671440ed1df0115ff1fe24eb69e Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Thu, 14 May 2026 18:04:22 +0100 Subject: [PATCH 1/7] Add nested dot-notation access to ConfigParser (#6837) Wrap dict/list results from ConfigParser.__getattr__ in _ConfigProxy so that parser.training.trainer.max_epochs chains through get_parsed_content using the '::' separator, equivalent to parser.get_parsed_content('training::trainer::max_epochs'). Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 81 +++++++++++++++++++++++++++++- tests/bundle/test_config_parser.py | 41 +++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index 1d9920a230..a82aac3bbc 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -35,6 +35,81 @@ _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] + + 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), @@ -127,6 +202,10 @@ 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``. @@ -134,7 +213,7 @@ def __getattr__(self, id): 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: """ diff --git a/tests/bundle/test_config_parser.py b/tests/bundle/test_config_parser.py index 5ead2af382..44c0249d89 100644 --- a/tests/bundle/test_config_parser.py +++ b/tests/bundle/test_config_parser.py @@ -326,6 +326,47 @@ def test_get_via_attributes(self): result = trans(np.ones(64)) self.assertTupleEqual(result.shape, (1, 8, 8)) + def test_nested_dot_notation(self): + 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 + 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) From 48eb4ab1327ec868d8e7898c17deba21a4274708 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Mon, 18 May 2026 18:05:01 +0100 Subject: [PATCH 2/7] Delegate item assignment/deletion in _ConfigProxy Preserve the pre-proxy behaviour where parser.x returned the raw dict/list, so parser.x[k] = v and del parser.x[k] still work. Add a regression test. Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 8 ++++++++ tests/bundle/test_config_parser.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index a82aac3bbc..a6df47335e 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -85,6 +85,14 @@ def __getitem__(self, key: str | int) -> Any: # indexing semantics apply (IndexError, negative indices, dict KeyError). return self._value[key] + 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) diff --git a/tests/bundle/test_config_parser.py b/tests/bundle/test_config_parser.py index 44c0249d89..dab0da14c2 100644 --- a/tests/bundle/test_config_parser.py +++ b/tests/bundle/test_config_parser.py @@ -367,6 +367,15 @@ def test_nested_dot_notation(self): 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 + 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) From fe55fcabe13c67ed72ab16de1625dc286a60adee Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 19 May 2026 11:57:41 +0100 Subject: [PATCH 3/7] Addrss reviewer comments Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 140 ++++++++++++++++++++++++----- tests/bundle/test_config_parser.py | 136 +++++++++++++++++----------- 2 files changed, 204 insertions(+), 72 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index a6df47335e..0e266d65ac 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -14,6 +14,7 @@ import json import re from collections.abc import Sequence +from copy import copy as _copy from copy import deepcopy from pathlib import Path from typing import TYPE_CHECKING, Any @@ -35,8 +36,24 @@ _default_globals = {"monai": "monai", "torch": "torch", "np": "numpy", "numpy": "numpy"} +def _identity(value: Any) -> Any: + """Module-level reconstructor used by ``_ConfigProxy.__reduce__`` so proxies pickle as their raw value.""" + return value + + 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.""" + """ + Wrap a parsed dict/list in a :class:`_ConfigProxy` so nested access keeps chaining; pass scalars through. + + Args: + parser: the owning :class:`ConfigParser`, used to resolve chained ids. + id: the ``::``-separated id that produced ``value``. + value: the parsed content to wrap. + + Returns: + A :class:`_ConfigProxy` wrapping ``value`` if it is a ``dict`` or ``list``, + otherwise ``value`` unchanged. + """ if isinstance(value, (dict, list)): return _ConfigProxy(parser, id, value) return value @@ -46,32 +63,85 @@ 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``. - + When :meth:`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 :meth:`ConfigParser.get_parsed_content`. For example:: + + parser.training.trainer.max_epochs + # equivalent to + parser.get_parsed_content("training::trainer::max_epochs") + + parser.transforms[0].keys # list indexing chains too + parser.A.B["C"] = 99 # writes update the config source + del parser.A.B["C"] # deletes update the config source + + Type caveat: + Accessing a ``dict``/``list`` member through a :class:`ConfigParser` now returns a + ``_ConfigProxy``, not the raw container, so ``type(parser.A)`` is ``_ConfigProxy`` + and ``isinstance(parser.A, dict)`` is ``False``. Code that needs the real container + should use ``parser.A._raw`` (read-only view) or ``parser.get_parsed_content("A")``. + + Precedence and fallback: + Config keys take precedence over ``dict``/``list`` attributes and methods. If a + config key is not found, the proxy falls back to the underlying ``dict``/``list`` + so that container methods (``.keys()``, ``.items()`` ...) and native indexing + semantics (``IndexError``, negative indices, dict ``KeyError``) still work. A + config key that collides with a container method name (e.g. ``"keys"``) shadows + that method on attribute access; access it via bracket notation, + :meth:`ConfigParser.get_parsed_content`, or ``._raw``. + + Writes: + ``__setitem__``/``__setattr__``/``__delitem__``/``__delattr__`` write through to + the config *source* (via :class:`ConfigParser`) and reset the reference resolver, + so the change is visible from both ``parser.`` and + ``parser.get_parsed_content("")``. """ + _INTERNAL = ("_parser", "_id", "_value") + def __init__(self, parser: ConfigParser, id: str, value: Any): + """ + Args: + parser: the owning :class:`ConfigParser`. + id: the ``::``-separated id this proxy represents. + value: the parsed ``dict``/``list`` content this proxy wraps. + """ self._parser = parser self._id = id self._value = value + def _child_id(self, key: str | int) -> str: + return f"{self._id}{ID_SEP_KEY}{key}" + def _chain(self, key: str) -> Any: - new_id = f"{self._id}{ID_SEP_KEY}{key}" + """ + Resolve ``key`` as a nested config id. + + Args: + key: the child key/index. + + Returns: + The parsed child content, wrapped via :func:`_wrap_parsed`. + + Raises: + KeyError: if there is no config item at the chained id. + """ + new_id = self._child_id(key) return _wrap_parsed(self._parser, new_id, self._parser.get_parsed_content(new_id)) def __getattr__(self, key: str) -> Any: + """ + Resolve ``key`` as a nested config attribute, falling back to the underlying container. + + Dunder names are never treated as config keys, so the proxy stays well-behaved + with ``copy``/``pickle``/``hasattr`` and other stdlib introspection. + + Raises: + AttributeError: if ``key`` is neither a config key nor an attribute of the + underlying ``dict``/``list``. + """ + if key.startswith("__") and key.endswith("__"): + raise AttributeError(key) try: return self._chain(key) except KeyError: @@ -86,12 +156,23 @@ def __getitem__(self, key: str | int) -> Any: return self._value[key] 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 + # write through to the config source and reset the resolver so the change is + # visible from both ``parser.`` and ``get_parsed_content``. + self._parser[self._child_id(key)] = value def __delitem__(self, key: str | int) -> None: - del self._value[key] + node = self._parser[self._id] + del node[key if isinstance(node, dict) else int(key)] + self._parser.ref_resolver.reset() + + def __setattr__(self, key: str, value: Any) -> None: + if key in _ConfigProxy._INTERNAL: + object.__setattr__(self, key, value) + return + self[key] = value + + def __delattr__(self, key: str) -> None: + del self[key] def __len__(self) -> int: return len(self._value) @@ -108,13 +189,23 @@ def __bool__(self) -> bool: def __repr__(self) -> str: return repr(self._value) - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object) -> Any: if isinstance(other, _ConfigProxy): other = other._value - return bool(self._value == other) + return self._value == other + + def __copy__(self) -> Any: + return _copy(self._value) + + def __deepcopy__(self, memo: Any) -> Any: + return deepcopy(self._value, memo) + + def __reduce__(self) -> Any: + return (_identity, (self._value,)) @property def _raw(self) -> Any: + """The underlying ``dict``/``list`` (read-only view; mutate via the proxy or the parser).""" return self._value @@ -218,6 +309,11 @@ def __getattr__(self, id): Args: id: id of the ``ConfigItem``. + Returns: + The parsed content (instance, evaluated expression, or config value). When it + is a ``dict`` or ``list`` it is wrapped in a :class:`_ConfigProxy` so nested + attributes/indices chain through the config hierarchy. + See also: :py:meth:`get_parsed_content` """ diff --git a/tests/bundle/test_config_parser.py b/tests/bundle/test_config_parser.py index dab0da14c2..4ed7dd21b1 100644 --- a/tests/bundle/test_config_parser.py +++ b/tests/bundle/test_config_parser.py @@ -11,7 +11,9 @@ from __future__ import annotations +import copy import os +import pickle import tempfile import unittest import warnings @@ -326,56 +328,6 @@ def test_get_via_attributes(self): result = trans(np.ones(64)) self.assertTupleEqual(result.shape, (1, 8, 8)) - def test_nested_dot_notation(self): - 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 - 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) @@ -438,5 +390,89 @@ def test_load_configs( self.assertEqual(parser["key2"], expected_merged_vals) +class TestConfigProxy(unittest.TestCase): + """Nested dot-/bracket-notation access on ConfigParser (issue #6837).""" + + def setUp(self): + self.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", + } + self.parser = ConfigParser(config=self.config, globals={"monai": "monai"}) + + def test_nested_attribute_access(self): + self.assertEqual(self.parser.A.B.C, 1) + self.assertEqual(self.parser.training.trainer.max_epochs, 100) + self.assertEqual(self.parser.training.trainer.lr, 0.001) + self.assertEqual(self.parser.dims_1, 3) + + def test_nested_index_access(self): + self.assertEqual(self.parser.A.B.D[0], 10) + self.assertEqual(self.parser.A.B.D[1], 20) + self.assertEqual(self.parser.transforms[0].keys, "image") + self.assertEqual(self.parser.transforms[1].keys, "label") + + def test_raw_and_container_protocol(self): + self.assertEqual(self.parser.A._raw, {"B": {"C": 1, "D": [10, 20]}}) + self.assertEqual(len(self.parser.A.B.D), 2) + self.assertEqual(list(self.parser.A.B.D), [10, 20]) + self.assertIn("B", self.parser.A) + self.assertTrue(self.parser.A.B.D) + self.assertFalse(ConfigParser(config={"e": []}, globals={"monai": "monai"}).e) + + def test_native_index_fallback(self): + # bracket access falls back to native container semantics when there is no + # config key of that name: negative indexing still works. + self.assertEqual(self.parser.A.B.D[-1], 20) + + def test_attribute_write_through(self): + # attribute assignment updates the config source and is visible from both + # ``parser.`` and ``get_parsed_content``. + self.parser.A.X = [2, 3] + self.assertEqual(self.parser.A.X, [2, 3]) + self.assertIn("X", self.parser.get_parsed_content("A")) + self.assertEqual(self.parser.get_parsed_content("A::X"), [2, 3]) + + def test_item_write_through(self): + self.parser.A.B["C"] = 99 + self.assertEqual(self.parser.A.B.C, 99) + self.assertEqual(self.parser.get_parsed_content("A::B::C"), 99) + self.parser.A.B.D[0] = 11 + self.assertEqual(self.parser.A.B.D._raw, [11, 20]) + self.assertEqual(self.parser.get_parsed_content("A::B::D"), [11, 20]) + + def test_delete_write_through(self): + del self.parser.A.B["C"] + self.assertNotIn("C", self.parser.get_parsed_content("A::B")) + del self.parser.training.trainer + self.assertNotIn("trainer", self.parser.get_parsed_content("training")) + + def test_copy_and_pickle_yield_raw_container(self): + # proxies copy/pickle as their underlying container (pre-proxy behaviour). + a = self.parser.A + self.assertEqual(copy.copy(a), {"B": {"C": 1, "D": [10, 20]}}) + self.assertEqual(copy.deepcopy(a), {"B": {"C": 1, "D": [10, 20]}}) + self.assertEqual(pickle.loads(pickle.dumps(a)), {"B": {"C": 1, "D": [10, 20]}}) + + def test_config_key_shadows_container_method(self): + # a config key named like a dict method shadows it on attribute access; + # use bracket notation / ._raw to reach the real container. + parser = ConfigParser(config={"sec": {"keys": "image"}}, globals={"monai": "monai"}) + self.assertEqual(parser.sec.keys, "image") + self.assertEqual(parser.sec["keys"], "image") + self.assertEqual(list(parser.sec._raw.keys()), ["keys"]) + + def test_missing_raises(self): + with self.assertRaises(IndexError): + _ = self.parser.A.B.D[5] + with self.assertRaises(KeyError): + _ = self.parser.A.B["nonexistent"] + with self.assertRaises(AttributeError): + _ = self.parser.A.nonexistent + + if __name__ == "__main__": unittest.main() From c2d1ddc7d928f28881e46bfe5b80e829b5a28e1a Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 19 May 2026 12:23:50 +0100 Subject: [PATCH 4/7] Address coderabbit commetns Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 18 ++++++++++++++---- tests/bundle/test_config_parser.py | 14 +++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index 0e266d65ac..15c328c373 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -113,6 +113,15 @@ def __init__(self, parser: ConfigParser, id: str, value: Any): def _child_id(self, key: str | int) -> str: return f"{self._id}{ID_SEP_KEY}{key}" + def _backing_id(self) -> str: + """Return the real config id this proxy writes to, resolving a single ``$@ref`` hop if needed.""" + raw = self._parser[self._id] + if isinstance(raw, str): + refs = ReferenceResolver.match_refs_pattern(raw) + if refs: + return next(iter(refs)) + return self._id + def _chain(self, key: str) -> Any: """ Resolve ``key`` as a nested config id. @@ -156,12 +165,13 @@ def __getitem__(self, key: str | int) -> Any: return self._value[key] def __setitem__(self, key: str | int, value: Any) -> None: - # write through to the config source and reset the resolver so the change is - # visible from both ``parser.`` and ``get_parsed_content``. - self._parser[self._child_id(key)] = value + # Resolve a potential $@ref so we write to the real backing config node. + backing = self._backing_id() + self._parser[f"{backing}{ID_SEP_KEY}{key}"] = value def __delitem__(self, key: str | int) -> None: - node = self._parser[self._id] + backing = self._backing_id() + node = self._parser[backing] del node[key if isinstance(node, dict) else int(key)] self._parser.ref_resolver.reset() diff --git a/tests/bundle/test_config_parser.py b/tests/bundle/test_config_parser.py index 4ed7dd21b1..9704d28ae7 100644 --- a/tests/bundle/test_config_parser.py +++ b/tests/bundle/test_config_parser.py @@ -455,7 +455,7 @@ def test_copy_and_pickle_yield_raw_container(self): a = self.parser.A self.assertEqual(copy.copy(a), {"B": {"C": 1, "D": [10, 20]}}) self.assertEqual(copy.deepcopy(a), {"B": {"C": 1, "D": [10, 20]}}) - self.assertEqual(pickle.loads(pickle.dumps(a)), {"B": {"C": 1, "D": [10, 20]}}) + self.assertEqual(pickle.loads(pickle.dumps(a)), {"B": {"C": 1, "D": [10, 20]}}) # trusted in-process roundtrip def test_config_key_shadows_container_method(self): # a config key named like a dict method shadows it on attribute access; @@ -465,6 +465,18 @@ def test_config_key_shadows_container_method(self): self.assertEqual(parser.sec["keys"], "image") self.assertEqual(list(parser.sec._raw.keys()), ["keys"]) + def test_ref_backed_proxy_write_through(self): + # Writes/deletes on a proxy reached via $@ref must update the real backing config + # node (i.e. "target"), not crash on the raw ref string (regression for the @ref + # write crash: parser.alias["x"] = ... raised ValueError before this fix). + parser = ConfigParser(config={"target": {"x": 1, "y": 2}, "alias": "$@target"}, globals={"monai": "monai"}) + parser.alias["x"] = 99 + # The change must be visible via both the backing id and a fresh alias proxy. + self.assertEqual(parser.get_parsed_content("target::x"), 99) + self.assertEqual(parser.alias["x"], 99) + del parser.alias["y"] + self.assertNotIn("y", parser.get_parsed_content("target")) + def test_missing_raises(self): with self.assertRaises(IndexError): _ = self.parser.A.B.D[5] From c73b66c872e76ec952fd35f1af3f38ed4c5073cf Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 19 May 2026 16:31:34 +0100 Subject: [PATCH 5/7] Fix _backing_id transitive ref resolution and enforce _raw read-only - _backing_id() now loops until no more $@ref hops remain, fixing chained-alias write/delete (alias -> mid -> target). - __setattr__ and __delattr__ raise AttributeError on _raw to prevent silently creating a config key named _raw. - Add regression tests for both cases. Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 24 ++++++++++++++++++------ tests/bundle/test_config_parser.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index 15c328c373..d5f861a6d8 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -114,13 +114,21 @@ def _child_id(self, key: str | int) -> str: return f"{self._id}{ID_SEP_KEY}{key}" def _backing_id(self) -> str: - """Return the real config id this proxy writes to, resolving a single ``$@ref`` hop if needed.""" - raw = self._parser[self._id] - if isinstance(raw, str): + """Return the real config id this proxy writes to, resolving all ``$@ref`` hops transitively.""" + current = self._id + seen: set[str] = set() + while True: + if current in seen: + break + seen.add(current) + raw = self._parser[current] + if not isinstance(raw, str): + break refs = ReferenceResolver.match_refs_pattern(raw) - if refs: - return next(iter(refs)) - return self._id + if not refs: + break + current = next(iter(refs)) + return current def _chain(self, key: str) -> Any: """ @@ -179,9 +187,13 @@ def __setattr__(self, key: str, value: Any) -> None: if key in _ConfigProxy._INTERNAL: object.__setattr__(self, key, value) return + if key == "_raw": + raise AttributeError("_raw is read-only") self[key] = value def __delattr__(self, key: str) -> None: + if key == "_raw": + raise AttributeError("_raw is read-only") del self[key] def __len__(self) -> int: diff --git a/tests/bundle/test_config_parser.py b/tests/bundle/test_config_parser.py index 9704d28ae7..546957ba7e 100644 --- a/tests/bundle/test_config_parser.py +++ b/tests/bundle/test_config_parser.py @@ -477,6 +477,22 @@ def test_ref_backed_proxy_write_through(self): del parser.alias["y"] self.assertNotIn("y", parser.get_parsed_content("target")) + def test_chained_ref_backed_proxy_write_through(self): + # _backing_id() must follow the full ref chain, not just one hop. + parser = ConfigParser( + config={"target": {"x": 1, "y": 2}, "mid": "$@target", "alias": "$@mid"}, globals={"monai": "monai"} + ) + parser.alias["x"] = 99 + self.assertEqual(parser.get_parsed_content("target::x"), 99) + del parser.alias["y"] + self.assertNotIn("y", parser.get_parsed_content("target")) + + def test_raw_is_read_only(self): + with self.assertRaises(AttributeError): + self.parser.A._raw = {"something": "else"} + with self.assertRaises(AttributeError): + del self.parser.A._raw + def test_missing_raises(self): with self.assertRaises(IndexError): _ = self.parser.A.B.D[5] From fffddfc2a7084c89391f6317701cceac6de1617e Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 19 May 2026 16:39:03 +0100 Subject: [PATCH 6/7] Clarify _raw docstring: reference is read-only, not the container contents Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index d5f861a6d8..fe5b99a8dd 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -227,7 +227,7 @@ def __reduce__(self) -> Any: @property def _raw(self) -> Any: - """The underlying ``dict``/``list`` (read-only view; mutate via the proxy or the parser).""" + """The underlying ``dict``/``list`` container (the reference is read-only; the container contents are not copied).""" return self._value From e32ce74ba45d254eab7c1308d65f13e5f6618470 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 19 May 2026 16:44:47 +0100 Subject: [PATCH 7/7] Fix __setitem__ to write directly to backing container, not via id path Preserves literal dict keys (e.g. "a::b") and matches the semantics of __delitem__ and __getitem__. Signed-off-by: Soumya Snigdha Kundu --- monai/bundle/config_parser.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index fe5b99a8dd..08c6fc296c 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -173,9 +173,12 @@ def __getitem__(self, key: str | int) -> Any: return self._value[key] def __setitem__(self, key: str | int, value: Any) -> None: - # Resolve a potential $@ref so we write to the real backing config node. + # Write directly to the backing container so literal dict keys are preserved, + # matching the semantics of __delitem__ and __getitem__. backing = self._backing_id() - self._parser[f"{backing}{ID_SEP_KEY}{key}"] = value + node = self._parser[backing] + node[key if isinstance(node, dict) else int(key)] = value + self._parser.ref_resolver.reset() def __delitem__(self, key: str | int) -> None: backing = self._backing_id()