dotted-notation 0.44.6__tar.gz → 0.44.8__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.6 → dotted_notation-0.44.8}/CHANGELOG.md +47 -0
  2. {dotted_notation-0.44.6/dotted_notation.egg-info → dotted_notation-0.44.8}/PKG-INFO +53 -1
  3. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/README.md +52 -0
  4. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/api.py +38 -4
  5. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/base.py +19 -5
  6. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/groups.py +150 -4
  7. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/recursive.py +50 -4
  8. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/wrappers.py +15 -0
  9. {dotted_notation-0.44.6 → dotted_notation-0.44.8/dotted_notation.egg-info}/PKG-INFO +53 -1
  10. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/pyproject.toml +1 -1
  11. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/LICENSE +0 -0
  12. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/MANIFEST.in +0 -0
  13. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/__init__.py +0 -0
  14. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/__main__.py +0 -0
  15. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/access.py +0 -0
  16. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/cli/__init__.py +0 -0
  17. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/cli/_compat.py +0 -0
  18. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/cli/formats.py +0 -0
  19. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/cli/main.py +0 -0
  20. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/containers.py +0 -0
  21. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/engine.py +0 -0
  22. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/filters.py +0 -0
  23. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/grammar.py +0 -0
  24. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/matchers.py +0 -0
  25. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/predicates.py +0 -0
  26. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/results.py +0 -0
  27. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/sql/__init__.py +0 -0
  28. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/sql/core.py +0 -0
  29. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/sql/pg.py +0 -0
  30. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/transforms.py +0 -0
  31. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/utils.py +0 -0
  32. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted/utypes.py +0 -0
  33. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted_notation.egg-info/SOURCES.txt +0 -0
  34. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted_notation.egg-info/dependency_links.txt +0 -0
  35. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted_notation.egg-info/entry_points.txt +0 -0
  36. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted_notation.egg-info/requires.txt +0 -0
  37. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/dotted_notation.egg-info/top_level.txt +0 -0
  38. {dotted_notation-0.44.6 → dotted_notation-0.44.8}/setup.cfg +0 -0
@@ -3,6 +3,53 @@
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.8]
7
+
8
+ ### Added
9
+ - `match()` supports variadic ops on the *path* side. A group or
10
+ recursive op in the path used to fall through to `None` (or match by
11
+ accident, as `**` did); the path is now treated as the set of paths it
12
+ denotes, and the pattern must *subsume* it — cover every expansion.
13
+ Every branch of a path-side group must match, so
14
+ `match('references.*', 'references.(a,b)')` matches while
15
+ `match('*.*', '(a.b,c)')` does not (branch `c` is a segment short).
16
+ Groups on both sides compose the two rules: some pattern branch must
17
+ cover every path branch. A path-side recursive is covered only by a
18
+ recursive pattern that subsumes it — `match('**', '*b')` matches,
19
+ `match('*', '**')` does not. Conjunctions match on any branch (their
20
+ expansions are the intersection); negations, denoting an open set,
21
+ only by an identical negation or a bare wildcard.
22
+
23
+ ### Changed
24
+ - A path-side group captures as one segment: itself, e.g.
25
+ `match('references.*', 'references.(a,b)', groups=True)` gives
26
+ `('references.(a,b)', ('references', '(a,b)'))`. When a variadic
27
+ pattern consumes across the group boundary the group folds into that
28
+ segment's capture, as nested patterns already do.
29
+ - Match dispatch is now per-op on both sides: variadic path ops
30
+ implement `do_match_path` (the mirror of `do_match`), and segment
31
+ coverage moved onto ops as `covered_by`, replacing the ad-hoc value
32
+ extraction `Recursive.do_match` used to do.
33
+
34
+ ## [0.44.7]
35
+
36
+ ### Fixed
37
+ - `groups='patterns'` capture positions for variadic patterns: literals
38
+ are now correctly excluded when the pattern contains a group or
39
+ recursive op, so `translate`'s `$N` numbering counts pattern segments
40
+ only, as documented. Previously `a.(x,y).*` numbered `$0='a'` (a
41
+ literal), and `**.c` captured the literal `c`.
42
+
43
+ ### Changed
44
+ - A group is one pattern segment: it captures the path segments its
45
+ matching branch consumed as a single group, keeping `$N` positions
46
+ stable across branches of different lengths. `x.(a.b,c)` matching
47
+ `x.a.b` now captures `('x', 'a.b')` instead of `('x', 'a', 'b')`.
48
+ Parentheses thereby act as a regex-like capture group: `x.(a.b)`
49
+ captures `'a.b'` as one group where `x.a.b` captures `'a', 'b'`.
50
+ Nested patterns inside a branch fold into the group's capture, as
51
+ with `**`.
52
+
6
53
  ## [0.44.6]
