|
14 | 14 | import json |
15 | 15 | import re |
16 | 16 | from collections.abc import Sequence |
| 17 | +from copy import copy as _copy |
17 | 18 | from copy import deepcopy |
18 | 19 | from pathlib import Path |
19 | 20 | from typing import TYPE_CHECKING, Any |
|
35 | 36 | _default_globals = {"monai": "monai", "torch": "torch", "np": "numpy", "numpy": "numpy"} |
36 | 37 |
|
37 | 38 |
|
| 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 | + |
38 | 237 | class ConfigParser: |
39 | 238 | """ |
40 | 239 | 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): |
127 | 326 | """ |
128 | 327 | Get the parsed result of ``ConfigItem`` with the specified ``id`` |
129 | 328 | 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")``. |
130 | 333 |
|
131 | 334 | Args: |
132 | 335 | id: id of the ``ConfigItem``. |
133 | 336 |
|
| 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 | +
|
134 | 342 | See also: |
135 | 343 | :py:meth:`get_parsed_content` |
136 | 344 | """ |
137 | | - return self.get_parsed_content(id) |
| 345 | + return _wrap_parsed(self, id, self.get_parsed_content(id)) |
138 | 346 |
|
139 | 347 | def __getitem__(self, id: str | int) -> Any: |
140 | 348 | """ |
|
0 commit comments