dotted-notation 0.44.2__tar.gz → 0.44.4__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.2 → dotted_notation-0.44.4}/CHANGELOG.md +28 -0
  2. {dotted_notation-0.44.2/dotted_notation.egg-info → dotted_notation-0.44.4}/PKG-INFO +82 -5
  3. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/README.md +81 -4
  4. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/__init__.py +2 -0
  5. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/api.py +32 -13
  6. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/base.py +2 -2
  7. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/engine.py +42 -0
  8. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/matchers.py +9 -2
  9. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/results.py +32 -5
  10. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/utils.py +19 -0
  11. {dotted_notation-0.44.2 → dotted_notation-0.44.4/dotted_notation.egg-info}/PKG-INFO +82 -5
  12. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/pyproject.toml +1 -1
  13. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/LICENSE +0 -0
  14. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/MANIFEST.in +0 -0
  15. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/__main__.py +0 -0
  16. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/access.py +0 -0
  17. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/cli/__init__.py +0 -0
  18. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/cli/_compat.py +0 -0
  19. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/cli/formats.py +0 -0
  20. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/cli/main.py +0 -0
  21. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/containers.py +0 -0
  22. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/filters.py +0 -0
  23. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/grammar.py +0 -0
  24. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/groups.py +0 -0
  25. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/predicates.py +0 -0
  26. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/recursive.py +0 -0
  27. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/sql/__init__.py +0 -0
  28. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/sql/core.py +0 -0
  29. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/sql/pg.py +0 -0
  30. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/transforms.py +0 -0
  31. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/utypes.py +0 -0
  32. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted/wrappers.py +0 -0
  33. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted_notation.egg-info/SOURCES.txt +0 -0
  34. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted_notation.egg-info/dependency_links.txt +0 -0
  35. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted_notation.egg-info/entry_points.txt +0 -0
  36. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted_notation.egg-info/requires.txt +0 -0
  37. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/dotted_notation.egg-info/top_level.txt +0 -0
  38. {dotted_notation-0.44.2 → dotted_notation-0.44.4}/setup.cfg +0 -0
@@ -3,6 +3,34 @@
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.4]
7
+
8
+ ### Added
9
+ - Module-level escape hatches: `dotted.set_simple_fastpath(False)` disables
10
+ the get() fast path (everything goes through walk()), and
11
+ `dotted.set_parse_cache(size)` resizes the LRU cache behind parse()
12
+ (0 disables, None unbounded). Both return the previous setting.
13
+
14
+ ### Performance
15
+ - `get()` fast path for simple paths (#58): a plain chain of literal
16
+ Key/Attr/Slot accesses (`a.b`, `a[0].b`, `a@x`) resolves with direct
17
+ dict/list/attr lookups, skipping the walk() machinery entirely. The
18
+ chain is computed once at parse time and cached on the `Dotted`
19
+ (`simple_chain`); unusual containers (dict subclasses, custom
20
+ mappings) fall back to the full traversal.
21
+ - `Const.value`/`Numeric.value` are now computed once and cached on the
22
+ instance instead of recomputed per property access.
23
+ - `Dotted.__hash__` is cached, making repeated cache lookups keyed on a
24
+ pre-parsed `Dotted` (e.g. `get(obj, parsed)`) much cheaper.
25
+
26
+ ## [0.44.3]
27
+
28
+ ### Fixed
29
+ - Transforms with a dict (or other nested-container) argument, e.g.
30
+ `code|lookup:{"a": 1}`, no longer raise `TypeError: unhashable type: 'dict'`
31
+ when the path is parsed. `Transform.__hash__` now freezes container params
32
+ recursively.
33
+
6
34
  ## [0.44.2]
7
35
 
8
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.2
3
+ Version: 0.44.4
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)
@@ -801,10 +802,13 @@ normal form. All three call `unpack()` internally.
801
802
  >>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
802
803
  {'b'}
803
804
 
804
- All three accept `attrs=` (same as `unpack`):
805
+ All three accept the same `attrs=`, `project=`, and `partial=` arguments as
806
+ `unpack`:
805
807
 
806
808
  >>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
807
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)])
808
812
 
809
813
  <a id="build"></a>
810
814
  ### Build
@@ -2956,7 +2960,7 @@ Fragments concatenate naturally via `+` / `__radd__`; metadata merges:
2956
2960
  >>> r = dotted.sqlize("age >= $(min_age)", driver='asyncpg')