7
54
 
8
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.6
3
+ Version: 0.44.8
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
@@ -508,6 +508,58 @@ excluding literals:
508
508
  >>> dotted.match('hello.*', 'hello.there.bye', groups=GroupMode.patterns)
509
509
  ('hello.there.bye', ('there.bye',))
510
510
 
511
+ Op groups match like regex alternation, and parentheses act as a capture
512
+ group: a group captures the segments its matching branch consumed as a
513
+ single group, and counts as one pattern position:
514
+
515
+ >>> dotted.match('a.(x,y).*', 'a.x.z', groups=GroupMode.patterns, partial=False)
516
+ ('a.x.z', ('x', 'z'))
517
+ >>> dotted.match('x.(a.b,c)', 'x.a.b', groups=True)
518
+ ('x.a.b', ('x', 'a.b'))
519
+ >>> dotted.match('x.(a.b)', 'x.a.b', groups=True)
520
+ ('x.a.b', ('x', 'a.b'))
521
+
522
+ The path may itself be a pattern, in which case `match` asks whether the
523
+ pattern *subsumes* it — covers everything the path denotes:
524
+
525
+ >>> dotted.match('*', '*.*')
526
+ '*.*'
527
+ >>> dotted.match('*.*', '*')
528
+
529
+ A group on the path side denotes all of its expansions, so every branch
530
+ must be covered. It captures as a single segment: itself.
531
+
532
+ >>> dotted.match('references.*', 'references.(a,b)')
533
+ 'references.(a,b)'
534
+ >>> dotted.match('references.*', 'references.(a,b)', groups=True)
535
+ ('references.(a,b)', ('references', '(a,b)'))
536
+
537
+ A branch that outruns the pattern is only covered when partial matching
538
+ is on, and never when the pattern needs more segments than the branch
539
+ has:
540
+
541
+ >>> dotted.match('*', '(a.b,c)')
542
+ '(a.b,c)'
543
+ >>> dotted.match('*', '(a.b,c)', partial=False)
544
+ >>> dotted.match('*.*', '(a.b,c)')
545
+
546
+ Groups on both sides compose the two rules — the pattern matches if
547
+ *some* branch of it covers *every* branch of the path:
548
+
549
+ >>> dotted.match('(a,b)', '(a.b,b)')
550
+ '(a.b,b)'
551
+ >>> dotted.match('(a,b)', '(a.b,b)', partial=False)
552
+ >>> dotted.match('(a,b)', '(a.b,c)')
553
+
554
+ A recursive op on the path side denotes unboundedly many expansions, so
555
+ only a recursive pattern that subsumes it will match:
556
+
557
+ >>> dotted.match('**', 'a.**')
558
+ 'a.**'
559
+ >>> dotted.match('**', '*b')
560
+ '*b'
561
+ >>> dotted.match('*', '**')
562
+
511
563
  <a id="replace"></a>
512
564
  ### Replace
513
565
 
@@ -468,6 +468,58 @@ excluding literals:
468
468
  >>> dotted.match('hello.*', 'hello.there.bye', groups=GroupMode.patterns)
469
469
  ('hello.there.bye', ('there.bye',))
470
470
 
