dotted-notation 0.49.4__tar.gz → 0.51.0__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 (48) hide show
  1. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/CHANGELOG.md +30 -0
  2. {dotted_notation-0.49.4/dotted_notation.egg-info → dotted_notation-0.51.0}/PKG-INFO +48 -1
  3. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/README.md +47 -0
  4. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/access.py +21 -7
  5. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/api.py +36 -19
  6. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/base.py +20 -0
  7. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/grammar.py +146 -10
  8. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/groups.py +13 -4
  9. {dotted_notation-0.49.4 → dotted_notation-0.51.0/dotted_notation.egg-info}/PKG-INFO +48 -1
  10. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/pyproject.toml +1 -1
  11. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/LICENSE +0 -0
  12. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/MANIFEST.in +0 -0
  13. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/__init__.py +0 -0
  14. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/__main__.py +0 -0
  15. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/access.pxd +0 -0
  16. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/base.pxd +0 -0
  17. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/cli/__init__.py +0 -0
  18. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/cli/_compat.py +0 -0
  19. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/cli/formats.py +0 -0
  20. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/cli/main.py +0 -0
  21. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/containers.py +0 -0
  22. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/engine.pxd +0 -0
  23. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/engine.py +0 -0
  24. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/filters.pxd +0 -0
  25. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/filters.py +0 -0
  26. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/groups.pxd +0 -0
  27. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/matchers.pxd +0 -0
  28. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/matchers.py +0 -0
  29. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/native.py +0 -0
  30. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/predicates.py +0 -0
  31. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/recursive.pxd +0 -0
  32. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/recursive.py +0 -0
  33. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/results.py +0 -0
  34. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/sql/__init__.py +0 -0
  35. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/sql/core.py +0 -0
  36. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/sql/pg.py +0 -0
  37. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/transforms.py +0 -0
  38. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/utils.py +0 -0
  39. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/utypes.py +0 -0
  40. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/wrappers.pxd +0 -0
  41. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted/wrappers.py +0 -0
  42. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted_notation.egg-info/SOURCES.txt +0 -0
  43. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted_notation.egg-info/dependency_links.txt +0 -0
  44. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted_notation.egg-info/entry_points.txt +0 -0
  45. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted_notation.egg-info/requires.txt +0 -0
  46. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/dotted_notation.egg-info/top_level.txt +0 -0
  47. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/setup.cfg +0 -0
  48. {dotted_notation-0.49.4 → dotted_notation-0.51.0}/setup.py +0 -0
