|
| 1 | +""" |
| 2 | +Deep-merge helpers replacing omegaconf's merge semantics. |
| 3 | +
|
| 4 | +`deep_merge(a, b)` returns a new dict that is `a` with `b` merged in: nested |
| 5 | +dicts are merged recursively, leaf values (and lists) from `b` replace those in |
| 6 | +`a`. This matches `OmegaConf.merge(a, b)` for BBOT's preset layering use case. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +from copy import deepcopy |
| 12 | +from typing import Any |
| 13 | + |
| 14 | + |
| 15 | +def deep_merge(base: dict[str, Any] | None, *updates: dict[str, Any] | None) -> dict[str, Any]: |
| 16 | + """ |
| 17 | + Deep-merge one or more update dicts into a copy of `base`. Last wins on |
| 18 | + leaf conflicts; lists are replaced wholesale (not concatenated). |
| 19 | +
|
| 20 | + The returned dict shares no mutable state with the inputs — nested dicts, |
| 21 | + lists, and other mutable values are deep-copied as they're carried over. |
| 22 | + """ |
| 23 | + result: dict[str, Any] = deepcopy(base) if base else {} |
| 24 | + for update in updates: |
| 25 | + if not update: |
| 26 | + continue |
| 27 | + for k, v in update.items(): |
| 28 | + if k in result and isinstance(result[k], dict) and isinstance(v, dict): |
| 29 | + result[k] = deep_merge(result[k], v) |
| 30 | + else: |
| 31 | + result[k] = deepcopy(v) |
| 32 | + return result |
| 33 | + |
| 34 | + |
| 35 | +def dotted_get(data: dict[str, Any], path: str, default: Any = None) -> Any: |
| 36 | + """ |
| 37 | + Look up a dotted path in a nested dict. |
| 38 | +
|
| 39 | + Note: keys containing literal `.` are not addressable (no escape syntax). |
| 40 | +
|
| 41 | + >>> dotted_get({"a": {"b": {"c": 1}}}, "a.b.c") |
| 42 | + 1 |
| 43 | + >>> dotted_get({"a": 1}, "a.b.c", default="x") |
| 44 | + 'x' |
| 45 | + """ |
| 46 | + cursor: Any = data |
| 47 | + for part in path.split("."): |
| 48 | + if not isinstance(cursor, dict) or part not in cursor: |
| 49 | + return default |
| 50 | + cursor = cursor[part] |
| 51 | + return cursor |
| 52 | + |
| 53 | + |
| 54 | +def dotted_set(data: dict[str, Any], path: str, value: Any) -> None: |
| 55 | + """ |
| 56 | + Set a dotted path in a nested dict, creating intermediate dicts as needed. |
| 57 | +
|
| 58 | + Non-dict intermediates are silently replaced. This is intentional — |
| 59 | + callers (CLI parsing) feed the result through pydantic validation, which |
| 60 | + surfaces any resulting type mismatch. |
| 61 | +
|
| 62 | + >>> d = {} |
| 63 | + >>> dotted_set(d, "a.b.c", 1) |
| 64 | + >>> d |
| 65 | + {'a': {'b': {'c': 1}}} |
| 66 | + """ |
| 67 | + parts = path.split(".") |
| 68 | + cursor = data |
| 69 | + for part in parts[:-1]: |
| 70 | + if part not in cursor or not isinstance(cursor[part], dict): |
| 71 | + cursor[part] = {} |
| 72 | + cursor = cursor[part] |
| 73 | + cursor[parts[-1]] = value |
| 74 | + |
| 75 | + |
| 76 | +def iter_dotted_paths(data: dict[str, Any], prefix: str = "") -> list[str]: |
| 77 | + """ |
| 78 | + Return every dotted leaf path in a nested dict. Empty dicts are treated |
| 79 | + as leaves (so they round-trip through dotted_get/dotted_set). |
| 80 | +
|
| 81 | + >>> iter_dotted_paths({"a": 1, "b": {"c": 2}}) |
| 82 | + ['a', 'b.c'] |
| 83 | + """ |
| 84 | + paths: list[str] = [] |
| 85 | + for k, v in data.items(): |
| 86 | + path = f"{prefix}.{k}" if prefix else k |
| 87 | + if isinstance(v, dict) and v: |
| 88 | + paths.extend(iter_dotted_paths(v, path)) |
| 89 | + else: |
| 90 | + paths.append(path) |
| 91 | + return paths |
| 92 | + |
| 93 | + |
| 94 | +__all__ = ["deep_merge", "dotted_get", "dotted_set", "iter_dotted_paths"] |
0 commit comments