471
+ Op groups match like regex alternation, and parentheses act as a capture
472
+ group: a group captures the segments its matching branch consumed as a
473
+ single group, and counts as one pattern position:
474
+
475
+ >>> dotted.match('a.(x,y).*', 'a.x.z', groups=GroupMode.patterns, partial=False)
476
+ ('a.x.z', ('x', 'z'))
477
+ >>> dotted.match('x.(a.b,c)', 'x.a.b', groups=True)
478
+ ('x.a.b', ('x', 'a.b'))
479
+ >>> dotted.match('x.(a.b)', 'x.a.b', groups=True)
480
+ ('x.a.b', ('x', 'a.b'))
481
+
482
+ The path may itself be a pattern, in which case `match` asks whether the
483
+ pattern *subsumes* it — covers everything the path denotes:
484
+
485
+ >>> dotted.match('*', '*.*')
486
+ '*.*'
487
+ >>> dotted.match('*.*', '*')
488
+
489
+ A group on the path side denotes all of its expansions, so every branch
490
+ must be covered. It captures as a single segment: itself.
491
+
492
+ >>> dotted.match('references.*', 'references.(a,b)')
493
+ 'references.(a,b)'
494
+ >>> dotted.match('references.*', 'references.(a,b)', groups=True)
495
+ ('references.(a,b)', ('references', '(a,b)'))
496
+
497
+ A branch that outruns the pattern is only covered when partial matching
498
+ is on, and never when the pattern needs more segments than the branch
499
+ has:
500
+
501
+ >>> dotted.match('*', '(a.b,c)')
502
+ '(a.b,c)'
503
+ >>> dotted.match('*', '(a.b,c)', partial=False)
504
+ >>> dotted.match('*.*', '(a.b,c)')
505
+
506
+ Groups on both sides compose the two rules — the pattern matches if
507
+ *some* branch of it covers *every* branch of the path:
508
+
509
+ >>> dotted.match('(a,b)', '(a.b,b)')
510
+ '(a.b,b)'
511
+ >>> dotted.match('(a,b)', '(a.b,b)', partial=False)
512
+ >>> dotted.match('(a,b)', '(a.b,c)')
513
+
514
+ A recursive op on the path side denotes unboundedly many expansions, so
515
+ only a recursive pattern that subsumes it will match:
516
+
517
+ >>> dotted.match('**', 'a.**')
518
+ 'a.**'
519
+ >>> dotted.match('**', '*b')
520
+ '*b'
521
+ >>> dotted.match('*', '**')
522
+
471
523
  <a id="replace"></a>
472
524
  ### Replace
473
525
 
@@ -762,12 +762,46 @@ def match(pattern, path, groups=False, partial=True, strict=False):
762
762
  'b'
763
763
  >>> match('(!a)', 'a')
764
764
 
765
+ A group is one pattern segment: it captures the segments its branch
766
+ consumed as a single group, and counts as one pattern position:
767
+ >>> match('x.(a.b,c)', 'x.a.b', groups=True)
768
+ ('x.a.b', ('x', 'a.b'))
769
+ >>> match('a.(x,y).*', 'a.x.z', groups='patterns', partial=False)
770
+ ('a.x.z', ('x', 'z'))
771
+
765
772
  Recursive patterns:
766
773
  >>> match('**.c', 'a.b.c')
767
774
  'a.b.c'
768
775
  >>> match('*b', 'b.b.b')
769
776
  'b.b.b'
770
777
  >>> match('*b', 'a.b.c')
778
+
779
+ A group on the *path* side denotes all its expansions; the pattern
780
+ must cover every branch (subsumption). The group captures as one
781
+ segment: itself.
782
+ >>> match('references.*', 'references.(a,b)')
783
+ 'references.(a,b)'
784
+ >>> match('references.*', 'references.(a,b)', groups=True)
785
+ ('references.(a,b)', ('references', '(a,b)'))
786
+ >>> match('**.z', '(a.b,c).z', groups=True)
787
+ ('(a.b,c).z', ('(a.b,c)', 'z'))
788
+ >>> match('*', '(a.b,c)')
789
+ '(a.b,c)'
790
+ >>> match('*', '(a.b,c)', partial=False)
791
+ >>> match('*.*', '(a.b,c)')
792
+ >>> match('(a,b)', '(a.b,c)')
793
+ >>> match('(a,b)', '(a.b,b)')
794
+ '(a.b,b)'
795
+ >>> match('**.z', '(a.b,c).z')
796
+ '(a.b,c).z'
797
+
798
+ A recursive op on the path side is covered only by a recursive
799
+ pattern that subsumes it:
800
+ >>> match('**', 'a.**')
801
+ 'a.**'
802
+ >>> match('**', '*b')
803
+ '*b'
804
+ >>> match('*', '**')
771
805
  """
772
806
  # groups can be a bool, a GroupMode member, or its string value.
773
807
  _patterns_only = (groups == GroupMode.patterns
@@ -784,13 +818,13 @@ def match(pattern, path, groups=False, partial=True, strict=False):
784
818
  path_ops = parse(path)
785
819
 
786
820
  # Variadic ops (recursive, groups) consume variable-length path
787
- # segments — use the recursive matcher
788
- if any(op.is_variadic() for op in pats):
821
+ # segments — use the recursive matcher. On the path side they make
822
+ # the path denote multiple expansions, which likewise needs it.
823
+ if any(op.is_variadic() for op in pats) or any(op.is_variadic() for op in path_ops):
789
824
  result = base.match_ops(list(pats), list(path_ops), partial)
790
825
  if result is None:
791
826
  return returns(None, [])
792
- # TODO: pattern-only filtering for variadic matches
793
- return returns(path, result)
827
+ return returns(path, [v for v, _ in result], [p for _, p in result])
794
828
 
795
829
  # Original non-recursive match logic
796
830
  _matches = []
@@ -45,10 +45,14 @@ def has_any(gen):
45
45
  def match_ops(pats, path_ops, partial):
46
46
  """
