dotted-notation 0.44.1__tar.gz → 0.44.3__tar.gz

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.
Files changed (38) hide show
  1. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/CHANGELOG.md +19 -0
  2. {dotted_notation-0.44.1/dotted_notation.egg-info → dotted_notation-0.44.3}/PKG-INFO +112 -5
  3. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/README.md +111 -4
  4. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/api.py +47 -11
  5. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/base.py +2 -2
  6. {dotted_notation-0.44.1 → dotted_notation-0.44.3/dotted_notation.egg-info}/PKG-INFO +112 -5
  7. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/pyproject.toml +1 -1
  8. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/LICENSE +0 -0
  9. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/MANIFEST.in +0 -0
  10. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/__init__.py +0 -0
  11. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/__main__.py +0 -0
  12. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/access.py +0 -0
  13. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/cli/__init__.py +0 -0
  14. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/cli/_compat.py +0 -0
  15. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/cli/formats.py +0 -0
  16. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/cli/main.py +0 -0
  17. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/containers.py +0 -0
  18. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/engine.py +0 -0
  19. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/filters.py +0 -0
  20. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/grammar.py +0 -0
  21. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/groups.py +0 -0
  22. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/matchers.py +0 -0
  23. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/predicates.py +0 -0
  24. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/recursive.py +0 -0
  25. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/results.py +0 -0
  26. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/sql/__init__.py +0 -0
  27. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/sql/core.py +0 -0
  28. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/sql/pg.py +0 -0
  29. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/transforms.py +0 -0
  30. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/utils.py +0 -0
  31. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/utypes.py +0 -0
  32. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted/wrappers.py +0 -0
  33. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted_notation.egg-info/SOURCES.txt +0 -0
  34. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted_notation.egg-info/dependency_links.txt +0 -0
  35. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted_notation.egg-info/entry_points.txt +0 -0
  36. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted_notation.egg-info/requires.txt +0 -0
  37. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/dotted_notation.egg-info/top_level.txt +0 -0
  38. {dotted_notation-0.44.1 → dotted_notation-0.44.3}/setup.cfg +0 -0
@@ -3,6 +3,25 @@
3
3
  All notable changes to `dotted` are recorded here. Versions prior to
4
4
  the ones listed are omitted — browse git history for earlier entries.
5
5
 
6
+ ## [0.44.3]
7
+
8
+ ### Fixed
9
+ - Transforms with a dict (or other nested-container) argument, e.g.
10
+ `code|lookup:{"a": 1}`, no longer raise `TypeError: unhashable type: 'dict'`
11
+ when the path is parsed. `Transform.__hash__` now freezes container params
12
+ recursively.
13
+
14
+ ## [0.44.2]
15
+
16
+ ### Added
17
+ - `unpack(obj, project=...)` keeps only the leaf paths selected by one or
18
+ more dotted patterns. Selection is directional (a leaf survives if it
19
+ `match`es a pattern), so projecting `a.b` never pulls in a shallower scalar
20
+ `a`. Matching defaults to `partial=True` (trailing segment is greedy); pass
21
+ `partial=False` for exact-depth matching, or override per field with a
22
+ `(pattern, partial)` tuple. `project=`/`partial=` also flow through `keys`,
23
+ `values`, and `items`.
24
+
6
25
  ## [0.44.1]
7
26
 
8
27
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.1
3
+ Version: 0.44.3
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Author-email: Frey Waid <logophage1@gmail.com>
6
6
  License: MIT
