modict 0.1.1__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.
- modict/__init__.py +3 -0
- modict/__main__.py +13 -0
- modict/_collections_utils.py +664 -0
- modict/_modict.py +844 -0
- modict/_modict_meta.py +458 -0
- modict/_typechecker.py +2215 -0
- modict-0.1.1.dist-info/METADATA +487 -0
- modict-0.1.1.dist-info/RECORD +17 -0
- modict-0.1.1.dist-info/WHEEL +5 -0
- modict-0.1.1.dist-info/entry_points.txt +2 -0
- modict-0.1.1.dist-info/licenses/LICENSE +21 -0
- modict-0.1.1.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/__pycache__/__init__.cpython-310.pyc +0 -0
- tests/__pycache__/test_modict.cpython-310-pytest-8.3.3.pyc +0 -0
- tests/run_tests.py +65 -0
- tests/test_modict.py +95 -0
modict/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
from ._modict import modict
|
|
2
|
+
from ._modict_meta import modictConfig, Field, Factory, Computed, Check
|
|
3
|
+
from ._typechecker import check_type, coerce, typechecked, TypeChecker, TypeCheckError, TypeCheckException, TypeCheckFailureError, TypeMismatchError, Coercer, CoercionError
|
modict/__main__.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""Collections utilities for nested data structures.
|
|
2
|
+
|
|
3
|
+
This module provides a comprehensive set of tools for working with nested data structures
|
|
4
|
+
in Python, particularly focusing on Mappings (like dictionaries) and Sequences (like lists).
|
|
5
|
+
It enables deep traversal, comparison, merging, and manipulation of nested structures
|
|
6
|
+
while maintaining type safety and providing clear error handling.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
- Path-based access: Access nested values using string paths ("a.b.0.c") or key tuples
|
|
10
|
+
- Deep operations: Compare, merge, and modify nested structures
|
|
11
|
+
- Type-safe: Comprehensive type hints and runtime type checking
|
|
12
|
+
- Container views: Create custom views over container data
|
|
13
|
+
- Structure traversal: Walk through nested structures with custom callbacks and filters
|
|
14
|
+
|
|
15
|
+
Main Components:
|
|
16
|
+
- View: Base class for creating custom container views
|
|
17
|
+
- MISSING: Sentinel value for distinguishing missing values from None
|
|
18
|
+
- Container operations: get_nested(), set_nested(), del_nested(), has_nested()
|
|
19
|
+
- Deep operations: deep_merge(), deep_equals(), diff_nested()
|
|
20
|
+
- Traversal: walk(), walked() for recursive container traversal
|
|
21
|
+
|
|
22
|
+
Typical Usage:
|
|
23
|
+
>>> from collections_utils import get_nested, set_nested, walk
|
|
24
|
+
|
|
25
|
+
# Access nested values with string paths
|
|
26
|
+
>>> data = {"users": [{"name": "Alice", "age": 30}]}
|
|
27
|
+
>>> get_nested(data, "users.0.name")
|
|
28
|
+
'Alice'
|
|
29
|
+
|
|
30
|
+
# Modify nested structures
|
|
31
|
+
>>> set_nested(data, "users.0.email", "alice@example.com")
|
|
32
|
+
>>> data
|
|
33
|
+
{"users": [{"name": "Alice", "age": 30, "email": "alice@example.com"}]}
|
|
34
|
+
|
|
35
|
+
# Walk through nested structures
|
|
36
|
+
>>> for path, value in walk(data):
|
|
37
|
+
... print(f"{path}: {value}")
|
|
38
|
+
users.0.name: Alice
|
|
39
|
+
users.0.age: 30
|
|
40
|
+
users.0.email: alice@example.com
|
|
41
|
+
|
|
42
|
+
Type Definitions:
|
|
43
|
+
- Key: Union[int, str] - Valid container keys/indices
|
|
44
|
+
- Path: Union[str, Tuple[Key, ...]] - Path to nested values
|
|
45
|
+
- Container: Union[Mapping, Sequence] - Any container type
|
|
46
|
+
- MutableContainer: Union[MutableMapping, MutableSequence] - Mutable containers
|
|
47
|
+
|
|
48
|
+
Notes:
|
|
49
|
+
- String paths use dots as separators: "a.0.b"
|
|
50
|
+
- Numeric path components are converted to integers: "users.0" -> ("users", 0)
|
|
51
|
+
- Container operations maintain the original container types
|
|
52
|
+
- The MISSING sentinel distinguishes missing values from None values
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from collections.abc import Collection,Mapping,Sequence,MutableMapping,MutableSequence
|
|
56
|
+
from typing import Any, Union, Dict, TypeVar, Tuple, Type, Optional, Set, Callable, TypeAlias, Generic, Iterator
|
|
57
|
+
from itertools import islice
|
|
58
|
+
|
|
59
|
+
# Type definitions for improved clarity and type safety
|
|
60
|
+
Key: TypeAlias = Union[int, str] # Valid key types for containers
|
|
61
|
+
Path: TypeAlias = Union[str, Tuple[Key, ...]] # Path can be string ("a.b.c") or tuple of keys
|
|
62
|
+
Container: TypeAlias = Union[Mapping, Sequence] # Any immutable container
|
|
63
|
+
MutableContainer: TypeAlias = Union[MutableMapping, MutableSequence] # Any mutable container
|
|
64
|
+
T = TypeVar('T') # Generic type for values
|
|
65
|
+
C = TypeVar('C', bound=Container) # Generic type constrained to Containers
|
|
66
|
+
MC = TypeVar('MC', bound=MutableContainer) # Generic type for mutable containers
|
|
67
|
+
CallbackFn = Callable[[Any], Any] # Type for callback functions
|
|
68
|
+
FilterFn = Callable[[str, Any], bool] # Type for filter predicates
|
|
69
|
+
|
|
70
|
+
class _MISSING:
|
|
71
|
+
"""Sentinel class representing a missing value.
|
|
72
|
+
|
|
73
|
+
Used instead of None when None is a valid value. This helps distinguish
|
|
74
|
+
between "no value set" and "value explicitly set to None".
|
|
75
|
+
"""
|
|
76
|
+
def __str__(self)->str:
|
|
77
|
+
return "MISSING"
|
|
78
|
+
|
|
79
|
+
def __repr__(self)->str:
|
|
80
|
+
return "MISSING"
|
|
81
|
+
|
|
82
|
+
def __bool__(self)->bool:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# Sentinel instance
|
|
86
|
+
MISSING=_MISSING()
|
|
87
|
+
|
|
88
|
+
class View(Collection[T], Generic[C, T]):
|
|
89
|
+
|
|
90
|
+
"""Base View class for creating custom views over any Mapping or Sequence.
|
|
91
|
+
|
|
92
|
+
Provides a read-only view over container data with custom element access logic.
|
|
93
|
+
Subclasses must implement _get_element(key) to determine how elements are accessed.
|
|
94
|
+
|
|
95
|
+
Type Parameters:
|
|
96
|
+
C: The container type (must be Mapping or Sequence)
|
|
97
|
+
T: The type of elements in the view
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
data: The container to create a view over
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TypeError: If data is not a Mapping or Sequence
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
>>> class Keys(View[Mapping, Key]):
|
|
107
|
+
... def _get_element(self, key: Key) -> Key:
|
|
108
|
+
... return key
|
|
109
|
+
>>> class Values(View[Mapping, T]):
|
|
110
|
+
... def _get_element(self, key: Key) -> T:
|
|
111
|
+
... return self.data[key]
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, data:C) -> None:
|
|
115
|
+
if not isinstance(data,(Mapping,Sequence)):
|
|
116
|
+
raise TypeError(f"The data on which a View is defined is expected to be a Mapping or Sequence. Got {type(data)}")
|
|
117
|
+
self._data:C = data
|
|
118
|
+
self._nmax: int = 10 # max number of elements to show in repr
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def data(self) -> C:
|
|
122
|
+
return self._data
|
|
123
|
+
|
|
124
|
+
def _get_element(self,key:Key) -> T:
|
|
125
|
+
"""Takes a key and returns the corresponding view element"""
|
|
126
|
+
raise NotImplementedError()
|
|
127
|
+
|
|
128
|
+
def __iter__(self) -> Iterator[T]:
|
|
129
|
+
"""Return an iterator over view elements"""
|
|
130
|
+
return iter(self._get_element(key) for key in keys(self.data))
|
|
131
|
+
|
|
132
|
+
def __len__(self) -> int:
|
|
133
|
+
"""Return the number of elements in the view."""
|
|
134
|
+
return len(self.data)
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
"""String representation of the view"""
|
|
138
|
+
content=', '.join(repr(self._get_element(key)) for key in islice(keys(self.data),self._nmax))
|
|
139
|
+
if len(self.data)>self._nmax:
|
|
140
|
+
content+=", ..."
|
|
141
|
+
return f"{self.__class__.__name__}({content})"
|
|
142
|
+
|
|
143
|
+
def __contains__(self, item:Any) -> bool:
|
|
144
|
+
"""Check if an element is in the view."""
|
|
145
|
+
return any(item==self._get_element(key) for key in keys(self.data))
|
|
146
|
+
|
|
147
|
+
def join_path(path_tuple:Tuple[Key,...])-> str:
|
|
148
|
+
"""
|
|
149
|
+
joins a path tuple into a path str : ('a',0,'b') -> "a.0.b"
|
|
150
|
+
"""
|
|
151
|
+
return '.'.join(str(k) for k in path_tuple)
|
|
152
|
+
|
|
153
|
+
def split_path(path_str:str)-> Tuple[Key,...]:
|
|
154
|
+
"""
|
|
155
|
+
splits a path str into a path tuple : "a.0.b" -> ('a',0,'b')
|
|
156
|
+
"""
|
|
157
|
+
def format(key:str)-> Key:
|
|
158
|
+
try:
|
|
159
|
+
return int(key)
|
|
160
|
+
except ValueError:
|
|
161
|
+
return key
|
|
162
|
+
return tuple(format(k) for k in path_str.split('.'))
|
|
163
|
+
|
|
164
|
+
def keys(obj:Container)-> Iterator[Key]:
|
|
165
|
+
"""Yield possible keys or indices of a container.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
obj: Mapping or Sequence to get keys from
|
|
169
|
+
|
|
170
|
+
Yields:
|
|
171
|
+
Keys for Mapping, indices for Sequence
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
TypeError: If obj is neither Mapping nor Sequence
|
|
175
|
+
"""
|
|
176
|
+
if isinstance(obj,Mapping):
|
|
177
|
+
yield from obj.keys()
|
|
178
|
+
elif isinstance(obj,Sequence):
|
|
179
|
+
yield from range(len(obj))
|
|
180
|
+
else:
|
|
181
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
182
|
+
|
|
183
|
+
def has_key(obj:Container,key:Key)->bool:
|
|
184
|
+
"""Check if a key/index exists in a container.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
obj: Container to check
|
|
188
|
+
key: Key (for Mapping) or index (for Sequence) to look for
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if key exists, False otherwise
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
TypeError: If obj is neither Mapping nor Sequence
|
|
195
|
+
"""
|
|
196
|
+
if isinstance(obj,Mapping):
|
|
197
|
+
return key in obj
|
|
198
|
+
elif isinstance(obj,Sequence):
|
|
199
|
+
return isinstance(key,int) and 0<=key<len(obj)
|
|
200
|
+
else:
|
|
201
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
202
|
+
|
|
203
|
+
def set_key(obj:MutableContainer,key:Key,value:Any):
|
|
204
|
+
if not is_mutable_container(obj):
|
|
205
|
+
raise TypeError(f"Expected a MutableMapping or MutableSequence container. Got {type(obj)}")
|
|
206
|
+
if isinstance(obj,MutableMapping):
|
|
207
|
+
obj[key]=value
|
|
208
|
+
elif isinstance(obj,MutableSequence):
|
|
209
|
+
if isinstance(key,int):
|
|
210
|
+
while len(obj)<=key:
|
|
211
|
+
obj.append(MISSING)
|
|
212
|
+
obj[key]=value
|
|
213
|
+
else:
|
|
214
|
+
raise IndexError(f"Invalid key type. Expected int, got {type(key)}")
|
|
215
|
+
|
|
216
|
+
def is_container(obj:Any, excluded:Optional[Tuple[Type,...]]=None)->bool:
|
|
217
|
+
"""Test if an object is a container (but not an excluded type).
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
obj: Object to test
|
|
221
|
+
excluded: Types to not consider as containers (default: str, bytes, bytearray)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if obj is a non-excluded container
|
|
225
|
+
"""
|
|
226
|
+
excluded= excluded if excluded is not None else (str,bytes,bytearray)
|
|
227
|
+
return (isinstance(obj,Mapping) or isinstance(obj,Sequence)) and not isinstance(obj,excluded)
|
|
228
|
+
|
|
229
|
+
def is_mutable_container(obj:Any)->bool:
|
|
230
|
+
"""Test if an object is a mutable container.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
obj: Object to test
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
True if obj is MutableMapping or MutableSequence
|
|
237
|
+
"""
|
|
238
|
+
return isinstance(obj,MutableMapping) or isinstance(obj,MutableSequence)
|
|
239
|
+
|
|
240
|
+
def unroll(obj: Container) -> Iterator[Tuple[Key, Any]]:
|
|
241
|
+
"""Yield (key, value) pairs from a container.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
obj: Container to unroll
|
|
245
|
+
|
|
246
|
+
Yields:
|
|
247
|
+
Tuple of (key, value) for each element
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
TypeError: If obj is not a container
|
|
251
|
+
"""
|
|
252
|
+
if not is_container(obj):
|
|
253
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
254
|
+
for key in keys(obj):
|
|
255
|
+
yield key,obj[key]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_nested(obj: Container, path: Path, default: Any = MISSING) -> Any:
|
|
259
|
+
"""Retrieve a nested value using a path.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
obj: Nested structure to traverse
|
|
263
|
+
path: Either dot-separated string ("a.0.b") or tuple of keys
|
|
264
|
+
default: Value to return if path doesn't exist
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Value at path or default if provided
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
TypeError: If obj is not a container
|
|
271
|
+
KeyError: If path doesn't exist and no default provided
|
|
272
|
+
|
|
273
|
+
Examples:
|
|
274
|
+
>>> data = {"a": {"b": [1, 2, {"c": 3}]}}
|
|
275
|
+
>>> get_nested(data, "a.b.2.c")
|
|
276
|
+
3
|
|
277
|
+
>>> get_nested(data, ("a", "b", 2, "c"))
|
|
278
|
+
3
|
|
279
|
+
>>> get_nested(data, "x.y.z", default=None)
|
|
280
|
+
None
|
|
281
|
+
"""
|
|
282
|
+
if not is_container(obj):
|
|
283
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
284
|
+
|
|
285
|
+
if isinstance(path,str):
|
|
286
|
+
keys = split_path(path)
|
|
287
|
+
else:
|
|
288
|
+
keys = path
|
|
289
|
+
|
|
290
|
+
value=obj
|
|
291
|
+
for key in keys:
|
|
292
|
+
if is_container(value) and has_key(value,key):
|
|
293
|
+
value = value[key]
|
|
294
|
+
else:
|
|
295
|
+
if default is not MISSING:
|
|
296
|
+
return default
|
|
297
|
+
else:
|
|
298
|
+
raise KeyError(f"Path {path!r} not found in container")
|
|
299
|
+
return value
|
|
300
|
+
|
|
301
|
+
def set_nested(obj:Container, path: Path, value):
|
|
302
|
+
"""Set a nested value, creating intermediate containers as needed.
|
|
303
|
+
|
|
304
|
+
Creates missing containers (dict for string keys, list for integer keys)
|
|
305
|
+
along the path if they don't exist.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
obj: Container to modify
|
|
309
|
+
path: Path to set value at
|
|
310
|
+
value: Value to set
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
TypeError: If obj is not a container or if any container we attempt to write in is immutable
|
|
314
|
+
|
|
315
|
+
Examples:
|
|
316
|
+
>>> data = {}
|
|
317
|
+
>>> set_nested(data, "a.b.0.c", 42)
|
|
318
|
+
>>> data
|
|
319
|
+
{'a': {'b': [{'c': 42}]}}
|
|
320
|
+
"""
|
|
321
|
+
if not is_container(obj):
|
|
322
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
323
|
+
|
|
324
|
+
if isinstance(path, str):
|
|
325
|
+
path = split_path(path)
|
|
326
|
+
current = obj
|
|
327
|
+
for i,key in enumerate(path):
|
|
328
|
+
if i==len(path)-1:
|
|
329
|
+
#terminal key reached, we set the value and return
|
|
330
|
+
set_key(current,key,value)
|
|
331
|
+
return
|
|
332
|
+
elif not has_key(current,key) or current[key] is MISSING:
|
|
333
|
+
if isinstance(path[i+1],int):
|
|
334
|
+
set_key(current,key,[])
|
|
335
|
+
else:# str
|
|
336
|
+
set_key(current,key,{})
|
|
337
|
+
current = current[key]
|
|
338
|
+
else:
|
|
339
|
+
current = current[key]
|
|
340
|
+
|
|
341
|
+
def pop_nested(obj:Container, path:Path, default=MISSING):
|
|
342
|
+
"""deletes a nested key/index and returns the value (if found).
|
|
343
|
+
If not found, returns default if provided, otherwise raises an error.
|
|
344
|
+
If provided, default will be returned in ANY case of failure.
|
|
345
|
+
This includes these cases:
|
|
346
|
+
- the path doesn't exist or doesn't make sense in the structure
|
|
347
|
+
- the path actually exists but ends in an immutable container in which we can't pop
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
obj: Container to modify
|
|
351
|
+
path: Path to the value to delete
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
TypeError: If obj is not a container, or we attempt to modify an immutable container
|
|
355
|
+
KeyError: If path doesn't exist
|
|
356
|
+
"""
|
|
357
|
+
if not is_container(obj):
|
|
358
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
359
|
+
|
|
360
|
+
if isinstance(path, str):
|
|
361
|
+
path = split_path(path)
|
|
362
|
+
|
|
363
|
+
current = obj
|
|
364
|
+
try:
|
|
365
|
+
for i, key in enumerate(path):
|
|
366
|
+
if is_container(current) and has_key(current,key):
|
|
367
|
+
if i == len(path) - 1:
|
|
368
|
+
if is_mutable_container(current):
|
|
369
|
+
value=current[key]
|
|
370
|
+
del current[key]
|
|
371
|
+
return value
|
|
372
|
+
else:
|
|
373
|
+
raise TypeError(f"Can't delete in an immutable container. Attempt to delete key={key!r} in {current} of immutable type {type(current)} caused this error.")
|
|
374
|
+
current = current[key]
|
|
375
|
+
else:
|
|
376
|
+
raise KeyError(f"Path {path!r} not found in container")
|
|
377
|
+
|
|
378
|
+
except (KeyError,TypeError):
|
|
379
|
+
if default is not MISSING:
|
|
380
|
+
return default
|
|
381
|
+
else:
|
|
382
|
+
raise
|
|
383
|
+
|
|
384
|
+
def del_nested(obj:Container, path:Path):
|
|
385
|
+
"""deletes a nested key/index (if found).
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
obj: Container to modify
|
|
389
|
+
path: Path to the value to delete
|
|
390
|
+
|
|
391
|
+
Raises:
|
|
392
|
+
TypeError: If obj is not a container, or we attempt to modify an immutable container
|
|
393
|
+
KeyError: If path doesn't exist
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
"""
|
|
397
|
+
# delegate the logic to pop_nested, we just don't return the output
|
|
398
|
+
pop_nested(obj,path)
|
|
399
|
+
|
|
400
|
+
def has_nested(obj:Container, path: Path):
|
|
401
|
+
"""Check if a nested path exists.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
obj: Container to check
|
|
405
|
+
path: Path to check for existence
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
True if path exists, False otherwise
|
|
409
|
+
"""
|
|
410
|
+
if not is_container(obj):
|
|
411
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
412
|
+
try:
|
|
413
|
+
get_nested(obj,path)
|
|
414
|
+
return True
|
|
415
|
+
except KeyError:
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def extract(obj: C, *extracted_keys: Key) -> Iterator[Tuple[Key, Any]]:
|
|
420
|
+
"""
|
|
421
|
+
Extract specified keys from a container, preserving the original order.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
obj (Mapping or Sequence): The source container.
|
|
425
|
+
*extracted_keys (str): Keys to extract.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Iterator[Tuple[Key, Any]]: A generator of (key, value) pairs.
|
|
429
|
+
"""
|
|
430
|
+
if not is_container(obj):
|
|
431
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
432
|
+
|
|
433
|
+
ek = set(extracted_keys)
|
|
434
|
+
return ((key, obj[key]) for key in keys(obj) if key in ek)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def exclude(obj: C, *excluded_keys: Key) -> Iterator[Tuple[Key, Any]]:
|
|
438
|
+
"""
|
|
439
|
+
Exclude specified keys from a container, preserving the original order.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
obj (Mapping or Sequence): The source container.
|
|
443
|
+
*excluded_keys (str): Keys to exclude.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Iterator[Tuple[Key, Any]]: A generator of (key, value) pairs.
|
|
447
|
+
"""
|
|
448
|
+
if not is_container(obj):
|
|
449
|
+
raise TypeError(f"Expected a Mapping or Sequence container, got {type(obj)}")
|
|
450
|
+
|
|
451
|
+
ek = set(excluded_keys)
|
|
452
|
+
return ((key, obj[key]) for key in keys(obj) if key not in ek)
|
|
453
|
+
|
|
454
|
+
def walk(
|
|
455
|
+
obj: Container,
|
|
456
|
+
callback: Optional[CallbackFn] = None,
|
|
457
|
+
filter: Optional[FilterFn] = None,
|
|
458
|
+
excluded: Optional[Tuple[Type, ...]] = None
|
|
459
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
460
|
+
"""Walk through a nested container yielding (path, value) pairs.
|
|
461
|
+
|
|
462
|
+
Recursively traverses the container, yielding paths and values for leaf nodes.
|
|
463
|
+
Leaves can be transformed by callback and filtered by the filter predicate.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
obj: Container to traverse
|
|
467
|
+
callback: Optional function to transform leaf values
|
|
468
|
+
filter: Optional predicate to filter paths/values (receives path and value)
|
|
469
|
+
excluded: Container types to treat as leaves (default: str, bytes, bytearray)
|
|
470
|
+
|
|
471
|
+
Yields:
|
|
472
|
+
Tuples of (path_string, value) for each leaf node
|
|
473
|
+
If callback provided, value is transformed by callback
|
|
474
|
+
If filter provided, only yields pairs that pass filter(path, value)
|
|
475
|
+
|
|
476
|
+
Examples:
|
|
477
|
+
>>> data = {"a": [1, {"b": 2}], "c": 3}
|
|
478
|
+
>>> list(walk(data))
|
|
479
|
+
[('a.0', 1), ('a.1.b', 2), ('c', 3)]
|
|
480
|
+
>>> list(walk(data, callback=str))
|
|
481
|
+
[('a.0', '1'), ('a.1.b', '2'), ('c', '3')]
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
def _walk(obj: Any, path: Tuple[Key, ...]) -> Iterator[Tuple[str, Any]]:
|
|
485
|
+
if is_container(obj,excluded=excluded):
|
|
486
|
+
for k, v in unroll(obj):
|
|
487
|
+
yield from _walk(v, path + (k,))
|
|
488
|
+
else:
|
|
489
|
+
joined_path=join_path(path)
|
|
490
|
+
if filter is None or filter(joined_path,obj):
|
|
491
|
+
yield joined_path, callback(obj) if callback is not None else obj
|
|
492
|
+
|
|
493
|
+
yield from _walk(obj, ())
|
|
494
|
+
|
|
495
|
+
def walked(
|
|
496
|
+
obj: Container,
|
|
497
|
+
callback: Optional[CallbackFn] = None,
|
|
498
|
+
filter: Optional[FilterFn] = None,
|
|
499
|
+
excluded: Optional[Tuple[Type, ...]] = None
|
|
500
|
+
) -> Dict[str, Any]:
|
|
501
|
+
"""Return a flattened dictionary of path:value pairs from a nested container.
|
|
502
|
+
|
|
503
|
+
Similar to walk(), but returns a dictionary instead of an iterator.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
obj: Container to traverse
|
|
507
|
+
callback: Optional function to transform leaf values
|
|
508
|
+
filter: Optional predicate to filter paths/values
|
|
509
|
+
excluded: Container types to treat as leaves
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
Dictionary mapping path strings to leaf values
|
|
513
|
+
|
|
514
|
+
Examples:
|
|
515
|
+
>>> data = {"a": [1, {"b": 2}], "c": 3}
|
|
516
|
+
>>> walked(data)
|
|
517
|
+
{'a.0': 1, 'a.1.b': 2, 'c': 3}
|
|
518
|
+
"""
|
|
519
|
+
return dict(walk(obj,callback=callback,filter=filter,excluded=excluded))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def first_keys(walked:Dict[str,Any])->Set[Key]:
|
|
523
|
+
"""
|
|
524
|
+
Return all the first keys encountered in walked paths
|
|
525
|
+
"""
|
|
526
|
+
return set(split_path(p)[0] for p in walked)
|
|
527
|
+
|
|
528
|
+
def is_seq_based(walked:Dict[str,Any])->bool:
|
|
529
|
+
"""
|
|
530
|
+
Determines if the walked structure was initially a Sequence
|
|
531
|
+
"""
|
|
532
|
+
fk=first_keys(walked)
|
|
533
|
+
return fk==set(range(len(fk)))
|
|
534
|
+
|
|
535
|
+
def unwalk(walked:Dict[str,Any])->MutableContainer:
|
|
536
|
+
"""
|
|
537
|
+
Recontructs a nested structure from a flattened dict.
|
|
538
|
+
Args:
|
|
539
|
+
walked: (Dict[str,Any]) a path:value flattened dictionary
|
|
540
|
+
Returns:
|
|
541
|
+
(MutableContainer) : Reconstructed Nested list / dict structure
|
|
542
|
+
"""
|
|
543
|
+
if is_seq_based(walked):
|
|
544
|
+
base=[]
|
|
545
|
+
else:
|
|
546
|
+
base={}
|
|
547
|
+
|
|
548
|
+
for path,value in walked.items():
|
|
549
|
+
set_nested(base,path,value)
|
|
550
|
+
|
|
551
|
+
return base
|
|
552
|
+
|
|
553
|
+
def deep_equals(obj1:Container,obj2:Container,excluded:Optional[Tuple[Type,...]]=None)->bool:
|
|
554
|
+
"""
|
|
555
|
+
Compares two nested structures deeply by comparing their walked dicts
|
|
556
|
+
"""
|
|
557
|
+
return walked(obj1,excluded=excluded)==walked(obj2,excluded=excluded)
|
|
558
|
+
|
|
559
|
+
def diff_nested(
|
|
560
|
+
obj1: Container,
|
|
561
|
+
obj2: Container,
|
|
562
|
+
path: Tuple[Key, ...] = ()
|
|
563
|
+
) -> Dict[str, Tuple[Any, Any]]:
|
|
564
|
+
"""Compare two nested structures and return their differences.
|
|
565
|
+
|
|
566
|
+
Recursively compares two containers and returns a dictionary of differences.
|
|
567
|
+
Keys are paths where values differ, values are tuples of (obj1_value, obj2_value).
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
obj1: First container to compare
|
|
571
|
+
obj2: Second container to compare
|
|
572
|
+
path: Current path in recursion (used internally)
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Dictionary mapping paths to value pairs that differ
|
|
576
|
+
MISSING is used when a key exists in one container but not the other
|
|
577
|
+
|
|
578
|
+
Examples:
|
|
579
|
+
>>> a = {"x": 1, "y": {"z": 2}}
|
|
580
|
+
>>> b = {"x": 1, "y": {"z": 3}, "w": 4}
|
|
581
|
+
>>> diff_nested(a, b)
|
|
582
|
+
{'y.z': (2, 3), 'w': (MISSING, 4)}
|
|
583
|
+
"""
|
|
584
|
+
diffs: Dict[str, Tuple[Any, Any]] = {}
|
|
585
|
+
|
|
586
|
+
if is_container(obj1) and is_container(obj2):
|
|
587
|
+
keys1 = set(keys(obj1))
|
|
588
|
+
keys2 = set(keys(obj2))
|
|
589
|
+
all_keys = keys1.union(keys2)
|
|
590
|
+
for key in all_keys:
|
|
591
|
+
new_path = path + (key,)
|
|
592
|
+
in_obj1 = has_key(obj1, key)
|
|
593
|
+
in_obj2 = has_key(obj2, key)
|
|
594
|
+
if in_obj1 and in_obj2:
|
|
595
|
+
val1, val2 = obj1[key], obj2[key]
|
|
596
|
+
if is_container(val1) and is_container(val2):
|
|
597
|
+
diffs.update(diff_nested(val1, val2, new_path))
|
|
598
|
+
else:
|
|
599
|
+
if val1 != val2:
|
|
600
|
+
diffs[join_path(new_path)] = (val1, val2)
|
|
601
|
+
elif in_obj1:
|
|
602
|
+
diffs[join_path(new_path)] = (obj1[key], MISSING)
|
|
603
|
+
elif in_obj2:
|
|
604
|
+
diffs[join_path(new_path)] = (MISSING, obj2[key])
|
|
605
|
+
else:
|
|
606
|
+
if obj1 != obj2:
|
|
607
|
+
diffs[join_path(path)] = (obj1, obj2)
|
|
608
|
+
|
|
609
|
+
return diffs
|
|
610
|
+
|
|
611
|
+
def deep_merge(
|
|
612
|
+
target: MutableContainer,
|
|
613
|
+
src: Container,
|
|
614
|
+
conflict_resolver: Optional[Callable[[Any, Any], Any]] = None
|
|
615
|
+
) -> None:
|
|
616
|
+
"""Deeply merge source container into target, modifying target in-place.
|
|
617
|
+
|
|
618
|
+
For mappings:
|
|
619
|
+
- If a key exists in both and both values are containers, merge recursively
|
|
620
|
+
- Otherwise, src value overwrites target value (or uses conflict_resolver)
|
|
621
|
+
|
|
622
|
+
For sequences:
|
|
623
|
+
- Elements are merged by index
|
|
624
|
+
- If src has more elements, they are appended to target
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
target: Mutable container to merge into
|
|
628
|
+
src: Container to merge from
|
|
629
|
+
conflict_resolver: Optional function to resolve value conflicts
|
|
630
|
+
Takes (target_value, src_value), returns resolved value
|
|
631
|
+
|
|
632
|
+
Raises:
|
|
633
|
+
TypeError: If target and src are incompatible container types
|
|
634
|
+
|
|
635
|
+
Examples:
|
|
636
|
+
>>> target = {"a": 1, "b": {"x": 1}}
|
|
637
|
+
>>> src = {"b": {"y": 2}, "c": 3}
|
|
638
|
+
>>> deep_merge(target, src)
|
|
639
|
+
>>> target
|
|
640
|
+
{'a': 1, 'b': {'x': 1, 'y': 2}, 'c': 3}
|
|
641
|
+
"""
|
|
642
|
+
if isinstance(target, MutableMapping) and isinstance(src, Mapping):
|
|
643
|
+
for key, src_value in src.items():
|
|
644
|
+
if has_key(target, key):
|
|
645
|
+
target_value = target[key]
|
|
646
|
+
if is_container(target_value) and is_container(src_value):
|
|
647
|
+
deep_merge(target_value, src_value, conflict_resolver)
|
|
648
|
+
else:
|
|
649
|
+
target[key] = conflict_resolver(target_value, src_value) if conflict_resolver else src_value
|
|
650
|
+
else:
|
|
651
|
+
target[key] = src_value
|
|
652
|
+
|
|
653
|
+
elif isinstance(target, MutableSequence) and isinstance(src, Sequence):
|
|
654
|
+
for idx, src_value in enumerate(src):
|
|
655
|
+
if idx < len(target):
|
|
656
|
+
target_value = target[idx]
|
|
657
|
+
if is_container(target_value) and is_container(src_value):
|
|
658
|
+
deep_merge(target_value, src_value, conflict_resolver)
|
|
659
|
+
else:
|
|
660
|
+
target[idx] = conflict_resolver(target_value, src_value) if conflict_resolver else src_value
|
|
661
|
+
else:
|
|
662
|
+
target.append(src_value)
|
|
663
|
+
else:
|
|
664
|
+
raise TypeError("Types of 'target' and 'src' aren't compatibles for deep merging.")
|