47
47
  Match a list of pattern ops against a list of path ops.
48
- Returns a list of match values on success, None on failure.
49
- Dispatches to each op's do_match, which decides how many path
50
- segments it consumes.
48
+ Returns a list of (value, is_pattern) capture pairs on success, None
49
+ on failure. Dispatch is per-op on both sides: a variadic op at the
50
+ head of the path (group, recursive) decides how the pattern must
51
+ cover it via do_match_path; otherwise the head pattern op decides
52
+ how many path segments it consumes via do_match.
51
53
  """
54
+ if path_ops and path_ops[0].is_variadic():
55
+ return path_ops[0].do_match_path(list(pats), list(path_ops[1:]), partial)
52
56
  if pats:
53
57
  return pats[0].do_match(pats[1:], path_ops, partial)
54
58
  if not path_ops:
@@ -207,6 +211,15 @@ class TraversalOp(Op):
207
211
  """
208
212
  return False
209
213
 
214
+ def covered_by(self, matcher):
215
+ """
216
+ True if *matcher* (a match op) matches every segment this op
217
+ denotes when it appears on the path side. Simple ops denote a
218
+ single concrete segment; variadic ops override.
219
+ """
220
+ val = getattr(getattr(self, 'op', self), 'value', self)
221
+ return has_any(matcher.matches((val,)))
222
+
210
223
  def do_match(self, rest_pats, path_ops, partial):
211
224
  """
212
225
  Match this op against exactly one path segment, then continue with
@@ -221,9 +234,10 @@ class TraversalOp(Op):
221
234
  rest = match_ops(rest_pats, path_ops[1:], partial)
222
235
  if rest is None:
223
236
  return None
237
+ is_pat = self.is_pattern()
224
238
  if isinstance(m, (tuple, list)):
225
- return [_m.val for _m in m] + rest
226
- return [m.val] + rest
239
+ return [(_m.val, is_pat) for _m in m] + rest
240
+ return [(m.val, is_pat)] + rest
227
241
 
228
242
 
229
243
  class MatchOp(Op):
@@ -109,13 +109,104 @@ class OpGroup(base.TraversalOp):
109
109
  concrete path can only be produced by one branch at a time, so
110
110
  disjunction, first-match, and conjunction all reduce to "any branch
111
111
  matches"; cut markers don't constrain matching either.
112
+
113
+ The group is one pattern segment: it contributes a single capture —
114
+ the path segments its branch consumed, assembled — flagged as a
115
+ pattern match (like Recursive).
116
+ """
117
+ from . import results
118
+ for branch in base.branches_only(self.branches):
119
+ branch_ops = list(branch)
120
+ for n in range(len(path_ops) + 1):
121
+ consumed = base.match_ops(branch_ops, path_ops[:n], False)
122
+ if consumed is None:
123
+ continue
124
+ rest = base.match_ops(list(rest_pats), path_ops[n:], partial)
125
+ if rest is None:
126
+ continue
127
+ combined = results.assemble(path_ops[:n])
128
+ return [(combined, True)] + rest
129
+ return None
130
+
131
+ def do_match_path(self, pats, rest_path, partial):
112
132
  """
133
+ Match when this group appears on the *path* side. The path
134
+ denotes every expansion of the group's branches, so the pattern
135
+ must cover all of them (subsumption): each branch is spliced
136
+ into the path in the group's place and matched; one failing
137
+ branch fails the whole match.
138
+
139
+ The group captures as itself — one capture — via an aligned
140
+ decomposition (a pattern prefix covering every branch), or
141
+ assembled into a variadic pattern segment's capture when the
142
+ pattern crosses the group boundary; only when neither
143
+ decomposition exists do captures fall back to the first
144
+ branch's expansion.
145
+ """
146
+ fallback = self._verify_covered(pats, rest_path, partial)
147
+ if fallback is None:
148
+ return None
149
+ r = self._aligned_captures(pats, rest_path, partial)
150
+ if r is not None:
151
+ return r
152
+ if pats and pats[0].is_variadic():
153
+ r = pats[0].do_match(pats[1:], [self] + list(rest_path), partial)
154
+ if r is not None:
155
+ return r
156
+ return fallback
157
+
158
+ def _verify_covered(self, pats, rest_path, partial):
159
+ """
160
+ Subsumption check over expansions: splice each branch into the
161
+ path and match. Returns the first branch's result (the capture
162
+ fallback) or None when any branch is uncovered.
163
+ """
164
+ first = base.marker
113
165
  for branch in base.branches_only(self.branches):