@@ -176,6 +176,7 @@ Or pick only what you need:
176
176
  - [Projection](#projection)
177
177
  - [Unpack](#unpack)
178
178
  - [Pack](#pack)
179
+ - [Recipes](#recipes)
179
180
  - [FAQ](#faq)
180
181
  - [Why do I get a tuple for my get?](#why-do-i-get-a-tuple-for-my-get)
181
182
  - [How do I craft an efficient path?](#how-do-i-craft-an-efficient-path)
@@ -729,6 +730,36 @@ For example:
729
730
 
730
731
  Pass both to include all attributes: `attrs=[Attrs.standard, Attrs.special]`.
731
732
 
733
+ Pass `project=` to keep only the leaf paths selected by one or more dotted
734
+ patterns. Selection is *directional* — a leaf survives if it `match`es a
735
+ projection pattern — so projecting `a.b` never drags in a shallower scalar leaf
736
+ `a`:
737
+
738
+ >>> o = {'a': {'b': 1, 'c': 2}, 'x': {'y': {'z': 3}}, 'extra': 9}
739
+ >>> dotted.unpack(o, project='a')
740
+ {'a.b': 1, 'a.c': 2}
741
+ >>> dotted.unpack(o, project=['a', 'x.y.z'])
742
+ {'a.b': 1, 'a.c': 2, 'x.y.z': 3}
743
+
744
+ Matching uses `match`'s `partial=True` by default, so a trailing segment is
745
+ greedy (`a.*` also keeps `a.b.c`). Set `partial=False` for exact-depth matching,
746
+ where `a.*`, `a.*.*`, and `a.**` are all distinct:
747
+
748
+ >>> deep = {'a': {'b': {'c': 1}}, 'd': 9}
749
+ >>> dotted.unpack(deep, project='a.*', partial=False)
750
+ {}
751
+ >>> dotted.unpack(deep, project='a.**', partial=False)
752
+ {'a.b.c': 1}
753
+
754
+ Override `partial` per field with a `(pattern, partial)` tuple — bare patterns
755
+ inherit the global setting. This matters for mid-pattern matches that `**`
756
+ cannot express (e.g. `a.*.c` keeping both `a.x.c` and `a.x.c.d`):
757
+
758
+ >>> dotted.unpack(deep, project=[('a.*', True), 'd'], partial=False)
759
+ {'a.b.c': 1, 'd': 9}
760
+
761
+ `project=` and `partial=` also flow through `keys()`, `values()`, and `items()`.
762
+
732
763
  <a id="pack"></a>
733
764
  ### Pack
734
765
 
@@ -771,10 +802,13 @@ normal form. All three call `unpack()` internally.
771
802
  >>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
772
803
  {'b'}
773
804
 
774
- All three accept `attrs=` (same as `unpack`):
805
+ All three accept the same `attrs=`, `project=`, and `partial=` arguments as
806
+ `unpack`:
775
807
 
776
808
  >>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
777
809
  dict_keys(['point@x', 'point@y'])
810
+ >>> dotted.items({'a': {'b': 1, 'c': 2}, 'x': 3}, project='a')
811
+ dict_items([('a.b', 1), ('a.c', 2)])
778
812
 
779
813
  <a id="build"></a>
780
814
  ### Build
@@ -2926,7 +2960,7 @@ Fragments concatenate naturally via `+` / `__radd__`; metadata merges:
2926
2960
  >>> r = dotted.sqlize("age >= $(min_age)", driver='asyncpg')
2927
2961
  >>> combined = "WHERE " + r.where
2928
2962
  >>> r.build(combined, min_age=30)
2929
- ('WHERE age >= $1::bigint', [30])
2963
+ ('WHERE age >= $1', [30])
2930
2964
 
2931
2965
  ### Hoisted params
2932
2966
 
@@ -3090,7 +3124,7 @@ Pass a shared `ParamPool` to every `sqlize()` call that should compose:
3090
3124
  >>> r2 = dotted.sqlize('age >= 30', driver='asyncpg', pool=pool)
3091
3125
  >>> combined = '(' + r1.where + ') AND (' + r2.where + ')'
3092
3126
  >>> dotted.Resolver.build(combined, paramstyle='dollar-numeric')
3093
- ('(status = $1) AND (age = $2)', ['active', 30])
3127
+ ('(status = $1) AND (age >= $2)', ['active', 30])
3094
3128
 
3095
3129
  Substitutions by the same original name dedup across Resolvers sharing
3096
3130
  a pool — one slot, one value, back-referenced in the rendered SQL:
@@ -3147,7 +3181,7 @@ segment before building a `Raw`:
3147
3181
  Col('matched.customer')
3148
3182
  >>> Col('schema', 'table', 'col')
3149
3183
  Col('schema.table.col')
3150
- >>> Col('bad; DROP TABLE')
3184
+ >>> Col('bad; DROP TABLE') # doctest: +IGNORE_EXCEPTION_DETAIL
3151
3185
  Traceback (most recent call last):
3152
3186
  ...
3153
3187
  dotted.TranslationError: Col part is not a plain identifier: 'bad; DROP TABLE'
@@ -3393,6 +3427,79 @@ example, removing an entire group without listing every key:
3393
3427
  echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
3394
3428
  # {"app.debug": true}
3395
3429
 
3430
+ <a id="recipes"></a>
3431
+ ## Recipes
3432
+
3433
+ A grab-bag of one-liners that show off what the notation can do. Every example
3434
+ below runs as-is.
3435
+
3436
+ **Flatten a nested list** (leaves only, any depth) — recurse through every slot
3437
+ with `*([*])`, then keep only the deepest match on each branch with `:-1`:
3438
+
3439
+ >>> import dotted
3440
+ >>> dotted.get([1, 2, 3, [4, 5, [6, 7]]], '*([*]):-1')
3441
+ (1, 2, 3, 4, 5, 6, 7)
3442
+
3443
+ Because `:-1` is deepest-*per-branch*, shallow leaves survive alongside deep
3444
+ ones — a ragged list still flattens completely.
3445
+
3446
+ **Collect every leaf value** of a nested dict — same idea with key recursion:
3447
+
3448
+ >>> dotted.get({'a': {'b': 1}, 'c': 2}, '**:-1')
3449
+ (1, 2)
3450
+
3451
+ **Find a key at any depth** — `**` recurses through dict keys, then continue
3452
+ with the key you want:
3453
+
3454
+ >>> dotted.get({'a': {'b': {'name': 'x'}}, 'name': 'y'}, '**.name')
3455
+ ('x',)
3456
+
3457
+ If the tree mixes lists and dicts, recurse through both with `*(*#, [*])`:
3458
+
3459
+ >>> dotted.get({'kids': [{'name': 'a'}, {'name': 'b'}]}, '*(*#, [*]).name')
3460
+ ('a', 'b')
3461
+
3462
+ **Find values matching a condition anywhere** — attach a value guard to the
3463
+ recursive walk:
3464
+
3465
+ >>> dotted.get({'a': {'b': 7, 'c': 3}, 'd': {'e': 9}}, '**>5')
3466
+ (7, 9)
3467
+
3468
+ **Bulk-update everything that matches** — the same pattern drives `update` and
3469
+ `remove`:
3470
+
3471
+ >>> dotted.update({'a': {'b': 7, 'c': 3}, 'd': 7}, '**=7', 99)
3472
+ {'a': {'b': 99, 'c': 3}, 'd': 99}
3473
+
3474
+ **Filter a list of dicts, then project a field** — combine a key-value filter
3475
+ with a continuation:
3476
+
3477
+ >>> users = [{'name': 'x', 'active': True},
3478
+ ... {'name': 'y', 'active': False},
3479
+ ... {'name': 'z', 'active': True}]
3480
+ >>> dotted.get(users, '[*&active=true].name')
3481
+ ('x', 'z')
3482
+
3483
+ **Upsert** — update a list entry if it exists, else append, using cut (`#`) in a
3484
+ disjunction so the first matching branch wins:
3485
+
3486
+ >>> dotted.update({'emails': [{'email': 'a@x'}]},
3487
+ ... 'emails[(*&email="a@x"#, +)].email', 'NEW')
3488
+ {'emails': [{'email': 'NEW'}]}
3489
+ >>> dotted.update({'emails': [{'email': 'a@x'}]},
3490
+ ... 'emails[(*&email="z@x"#, +)]', {'email': 'z@x'})
3491
+ {'emails': [{'email': 'a@x'}, {'email': 'z@x'}]}
3492
+
3493
+ **Coerce on the way out** — pipe a value through transforms:
3494
+
3495
+ >>> dotted.get({'name': 'bob', 'tags': [1, 2, 3]}, 'name|uppercase')
3496
+ 'BOB'
3497
+ >>> dotted.get({'name': 'bob', 'tags': [1, 2, 3]}, 'tags|len')
3498
+ 3
3499
+
3500
+ See [Recursive Traversal](#recursive-traversal), [Filters](#filters), and
3501
+ [Transforms](#transforms) for the full story behind each of these.
3502
+
3396
3503
  <a id="faq"></a>
3397
3504
  ## FAQ
3398
3505
 
@@ -136,6 +136,7 @@ Or pick only what you need:
136
136
  - [Projection](#projection)
137
137
  - [Unpack](#unpack)
138
138
  - [Pack](#pack)
139
+ - [Recipes](#recipes)
139
140
  - [FAQ](#faq)
140
141
  - [Why do I get a tuple for my get?](#why-do-i-get-a-tuple-for-my-get)
141
142
  - [How do I craft an efficient path?](#how-do-i-craft-an-efficient-path)
@@ -689,6 +690,36 @@ For example:
689
690
 
690
691
  Pass both to include all attributes: `attrs=[Attrs.standard, Attrs.special]`.
691
692
 
693
+ Pass `project=` to keep only the leaf paths selected by one or more dotted
694
+ patterns. Selection is *directional* — a leaf survives if it `match`es a
695
+ projection pattern — so projecting `a.b` never drags in a shallower scalar leaf
696
+ `a`:
697
+
698
+ >>> o = {'a': {'b': 1, 'c': 2}, 'x': {'y': {'z': 3}}, 'extra': 9}
699
+ >>> dotted.unpack(o, project='a')
700
+ {'a.b': 1, 'a.c': 2}
701
+ >>> dotted.unpack(o, project=['a', 'x.y.z'])
702
+ {'a.b': 1, 'a.c': 2, 'x.y.z': 3}
703
+
704
+ Matching uses `match`'s `partial=True` by default, so a trailing segment is
705
+ greedy (`a.*` also keeps `a.b.c`). Set `partial=False` for exact-depth matching,
706
+ where `a.*`, `a.*.*`, and `a.**` are all distinct:
707
+
708
+ >>> deep = {'a': {'b': {'c': 1}}, 'd': 9}
709
+ >>> dotted.unpack(deep, project='a.*', partial=False)
710
+ {}
711
+ >>> dotted.unpack(deep, project='a.**', partial=False)
712
+ {'a.b.c': 1}
713
+
714
+ Override `partial` per field with a `(pattern, partial)` tuple — bare patterns
715
+ inherit the global setting. This matters for mid-pattern matches that `**`
716
+ cannot express (e.g. `a.*.c` keeping both `a.x.c` and `a.x.c.d`):
717
+
718
+ >>> dotted.unpack(deep, project=[('a.*', True), 'd'], partial=False)
719
+ {'a.b.c': 1, 'd': 9}
720
+
721
+ `project=` and `partial=` also flow through `keys()`, `values()`, and `items()`.
722
+
692
723
  <a id="pack"></a>
693
724
  ### Pack
694
725
 
@@ -731,10 +762,13 @@ normal form. All three call `unpack()` internally.
731
762
  >>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
732
763
  {'b'}
733
764
 
734
- All three accept `attrs=` (same as `unpack`):
765
+ All three accept the same `attrs=`, `project=`, and `partial=` arguments as
766
+ `unpack`:
735
767
 
736
768
  >>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
737
769
  dict_keys(['point@x', 'point@y'])
770
+ >>> dotted.items({'a': {'b': 1, 'c': 2}, 'x': 3}, project='a')
771
+ dict_items([('a.b', 1), ('a.c', 2)])
738
772
 
739
773
  <a id="build"></a>
740
774
  ### Build
@@ -2886,7 +2920,7 @@ Fragments concatenate naturally via `+` / `__radd__`; metadata merges:
2886
2920
  >>> r = dotted.sqlize("age >= $(min_age)", driver='asyncpg')
2887
2921
  >>> combined = "WHERE " + r.where
2888
2922
  >>> r.build(combined, min_age=30)
2889
- ('WHERE age >= $1::bigint', [30])
2923
+ ('WHERE age >= $1', [30])
2890
2924
 
2891
2925
  ### Hoisted params
2892
2926
 
@@ -3050,7 +3084,7 @@ Pass a shared `ParamPool` to every `sqlize()` call that should compose:
3050
3084
  >>> r2 = dotted.sqlize('age >= 30', driver='asyncpg', pool=pool)
3051
3085
  >>> combined = '(' + r1.where + ') AND (' + r2.where + ')'
3052
3086
  >>> dotted.Resolver.build(combined, paramstyle='dollar-numeric')
3053
- ('(status = $1) AND (age = $2)', ['active', 30])
3087
+ ('(status = $1) AND (age >= $2)', ['active', 30])
3054
3088
 
3055
3089
  Substitutions by the same original name dedup across Resolvers sharing
3056
3090
  a pool — one slot, one value, back-referenced in the rendered SQL:
@@ -3107,7 +3141,7 @@ segment before building a `Raw`:
3107
3141
  Col('matched.customer')
3108
3142
  >>> Col('schema', 'table', 'col')
3109
3143
  Col('schema.table.col')
3110
- >>> Col('bad; DROP TABLE')
3144
+ >>> Col('bad; DROP TABLE') # doctest: +IGNORE_EXCEPTION_DETAIL
3111
3145
  Traceback (most recent call last):
3112
3146
  ...
3113
3147
  dotted.TranslationError: Col part is not a plain identifier: 'bad; DROP TABLE'
@@ -3353,6 +3387,79 @@ example, removing an entire group without listing every key:
3353
3387
  echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
3354
3388
  # {"app.debug": true}
3355
3389
 
3390
+ <a id="recipes"></a>
3391
+ ## Recipes
3392
+
3393
+ A grab-bag of one-liners that show off what the notation can do. Every example
3394
+ below runs as-is.
3395
+
3396
+ **Flatten a nested list** (leaves only, any depth) — recurse through every slot
3397
+ with `*([*])`, then keep only the deepest match on each branch with `:-1`:
3398
+
3399
+ >>> import dotted
3400
+ >>> dotted.get([1, 2, 3, [4, 5, [6, 7]]], '*([*]):-1')
3401
+ (1, 2, 3, 4, 5, 6, 7)
3402
+
3403
+ Because `:-1` is deepest-*per-branch*, shallow leaves survive alongside deep
3404
+ ones — a ragged list still flattens completely.
3405
+
3406
+ **Collect every leaf value** of a nested dict — same idea with key recursion:
3407
+
3408
+ >>> dotted.get({'a': {'b': 1}, 'c': 2}, '**:-1')
3409
+ (1, 2)
3410
+
3411
+ **Find a key at any depth** — `**` recurses through dict keys, then continue
3412
+ with the key you want:
3413
+
3414
+ >>> dotted.get({'a': {'b': {'name': 'x'}}, 'name': 'y'}, '**.name')
3415
+ ('x',)
3416
+
3417
+ If the tree mixes lists and dicts, recurse through both with `*(*#, [*])`:
3418
+
3419
+ >>> dotted.get({'kids': [{'name': 'a'}, {'name': 'b'}]}, '*(*#, [*]).name')
3420
+ ('a', 'b')
3421
+
3422
+ **Find values matching a condition anywhere** — attach a value guard to the
3423
+ recursive walk:
3424
+
3425
+ >>> dotted.get({'a': {'b': 7, 'c': 3}, 'd': {'e': 9}}, '**>5')
3426
+ (7, 9)
3427
+
3428
+ **Bulk-update everything that matches** — the same pattern drives `update` and
3429
+ `remove`:
3430
+
3431
+ >>> dotted.update({'a': {'b': 7, 'c': 3}, 'd': 7}, '**=7', 99)
3432
+ {'a': {'b': 99, 'c': 3}, 'd': 99}
3433
+
3434
+ **Filter a list of dicts, then project a field** — combine a key-value filter
3435
+ with a continuation:
3436
+
3437
+ >>> users = [{'name': 'x', 'active': True},
3438
+ ... {'name': 'y', 'active': False},
3439
+ ... {'name': 'z', 'active': True}]
3440
+ >>> dotted.get(users, '[*&active=true].name')
3441
+ ('x', 'z')
3442
+
3443
+ **Upsert** — update a list entry if it exists, else append, using cut (`#`) in a
3444
+ disjunction so the first matching branch wins:
3445
+
3446
+ >>> dotted.update({'emails': [{'email': 'a@x'}]},
3447
+ ... 'emails[(*&email="a@x"#, +)].email', 'NEW')
3448
+ {'emails': [{'email': 'NEW'}]}
3449
+ >>> dotted.update({'emails': [{'email': 'a@x'}]},
3450
+ ... 'emails[(*&email="z@x"#, +)]', {'email': 'z@x'})
3451
+ {'emails': [{'email': 'a@x'}, {'email': 'z@x'}]}
3452
+
3453
+ **Coerce on the way out** — pipe a value through transforms:
3454
+
3455
+ >>> dotted.get({'name': 'bob', 'tags': [1, 2, 3]}, 'name|uppercase')
3456
+ 'BOB'
3457
+ >>> dotted.get({'name': 'bob', 'tags': [1, 2, 3]}, 'tags|len')
3458
+ 3
3459
+
3460
+ See [Recursive Traversal](#recursive-traversal), [Filters](#filters), and
3461
+ [Transforms](#transforms) for the full story behind each of these.
3462
+
3356
3463
  <a id="faq"></a>
3357
3464
  ## FAQ
3358
3465
 
@@ -1099,7 +1099,7 @@ def pack(pathvalues, apply_transforms=True, strict=False, bindings=None):
1099
1099
  return update_multi(AUTO, pathvalues, apply_transforms=apply_transforms, strict=strict, bindings=bindings)
1100
1100
 
1101
1101
 
1102
- def unpack(obj, attrs=None):
1102
+ def unpack(obj, attrs=None, project=None, partial=True):
1103
1103
  """
1104
1104
  Convert obj to dotted normal form. A dict mapping dotted paths to leaf
1105
1105
  values, which can be replayed to regenerate the obj (see `pack`).
@@ -1117,6 +1117,27 @@ def unpack(obj, attrs=None):
1117
1117
  {'a.b': [1, 2, 3], 'x.y.z': [4, 5], 'extra': 'stuff'}
1118
1118
  >>> pack(r) == d
1119
1119
  True
1120
+
1121
+ Pass project= to keep only the leaf paths selected by one or more dotted
1122
+ patterns. A leaf survives if it `match`es any projection pattern, so
1123
+ selection is directional: projecting 'a.b' never pulls in a scalar leaf
1124
+ 'a'. project= accepts a single pattern or an iterable of them:
1125
+
1126
+ >>> o = {'a': {'b': 1, 'c': 2}, 'x': {'y': {'z': 3}}, 'extra': 9}
1127
+ >>> unpack(o, project='a')
1128
+ {'a.b': 1, 'a.c': 2}
1129
+ >>> unpack(o, project=['a', 'x.y.z'])
1130
+ {'a.b': 1, 'a.c': 2, 'x.y.z': 3}
1131
+
1132
+ Matching uses match()'s `partial=True` by default, so a trailing segment
1133
+ is greedy ('a.*' also keeps 'a.b.c'). Set partial=False for exact-depth
1134
+ matching, or override it per-field with a (pattern, partial) tuple:
1135
+
1136
+ >>> deep = {'a': {'b': {'c': 1}}, 'd': 9}
1137
+ >>> unpack(deep, project='a.*', partial=False)
1138
+ {}
1139
+ >>> unpack(deep, project=[('a.*', True), 'd'])
1140
+ {'a.b.c': 1, 'd': 9}
1120
1141
  """
1121
1142
  # Accept either `Attrs` enum members or their string values.
1122
1143
  attr_values = {a.value if isinstance(a, Attrs) else a for a in (attrs or ())}
@@ -1128,43 +1149,58 @@ def unpack(obj, attrs=None):
1128
1149
  extra = ', @/(?!__).*/'
1129
1150
  else:
1130
1151
  extra = ', @/__.*/'
1131
- return dict(pluck(obj, f'*(*#, [*]:!(str, bytes){extra}):-2(.*, []{extra})##, (*, []{extra})'))
1152
+ result = dict(pluck(obj, f'*(*#, [*]:!(str, bytes){extra}):-2(.*, []{extra})##, (*, []{extra})'))
1153
+ if project is None:
1154
+ return result
1155
+ if isinstance(project, str):
1156
+ project = [project]
1157
+ # Normalize each entry to (pattern, partial); bare patterns inherit the
1158
+ # global `partial`, (pattern, partial) tuples override it per-field.
1159
+ specs = [(p, partial) if isinstance(p, str) else tuple(p) for p in project]
1160
+ return {k: v for k, v in result.items()
1161
+ if any(match(pat, k, partial=pp) for pat, pp in specs)}
1132
1162
 
1133
1163
 
1134
- def items(obj, attrs=None):
1164
+ def items(obj, attrs=None, project=None, partial=True):
1135
1165
  """
1136
1166
  Return (path, value) pairs of obj in normal form as a dict_items view.
1137
- Internally calls unpack().
1167
+ Internally calls unpack(); accepts project=/partial= (see unpack).
1138
1168
 
1139
1169
  >>> d = {'a': {'b': 1}, 'x': 2}
1140
1170
  >>> sorted(items(d))
1141
1171
  [('a.b', 1), ('x', 2)]
1172
+ >>> sorted(items(d, project='a'))
1173
+ [('a.b', 1)]
1142
1174
  """
1143
- return unpack(obj, attrs=attrs).items()
1175
+ return unpack(obj, attrs=attrs, project=project, partial=partial).items()
1144
1176
 
1145
1177
 
1146
- def keys(obj, attrs=None):
1178
+ def keys(obj, attrs=None, project=None, partial=True):
1147
1179
  """
1148
1180
  Return the dotted paths of obj in normal form as dict_keys.
1149
- Internally calls unpack().
1181
+ Internally calls unpack(); accepts project=/partial= (see unpack).
1150
1182
 
1151
1183
  >>> d = {'a': {'b': 1}, 'x': 2}
1152
1184
  >>> sorted(keys(d))
1153
1185
  ['a.b', 'x']
1186
+ >>> sorted(keys(d, project='a'))
1187
+ ['a.b']
1154
1188
  """
1155
- return unpack(obj, attrs=attrs).keys()
1189
+ return unpack(obj, attrs=attrs, project=project, partial=partial).keys()
1156
1190
 
1157
1191
 
1158
- def values(obj, attrs=None):
1192
+ def values(obj, attrs=None, project=None, partial=True):
1159
1193
  """
1160
1194
  Return the leaf values of obj in normal form.
1161
- Internally calls unpack().
1195
+ Internally calls unpack(); accepts project=/partial= (see unpack).
1162
1196
 
1163
1197
  >>> d = {'a': {'b': 1}, 'x': 2}
1164
1198
  >>> sorted(values(d))
1165
1199
  [1, 2]
1200
+ >>> sorted(values(d, project='a'))
1201
+ [1]
1166
1202
  """
1167
- return unpack(obj, attrs=attrs).values()
1203
+ return unpack(obj, attrs=attrs, project=project, partial=partial).values()
1168
1204
 
1169
1205
 
1170
1206
  #
@@ -272,8 +272,8 @@ class Transform(Op):
272
272
  try:
273
273
  return hash(('transform', self.name, self.params))
274
274
  except TypeError:
275
- return hash(('transform', self.name,
276
- tuple(tuple(p) if isinstance(p, list) else p for p in self.params)))
275
+ from .results import Dotted
276
+ return hash(('transform', self.name, Dotted._hashable(self.params)))
277
277
 
278
278
  def __eq__(self, other):
279
279
  return (isinstance(other, Transform)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.1
3
+ Version: 0.44.3
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Author-email: Frey Waid <logophage1@gmail.com>
6
6
  License: MIT
@@ -176,6 +176,7 @@ Or pick only what you need:
176
176
  - [Projection](#projection)
177
177
  - [Unpack](#unpack)
178
178
  - [Pack](#pack)
179
+ - [Recipes](#recipes)
179
180
  - [FAQ](#faq)
180
181
  - [Why do I get a tuple for my get?](#why-do-i-get-a-tuple-for-my-get)
181
182
  - [How do I craft an efficient path?](#how-do-i-craft-an-efficient-path)
@@ -729,6 +730,36 @@ For example:
729
730
 
730
731
  Pass both to include all attributes: `attrs=[Attrs.standard, Attrs.special]`.
731
732
 
733
+ Pass `project=` to keep only the leaf paths selected by one or more dotted
734
+ patterns. Selection is *directional* — a leaf survives if it `match`es a
735
+ projection pattern — so projecting `a.b` never drags in a shallower scalar leaf
736
+ `a`:
737
+
738
+ >>> o = {'a': {'b': 1, 'c': 2}, 'x': {'y': {'z': 3}}, 'extra': 9}
739
+ >>> dotted.unpack(o, project='a')
740
+ {'a.b': 1, 'a.c': 2}
741
+ >>> dotted.unpack(o, project=['a', 'x.y.z'])
742
+ {'a.b': 1, 'a.c': 2, 'x.y.z': 3}
743
+
744
+ Matching uses `match`'s `partial=True` by default, so a trailing segment is
745
+ greedy (`a.*` also keeps `a.b.c`). Set `partial=False` for exact-depth matching,
746
+ where `a.*`, `a.*.*`, and `a.**` are all distinct:
747
+
748
+ >>> deep = {'a': {'b': {'c': 1}}, 'd': 9}
749
+ >>> dotted.unpack(deep, project='a.*', partial=False)
750
+ {}
751
+ >>> dotted.unpack(deep, project='a.**', partial=False)
752
+ {'a.b.c': 1}
753
+
754
+ Override `partial` per field with a `(pattern, partial)` tuple — bare patterns
755
+ inherit the global setting. This matters for mid-pattern matches that `**`
756
+ cannot express (e.g. `a.*.c` keeping both `a.x.c` and `a.x.c.d`):
757
+
758
+ >>> dotted.unpack(deep, project=[('a.*', True), 'd'], partial=False)
759
+ {'a.b.c': 1, 'd': 9}
760
+
761
+ `project=` and `partial=` also flow through `keys()`, `values()`, and `items()`.
762
+
732
763
  <a id="pack"></a>
733
764
  ### Pack
734
765
 
@@ -771,10 +802,13 @@ normal form. All three call `unpack()` internally.
771
802
  >>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
772
803
  {'b'}
773
804
 
774
- All three accept `attrs=` (same as `unpack`):
805
+ All three accept the same `attrs=`, `project=`, and `partial=` arguments as
806
+ `unpack`:
775
807
 
776
808
  >>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
777
809
  dict_keys(['point@x', 'point@y'])
810
+ >>> dotted.items({'a': {'b': 1, 'c': 2}, 'x': 3}, project='a')
811
+ dict_items([('a.b', 1), ('a.c', 2)])
778
812
 
779
813
  <a id="build"></a>
780
814
  ### Build
@@ -2926,7 +2960,7 @@ Fragments concatenate naturally via `+` / `__radd__`; metadata merges:
2926
2960
  >>> r = dotted.sqlize("age >= $(min_age)", driver='asyncpg')
2927
2961
  >>> combined = "WHERE " + r.where
2928
2962
  >>> r.build(combined, min_age=30)
2929
- ('WHERE age >= $1::bigint', [30])
2963
+ ('WHERE age >= $1', [30])
2930
2964
 
2931
2965
  ### Hoisted params
2932
2966
 
@@ -3090,7 +3124,7 @@ Pass a shared `ParamPool` to every `sqlize()` call that should compose:
3090
3124
  >>> r2 = dotted.sqlize('age >= 30', driver='asyncpg', pool=pool)
3091
3125
  >>> combined = '(' + r1.where + ') AND (' + r2.where + ')'
3092
3126
  >>> dotted.Resolver.build(combined, paramstyle='dollar-numeric')
3093
- ('(status = $1) AND (age = $2)', ['active', 30])
3127
+ ('(status = $1) AND (age >= $2)', ['active', 30])
3094
3128
 
3095
3129
  Substitutions by the same original name dedup across Resolvers sharing
3096
3130
  a pool — one slot, one value, back-referenced in the rendered SQL:
@@ -3147,7 +3181,7 @@ segment before building a `Raw`:
3147
3181
  Col('matched.customer')
3148
3182
  >>> Col('schema', 'table', 'col')
3149
3183
  Col('schema.table.col')
3150
- >>> Col('bad; DROP TABLE')
3184
+ >>> Col('bad; DROP TABLE') # doctest: +IGNORE_EXCEPTION_DETAIL
3151
3185
  Traceback (most recent call last):
3152
3186
  ...
3153
3187
  dotted.TranslationError: Col part is not a plain identifier: 'bad; DROP TABLE'
@@ -3393,6 +3427,79 @@ example, removing an entire group without listing every key:
3393
3427
  echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
3394
3428
  # {"app.debug": true}
3395
3429
 
3430
+ <a id="recipes"></a>
3431
+ ## Recipes
3432
+
3433
+ A grab-bag of one-liners that show off what the notation can do. Every example
3434
+ below runs as-is.
3435
+
3436
+ **Flatten a nested list** (leaves only, any depth) — recurse through every slot
3437
+ with `*([*])`, then keep only the deepest match on each branch with `:-1`:
3438
+
3439
+ >>> import dotted
3440
+ >>> dotted.get([1, 2, 3, [4, 5, [6, 7]]], '*([*]):-1')
3441
+ (1, 2, 3, 4, 5, 6, 7)
3442
+
3443
+ Because `:-1` is deepest-*per-branch*, shallow leaves survive alongside deep
3444
+ ones — a ragged list still flattens completely.
3445
+
3446
+ **Collect every leaf value** of a nested dict — same idea with key recursion:
3447
+
3448
+ >>> dotted.get({'a': {'b': 1}, 'c': 2}, '**:-1')
3449
+ (1, 2)
3450
+
3451
+ **Find a key at any depth** — `**` recurses through dict keys, then continue
3452
+ with the key you want:
3453
+
3454
+ >>> dotted.get({'a': {'b': {'name': 'x'}}, 'name': 'y'}, '**.name')
3455
+ ('x',)
3456
+
3457
+ If the tree mixes lists and dicts, recurse through both with `*(*#, [*])`:
3458
+
3459
+ >>> dotted.get({'kids': [{'name': 'a'}, {'name': 'b'}]}, '*(*#, [*]).name')
3460
+ ('a', 'b')
3461
+
3462
+ **Find values matching a condition anywhere** — attach a value guard to the
3463
+ recursive walk:
3464
+
3465
+ >>> dotted.get({'a': {'b': 7, 'c': 3}, 'd': {'e': 9}}, '**>5')
3466
+ (7, 9)
3467
+
3468
+ **Bulk-update everything that matches** — the same pattern drives `update` and
3469
+ `remove`:
3470
+
3471
+ >>> dotted.update({'a': {'b': 7, 'c': 3}, 'd': 7}, '**=7', 99)
3472
+ {'a': {'b': 99, 'c': 3}, 'd': 99}
3473
+
3474
+ **Filter a list of dicts, then project a field** — combine a key-value filter
3475
+ with a continuation:
3476
+
3477
+ >>> users = [{'name': 'x', 'active': True},
3478
+ ... {'name': 'y', 'active': False},
3479
+ ... {'name': 'z', 'active': True}]
3480
+ >>> dotted.get(users, '[*&active=true].name')
3481
+ ('x', 'z')
3482
+
3483
+ **Upsert** — update a list entry if it exists, else append, using cut (`#`) in a
3484
+ disjunction so the first matching branch wins:
3485
+
3486
+ >>> dotted.update({'emails': [{'email': 'a@x'}]},
3487
+ ... 'emails[(*&email="a@x"#, +)].email', 'NEW')
3488
+ {'emails': [{'email': 'NEW'}]}
3489
+ >>> dotted.update({'emails': [{'email': 'a@x'}]},
3490
+ ... 'emails[(*&email="z@x"#, +)]', {'email': 'z@x'})
3491
+ {'emails': [{'email': 'a@x'}, {'email': 'z@x'}]}
3492
+
3493
+ **Coerce on the way out** — pipe a value through transforms:
3494
+
3495
+ >>> dotted.get({'name': 'bob', 'tags': [1, 2, 3]}, 'name|uppercase')
3496
+ 'BOB'
3497
+ >>> dotted.get({'name': 'bob', 'tags': [1, 2, 3]}, 'tags|len')
3498
+ 3
3499
+
3500
+ See [Recursive Traversal](#recursive-traversal), [Filters](#filters), and
3501
+ [Transforms](#transforms) for the full story behind each of these.
3502
+
3396
3503
  <a id="faq"></a>
3397
3504
  ## FAQ
3398
3505
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dotted_notation"
7
- version = "0.44.1"
7
+ version = "0.44.3"
8
8
  description = "Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.6"