2957
2961
  >>> combined = "WHERE " + r.where
2958
2962
  >>> r.build(combined, min_age=30)
2959
- ('WHERE age >= $1::bigint', [30])
2963
+ ('WHERE age >= $1', [30])
2960
2964
 
2961
2965
  ### Hoisted params
2962
2966
 
@@ -3120,7 +3124,7 @@ Pass a shared `ParamPool` to every `sqlize()` call that should compose:
3120
3124
  >>> r2 = dotted.sqlize('age >= 30', driver='asyncpg', pool=pool)
3121
3125
  >>> combined = '(' + r1.where + ') AND (' + r2.where + ')'
3122
3126
  >>> dotted.Resolver.build(combined, paramstyle='dollar-numeric')
3123
- ('(status = $1) AND (age = $2)', ['active', 30])
3127
+ ('(status = $1) AND (age >= $2)', ['active', 30])
3124
3128
 
3125
3129
  Substitutions by the same original name dedup across Resolvers sharing
3126
3130
  a pool — one slot, one value, back-referenced in the rendered SQL:
@@ -3177,7 +3181,7 @@ segment before building a `Raw`:
3177
3181
  Col('matched.customer')
3178
3182
  >>> Col('schema', 'table', 'col')
3179
3183
  Col('schema.table.col')
3180
- >>> Col('bad; DROP TABLE')
3184
+ >>> Col('bad; DROP TABLE') # doctest: +IGNORE_EXCEPTION_DETAIL
3181
3185
  Traceback (most recent call last):
3182
3186
  ...
3183
3187
  dotted.TranslationError: Col part is not a plain identifier: 'bad; DROP TABLE'
@@ -3423,6 +3427,79 @@ example, removing an entire group without listing every key:
3423
3427
  echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
3424
3428
  # {"app.debug": true}
3425
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
+
3426
3503
  <a id="faq"></a>
3427
3504
  ## FAQ
3428
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)
@@ -761,10 +762,13 @@ normal form. All three call `unpack()` internally.
761
762
  >>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
762
763
  {'b'}
763
764
 
764
- All three accept `attrs=` (same as `unpack`):
765
+ All three accept the same `attrs=`, `project=`, and `partial=` arguments as
766
+ `unpack`:
765
767
 
766
768
  >>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
767
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)])
768
772
 
769
773
  <a id="build"></a>
770
774
  ### Build
@@ -2916,7 +2920,7 @@ Fragments concatenate naturally via `+` / `__radd__`; metadata merges:
2916
2920
  >>> r = dotted.sqlize("age >= $(min_age)", driver='asyncpg')
2917
2921
  >>> combined = "WHERE " + r.where
2918
2922
  >>> r.build(combined, min_age=30)
2919
- ('WHERE age >= $1::bigint', [30])
2923
+ ('WHERE age >= $1', [30])
2920
2924
 
2921
2925
  ### Hoisted params
2922
2926
 
@@ -3080,7 +3084,7 @@ Pass a shared `ParamPool` to every `sqlize()` call that should compose:
3080
3084
  >>> r2 = dotted.sqlize('age >= 30', driver='asyncpg', pool=pool)
3081
3085
  >>> combined = '(' + r1.where + ') AND (' + r2.where + ')'
3082
3086
  >>> dotted.Resolver.build(combined, paramstyle='dollar-numeric')
3083
- ('(status = $1) AND (age = $2)', ['active', 30])
3087
+ ('(status = $1) AND (age >= $2)', ['active', 30])
3084
3088
 
3085
3089
  Substitutions by the same original name dedup across Resolvers sharing
3086
3090
  a pool — one slot, one value, back-referenced in the rendered SQL:
@@ -3137,7 +3141,7 @@ segment before building a `Raw`:
3137
3141
  Col('matched.customer')
3138
3142
  >>> Col('schema', 'table', 'col')
3139
3143
  Col('schema.table.col')
3140
- >>> Col('bad; DROP TABLE')
3144
+ >>> Col('bad; DROP TABLE') # doctest: +IGNORE_EXCEPTION_DETAIL
3141
3145
  Traceback (most recent call last):
3142
3146
  ...
3143
3147
  dotted.TranslationError: Col part is not a plain identifier: 'bad; DROP TABLE'
@@ -3383,6 +3387,79 @@ example, removing an entire group without listing every key:
3383
3387
  echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
3384
3388
  # {"app.debug": true}
3385
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
+
3386
3463
  <a id="faq"></a>