114
- result = base.match_ops(list(branch) + list(rest_pats), path_ops, partial)
115
- if result is not None:
116
- return result
166
+ r = base.match_ops(pats, list(branch) + list(rest_path), partial)
167
+ if r is None:
168
+ return None
169
+ if first is base.marker:
170
+ first = r
171
+ if first is base.marker:
172
+ return base.match_ops(pats, list(rest_path), partial)
173
+ return first
174
+
175
+ def _aligned_captures(self, pats, rest_path, partial):
176
+ """
177
+ Group-as-itself captures: find a pattern prefix that covers the
178
+ branch set exactly (or up to a partial tail when the pattern
179
+ ends inside the group); the group then captures as one segment
180
+ and the remaining pattern matches the remaining path.
181
+ """
182
+ from . import results
183
+ for k in range(1, len(pats) + 1):
184
+ head = list(pats[:k])
185
+ tail_partial = partial and k == len(pats) and not rest_path
186
+ if not self._covers_branches(head, tail_partial):
187
+ continue
188
+ rest = base.match_ops(list(pats[k:]), list(rest_path), partial)
189
+ if rest is None:
190
+ continue
191
+ is_pat = any(p.is_pattern() for p in head)
192
+ return [(results.assemble([self]), is_pat)] + rest
117
193
  return None
118
194
 
195
+ def _covers_branches(self, head_pats, tail_partial=False):
196
+ """
197
+ True if head_pats covers every branch of this group.
198
+ """
199
+ return all(base.match_ops(head_pats, list(b), tail_partial) is not None
200
+ for b in base.branches_only(self.branches))
201
+
202
+ def covered_by(self, matcher):
203
+ """
204
+ A group path op is covered when every segment of every branch is.
205
+ """
206
+ return all(op.covered_by(matcher)
207
+ for branch in base.branches_only(self.branches)
208
+ for op in branch)
209
+
119
210
  def _render(self, top=True):
120
211
  """
121
212
  Render the group as a string. Subclasses override this.
@@ -417,6 +508,25 @@ class OpGroupAnd(OpGroup):
417
508
  def __repr__(self):
418
509
  return self._render(top=True)
419
510
 
511
+ def _verify_covered(self, pats, rest_path, partial):
512
+ """
513
+ A conjunction's expansions are the intersection of its
514
+ branches', so a pattern covering any single branch covers the
515
+ whole (sufficient, conservatively incomplete).
516
+ """
517
+ for branch in base.branches_only(self.branches):
518
+ r = base.match_ops(pats, list(branch) + list(rest_path), partial)
519
+ if r is not None:
520
+ return r
521
+ return None
522
+
523
+ def _covers_branches(self, head_pats, tail_partial=False):
524
+ """
525
+ Any single covered branch suffices for a conjunction.
526
+ """
527
+ return any(base.match_ops(head_pats, list(b), tail_partial) is not None
528
+ for b in base.branches_only(self.branches))
529
+
420
530
  def push_children(self, stack, frame, paths):
421
531
  """
