fix: deep_set fails loud on a tuple instead of silently replacing it; add deep_gets/deep_sets wildcard verbs

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>
This commit is contained in:
2026-07-06 16:35:58 -04:00
parent 718c8a79b0
commit 7fa5916eda
3 changed files with 164 additions and 15 deletions
+33 -5
View File
@@ -89,13 +89,17 @@ timing.FAST_MODE = True # in test setup
## paths
Two pairs of verbs: **single-path** (scalar in/out) and **wildcard** (bulk, always a list).
### single-path — `deep_get` / `deep_set`
```python
from commons import deep_get, deep_set
data = {"in": {"this": {"old": {"notation": 42}}}, "items": [{"id": "a"}, {"id": "b"}]}
deep_get(data, "in.this.old.notation") # 42
deep_get(data, "items.1.id") # "b" (numeric segment indexes a list)
deep_get(data, "items.1.id") # "b" (numeric segment indexes a list/tuple)
deep_get(data, "in.nope.here", "DEF") # "DEF" (missing -> default, no raise)
deep_set({}, "a.b.c", 9) # {"a": {"b": {"c": 9}}}
@@ -105,10 +109,34 @@ deep_set(data, "items.9.id", "X") # raises IndexError (out of range, no
# silent mis-store)
```
`deep_set` mirrors `deep_get`'s list indexing: a numeric segment over an existing
list/tuple updates that element in place rather than replacing the list with a dict.
An out-of-range numeric segment raises `IndexError` instead of silently corrupting
the structure.
`deep_get` steps into both lists and tuples on a numeric segment. `deep_set` updates a
**list** element in place; setting into (or through) a **tuple** raises `TypeError`
tuples are immutable, so "update in place" is impossible; flatten to a list first. An
out-of-range numeric segment raises `IndexError` instead of silently corrupting the
structure.
### wildcard — `deep_gets` / `deep_sets`
A `*` segment iterates every element at that level (dict values or list/tuple items);
multiple `*` fan out cartesian. These are **separate verbs** with a fixed list/bulk
return type — a `*` passed to `deep_get`/`deep_set` raises `ValueError` (use the plural
verb).
```python
from commons import deep_gets, deep_sets
data = {"users": [{"username": "al"}, {"username": "bo"}, {"username": "cy"}]}
deep_gets(data, "users.*.username") # ["al", "bo", "cy"] (ALWAYS a list, in order)
deep_gets(data, "users.*.nope") # [] (no match -> empty list, no raise)
deep_sets(data, "users.*.username", "X") # set every match to "X"
deep_sets(data, "users.*.username", str.upper) # callable fn(current)->new per match
```
`deep_sets`'s second arg is either a plain value (write it to every match) or a callable
`fn(current) -> new` (compute each). It writes only matches that already exist (it does
not create missing keys). Setting through/into a tuple raises `TypeError`.
## masking