3387
3464
  ## FAQ
3388
3465
 
@@ -58,6 +58,7 @@ For full documentation including all options and flags:
58
58
  from .api import \
59
59
  parse, is_pattern, is_template, is_reference, is_indeterminate, is_simple, \
60
60
  is_inverted, is_mutable, mutable, quote, ANY, AUTO, Attrs, GroupMode, \
61
+ set_simple_fastpath, set_parse_cache, \
61
62
  register, transform, \
62
63
  assemble, assemble_multi, \
63
64
  build, build_multi, \
@@ -87,6 +88,7 @@ __all__ = [
87
88
  'is_pattern', 'is_template', 'is_reference',
88
89
  'is_indeterminate', 'is_simple',
89
90
  'is_inverted', 'is_mutable', 'mutable',
91
+ 'set_simple_fastpath', 'set_parse_cache',
90
92
  # SQL
91
93
  'sqlize', 'Resolver', 'SQLFragment', 'ParamStyle', 'ParamPool', 'TranslationError',
92
94
  # Constants
@@ -69,6 +69,32 @@ class ParseError(Exception):
69
69
 
70
70
  _parse_lock = threading.Lock()
71
71
 
72
+ _SIMPLE_FASTPATH = True
73
+
74
+
75
+ def set_simple_fastpath(enable=True):
76
+ """
77
+ Enable/disable the simple-path fast path in get() (escape hatch: when
78
+ off, every lookup goes through the full walk() traversal). On by
79
+ default. Returns the previous setting.
80
+ """
81
+ global _SIMPLE_FASTPATH
82
+ prev = _SIMPLE_FASTPATH
83
+ _SIMPLE_FASTPATH = bool(enable)
84
+ return prev
85
+
86
+
87
+ def set_parse_cache(size=CACHE_SIZE):
88
+ """
89
+ Resize the LRU cache behind parse(). 0 disables caching (every path
90
+ string is re-parsed from scratch); None makes it unbounded. Resizing
91
+ discards currently cached parses. Returns the previous size.
92
+ """
93
+ global _parse
94
+ prev = _parse.cache_info().maxsize
95
+ _parse = functools.lru_cache(size)(_parse.__wrapped__)
96
+ return prev
97
+
72
98
 
73
99
  @functools.lru_cache(CACHE_SIZE)
74
100
  def _parse(ops):
@@ -234,16 +260,6 @@ def is_indeterminate(path):
234
260
  return _is_template(parsed) or _is_reference(parsed)
235
261
 
236
262
 
237
- @functools.lru_cache(CACHE_SIZE)
238
- def _is_simple(ops):
239
- for op in ops:
240
- if not isinstance(op, (access.Key, access.Attr, access.Slot)):
241
- return False
242
- if not isinstance(op.op, matchers.Const):
243
- return False
244
- return True
245
-
246
-
247
263
  def is_simple(path):
248
264
  """
249
265
  True if the path is a plain chain of access ops (Key, Attr, Slot)
@@ -271,9 +287,7 @@ def is_simple(path):
271
287
  False
272
288
  """
273
289
  parsed = path if isinstance(path, results.Dotted) else parse(path)
274
- if parsed.transforms:
275
- return False
276
- return _is_simple(parsed)
290
+ return parsed.simple_chain is not None
277
291
 
278
292
 
279
293
  def is_inverted(path):
@@ -433,6 +447,11 @@ def get(obj, path, default=None, pattern_default=(), apply_transforms=True, stri
433
447
  (7,)
434
448
  """
435
449
  ops = parse(path, bindings=bindings, partial=False)
450
+ chain = ops.simple_chain if _SIMPLE_FASTPATH else None
451
+ if chain is not None:
452
+ val = engine.simple_get(chain, obj, strict=strict)
453
+ if val is not engine.SIMPLE_BAIL:
454
+ return default if val is base.marker else val
436
455
  vals = engine.iter_until_cut(engine.gets(ops, obj, strict=strict))
437
456
  if apply_transforms:
438
457
  vals = ( ops.apply(v) for v in vals )
@@ -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)
@@ -48,6 +48,48 @@ def build(ops, node, deepcopy=True, **kwargs):
48
48
  return built or build_default([cur]+ops)
49
49
 
50
50
 
51
+ SIMPLE_BAIL = object()
52
+
53
+
54
+ def simple_get(chain, node, strict=False):
55
+ """
56
+ Fast path for simple paths (see Dotted.simple_chain): follow literal
57
+ keys with direct dict/list/attr access, skipping walk() entirely.
58
+ Returns the found value, base.marker when the path misses, or
59
+ SIMPLE_BAIL when a node's type falls outside the fast path — the
60
+ caller then falls back to the full traversal.
61
+ """
62
+ for kind, key in chain:
63
+ if kind == 'attr':
64
+ node = getattr(node, key, base.marker)
65
+ if node is base.marker:
66
+ return base.marker
67
+ continue
68
+ t = type(node)
69
+ if t is dict:
70
+ # strict: Slot never coerces to dict keys
71
+ if strict and kind == 'slot':
72
+ return base.marker
73
+ node = node.get(key, base.marker)
74
+ if node is base.marker:
75
+ return base.marker
76
+ continue
77
+ if t is list or t is tuple:
78
+ # strict: Key never coerces to sequence indices
79
+ if strict and kind == 'key':
80
+ return base.marker
81
+ if type(key) is not int:
82
+ return base.marker
83
+ try:
84
+ node = node[key]
85
+ except IndexError:
86
+ return base.marker
87
+ continue
88
+ # dict subclasses, custom containers, None, etc: full traversal
89
+ return SIMPLE_BAIL
90
+ return node
91
+
92
+
51
93
  def iter_until_cut(gen):
52
94
  """
53
95
  Consume a get generator until base.CUT_SENTINEL; yield values, stop on sentinel.
@@ -10,6 +10,7 @@ import pyparsing as pp
10
10
 
11
11
  from . import base
12
12
  from .base import MatchOp
13
+ from .utils import lazyprop
13
14
  from .utypes import ANY
14
15
 
15
16
 
@@ -19,8 +20,11 @@ _MISSING = object()
19
20
  class Const(MatchOp):
20
21
  _match_from = ('Const',)
21
22
 
22
- @property
23
+ @lazyprop
23
24
  def value(self):
25
+ """
26
+ The literal value; computed once and cached on the instance.
27
+ """
24
28
  return self.args[0]
25
29
  def matches(self, vals):
26
30
  return (v for v in vals if self.value == v)
@@ -32,8 +36,11 @@ class Numeric(Const):
32
36
  return str(self.args[0]) == str(int(self.args[0]))
33
37
  except (ValueError, TypeError):
34
38
  return False
35
- @property
39
+ @lazyprop
36
40
  def value(self):
41
+ """
42
+ The numeric value (int when possible); computed once and cached.
43
+ """
37
44
  return int(self.args[0]) if self.is_int() else float(self.args[0])
38
45
  def __repr__(self):
39
46
  return f'{self.value}'
@@ -5,7 +5,9 @@ import itertools
5
5
 
6
6
  from . import predicates
7
7
  from . import utils
8
- from .access import Invert
8
+ from .access import Attr, Invert, Key, Slot
9
+ from .matchers import Const
10
+ from .utils import lazyprop
9
11
 
10
12
 
11
13
  class rdoc(str):
@@ -35,6 +37,7 @@ class Dotted:
35
37
  else:
36
38
  self.guard = None
37
39
  self.guard_op = predicates.EQ
40
+ self._hash = None
38
41
 
39
42
  @property
40
43
  def guard_negate(self):
@@ -72,10 +75,12 @@ class Dotted:
72
75
  return tuple(sorted((k, Dotted._hashable(v)) for k, v in iterable))
73
76
  return obj
74
77
  def __hash__(self):
75
- try:
76
- return hash((self.ops, self.transforms, self.guard, self.guard_op))
77
- except TypeError:
78
- return hash((self.ops, Dotted._hashable(self.transforms), self.guard, self.guard_op))
78
+ if self._hash is None:
79
+ try:
80
+ self._hash = hash((self.ops, self.transforms, self.guard, self.guard_op))
81
+ except TypeError:
82
+ self._hash = hash((self.ops, Dotted._hashable(self.transforms), self.guard, self.guard_op))
83
+ return self._hash
79
84
  def __len__(self):
80
85
  return len(self.ops)
81
86
  def __iter__(self):
@@ -105,6 +110,28 @@ class Dotted:
105
110
  def apply(self, val):
106
111
  return apply_transforms(val, self.transforms)
107
112
 
113
+ @lazyprop
114
+ def simple_chain(self):
115
+ """
116
+ Tuple of (kind, key) pairs when this path is simple — a plain chain
117
+ of concrete Key/Attr/Slot accesses with no patterns, substitutions,
118
+ references, guards, transforms, or filters — else None. Computed
119
+ once and cached; drives the fast path in get() that skips walk().
120
+ kind is 'key', 'attr', or 'slot'.
121
+ """
122
+ if self.transforms or self.guard is not None:
123
+ return None
124
+ chain = []
125
+ for op in self.ops:
126
+ # exact types: subclasses like SlotSpecial have different semantics
127
+ if type(op) not in (Key, Attr, Slot):
128
+ return None
129
+ if not isinstance(op.op, Const):
130
+ return None
131
+ kind = 'attr' if isinstance(op, Attr) else 'slot' if isinstance(op, Slot) else 'key'
132
+ chain.append((kind, op.op.value))
133
+ return tuple(chain)
134
+
108
135
  Dotted.registry.__doc__ = rdoc()
109
136
 
110
137
 
@@ -2,6 +2,25 @@
2
2
  Shared type-checking helpers (duck-typing).
3
3
  """
4
4
 
5
+ class lazyprop:
6
+ """
7
+ Non-data descriptor: compute once on first access, cache the result
8
+ in the instance __dict__ (which then shadows the descriptor). Like
9
+ functools.cached_property but lock-free and available on 3.6+.
10
+ """
11
+ def __init__(self, fn):
12
+ self.fn = fn
13
+ self.name = fn.__name__
14
+ self.__doc__ = fn.__doc__
15
+
16
+ def __get__(self, obj, owner=None):
17
+ if obj is None:
18
+ return self
19
+ val = self.fn(obj)
20
+ obj.__dict__[self.name] = val
21
+ return val
22
+
23
+
5
24
  try:
6
25
  import dataclasses as _dc
7
26
  except ImportError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.2
3
+ Version: 0.44.4
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)
@@ -801,10 +802,13 @@ normal form. All three call `unpack()` internally.
801
802
  >>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
802
803
  {'b'}
803
804
 
804
- All three accept `attrs=` (same as `unpack`):
805
+ All three accept the same `attrs=`, `project=`, and `partial=` arguments as
806
+ `unpack`:
805
807
 
806
808
  >>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
807
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)])
808
812
 
809
813
  <a id="build"></a>
810
814
  ### Build
@@ -2956,7 +2960,7 @@ Fragments concatenate naturally via `+` / `__radd__`; metadata merges:
2956
2960
  >>> r = dotted.sqlize("age >= $(min_age)", driver='asyncpg')
2957
2961
  >>> combined = "WHERE " + r.where
2958
2962
  >>> r.build(combined, min_age=30)
2959
- ('WHERE age >= $1::bigint', [30])
2963
+ ('WHERE age >= $1', [30])
2960
2964
 
2961
2965
  ### Hoisted params
2962
2966
 
@@ -3120,7 +3124,7 @@ Pass a shared `ParamPool` to every `sqlize()` call that should compose:
3120
3124
  >>> r2 = dotted.sqlize('age >= 30', driver='asyncpg', pool=pool)
3121
3125
  >>> combined = '(' + r1.where + ') AND (' + r2.where + ')'
3122
3126
  >>> dotted.Resolver.build(combined, paramstyle='dollar-numeric')
3123
- ('(status = $1) AND (age = $2)', ['active', 30])
3127
+ ('(status = $1) AND (age >= $2)', ['active', 30])
3124
3128
 
3125
3129
  Substitutions by the same original name dedup across Resolvers sharing
3126
3130
  a pool — one slot, one value, back-referenced in the rendered SQL:
@@ -3177,7 +3181,7 @@ segment before building a `Raw`:
3177
3181
  Col('matched.customer')
3178
3182
  >>> Col('schema', 'table', 'col')
3179
3183
  Col('schema.table.col')
3180
- >>> Col('bad; DROP TABLE')
3184
+ >>> Col('bad; DROP TABLE') # doctest: +IGNORE_EXCEPTION_DETAIL
3181
3185
  Traceback (most recent call last):
3182
3186
  ...
3183
3187
  dotted.TranslationError: Col part is not a plain identifier: 'bad; DROP TABLE'
@@ -3423,6 +3427,79 @@ example, removing an entire group without listing every key:
3423
3427
  echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
3424
3428
  # {"app.debug": true}
3425
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
+
3426
3503
  <a id="faq"></a>
3427
3504
  ## FAQ
3428
3505
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dotted_notation"
7
- version = "0.44.2"
7
+ version = "0.44.4"
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"