422
532
  All branches must match. Collect results per branch;
@@ -552,7 +662,43 @@ class OpGroupNot(OpGroup):
552
662
  if rest is None:
553
663
  return None
554
664
  seg_val = getattr(getattr(kop, 'op', kop), 'value', kop)
555
- return [seg_val] + rest
665
+ return [(seg_val, True)] + rest
666
+
667
+ def do_match_path(self, pats, rest_path, partial):
668
+ """
669
+ Match when this negation appears on the *path* side. It denotes
670
+ every segment its inner pattern excludes — an open set — so it
671
+ is covered only by an identical negation or by a bare wildcard
672
+ segment.
673
+ """
674
+ from . import results
675
+ if not pats:
676
+ return [] if partial else None
677
+ head = pats[0]
678
+ if head != self and not self._wildcard_covers(head):
679
+ return None
680
+ rest = base.match_ops(pats[1:], rest_path, partial)
681
+ if rest is None:
682
+ return None
683
+ return [(results.assemble([self]), True)] + rest
684
+
685
+ def _wildcard_covers(self, head):
686
+ """
687
+ True if pattern op *head* is a bare single-segment wildcard and
688
+ this negation excludes only single segments.
689
+ """
690
+ from . import matchers
691
+ if any(len(b) != 1 for b in base.branches_only(self.branches)):
692
+ return False
693
+ return isinstance(getattr(head, 'op', None), matchers.Wildcard)
694
+
695
+ def covered_by(self, matcher):
696
+ """
697
+ A negation denotes an open set of segments; only a wildcard
698
+ covers them all.
699
+ """
700
+ from . import matchers
701
+ return isinstance(matcher, matchers.Wildcard)
556
702
 
557
703
  def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, **kwargs):
558
704
  inner = self.inner
@@ -125,17 +125,63 @@ class Recursive(BaseOp):
125
125
  """
126
126
  from . import results
127
127
  for n in range(1, len(path_ops) + 1):
128
- kop = path_ops[n - 1]
129
- seg_val = getattr(getattr(kop, 'op', kop), 'value', kop)
130
- if not any(True for _ in self.inner.matches((seg_val,))):
128
+ if not path_ops[n - 1].covered_by(self.inner):
131
129
  return None
132
130
  rest = base.match_ops(rest_pats, path_ops[n:], partial)
133
131
  if rest is None:
134
132
  continue
135
133
  combined = results.assemble(path_ops[:n])
136
- return [combined] + rest
134
+ return [(combined, True)] + rest
137
135
  return None
138
136
 
137
+ def do_match_path(self, pats, rest_path, partial):
138
+ """
139
+ Match when this recursive op appears on the *path* side. It
140
+ denotes unboundedly many expansions, so only a pattern segment
141
+ that itself covers arbitrary depth can subsume it: a recursive
142
+ whose inner pattern covers this op's chain segments. The
143
+ subsuming pattern op is tried both consumed and retained, since
144
+ a recursive pattern may keep covering segments after the group.
145
+ """
146
+ from . import results
147
+ if not pats:
148
+ return [] if partial else None
149
+ head = pats[0]
150
+ if not isinstance(head, Recursive):
151
+ return None
152
+ if not self._subsumed_by(head):
153
+ return None
154
+ rest = base.match_ops(pats[1:], rest_path, partial)
155
+ if rest is None:
156
+ rest = base.match_ops(pats, rest_path, partial)
157
+ if rest is None:
158
+ return None
159
+ return [(results.assemble([self]), True)] + rest
160
+
161
+ def _subsumed_by(self, pat):
162
+ """
163
+ True if recursive pattern op *pat* covers every expansion of
164
+ this path op. Identical recursives cover; otherwise an
165
+ unsliced recursive with the same accessors whose inner matcher
166
+ covers every chain segment this op can produce.
167
+ """
168
+ if pat == self:
169
+ return True
170
+ if pat.depth_start is not None or pat.depth_stop is not None or pat.depth_step is not None:
171
+ return False
172
+ if pat.accessors != self.accessors:
173
+ return False
174
+ return self.covered_by(pat.inner)
175
+
176
+ def covered_by(self, matcher):
177
+ """
178
+ A recursive path op denotes unboundedly many segments; only a
179
+ wildcard, or this op's own inner matcher, covers them all.
180
+ """
181
+ if isinstance(matcher, matchers.Wildcard):
182
+ return True
183
+ return matcher == self.inner
184
+
139
185
  def _effective_branches(self):
140
186
  """
