confarg 0.0.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
confarg/_merge.py ADDED
@@ -0,0 +1,284 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ """Deep merge and nested dict utilities for confarg."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from confarg._errors import ConfargError
12
+
13
+ # Special key used in the intermediate dict to signal "append these items to the list".
14
+ # The value may be a list (from CLI), a scalar (single-value append), or a dict with
15
+ # integer string keys (from future env-var support).
16
+ LIST_APPEND_KEY = "+"
17
+
18
+ # Special key used in the intermediate dict to signal "delete these indices from the list".
19
+ # The value is a sorted list of non-negative integers (original indices before deletion).
20
+ LIST_DELETE_KEY = "-"
21
+
22
+
23
+ class _DeleteSentinel:
24
+ """Sentinel value indicating that a dict key should be removed during merge."""
25
+
26
+ __slots__ = ()
27
+
28
+ def __repr__(self) -> str:
29
+ return "_DELETE_"
30
+
31
+
32
+ # Singleton sentinel stored as a dict value to mark that key for deletion.
33
+ DICT_DELETE: _DeleteSentinel = _DeleteSentinel()
34
+
35
+
36
+ def _to_append_list(val: Any) -> list[Any]:
37
+ """Convert the value stored under LIST_APPEND_KEY to a flat list of items."""
38
+ if isinstance(val, list | set | frozenset | tuple):
39
+ return list(val)
40
+ if isinstance(val, dict):
41
+ if not val:
42
+ return []
43
+ try:
44
+ max_idx = max(int(k) for k in val)
45
+ except ValueError:
46
+ raise ConfargError(f"Append dict keys must be integer indices, got: {sorted(val.keys())!r}") from None
47
+ return [val.get(str(i)) for i in range(max_idx + 1)]
48
+ return [val] # scalar single-value append
49
+
50
+
51
+ def _normalize_merge_ops(d: Any) -> Any:
52
+ """Recursively normalise ``key+`` / ``key-`` shorthand in file-sourced dicts.
53
+
54
+ Transforms:
55
+ - ``key+: val`` → ``key: {"+": list(val)}`` (append to list)
56
+ - ``key-: val`` → ``key: DICT_DELETE`` (remove dict key)
57
+ - ``"N-": val`` → accumulated ``{"-": [N]}`` (delete list index N)
58
+
59
+ Only dict keys are inspected; list items are left untouched.
60
+ """
61
+ if not isinstance(d, dict):
62
+ return d
63
+
64
+ has_change = False
65
+ result: dict[str, Any] = {}
66
+ delete_indices: list[int] = []
67
+
68
+ for key, val in d.items():
69
+ new_val = _normalize_merge_ops(val)
70
+ if new_val is not val:
71
+ has_change = True
72
+
73
+ if not isinstance(key, str) or len(key) <= 1:
74
+ result[key] = new_val
75
+ continue
76
+
77
+ if key.endswith("+"):
78
+ has_change = True
79
+ plain_key = key[:-1]
80
+ items = list(new_val) if isinstance(new_val, list) else [new_val]
81
+ existing = result.get(plain_key)
82
+ if isinstance(existing, list):
83
+ result[plain_key] = existing + items
84
+ elif isinstance(existing, dict) and LIST_APPEND_KEY in existing:
85
+ result[plain_key] = {LIST_APPEND_KEY: existing[LIST_APPEND_KEY] + items}
86
+ elif isinstance(existing, dict) and LIST_DELETE_KEY in existing:
87
+ # key already produced a delete spec; add the append spec alongside it
88
+ result[plain_key] = {**existing, LIST_APPEND_KEY: items}
89
+ else:
90
+ result[plain_key] = {LIST_APPEND_KEY: items}
91
+
92
+ elif key.endswith("-"):
93
+ has_change = True
94
+ plain_key = key[:-1]
95
+ try:
96
+ delete_indices.append(int(plain_key))
97
+ except ValueError:
98
+ result[plain_key] = DICT_DELETE
99
+
100
+ else:
101
+ # Regular key: if new_val is a delete-spec dict and a prior key+ already set an
102
+ # append spec here, preserve the append spec rather than overwriting it.
103
+ prev = result.get(key)
104
+ if (
105
+ isinstance(new_val, dict)
106
+ and LIST_DELETE_KEY in new_val
107
+ and isinstance(prev, dict)
108
+ and LIST_APPEND_KEY in prev
109
+ ):
110
+ result[key] = {**new_val, LIST_APPEND_KEY: prev[LIST_APPEND_KEY]}
111
+ else:
112
+ result[key] = new_val
113
+
114
+ if delete_indices:
115
+ existing_del = result.get(LIST_DELETE_KEY)
116
+ if isinstance(existing_del, list):
117
+ result[LIST_DELETE_KEY] = sorted(set(existing_del) | set(delete_indices))
118
+ else:
119
+ result[LIST_DELETE_KEY] = sorted(delete_indices)
120
+
121
+ return result if has_change else d
122
+
123
+
124
+ def _deep_merge(
125
+ base: dict[str, Any],
126
+ override: dict[str, Any],
127
+ *,
128
+ union_tag: str | None = None,
129
+ ) -> dict[str, Any]:
130
+ """Recursively merge override into base, with override winning on conflict.
131
+
132
+ Dict values are merged recursively. Special override values:
133
+
134
+ - ``DICT_DELETE`` as a value → removes that key from the result.
135
+ - ``{"+": items}`` as a value for a list base → appends items.
136
+ - ``{"-": [i, j]}`` as a value for a list base → deletes original indices i, j.
137
+ - ``{"N": v}`` (integer string keys) as a value for a list base → patches by index.
138
+
139
+ When ``union_tag`` is set and the override dict contains that key, the override
140
+ is treated as a fresh object specification and the base is discarded entirely.
141
+
142
+ Args:
143
+ base: The base dict to merge into.
144
+ override: The dict whose values take precedence.
145
+ union_tag: The discriminator key used for union disambiguation.
146
+
147
+ Returns:
148
+ A new dict containing the merged result.
149
+
150
+ Raises:
151
+ ConfargError: If a list is patched/deleted with an out-of-range index, or
152
+ if a non-integer key is used for list patching.
153
+ """
154
+ base = _normalize_merge_ops(base)
155
+ override = _normalize_merge_ops(override)
156
+
157
+ if union_tag is not None and union_tag in override:
158
+ return dict(override)
159
+
160
+ # Copy base, skipping any DICT_DELETE sentinels left over from normalization.
161
+ result = {k: v for k, v in base.items() if not isinstance(v, _DeleteSentinel)}
162
+
163
+ for key, val in override.items():
164
+ # Dict-key deletion: remove from result regardless of base value.
165
+ if isinstance(val, _DeleteSentinel):
166
+ result.pop(key, None)
167
+ continue
168
+
169
+ if key in result:
170
+ bv = result[key]
171
+ if isinstance(bv, dict) and isinstance(val, dict):
172
+ # Both sides carry append entries → concatenate.
173
+ if LIST_APPEND_KEY in bv and LIST_APPEND_KEY in val:
174
+ combined = _to_append_list(bv[LIST_APPEND_KEY]) + _to_append_list(val[LIST_APPEND_KEY])
175
+ rest = _deep_merge(
176
+ {k: v for k, v in bv.items() if k != LIST_APPEND_KEY},
177
+ {k: v for k, v in val.items() if k != LIST_APPEND_KEY},
178
+ union_tag=union_tag,
179
+ )
180
+ result[key] = {**rest, LIST_APPEND_KEY: combined}
181
+ # Both sides carry delete entries → union the index sets.
182
+ elif LIST_DELETE_KEY in bv and LIST_DELETE_KEY in val:
183
+ combined_del = sorted(set(bv[LIST_DELETE_KEY]) | set(val[LIST_DELETE_KEY]))
184
+ rest = _deep_merge(
185
+ {k: v for k, v in bv.items() if k != LIST_DELETE_KEY},
186
+ {k: v for k, v in val.items() if k != LIST_DELETE_KEY},
187
+ union_tag=union_tag,
188
+ )
189
+ result[key] = {**rest, LIST_DELETE_KEY: combined_del}
190
+ else:
191
+ result[key] = _deep_merge(bv, val, union_tag=union_tag)
192
+
193
+ elif isinstance(bv, list) and isinstance(val, dict):
194
+ if LIST_DELETE_KEY in val:
195
+ del_indices = val[LIST_DELETE_KEY]
196
+ for idx in del_indices:
197
+ if idx < 0 or idx >= len(bv):
198
+ raise ConfargError(
199
+ f"Cannot delete index {idx} from '{key}':"
200
+ f" the list has {len(bv)} element(s)"
201
+ f" (valid indices 0-{len(bv) - 1})."
202
+ )
203
+ del_set = set(del_indices)
204
+ current = [item for i, item in enumerate(bv) if i not in del_set]
205
+ if LIST_APPEND_KEY in val:
206
+ result[key] = current + _to_append_list(val[LIST_APPEND_KEY])
207
+ else:
208
+ result[key] = current
209
+ elif LIST_APPEND_KEY in val:
210
+ result[key] = list(bv) + _to_append_list(val[LIST_APPEND_KEY])
211
+ else:
212
+ patched = list(bv)
213
+ for ik, iv in val.items():
214
+ try:
215
+ idx = int(ik)
216
+ except ValueError:
217
+ raise ConfargError(
218
+ f"Cannot patch list '{key}' with non-integer key {ik!r}."
219
+ " List patches must use integer string keys (e.g. {'0': ..., '1': ...})."
220
+ ) from None
221
+ if idx < 0:
222
+ raise ConfargError(f"Cannot patch list '{key}' with negative index {idx}")
223
+ if idx >= len(patched):
224
+ raise ConfargError(
225
+ f"Cannot extend list '{key}' at index {idx}:"
226
+ f" the list has {len(patched)} element(s)"
227
+ f" (valid indices 0-{len(patched) - 1})."
228
+ " Use the + append syntax (e.g. --field+ for CLI) to add new elements."
229
+ )
230
+ patched[idx] = (
231
+ _deep_merge(patched[idx], iv, union_tag=union_tag)
232
+ if isinstance(patched[idx], dict) and isinstance(iv, dict)
233
+ else iv
234
+ )
235
+ result[key] = patched
236
+ else:
237
+ result[key] = val
238
+ else:
239
+ result[key] = val
240
+ return result
241
+
242
+
243
+ def _set_nested(d: dict[str, Any], path: list[str], value: Any) -> None:
244
+ """Set a value in a nested dict by following a list of keys.
245
+
246
+ Intermediate dicts are created as needed.
247
+
248
+ Args:
249
+ d: The root dict to modify in place.
250
+ path: A list of keys forming the path to the target location.
251
+ value: The value to set at the target path.
252
+ """
253
+ for part in path[:-1]:
254
+ if part not in d:
255
+ d[part] = {}
256
+ d = d[part]
257
+ if path:
258
+ d[path[-1]] = value
259
+
260
+
261
+ def _accumulate_list_delete(d: dict[str, Any], path: list[str], idx: int, source: str) -> None:
262
+ """Add a list-deletion index at ``path`` inside ``d``, raising on duplicates.
263
+
264
+ Args:
265
+ d: The root data dict to modify in place.
266
+ path: Path segments leading to the list field.
267
+ idx: The (original) list index to delete.
268
+ source: Human-readable description of the source (for error messages).
269
+
270
+ Raises:
271
+ ConfargError: If ``idx`` has already been scheduled for deletion.
272
+ """
273
+ node = d
274
+ for key in path:
275
+ if key not in node:
276
+ node[key] = {}
277
+ node = node[key]
278
+ existing = node.get(LIST_DELETE_KEY)
279
+ if isinstance(existing, list):
280
+ if idx in existing:
281
+ raise ConfargError(f"Duplicate list-deletion index {idx} for {source!r}.")
282
+ node[LIST_DELETE_KEY] = sorted(existing + [idx])
283
+ else:
284
+ node[LIST_DELETE_KEY] = [idx]