deep_set stepping into or through a tuple used to fall through to the dict branch and
replace the tuple with {}, silently corrupting data on a deep_get/deep_set round-trip
(deep_get traverses tuples read-only). it now raises TypeError - tuples are immutable, so
an in-place update is impossible. also adds deep_gets/deep_sets: '*' path segments for
bulk get (always a list) and bulk set (plain value or fn(current)->new); a '*' passed to
the scalar deep_get/deep_set raises ValueError so the return type is never ambiguous.
Signed-off-by: disqualifier <dev@disqualifier.me>
183 lines
7.1 KiB
Python
183 lines
7.1 KiB
Python
"""nested dict/list access by dotted path
|
|
|
|
- deep_get/deep_set: single path, scalar in/out. numeric segment indexes a list;
|
|
setting into a tuple raises (immutable).
|
|
- deep_gets/deep_sets: `*` segments iterate every element at a level (bulk get -> list,
|
|
bulk set -> value or fn(current)->new); multiple `*` fan out cartesian.
|
|
|
|
a `*` in the scalar verbs raises ValueError - use the plural verbs so the return type
|
|
is never ambiguous.
|
|
"""
|
|
from typing import Any, Callable, List, Union
|
|
|
|
_MISSING = object()
|
|
_WILDCARD = "*"
|
|
|
|
|
|
def deep_get(data: Any, path: str, default: Any = None, *, sep: str = ".") -> Any:
|
|
"""get a nested value by dotted path, returning default if absent
|
|
|
|
dict keys are matched by name; a numeric segment indexes a list/tuple
|
|
(e.g. "items.0.id"). any missing key, out-of-range index, or non-container
|
|
along the way yields `default` rather than raising. a `*` segment raises
|
|
ValueError - use deep_gets for wildcard paths.
|
|
"""
|
|
segments = path.split(sep)
|
|
if _WILDCARD in segments:
|
|
raise ValueError(f"deep_get: wildcard '*' in path {path!r}; use deep_gets for wildcard paths")
|
|
cur = data
|
|
for seg in segments:
|
|
if isinstance(cur, dict):
|
|
cur = cur.get(seg, _MISSING)
|
|
if cur is _MISSING:
|
|
return default
|
|
elif isinstance(cur, (list, tuple)):
|
|
try:
|
|
cur = cur[int(seg)]
|
|
except (ValueError, IndexError):
|
|
return default
|
|
else:
|
|
return default
|
|
return cur
|
|
|
|
|
|
def deep_set(data: dict, path: str, value: Any, *, sep: str = ".") -> dict:
|
|
"""set a nested value by dotted path, creating intermediate dicts; returns data for chaining
|
|
|
|
a numeric segment updates a LIST element in place (out-of-range raises IndexError);
|
|
setting through or into a tuple raises TypeError (immutable). a `*` raises ValueError -
|
|
use deep_sets for wildcard paths.
|
|
"""
|
|
segments = path.split(sep)
|
|
if _WILDCARD in segments:
|
|
raise ValueError(f"deep_set: wildcard '*' in path {path!r}; use deep_sets for wildcard paths")
|
|
cur = data
|
|
for seg in segments[:-1]:
|
|
if isinstance(cur, tuple):
|
|
raise TypeError(f"deep_set: cannot set into an immutable tuple at {path!r}")
|
|
if isinstance(cur, list):
|
|
idx = int(seg)
|
|
if not -len(cur) <= idx < len(cur):
|
|
raise IndexError(f"deep_set: index {seg!r} out of range for list of length {len(cur)}")
|
|
nxt = cur[idx]
|
|
if not isinstance(nxt, (dict, list, tuple)):
|
|
nxt = {}
|
|
cur[idx] = nxt
|
|
cur = nxt
|
|
else:
|
|
nxt = cur.get(seg)
|
|
if not isinstance(nxt, (dict, list, tuple)):
|
|
nxt = {}
|
|
cur[seg] = nxt
|
|
cur = nxt
|
|
last = segments[-1]
|
|
if isinstance(cur, tuple):
|
|
raise TypeError(f"deep_set: cannot set into an immutable tuple at {path!r}")
|
|
if isinstance(cur, list):
|
|
idx = int(last)
|
|
if not -len(cur) <= idx < len(cur):
|
|
raise IndexError(f"deep_set: index {last!r} out of range for list of length {len(cur)}")
|
|
cur[idx] = value
|
|
else:
|
|
cur[last] = value
|
|
return data
|
|
|
|
|
|
def _iter_children(node: Any):
|
|
"""yield (key, child) pairs for a `*` step: dict items, or list/tuple index/item pairs"""
|
|
if isinstance(node, dict):
|
|
yield from node.items()
|
|
elif isinstance(node, (list, tuple)):
|
|
yield from enumerate(node)
|
|
|
|
|
|
def deep_gets(data: Any, path: str, *, sep: str = ".") -> List[Any]:
|
|
"""get every value matching a dotted path with `*` wildcards, as a list (may be empty)
|
|
|
|
a `*` iterates every element at that level; multiple `*` fan out cartesian. a branch
|
|
that dead-ends (missing key, out-of-range index, non-container) is skipped, not raised.
|
|
a path with no `*` returns a one- or zero-element list.
|
|
"""
|
|
segments = path.split(sep)
|
|
frontier = [data]
|
|
for seg in segments:
|
|
nxt: List[Any] = []
|
|
if seg == _WILDCARD:
|
|
for node in frontier:
|
|
for _, child in _iter_children(node):
|
|
nxt.append(child)
|
|
else:
|
|
for node in frontier:
|
|
if isinstance(node, dict):
|
|
got = node.get(seg, _MISSING)
|
|
if got is not _MISSING:
|
|
nxt.append(got)
|
|
elif isinstance(node, (list, tuple)):
|
|
try:
|
|
nxt.append(node[int(seg)])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
frontier = nxt
|
|
return frontier
|
|
|
|
|
|
def deep_sets(data: Any, path: str, value: "Union[Any, Callable[[Any], Any]]", *, sep: str = ".") -> Any:
|
|
"""set every value matching a dotted path with `*` wildcards; returns data for chaining
|
|
|
|
`value` is a plain value written to every match, or a callable `fn(current) -> new`
|
|
(a bare callable is always invoked - to store one, wrap it `lambda _c, f=fn: f`). a `*`
|
|
iterates every element at that level; multiple `*` fan out cartesian. best-effort: any
|
|
dead-end (missing key, out-of-range index) is skipped, not raised. setting through or
|
|
into a tuple raises TypeError (immutable).
|
|
"""
|
|
compute: Callable[[Any], Any] = value if callable(value) else (lambda _cur, _v=value: _v)
|
|
segments = path.split(sep)
|
|
parents = segments[:-1]
|
|
last = segments[-1]
|
|
|
|
frontier = [data]
|
|
for seg in parents:
|
|
nxt: List[Any] = []
|
|
if seg == _WILDCARD:
|
|
for node in frontier:
|
|
if isinstance(node, tuple):
|
|
raise TypeError(f"deep_sets: cannot set through an immutable tuple at {path!r}")
|
|
for _, child in _iter_children(node):
|
|
nxt.append(child)
|
|
else:
|
|
for node in frontier:
|
|
if isinstance(node, tuple):
|
|
raise TypeError(f"deep_sets: cannot set through an immutable tuple at {path!r}")
|
|
if isinstance(node, dict):
|
|
got = node.get(seg, _MISSING)
|
|
if got is not _MISSING:
|
|
nxt.append(got)
|
|
elif isinstance(node, list):
|
|
try:
|
|
nxt.append(node[int(seg)])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
frontier = nxt
|
|
|
|
for node in frontier:
|
|
if isinstance(node, tuple):
|
|
raise TypeError(f"deep_sets: cannot set into an immutable tuple at {path!r}")
|
|
if last == _WILDCARD:
|
|
if isinstance(node, dict):
|
|
for key in list(node.keys()):
|
|
node[key] = compute(node[key])
|
|
elif isinstance(node, list):
|
|
for idx in range(len(node)):
|
|
node[idx] = compute(node[idx])
|
|
elif isinstance(node, dict):
|
|
if node.get(last, _MISSING) is not _MISSING:
|
|
node[last] = compute(node[last])
|
|
elif isinstance(node, list):
|
|
try:
|
|
idx = int(last)
|
|
except ValueError:
|
|
continue
|
|
if -len(node) <= idx < len(node):
|
|
node[idx] = compute(node[idx])
|
|
return data
|