141
187
  Return accessor branches that drive recursion.
@@ -84,6 +84,21 @@ class Wrap(base.TraversalOp):
84
84
  return self.inner.do_match(rest_pats, path_ops, partial)
85
85
  return super().do_match(rest_pats, path_ops, partial)
86
86
 
87
+ def do_match_path(self, pats, rest_path, partial):
88
+ """
89
+ Delegate path-side matching to the wrapped op (only reached for
90
+ variadic inners, via is_variadic).
91
+ """
92
+ return self.inner.do_match_path(pats, rest_path, partial)
93
+
94
+ def covered_by(self, matcher):
95
+ """
96
+ Delegate segment coverage to the wrapped op.
97
+ """
98
+ if hasattr(self.inner, 'covered_by'):
99
+ return self.inner.covered_by(matcher)
100
+ return super().covered_by(matcher)
101
+
87
102
  def concrete(self, val):
88
103
  return self.inner.concrete(val)
89
104
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.6
3
+ Version: 0.44.8
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
@@ -508,6 +508,58 @@ excluding literals:
508
508
  >>> dotted.match('hello.*', 'hello.there.bye', groups=GroupMode.patterns)
509
509
  ('hello.there.bye', ('there.bye',))
510
510
 
511
+ Op groups match like regex alternation, and parentheses act as a capture
512
+ group: a group captures the segments its matching branch consumed as a
513
+ single group, and counts as one pattern position:
514
+
515
+ >>> dotted.match('a.(x,y).*', 'a.x.z', groups=GroupMode.patterns, partial=False)
516
+ ('a.x.z', ('x', 'z'))
517
+ >>> dotted.match('x.(a.b,c)', 'x.a.b', groups=True)
518
+ ('x.a.b', ('x', 'a.b'))
519
+ >>> dotted.match('x.(a.b)', 'x.a.b', groups=True)
520
+ ('x.a.b', ('x', 'a.b'))
521
+
522
+ The path may itself be a pattern, in which case `match` asks whether the
523
+ pattern *subsumes* it — covers everything the path denotes:
524
+
525
+ >>> dotted.match('*', '*.*')
526
+ '*.*'
527
+ >>> dotted.match('*.*', '*')
528
+
529
+ A group on the path side denotes all of its expansions, so every branch
530
+ must be covered. It captures as a single segment: itself.
531
+
532
+ >>> dotted.match('references.*', 'references.(a,b)')
533
+ 'references.(a,b)'
534
+ >>> dotted.match('references.*', 'references.(a,b)', groups=True)
535
+ ('references.(a,b)', ('references', '(a,b)'))
536
+
537
+ A branch that outruns the pattern is only covered when partial matching
538
+ is on, and never when the pattern needs more segments than the branch
539
+ has:
540
+
541
+ >>> dotted.match('*', '(a.b,c)')
542
+ '(a.b,c)'
543
+ >>> dotted.match('*', '(a.b,c)', partial=False)
544
+ >>> dotted.match('*.*', '(a.b,c)')
545
+
546
+ Groups on both sides compose the two rules — the pattern matches if
547
+ *some* branch of it covers *every* branch of the path:
548
+
549
+ >>> dotted.match('(a,b)', '(a.b,b)')
550
+ '(a.b,b)'
551
+ >>> dotted.match('(a,b)', '(a.b,b)', partial=False)
552
+ >>> dotted.match('(a,b)', '(a.b,c)')
553
+
554
+ A recursive op on the path side denotes unboundedly many expansions, so
555
+ only a recursive pattern that subsumes it will match:
556
+
557
+ >>> dotted.match('**', 'a.**')
558
+ 'a.**'
559
+ >>> dotted.match('**', '*b')
560
+ '*b'
561
+ >>> dotted.match('*', '**')
562
+
511
563
  <a id="replace"></a>
512
564
  ### Replace
513
565
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dotted_notation"
7
- version = "0.44.6"
7
+ version = "0.44.8"
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"