@@ -3,6 +3,36 @@
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.51.0]
7
+
8
+ ### Added
9
+ - `ReferenceResolver.lex(text, position)`, a parse-time hook for the
10
+ tokens dotted's grammar cannot read: a slot, `merged[k]`, a slice
11
+ bound, `items[:n]`, a filter value, `[*&cur=lot.cur]`, or a guard
12
+ value, `a=k`. A dotted name is one token. It answers with dotted text
13
+ read in the token's place (`'$$(k)'`), `None` to leave the
14
+ `ParseError`, or raises to fail the parse at the token. Tokens dotted
15
+ reads are never offered; `literals` remaps those at run time. `parse`
16
+ takes `resolver=`, and the calls taking one parse with it (#66).
17
+
18
+ ## [0.50.0]
19
+
20
+ ### Changed
21
+ - **Breaking**: `[+]` and `[+?]` are the slot past the end, so nothing
22
+ is ever there to read. `get` through them is the default, not the
23
+ last item (use `[-1]`); `has` is `False`; `setdefault` always appends,
24
+ `[+?]` only when the value is absent, and returns the value it set;
25
+ `remove`, `pluck` and `expand` through them find nothing; a guard on
26
+ them, `[+]=2`, never passes.
27
+ - In an update, a group branch holding `[+]` always applies, since a
28
+ write to it always does. `[(*&k=1, +)]` updates the match and appends,
29
+ as before, and `(a, b[+])` now appends to `b` when it is empty too;
30
+ with a cut, `[(*&k=1#, +)]`, the append is still the fallback.
31
+
32
+ ### Fixed
33
+ - `update` through a softcut group with an append branch,
34
+ `[(*&k=1##, +)]`, raised `AttributeError` once the filter matched.
35
+
6
36
  ## [0.49.4]
7
37
 
8
38
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.49.4
3
+ Version: 0.51.0
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-Expression: MIT
@@ -275,6 +275,18 @@ Several Python libraries handle nested data access. Here's how dotted compares:
275
275
  <a id="breaking-changes"></a>
276
276
  ## Breaking Changes
277
277
 
278
+ ### v0.50.0
279
+ - **Nothing is read through `[+]` or `[+?]`.** They are the slot past the
280
+ end, so `get(d, 'a[+]')` is the default, not the last item; use `a[-1]`.
281
+ `has` is `False`, `remove`, `pluck` and `expand` through them find
282
+ nothing, a guard on them never passes, and `setdefault` always appends
283
+ (`[+?]` only when the value is absent). See
284
+ [The append `+` operator](#the-append--operator).
285
+ - **An append branch in a group always applies in an update.**
286
+ `(a, b[+])` appends to `b` whether or not it is empty; it used to append
287
+ only when `b` already had items. With a cut, `[(*&k=1#, +)]`, the append
288
+ is still the fallback.
289
+
278
290
  ### v0.49.0
279
291
  - **`;` is now a reserved character**: it packs a group, `(a,b;)` (see
280
292
  [Packing](#packing)). If you have keys containing a literal `;`, quote
@@ -1757,6 +1769,29 @@ A path handed in already parsed goes through `fold()` again, so a hook must
1757
1769
  be content to see its own output. `Dotted.segments()` gives any parsed
1758
1770
  path's segment values, and a slice of a parsed path, `ops[1:]`, is a path.
1759
1771
 
1772
+ A host's own names may stand where dotted has no reading for a bare token:
1773
+ a slot, `merged[k]`, a slice bound, `items[:n]`, a filter value,
1774
+ `[*&cur=want]`, or a guard value, `a=k`. The parse-time hook
1775
+ `lex(text, position)` is offered each such token as written, a dotted name
1776
+ whole, with its position, one of `'slots'`, `'slices'`, `'filters'` or
1777
+ `'guards'`. It answers with dotted text to read in the token's place, `None`
1778
+ to leave the `ParseError`, or raises to fail the parse at the token. The
1779
+ answer must be one token dotted reads there, and is not offered again.
1780
+
1781
+ >>> class Names(dotted.ReferenceResolver):
1782
+ ... def lex(self, text, position):
1783
+ ... return f'$$({text})'
1784
+ >>> d = {'merged': {'x': 1, 'y': 2}, 'k': 'x', 'c': {'account': 'y'}}
1785
+ >>> dotted.get(d, 'merged[k]', resolver=Names())
1786
+ 1
1787
+ >>> dotted.get(d, 'merged[c.account]', resolver=Names())
1788
+ 2
1789
+
1790
+ A token dotted reads, `m[$x]` or `|sub:taken`, is never offered to `lex()`;
1791
+ a host that means something else by one remaps it at run time, through
1792
+ `literals`. `parse` takes `resolver=` too, and a path is cached per resolver
1793
+ that lexes.
1794
+
1760
1795
  <a id="escaping"></a>
1761
1796
  ### Escaping
1762
1797
 
@@ -2403,6 +2438,18 @@ sequence. You may append an item or slice to the end a sequence.
2403
2438
  >>> dotted.update(d, '*.there[+:]', [999])
2404
2439
  {'hi': {'there': [1, 2, 3, 8, 999]}, 'bye': {'there': [4, 5, 6, 8, 999]}}
2405
2440
 
2441
+ `[+]` is the slot past the end, so nothing is ever there to read: a `get`
2442
+ through it is the default, and `[-1]` is the last item. A write to it always
2443
+ applies, so in a group an append branch is updated along with any branch
2444
+ that matches:
2445
+
2446
+ >>> dotted.get(d, 'hi.there[+]') is None
2447
+ True
2448
+ >>> dotted.update([{'k': 1}, {'k': 2}], '[(*&k=1, +)]', {'k': 9})
2449
+ [{'k': 9}, {'k': 2}, {'k': 9}]
2450
+ >>> dotted.update([{'k': 1}, {'k': 2}], '[(*&k=1#, +)]', {'k': 9})
2451
+ [{'k': 9}, {'k': 2}]
2452
+
2406
2453
  <a id="the-append-unique--operator"></a>
2407
2454
  ### The append-unique `+?` operator
2408
2455
 
@@ -227,6 +227,18 @@ Several Python libraries handle nested data access. Here's how dotted compares:
227
227
  <a id="breaking-changes"></a>
228
228
  ## Breaking Changes
229
229
 
230
+ ### v0.50.0
231
+ - **Nothing is read through `[+]` or `[+?]`.** They are the slot past the
232
+ end, so `get(d, 'a[+]')` is the default, not the last item; use `a[-1]`.
233
+ `has` is `False`, `remove`, `pluck` and `expand` through them find
234
+ nothing, a guard on them never passes, and `setdefault` always appends
235
+ (`[+?]` only when the value is absent). See
236
+ [The append `+` operator](#the-append--operator).
237
+ - **An append branch in a group always applies in an update.**
238
+ `(a, b[+])` appends to `b` whether or not it is empty; it used to append
239
+ only when `b` already had items. With a cut, `[(*&k=1#, +)]`, the append
240
+ is still the fallback.
241
+
230
242
  ### v0.49.0
231
243
  - **`;` is now a reserved character**: it packs a group, `(a,b;)` (see
232
244
  [Packing](#packing)). If you have keys containing a literal `;`, quote
@@ -1709,6 +1721,29 @@ A path handed in already parsed goes through `fold()` again, so a hook must
1709
1721
  be content to see its own output. `Dotted.segments()` gives any parsed
1710
1722
  path's segment values, and a slice of a parsed path, `ops[1:]`, is a path.
1711
1723
 
1724
+ A host's own names may stand where dotted has no reading for a bare token:
1725
+ a slot, `merged[k]`, a slice bound, `items[:n]`, a filter value,
1726
+ `[*&cur=want]`, or a guard value, `a=k`. The parse-time hook
1727
+ `lex(text, position)` is offered each such token as written, a dotted name
1728
+ whole, with its position, one of `'slots'`, `'slices'`, `'filters'` or
1729
+ `'guards'`. It answers with dotted text to read in the token's place, `None`
1730
+ to leave the `ParseError`, or raises to fail the parse at the token. The
1731
+ answer must be one token dotted reads there, and is not offered again.
1732
+
1733
+ >>> class Names(dotted.ReferenceResolver):
1734
+ ... def lex(self, text, position):
1735
+ ... return f'$$({text})'
1736
+ >>> d = {'merged': {'x': 1, 'y': 2}, 'k': 'x', 'c': {'account': 'y'}}
1737
+ >>> dotted.get(d, 'merged[k]', resolver=Names())
1738
+ 1
1739
+ >>> dotted.get(d, 'merged[c.account]', resolver=Names())
1740
+ 2
1741
+
1742
+ A token dotted reads, `m[$x]` or `|sub:taken`, is never offered to `lex()`;
1743
+ a host that means something else by one remaps it at run time, through
1744
+ `literals`. `parse` takes `resolver=` too, and a path is cached per resolver
1745
+ that lexes.
1746
+
1712
1747
  <a id="escaping"></a>
1713
1748
  ### Escaping
1714
1749
 
@@ -2355,6 +2390,18 @@ sequence. You may append an item or slice to the end a sequence.
2355
2390
  >>> dotted.update(d, '*.there[+:]', [999])
2356
2391
  {'hi': {'there': [1, 2, 3, 8, 999]}, 'bye': {'there': [4, 5, 6, 8, 999]}}
2357
2392
 
2393
+ `[+]` is the slot past the end, so nothing is ever there to read: a `get`
2394
+ through it is the default, and `[-1]` is the last item. A write to it always
2395
+ applies, so in a group an append branch is updated along with any branch
2396
+ that matches:
2397
+
2398
+ >>> dotted.get(d, 'hi.there[+]') is None
2399
+ True
2400
+ >>> dotted.update([{'k': 1}, {'k': 2}], '[(*&k=1, +)]', {'k': 9})
2401
+ [{'k': 9}, {'k': 2}, {'k': 9}]
2402
+ >>> dotted.update([{'k': 1}, {'k': 2}], '[(*&k=1#, +)]', {'k': 9})
2403
+ [{'k': 9}, {'k': 2}]
2404
+
2358
2405
  <a id="the-append-unique--operator"></a>
2359
2406
  ### The append-unique `+?` operator
2360
2407
 
@@ -816,17 +816,31 @@ class SlotSpecial(Slot):
816
816
  return []
817
817
 
818
818
  def items(self, node, settings=base.SETTINGS, filtered=True):
819
- return self._last_item(node)
820
-
821
- def _last_item(self, node):
822
- try:
823
- yield -1, node[-1]
824
- except (TypeError, IndexError):
825
- pass
819
+ """
820
+ Nothing: an append slot is the one past the end, so there is never
821
+ a value there to read. A write to it always applies (see update).
822
+ """
823
+ return iter(())
826
824
 
827
825
  def is_empty(self, node):
828
826
  return True
829
827
 
828
+ def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, settings=base.SETTINGS):
829
+ """
830
+ In a default skeleton (has_defaults, see engine.build_default),
831
+ upsert already appended this slot's placeholder, so the rest of the
832
+ path is written into that last item instead of a new one.
833
+ """
834
+ if not (ops and has_defaults):
835
+ return super().do_update(ops, node, val, has_defaults, _path, nop, nop_from_unwrap=nop_from_unwrap, settings=settings)
836
+ try:
837
+ last = node[-1]
838
+ except (TypeError, IndexError):
839
+ return node
840
+ pass_nop = nop and not nop_from_unwrap
841
+ built = engine.updates(ops, last, val, True, _path + [(self, -1)], pass_nop, settings=settings.below(node))
842
+ return self.update(node, -1, built)
843
+
830
844
  def update(self, node, key, val):
831
845
  if not isinstance(key, str):
832
846
  return super().update(node, key, val)
@@ -8,6 +8,7 @@ import threading
8
8
  import pyparsing as pp
9
9
  from . import grammar
10
10
  from . import engine
11
+ from . import groups
11
12
  from . import results
12
13
  from . import transforms
13
14
  from . import base
@@ -98,21 +99,35 @@ def set_parse_cache(size=CACHE_SIZE):
98
99
 
99
100
 
100
101
  @functools.lru_cache(CACHE_SIZE)
101
- def _parse(ops):
102
- with _parse_lock:
102
+ def _parse(ops, lexer=None):
103
+ with _parse_lock, grammar.lexing(lexer):
103
104
  try:
104
105
  parsed = grammar.template.parse_string(ops, parse_all=True)
105
- except pp.ParseException as e:
106
+ except pp.ParseBaseException as e:
106
107
  raise ParseError(f"Invalid dotted notation: {e.msg}\n {repr(ops)}\n {' ' * e.loc}^") from None
107
108
  return results.Dotted({'ops': parsed['ops']})
108
109
 
109
110
 
110
- def parse(path, bindings=None, partial=True):
111
+ def _lexer(resolver):
112
+ """
113
+ The resolver whose lex() a parse offers what dotted cannot read: the
114
+ call's own or the process default, or None when it keeps the default,
115
+ which lexes nothing, so the parse is cached by path alone.
116
+ """
117
+ r = base.RESOLVER if resolver is None else resolver
118
+ if type(r).lex is base.Resolver.lex:
119
+ return None
120
+ return r
121
+
122
+
123
+ def parse(path, bindings=None, partial=True, resolver=None):
111
124
  """
112
125
  Parse dotted notation. Results are LRU-cached (same path string reuses cached parse).
113
126
 
114
127
  If bindings is provided, resolve substitutions against them.
115
128
  If partial is False, raise TypeError on unresolved templates.
129
+ A token dotted cannot read is offered to the resolver's lex(), the
130
+ call's own or the process default (see ReferenceResolver).
116
131
 
117
132
  >>> parse('hello.there|str:"=%s"')
118
133
  Dotted([hello, there|str:'=%s'])
@@ -122,7 +137,7 @@ def parse(path, bindings=None, partial=True):
122
137
  elif isinstance(path, tuple):
123
138
  ops = results.Dotted({'ops': path, 'transforms': ()})
124
139
  else:
125
- ops = _parse(path)
140
+ ops = _parse(path, _lexer(resolver))
126
141
  if bindings is not None:
127
142
  ops = ops.resolve(bindings, partial=partial)
128
143
  if not partial and _is_template(ops):
@@ -366,7 +381,7 @@ def mutable(obj, path, strict=False, bindings=None, resolver=None):
366
381
  >>> mutable(((1, 2),), '[0][0]')
367
382
  False
368
383
  """
369
- ops = parse(path, bindings=bindings, partial=False)
384
+ ops = parse(path, bindings=bindings, partial=False, resolver=resolver)
370
385
 
371
386
  # Empty path can never mutate (can't rebind caller's variable)
372
387
  if len(ops) == 1 and isinstance(ops[0], access.Empty):
@@ -427,7 +442,7 @@ def build_multi(obj, paths, strict=False, bindings=None, resolver=None):
427
442
  if obj is AUTO:
428
443
  obj = _auto_root(((p, None) for p in paths))
429
444
  for path in paths:
430
- ops = _prepare(parse(path, bindings=bindings, partial=False), resolver)
445
+ ops = _prepare(parse(path, bindings=bindings, partial=False, resolver=resolver), resolver)
431
446
  built = engine.build(ops, obj, strict=strict, resolver=resolver)
432
447
  # walked paths are concrete: nothing for update() to resolve or check
433
448
  for found, val in _pluck_parsed(built, ops, strict=strict, resolver=resolver):
@@ -496,7 +511,7 @@ def get(obj, path, default=None, pattern_default=(), apply_transforms=True, stri
496
511
  >>> get({'a': {'b': 7, 'c': 3}}, '**=7')
497
512
  (7,)
498
513
  """
499
- ops = _prepare(parse(path, bindings=bindings, partial=False), resolver)
514
+ ops = _prepare(parse(path, bindings=bindings, partial=False, resolver=resolver), resolver)
500
515
  chain = ops.simple_chain if _SIMPLE_FASTPATH and not _literals(resolver) else None
501
516
  if chain is not None:
502
517
  val = engine.simple_get(chain, obj, strict=strict)
@@ -550,13 +565,15 @@ def setdefault(obj, path, val, apply_transforms=True, strict=False, bindings=Non
550
565
  >>> setdefault({}, 'a.b.c', 7)
551
566
  7
552
567
  """
553
- ops = _prepare(parse(path, bindings=bindings, partial=False), resolver)
568
+ ops = _prepare(parse(path, bindings=bindings, partial=False, resolver=resolver), resolver)
554
569
  if obj is AUTO:
555
570
  obj = _auto_root_from_path(ops)
556
571
  if has(obj, ops, strict=strict, resolver=resolver):
557
572
  return get(obj, ops, apply_transforms=apply_transforms, strict=strict, resolver=resolver)
558
573
  obj = update(obj, ops, val, apply_transforms=apply_transforms, strict=strict, resolver=resolver)
559
- return get(obj, ops, apply_transforms=False, strict=strict, resolver=resolver)
574
+ # an append slot, [+], has nothing to read back: what was set is val
575
+ default = val if groups.appends(ops) else None
576
+ return get(obj, ops, default=default, apply_transforms=False, strict=strict, resolver=resolver)
560
577
 
561
578
 
562
579
  def setdefault_multi(obj, pathvalues, apply_transforms=True, strict=False, bindings=None, resolver=None):
@@ -590,7 +607,7 @@ def update_if(obj, path, val, pred=lambda val: val is not None, mutable=True, ap
590
607
  """
591
608
  parsed = path
592
609
  if obj is AUTO:
593
- parsed = parse(path)
610
+ parsed = parse(path, resolver=resolver)
594
611
  obj = _auto_root_from_path(parsed)
595
612
  if not mutable and _is_mutable_container(obj):
596
613
  obj = utils.deepcopy(obj)
@@ -599,7 +616,7 @@ def update_if(obj, path, val, pred=lambda val: val is not None, mutable=True, ap
599
616
  if pred is not None and not pred(val):
600
617
  return obj
601
618
 
602
- ops = _prepare(parse(parsed, bindings=bindings, partial=False), resolver)
619
+ ops = _prepare(parse(parsed, bindings=bindings, partial=False, resolver=resolver), resolver)
603
620
  return engine.updates(ops, obj, val, strict=strict, resolver=resolver, transforms=apply_transforms)
604
621
 
605
622
 
@@ -694,7 +711,7 @@ def remove_if(obj, path, pred=lambda path: path is not None, val=ANY, mutable=Tr
694
711
  """
695
712
  parsed = path
696
713
  if obj is AUTO:
697
- parsed = parse(path)
714
+ parsed = parse(path, resolver=resolver)
698
715
  obj = _auto_root_from_path(parsed)
699
716
  if not mutable and _is_mutable_container(obj):
700
717
  obj = utils.deepcopy(obj)
@@ -703,7 +720,7 @@ def remove_if(obj, path, pred=lambda path: path is not None, val=ANY, mutable=Tr
703
720
  if pred is not None and not pred(path):
704
721
  return obj
705
722
 
706
- ops = _prepare(parse(parsed, bindings=bindings, partial=False), resolver)
723
+ ops = _prepare(parse(parsed, bindings=bindings, partial=False, resolver=resolver), resolver)
707
724
  return engine.removes(ops, obj, val, strict=strict, resolver=resolver)
708
725
 
709
726
 
@@ -1071,7 +1088,7 @@ def expand_multi(obj, patterns, strict=False, bindings=None, resolver=None):
1071
1088
  """
1072
1089
  seen = {}
1073
1090
  for pat in patterns:
1074
- ops = _prepare(parse(pat, bindings=bindings, partial=False), resolver)
1091
+ ops = _prepare(parse(pat, bindings=bindings, partial=False, resolver=resolver), resolver)
1075
1092
  paths = (o.assemble() for o in engine.expands(ops, obj, strict=strict, resolver=resolver))
1076
1093
  for found in paths:
1077
1094
  if found in seen:
@@ -1106,7 +1123,7 @@ def apply_multi(obj, patterns, strict=False, bindings=None, resolver=None):
1106
1123
  seen = {}
1107
1124
  _marker = object()
1108
1125
  for pat in patterns:
1109
- for ops in engine.expands(_prepare(parse(pat, bindings=bindings, partial=False), resolver), obj, strict=strict, resolver=resolver):
1126
+ for ops in engine.expands(_prepare(parse(pat, bindings=bindings, partial=False, resolver=resolver), resolver), obj, strict=strict, resolver=resolver):
1110
1127
  if ops in seen:
1111
1128
  continue
1112
1129
  seen[ops] = None
@@ -1147,7 +1164,7 @@ def _pluck_written(obj, patterns, strict=False, bindings=None, resolver=None):
1147
1164
  """
1148
1165
  seen = {}
1149
1166
  for pattern in patterns:
1150
- ops = _prepare(parse(pattern, bindings=bindings, partial=False), resolver)
1167
+ ops = _prepare(parse(pattern, bindings=bindings, partial=False, resolver=resolver), resolver)
1151
1168
  for path, val in engine.walk(ops, obj, paths=True, strict=strict, resolver=resolver, transforms=False):
1152
1169
  if path is utypes.CUT_SENTINEL:
1153
1170
  break
@@ -1167,7 +1184,7 @@ def pluck(obj, pattern, default=None, strict=False, bindings=None, resolver=None
1167
1184
  >>> pluck(d, 'a.b')
1168
1185
  ('a.b', 'seven')
1169
1186
  """
1170
- parsed = parse(pattern)
1187
+ parsed = parse(pattern, resolver=resolver)
1171
1188
  out = tuple(pluck_multi(obj, (parsed,), default=default, strict=strict, resolver=resolver, bindings=bindings))
1172
1189
  if not out:
1173
1190
  return ()
@@ -1185,7 +1202,7 @@ def walk(obj, pattern, strict=False, bindings=None, resolver=None):
1185
1202
  >>> list(walk(d, 'a.*'))
1186
1203
  [('a.b', 1), ('a.c', 2)]
1187
1204
  """
1188
- ops = _prepare(parse(pattern, bindings=bindings, partial=False), resolver)
1205
+ ops = _prepare(parse(pattern, bindings=bindings, partial=False, resolver=resolver), resolver)
1189
1206
  for path, val in engine.walk(ops, obj, paths=True, strict=strict, resolver=resolver, transforms=False):
1190
1207
  if path is utypes.CUT_SENTINEL:
1191
1208
  break
@@ -154,6 +154,10 @@ class NOP(metaclass=MetaNOP):
154
154
  # and filter values.
155
155
  LITERALS = ('segments', 'transforms', 'guards', 'filters')
156
156
 
157
+ # The positions a resolver's lex() may be offered a token in: a slot, a
158
+ # slice bound, a filter value and a guard value.
159
+ LEXED = ('slots', 'slices', 'filters', 'guards')
160
+
157
161
 
158
162
  class Resolver:
159
163
  """
@@ -163,6 +167,7 @@ class Resolver:
163
167
  in and that node's ancestors. This default reads it as dotted does:
164
168
  a path against the root, or with ^ against the node, ^^ its parent,
165
169
  and so on. Raises UnresolvedReference when there is nothing there.
170
+ Its lex() is the parse-time hook for tokens dotted cannot read.
166
171
  """
167
172
 
168
173
  # True to be offered every plain token in a var-permitting position as
@@ -191,6 +196,21 @@ class Resolver:
191
196
  check_literals(literals)
192
197
  self.literals = literals
193
198
 
199
+ def lex(self, text, position):
200
+ """
201
+ The parse-time hook, offered a token dotted's grammar has no
202
+ reading for: text as written, merged[k] offering 'k', and the
203
+ position it stands in, one of LEXED. A dotted name is one token,
204
+ merged[a.b] offering 'a.b'. It answers with dotted text to read
205
+ in the token's place, '$$(k)' making the path merged[$$(k)], or
206
+ None to leave the path unparsed; raising fails the parse at the
207
+ token. The text is read in that position as one token, and is
208
+ not offered to lex() again. A token dotted reads is never
209
+ offered: a host that means something else by one says so at run
210
+ time, through literals. The default lexes nothing.
211
+ """
212
+ return None
213
+
194
214
  def fold(self, segments):
195
215
  """
196
216
  The path-level hook, called once with a path's segment strings
@@ -1,5 +1,6 @@
1
1
  """
2
2
  """
3
+ import contextlib
3
4
  import decimal
4
5
  import re
5
6
  import pyparsing as pp
@@ -288,10 +289,134 @@ var = _escaped_dollar | _reference | _paren_subst | _raw_subst | _dollar_word
288
289
  # |min:$amount.value or =^lots.taken: one string, for a resolver to read
289
290
  # whole. In a segment position the . separates segments, so not there.
290
291
  _arg_sigil = pp.Regex(r'(?:\$(?![0-9($])|\^)[^' + re.escape(reserved.replace('.', '')) + r' \t\n\r]+').set_parse_action(matchers.Word)
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Lexed tokens: what dotted cannot read in a slot, a slice bound, a filter
295
+ # value or a guard value is offered to the resolver's lex() (see
296
+ # base.Resolver.lex), last, once dotted's own readings have failed.
297
+ # ---------------------------------------------------------------------------
298
+
299
+ # The resolver a path is being parsed for, when it lexes, and its answers
300
+ # so far, by (text, position): reading an answer resets pyparsing's
301
+ # packrat cache, so the parse may come back to a token it has lexed.
302
+ LEXER = None
303
+ _ANSWERS = {}
304
+
305
+ _WORD_CHARS = frozenset(pp.alphanums + '_')
306
+
307
+
308
+ @contextlib.contextmanager
309
+ def lexing(lexer):
310
+ """
311
+ Parse with lexer offered what dotted cannot read (see Lexed); None
312
+ for no lexer. Not reentrant: api._parse holds its lock around it.
313
+ """
314
+ global LEXER
315
+ LEXER = lexer
316
+ _ANSWERS.clear()
317
+ try:
318
+ yield
319
+ finally:
320
+ LEXER = None
321
+ _ANSWERS.clear()
322
+
323
+
324
+ def _token_end(s, loc, stops):
325
+ """
326
+ Where the token at loc ends: at the first of stops, whitespace or a
327
+ closing bracket outside the brackets and quotes it opens itself.
328
+ """
329
+ depth = 0
330
+ quote = None
331
+ i = loc
332
+ while i < len(s):
333
+ c = s[i]
334
+ if quote:
335
+ if c == '\\':
336
+ i += 2
337
+ continue
338
+ if c == quote:
339
+ quote = None
340
+ elif c in '"\'':
341
+ quote = c
342
+ elif c in '([{':
343
+ depth += 1
344
+ elif depth:
345
+ if c in ')]}':
346
+ depth -= 1
347
+ elif c in stops or c in ')]}' or c.isspace():
348
+ return i
349
+ i += 1
350
+ return i
351
+
352
+
353
+ class Lexed(pp.Token):
354
+ """
355
+ A token dotted has no reading for at position, offered to LEXER. It
356
+ runs to the first of stops (see _token_end) and is offered only when
357
+ one of follow, or with end the end of the path, comes after it. The
358
+ text the lexer answers is read whole with reading, without LEXER.
359
+ """
360
+ def __init__(self, position, reading, stops, follow=None, end=False):
361
+ super().__init__()
362
+ self.position = position
363
+ self.reading = reading
364
+ self.stops = stops
365
+ self.follow = stops if follow is None else follow
366
+ self.end = end
367
+ self.mayReturnEmpty = False
368
+ self.mayIndexError = False
369
+ self.errmsg = f'Expected a token in {position}'
370
+
371
+ def _generateDefaultName(self):
372
+ return f'Lexed({self.position})'
373
+
374
+ def parseImpl(self, instring, loc, do_actions=True):
375
+ global LEXER
376
+ lexer = LEXER
377
+ if lexer is None:
378
+ raise pp.ParseException(instring, loc, self.errmsg, self)
379
+ stop = _token_end(instring, loc, self.stops)
380
+ if stop == loc:
381
+ raise pp.ParseException(instring, loc, self.errmsg, self)
382
+ if stop == len(instring) and not self.end:
383
+ raise pp.ParseException(instring, loc, self.errmsg, self)
384
+ if stop < len(instring) and instring[stop] not in self.follow:
385
+ raise pp.ParseException(instring, loc, self.errmsg, self)
386
+ text = instring[loc:stop]
387
+ key = (text, self.position)
388
+ if key not in _ANSWERS:
389
+ try:
390
+ _ANSWERS[key] = lexer.lex(text, self.position)
391
+ except Exception as e:
392
+ raise pp.ParseFatalException(instring, loc, f'{text!r} in {self.position}: {e}') from e
393
+ lexed = _ANSWERS[key]
394
+ if lexed is None:
395
+ raise pp.ParseException(instring, loc, self.errmsg, self)
396
+ LEXER = None
397
+ try:
398
+ tokens = self.reading.parse_string(lexed, parse_all=True)
399
+ except pp.ParseBaseException:
400
+ raise pp.ParseFatalException(
401
+ instring, loc, f'{text!r} lexed as {lexed!r}, not one token in {self.position}') from None
402
+ finally:
403
+ LEXER = lexer
404
+ return stop, list(tokens)
405
+
406
+
407
+ # After a token dotted reads, while a lexer is in force: no word character
408
+ # follows, so null in =nullable or 3 in [:3x] is not read as a prefix of
409
+ # the token, which is the lexer's to read whole.
410
+ _ends = pp.Empty().leave_whitespace().add_condition(
411
+ lambda s, loc, t: LEXER is None or s[loc:loc + 1] not in _WORD_CHARS, call_during_try=True)
412
+
291
413
  # A slice bound: an integer, + (the length), or a $$(path) reference or
292
- # $(name)/$N substitution standing for one.
293
- _slice_bound = integer | plus | _reference | _paren_subst | _raw_subst
294
- slice = pp.Optional(_slice_bound) + ':' + pp.Optional(_slice_bound) \
414
+ # $(name)/$N substitution standing for one. A start bound is lexed only
415
+ # before its :, so [k] is not offered as one.
416
+ _slice_read = integer | plus | _reference | _paren_subst | _raw_subst
417
+ _slice_bound = (_slice_read + _ends) | Lexed('slices', _slice_read, ':]')
418
+ _slice_start = (_slice_read + _ends) | Lexed('slices', _slice_read, ':]', follow=':')
419
+ slice = pp.Optional(_slice_start) + ':' + pp.Optional(_slice_bound) \
295
420
  + pp.Optional(':') + pp.Optional(_slice_bound)
296
421
 
297
422
  _common_pats = wildcard_first | wildcard | var | regex_first | regex
@@ -548,6 +673,11 @@ value = pp.Forward()
548
673
  _value_group_inner = value + OM(_ccomma + value)
549
674
  value_group = (S('(') + _value_group_inner + S(')')).set_parse_action(lambda t: [ct.ValueGroup(*t)])
550
675
  value <<= value_group | container_value | string_glob | bytes_literal | string | wildcard | _arg_sigil | var | regex | numeric_quoted | none | true | false | numeric_extended | numeric_slot
676
+ # A filter's or guard's value, else a token for the lexer: it runs to what
677
+ # may follow a value, a dot not among them, so =lot.x is one token.
678
+ _value_stops = '&,)]|?#;'
679
+ filter_value = (value + _ends) | Lexed('filters', value, _value_stops, end=True)
680
+ guard_value = (value + _ends) | Lexed('guards', value, _value_stops, end=True)
551
681
  # ---------------------------------------------------------------------------
552
682
  # Concat: part+part for key construction using Python's native + operator.
553
683
  # Each part can carry per-part transforms: part|xform+part|xform
@@ -631,7 +761,7 @@ def _filter_single_action(t):
631
761
  cls = flt.FILTER_PRED_CLS[op_str]
632
762
  return cls(rest + [val])
633
763
 
634
- _filter_single_all = (filter_key + ZM(pipe + transform) + _pred_op_re + value).set_parse_action(_filter_single_action)
764
+ _filter_single_all = (filter_key + ZM(pipe + transform) + _pred_op_re + filter_value).set_parse_action(_filter_single_action)
635
765
 
636
766
  # Recursive filter expression with grouping
637
767
  filter_expr = pp.Forward()
@@ -736,6 +866,12 @@ slicecmd = (lb + Opt(L('~')) + Opt(slice) + rb).set_parse_action(
736
866
  lambda t: wrappers.NopWrap(access.Slice(*t[1:])) if t and t[0] == '~' else access.Slice(*t))
737
867
  slicefilter = (lb + filters + ZM(amp + filters) + rb).set_parse_action(access.SliceFilter)
738
868
 
869
+ # A slot dotted cannot read, [k] or [a.b], offered to the lexer: tried
870
+ # after every other bracket, so [a.b=1] stays a filter.
871
+ _slot_read = _slot_concat | _commons | numeric_extended | numeric_slot
872
+ lexedcmd = (lb + Opt(L('~')) + Lexed('slots', _slot_read, ']&') + ZM(amp + filters) + rb
873
+ + Opt(_seg_mid)).set_parse_action(_slotcmd_action)
874
+
739
875
  # Cut markers: ## = soft cut, # = hard cut (try ## first so ## isn't parsed as # + #)
740
876
  softcut_marker = L('##')
741
877
  cut_marker = softcut_marker | L('#')
@@ -983,7 +1119,7 @@ _at_nop_grouped = _at_nop_grouped.set_parse_action(
983
1119
  )
984
1120
 
985
1121
  # NOP (~): match but don't update
986
- _nop_inner = inner_grouped_first | inner_grouped | recursive_op | keycmd | attrcmd | slotgroup_first | slotgroup | slotcmd | slotspecial | slicefilter | slicecmd
1122
+ _nop_inner = inner_grouped_first | inner_grouped | recursive_op | keycmd | attrcmd | slotgroup_first | slotgroup | slotcmd | slotspecial | slicefilter | slicecmd | lexedcmd
987
1123
  _nop_wrap = (tilde + _nop_inner).set_parse_action(lambda t: wrappers.NopWrap(t[1]))
988
1124
 
989
1125
  # Explicit NOP (~): like _nop_wrap but inner must start with an access op (./@/[]).
@@ -998,7 +1134,7 @@ _explicit_nop_inner = (
998
1134
  attrcmd |
999
1135
  slotgroup_first | slotgroup |
1000
1136
  slotcmd |
1001
- slotspecial | slicefilter | slicecmd
1137
+ slotspecial | slicefilter | slicecmd | lexedcmd
1002
1138
  )
1003
1139
  _explicit_nop_wrap = (tilde + _explicit_nop_inner).set_parse_action(lambda t: wrappers.NopWrap(t[1]))
1004
1140
 
@@ -1015,7 +1151,7 @@ _op_seq_cont_items = (
1015
1151
  attrcmd |
1016
1152
  slotgroup_first | slotgroup |
1017
1153
  slotcmd |
1018
- slotspecial | slicefilter | slicecmd
1154
+ slotspecial | slicefilter | slicecmd | lexedcmd
1019
1155
  )
1020
1156
  _op_seq_cont << _op_seq_cont_items
1021
1157
  _continues <<= pp.FollowedBy(_op_seq_cont)
@@ -1034,7 +1170,7 @@ _op_seq_first_items = (
1034
1170
  attrcmd |
1035
1171
  slotgroup_first | slotgroup |
1036
1172
  slotcmd |
1037
- slotspecial | slicefilter | slicecmd
1173
+ slotspecial | slicefilter | slicecmd | lexedcmd
1038
1174
  )
1039
1175
  # First item also allows bare grouped expressions — (a,b) with bare keys.
1040
1176
  # This is valid at the start of a path (top-level inference).
@@ -1051,7 +1187,7 @@ op_seq_item << (inner_grouped_first | inner_grouped | _op_seq_first_items)
1051
1187
  # guard, they transform the value.
1052
1188
  # ---------------------------------------------------------------------------
1053
1189
 
1054
- _postfix = Opt(type_restriction) + ZM(amp + filters) + ZM(pipe + transform) + Opt(_pred_op_re + value + ZM(pipe + transform))
1190
+ _postfix = Opt(type_restriction) + ZM(amp + filters) + ZM(pipe + transform) + Opt(_pred_op_re + guard_value + ZM(pipe + transform))
1055
1191
 
1056
1192
 
1057
1193
  def _split_postfix(tokens):
@@ -1148,7 +1284,7 @@ _explicit_op_seq_cont_items = (
1148
1284
  attrcmd |
1149
1285
  slotgroup_first | slotgroup |
1150
1286
  slotcmd |
1151
- slotspecial | slicefilter | slicecmd
1287
+ slotspecial | slicefilter | slicecmd | lexedcmd
1152
1288
  )
1153
1289
  _explicit_op_seq = pp.Group(_explicit_op_seq_cont_items + ZM(_explicit_op_seq_cont_items))
1154
1290
  _explicit_inner_atom = _explicit_op_seq
@@ -5,7 +5,7 @@ from . import matchers
5
5
  from . import wrappers
6
6
  from . import engine
7
7
  from . import results
8
- from .access import AccessOp, Key, Attr, Slot
8
+ from .access import AccessOp, Key, Attr, Slot, SlotSpecial
9
9
 
10
10
 
11
11
  def leading_keys(path):
@@ -415,12 +415,12 @@ class OpGroupOr(OpGroup):
415
415
  if softcut_paths and path and softcut_paths.overlaps(path):
416
416
  continue
417
417
  paths.append(path)
418
- if not paths:
418
+ if not paths and not appends(item):
419
419
  continue
420
420
  matched_any = True
421
421
  if marker is base.BRANCH_SOFTCUT:
422
422
  softcut_paths.extend(p for p in paths if p)
423
- if softcut_paths:
423
+ if softcut_paths and paths:
424
424
  branch_nop = nop or any(isinstance(op, wrappers.NopWrap) for op in item)
425
425
  for path in paths:
426
426
  node = engine.updates(list(path), node, val, has_defaults, _path, branch_nop, settings=settings)
@@ -524,7 +524,7 @@ class OpGroupFirst(OpGroup):
524
524
  branch_ops = list(branch) + list(ops)
525
525
  if not branch_ops:
526
526
  continue
527
- if base.has_any(engine.gets(branch_ops, node, settings=settings)):
527
+ if appends(branch) or base.has_any(engine.gets(branch_ops, node, settings=settings)):
528
528
  return engine.updates(branch_ops, node, val, has_defaults, _path, nop, settings=settings)
529
529
  return _disjunction_fallback(self, ops, node, val, has_defaults, _path, nop, settings=settings)
530
530
 
@@ -968,6 +968,15 @@ def _attr_branch(branch):
968
968
  return branch
969
969
 
970
970
 
971
+ def appends(ops):
972
+ """
973
+ True if ops hold an append slot, [+] or [+?]. Nothing is ever there to
974
+ read, but a write always applies, so in an update a group branch that
975
+ holds one counts as matched.
976
+ """
977
+ return any(isinstance(op.most_inner, SlotSpecial) for op in ops)
978
+
979
+
971
980
  def _is_concrete_path(branch_ops):
972
981
  """
973
982
  Return True if branch_ops represents a concrete path (no wildcards/patterns).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.49.4
3
+ Version: 0.51.0
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-Expression: MIT
@@ -275,6 +275,18 @@ Several Python libraries handle nested data access. Here's how dotted compares:
275
275
  <a id="breaking-changes"></a>
276
276
  ## Breaking Changes
277
277
 
278
+ ### v0.50.0
279
+ - **Nothing is read through `[+]` or `[+?]`.** They are the slot past the
280
+ end, so `get(d, 'a[+]')` is the default, not the last item; use `a[-1]`.
281
+ `has` is `False`, `remove`, `pluck` and `expand` through them find
282
+ nothing, a guard on them never passes, and `setdefault` always appends
283
+ (`[+?]` only when the value is absent). See
284
+ [The append `+` operator](#the-append--operator).
285
+ - **An append branch in a group always applies in an update.**
286
+ `(a, b[+])` appends to `b` whether or not it is empty; it used to append
287
+ only when `b` already had items. With a cut, `[(*&k=1#, +)]`, the append
288
+ is still the fallback.
289
+
278
290
  ### v0.49.0
279
291
  - **`;` is now a reserved character**: it packs a group, `(a,b;)` (see
280
292
  [Packing](#packing)). If you have keys containing a literal `;`, quote
@@ -1757,6 +1769,29 @@ A path handed in already parsed goes through `fold()` again, so a hook must
1757
1769
  be content to see its own output. `Dotted.segments()` gives any parsed
1758
1770
  path's segment values, and a slice of a parsed path, `ops[1:]`, is a path.
1759
1771
 
1772
+ A host's own names may stand where dotted has no reading for a bare token:
1773
+ a slot, `merged[k]`, a slice bound, `items[:n]`, a filter value,
1774
+ `[*&cur=want]`, or a guard value, `a=k`. The parse-time hook
1775
+ `lex(text, position)` is offered each such token as written, a dotted name
1776
+ whole, with its position, one of `'slots'`, `'slices'`, `'filters'` or
1777
+ `'guards'`. It answers with dotted text to read in the token's place, `None`
1778
+ to leave the `ParseError`, or raises to fail the parse at the token. The
1779
+ answer must be one token dotted reads there, and is not offered again.
1780
+
1781
+ >>> class Names(dotted.ReferenceResolver):
1782
+ ... def lex(self, text, position):
1783
+ ... return f'$$({text})'
1784
+ >>> d = {'merged': {'x': 1, 'y': 2}, 'k': 'x', 'c': {'account': 'y'}}
1785
+ >>> dotted.get(d, 'merged[k]', resolver=Names())
1786
+ 1
1787
+ >>> dotted.get(d, 'merged[c.account]', resolver=Names())
1788
+ 2
1789
+
1790
+ A token dotted reads, `m[$x]` or `|sub:taken`, is never offered to `lex()`;
1791
+ a host that means something else by one remaps it at run time, through
1792
+ `literals`. `parse` takes `resolver=` too, and a path is cached per resolver
1793
+ that lexes.
1794
+
1760
1795
  <a id="escaping"></a>
1761
1796
  ### Escaping
1762
1797
 
@@ -2403,6 +2438,18 @@ sequence. You may append an item or slice to the end a sequence.
2403
2438
  >>> dotted.update(d, '*.there[+:]', [999])
2404
2439
  {'hi': {'there': [1, 2, 3, 8, 999]}, 'bye': {'there': [4, 5, 6, 8, 999]}}
2405
2440
 
2441
+ `[+]` is the slot past the end, so nothing is ever there to read: a `get`
2442
+ through it is the default, and `[-1]` is the last item. A write to it always
2443
+ applies, so in a group an append branch is updated along with any branch
2444
+ that matches:
2445
+
2446
+ >>> dotted.get(d, 'hi.there[+]') is None
2447
+ True
2448
+ >>> dotted.update([{'k': 1}, {'k': 2}], '[(*&k=1, +)]', {'k': 9})
2449
+ [{'k': 9}, {'k': 2}, {'k': 9}]
2450
+ >>> dotted.update([{'k': 1}, {'k': 2}], '[(*&k=1#, +)]', {'k': 9})
2451
+ [{'k': 9}, {'k': 2}]
2452
+
2406
2453
  <a id="the-append-unique--operator"></a>
2407
2454
  ### The append-unique `+?` operator
2408
2455
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "dotted_notation"
9
- version = "0.49.4"
9
+ version = "0.51.0"
10
10
  description = "Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.6"