dotted-notation 0.44.7__tar.gz → 0.44.9__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 (39) hide show
  1. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/CHANGELOG.md +49 -0
  2. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/PKG-INFO +59 -2
  3. dotted_notation-0.44.7/dotted_notation.egg-info/PKG-INFO → dotted_notation-0.44.9/README.md +52 -41
  4. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/api.py +35 -7
  5. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/base.py +15 -2
  6. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/engine.py +2 -2
  7. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/groups.py +134 -0
  8. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/recursive.py +49 -3
  9. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/utils.py +6 -0
  10. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/wrappers.py +15 -0
  11. dotted_notation-0.44.7/README.md → dotted_notation-0.44.9/dotted_notation.egg-info/PKG-INFO +98 -1
  12. dotted_notation-0.44.9/dotted_notation.egg-info/requires.txt +29 -0
  13. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/pyproject.toml +8 -1
  14. dotted_notation-0.44.7/dotted_notation.egg-info/requires.txt +0 -17
  15. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/LICENSE +0 -0
  16. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/MANIFEST.in +0 -0
  17. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/__init__.py +0 -0
  18. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/__main__.py +0 -0
  19. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/access.py +0 -0
  20. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/cli/__init__.py +0 -0
  21. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/cli/_compat.py +0 -0
  22. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/cli/formats.py +0 -0
  23. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/cli/main.py +0 -0
  24. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/containers.py +0 -0
  25. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/filters.py +0 -0
  26. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/grammar.py +0 -0
  27. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/matchers.py +0 -0
  28. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/predicates.py +0 -0
  29. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/results.py +0 -0
  30. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/sql/__init__.py +0 -0
  31. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/sql/core.py +0 -0
  32. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/sql/pg.py +0 -0
  33. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/transforms.py +0 -0
  34. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted/utypes.py +0 -0
  35. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted_notation.egg-info/SOURCES.txt +0 -0
  36. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted_notation.egg-info/dependency_links.txt +0 -0
  37. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted_notation.egg-info/entry_points.txt +0 -0
  38. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/dotted_notation.egg-info/top_level.txt +0 -0
  39. {dotted_notation-0.44.7 → dotted_notation-0.44.9}/setup.cfg +0 -0
@@ -3,6 +3,55 @@
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.9]
7
+
8
+ ### Performance
9
+ - Optional [copium](https://github.com/Bobronium/copium) support: a C
10
+ implementation of `copy.deepcopy` with the same semantics. When
11
+ installed, the copies made by `mutable=False` updates and removes and
12
+ by `build()` use it (roughly 5-20x faster on plain nested data);
13
+ otherwise dotted falls back to `copy.deepcopy`. Install with
14
+ `pip install dotted-notation[copium]` (CPython 3.10+).
15
+
16
+ ### Added
17
+ - `copium` extra, and a `formats` extra bundling YAML and TOML support.
18
+
19
+ ### Deprecated
20
+ - The `all` extra. It is an alias for `formats` and will be removed in
21
+ a future release.
22
+
23
+ ### Changed
24
+ - When copium is installed the test suite runs every test function
25
+ under both deepcopy implementations.
26
+
27
+ ## [0.44.8]
28
+
29
+ ### Added
30
+ - `match()` supports variadic ops on the *path* side. A group or
31
+ recursive op in the path used to fall through to `None` (or match by
32
+ accident, as `**` did); the path is now treated as the set of paths it
33
+ denotes, and the pattern must *subsume* it — cover every expansion.
34
+ Every branch of a path-side group must match, so
35
+ `match('references.*', 'references.(a,b)')` matches while
36
+ `match('*.*', '(a.b,c)')` does not (branch `c` is a segment short).
37
+ Groups on both sides compose the two rules: some pattern branch must
38
+ cover every path branch. A path-side recursive is covered only by a
39
+ recursive pattern that subsumes it — `match('**', '*b')` matches,
40
+ `match('*', '**')` does not. Conjunctions match on any branch (their
41
+ expansions are the intersection); negations, denoting an open set,
42
+ only by an identical negation or a bare wildcard.
43
+
44
+ ### Changed
45
+ - A path-side group captures as one segment: itself, e.g.
46
+ `match('references.*', 'references.(a,b)', groups=True)` gives
47
+ `('references.(a,b)', ('references', '(a,b)'))`. When a variadic
48
+ pattern consumes across the group boundary the group folds into that
49
+ segment's capture, as nested patterns already do.
50
+ - Match dispatch is now per-op on both sides: variadic path ops
51
+ implement `do_match_path` (the mirror of `do_match`), and segment
52
+ coverage moved onto ops as `covered_by`, replacing the ad-hoc value
53
+ extraction `Recursive.do_match` used to do.
54
+
6
55
  ## [0.44.7]
7
56
 
8
57
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.44.7
3
+ Version: 0.44.9
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
@@ -27,11 +27,17 @@ Requires-Python: >=3.6
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE
29
29
  Requires-Dist: pyparsing>=3.0
30
+ Provides-Extra: copium
31
+ Requires-Dist: copium>=0.1.0; (python_version >= "3.10" and platform_python_implementation == "CPython") and extra == "copium"
30
32
  Provides-Extra: yaml
31
33
  Requires-Dist: PyYAML>=5.0; extra == "yaml"
32
34
  Provides-Extra: toml
33
35
  Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "toml"
34
36
  Requires-Dist: tomli_w>=1.0; extra == "toml"
37
+ Provides-Extra: formats
38
+ Requires-Dist: PyYAML>=5.0; extra == "formats"
39
+ Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "formats"
40
+ Requires-Dist: tomli_w>=1.0; extra == "formats"
35
41
  Provides-Extra: all
36
42
  Requires-Dist: PyYAML>=5.0; extra == "all"
37
43
  Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "all"
@@ -61,12 +67,22 @@ Since this package includes the [**`dq`** command-line tool](#cli-dq), several d
61
67
 
62
68
  To install optional format support:
63
69
 
64
- pip install dotted-notation[all]
70
+ pip install dotted-notation[formats]
65
71
 
66
72
  Or pick only what you need:
67
73
 
68
74
  pip install dotted-notation[yaml,toml]
69
75
 
76
+ > **Deprecated:** the `all` extra is an alias for `formats` and will be removed
77
+ > in a future release. Use `formats` instead.
78
+
79
+ For faster `mutable=False` updates and removes, install the
80
+ [copium](https://github.com/Bobronium/copium) extra, a C implementation of
81
+ `copy.deepcopy` (CPython 3.10+). Dotted uses it when present and falls back to
82
+ `copy.deepcopy` otherwise:
83
+
84
+ pip install dotted-notation[copium]
85
+
70
86
  ## Table of Contents
71
87
 
72
88
  - [Safe Traversal (Optional Chaining)](#safe-traversal-optional-chaining)
@@ -519,6 +535,47 @@ single group, and counts as one pattern position:
519
535
  >>> dotted.match('x.(a.b)', 'x.a.b', groups=True)
520
536
  ('x.a.b', ('x', 'a.b'))
521
537
 
538
+ The path may itself be a pattern, in which case `match` asks whether the
539
+ pattern *subsumes* it — covers everything the path denotes:
540
+
541
+ >>> dotted.match('*', '*.*')
542
+ '*.*'
543
+ >>> dotted.match('*.*', '*')
544
+
545
+ A group on the path side denotes all of its expansions, so every branch
546
+ must be covered. It captures as a single segment: itself.
547
+
548
+ >>> dotted.match('references.*', 'references.(a,b)')
549
+ 'references.(a,b)'
550
+ >>> dotted.match('references.*', 'references.(a,b)', groups=True)
551
+ ('references.(a,b)', ('references', '(a,b)'))
552
+
553
+ A branch that outruns the pattern is only covered when partial matching
554
+ is on, and never when the pattern needs more segments than the branch
555
+ has:
556
+
557
+ >>> dotted.match('*', '(a.b,c)')
558
+ '(a.b,c)'
559
+ >>> dotted.match('*', '(a.b,c)', partial=False)
560
+ >>> dotted.match('*.*', '(a.b,c)')
561
+
562
+ Groups on both sides compose the two rules — the pattern matches if
563
+ *some* branch of it covers *every* branch of the path:
564
+
565
+ >>> dotted.match('(a,b)', '(a.b,b)')
566
+ '(a.b,b)'
567
+ >>> dotted.match('(a,b)', '(a.b,b)', partial=False)
568
+ >>> dotted.match('(a,b)', '(a.b,c)')
569
+
570
+ A recursive op on the path side denotes unboundedly many expansions, so
571
+ only a recursive pattern that subsumes it will match:
572
+
573
+ >>> dotted.match('**', 'a.**')
574
+ 'a.**'
575
+ >>> dotted.match('**', '*b')
576
+ '*b'
577
+ >>> dotted.match('*', '**')
578
+
522
579
  <a id="replace"></a>
523
580
  ### Replace
524
581
 
@@ -1,43 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: dotted_notation
3
- Version: 0.44.7
4
- Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
- Author-email: Frey Waid <logophage1@gmail.com>
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/freywaid/dotted
8
- Project-URL: Source, https://github.com/freywaid/dotted
9
- Project-URL: Changelog, https://github.com/freywaid/dotted/blob/master/CHANGELOG.md
10
- Keywords: dotted,nested,path,pattern-matching,json,jsonb,sql,traversal
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.6
17
- Classifier: Programming Language :: Python :: 3.7
18
- Classifier: Programming Language :: Python :: 3.8
19
- Classifier: Programming Language :: Python :: 3.9
20
- Classifier: Programming Language :: Python :: 3.10
21
- Classifier: Programming Language :: Python :: 3.11
22
- Classifier: Programming Language :: Python :: 3.12
23
- Classifier: Programming Language :: Python :: 3.13
24
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
- Classifier: Topic :: Utilities
26
- Requires-Python: >=3.6
27
- Description-Content-Type: text/markdown
28
- License-File: LICENSE
29
- Requires-Dist: pyparsing>=3.0
30
- Provides-Extra: yaml
31
- Requires-Dist: PyYAML>=5.0; extra == "yaml"
32
- Provides-Extra: toml
33
- Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "toml"
34
- Requires-Dist: tomli_w>=1.0; extra == "toml"
35
- Provides-Extra: all
36
- Requires-Dist: PyYAML>=5.0; extra == "all"
37
- Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "all"
38
- Requires-Dist: tomli_w>=1.0; extra == "all"
39
- Dynamic: license-file
40
-
41
1
  # Dotted
42
2
 
43
3
  Sometimes you want to fetch data from a deeply nested data structure. Dotted notation
@@ -61,12 +21,22 @@ Since this package includes the [**`dq`** command-line tool](#cli-dq), several d
61
21
 
62
22
  To install optional format support:
63
23
 
64
- pip install dotted-notation[all]
24
+ pip install dotted-notation[formats]
65
25
 
66
26
  Or pick only what you need:
67
27
 
68
28
  pip install dotted-notation[yaml,toml]
69
29
 
30
+ > **Deprecated:** the `all` extra is an alias for `formats` and will be removed
31
+ > in a future release. Use `formats` instead.
32
+
33
+ For faster `mutable=False` updates and removes, install the
34
+ [copium](https://github.com/Bobronium/copium) extra, a C implementation of
35
+ `copy.deepcopy` (CPython 3.10+). Dotted uses it when present and falls back to
36
+ `copy.deepcopy` otherwise:
37
+
38
+ pip install dotted-notation[copium]
39
+
70
40
  ## Table of Contents
71
41
 
72
42
  - [Safe Traversal (Optional Chaining)](#safe-traversal-optional-chaining)
@@ -519,6 +489,47 @@ single group, and counts as one pattern position:
519
489
  >>> dotted.match('x.(a.b)', 'x.a.b', groups=True)
520
490
  ('x.a.b', ('x', 'a.b'))
521
491
 
492
+ The path may itself be a pattern, in which case `match` asks whether the
493
+ pattern *subsumes* it — covers everything the path denotes:
494
+
495
+ >>> dotted.match('*', '*.*')
496
+ '*.*'
497
+ >>> dotted.match('*.*', '*')
498
+
499
+ A group on the path side denotes all of its expansions, so every branch
500
+ must be covered. It captures as a single segment: itself.
501
+
502
+ >>> dotted.match('references.*', 'references.(a,b)')
503
+ 'references.(a,b)'
504
+ >>> dotted.match('references.*', 'references.(a,b)', groups=True)
505
+ ('references.(a,b)', ('references', '(a,b)'))
506
+
507
+ A branch that outruns the pattern is only covered when partial matching
508
+ is on, and never when the pattern needs more segments than the branch
509
+ has:
510
+
511
+ >>> dotted.match('*', '(a.b,c)')
512
+ '(a.b,c)'
513
+ >>> dotted.match('*', '(a.b,c)', partial=False)
514
+ >>> dotted.match('*.*', '(a.b,c)')
515
+
516
+ Groups on both sides compose the two rules — the pattern matches if
517
+ *some* branch of it covers *every* branch of the path:
518
+
519
+ >>> dotted.match('(a,b)', '(a.b,b)')
520
+ '(a.b,b)'
521
+ >>> dotted.match('(a,b)', '(a.b,b)', partial=False)
522
+ >>> dotted.match('(a,b)', '(a.b,c)')
523
+
524
+ A recursive op on the path side denotes unboundedly many expansions, so
525
+ only a recursive pattern that subsumes it will match:
526
+
527
+ >>> dotted.match('**', 'a.**')
528
+ 'a.**'
529
+ >>> dotted.match('**', '*b')
530
+ '*b'
531
+ >>> dotted.match('*', '**')
532
+
522
533
  <a id="replace"></a>
523
534
  ### Replace
524
535
 
@@ -1,7 +1,6 @@
1
1
  """
2
2
  Main api
3
3
  """
4
- import copy
5
4
  import enum
6
5
  import functools
7
6
  import itertools
@@ -14,6 +13,7 @@ from . import transforms
14
13
  from . import base
15
14
  from . import access
16
15
  from . import matchers
16
+ from . import utils
17
17
  from . import utypes
18
18
 
19
19
 
@@ -544,7 +544,7 @@ def update_if(obj, path, val, pred=lambda val: val is not None, mutable=True, ap
544
544
  if obj is AUTO:
545
545
  obj = _auto_root_from_path(path)
546
546
  if not mutable and _is_mutable_container(obj):
547
- obj = copy.deepcopy(obj)
547
+ obj = utils.deepcopy(obj)
548
548
  mutable = True
549
549
 
550
550
  if pred is not None and not pred(val):
@@ -562,7 +562,7 @@ def update_if_multi(obj, items, pred=lambda val: val is not None, mutable=True,
562
562
  {'a': 1, 'c': 3}
563
563
  """
564
564
  if not mutable and _is_mutable_container(obj):
565
- obj = copy.deepcopy(obj)
565
+ obj = utils.deepcopy(obj)
566
566
  mutable = True
567
567
  for item in items:
568
568
  path, val, *rest = item
@@ -646,7 +646,7 @@ def remove_if(obj, path, pred=lambda path: path is not None, val=ANY, mutable=Tr
646
646
  if obj is AUTO:
647
647
  obj = _auto_root_from_path(path)
648
648
  if not mutable and _is_mutable_container(obj):
649
- obj = copy.deepcopy(obj)
649
+ obj = utils.deepcopy(obj)
650
650
  mutable = True
651
651
 
652
652
  if pred is not None and not pred(path):
@@ -663,7 +663,7 @@ def remove_if_multi(obj, items, paths_only=True, pred=lambda path: path is not N
663
663
  {}
664
664
  """
665
665
  if not mutable and _is_mutable_container(obj):
666
- obj = copy.deepcopy(obj)
666
+ obj = utils.deepcopy(obj)
667
667
  mutable = True
668
668
 
669
669
  if paths_only:
@@ -775,6 +775,33 @@ def match(pattern, path, groups=False, partial=True, strict=False):
775
775
  >>> match('*b', 'b.b.b')
776
776
  'b.b.b'
777
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('*', '**')
778
805
  """
779
806
  # groups can be a bool, a GroupMode member, or its string value.
780
807
  _patterns_only = (groups == GroupMode.patterns
@@ -791,8 +818,9 @@ def match(pattern, path, groups=False, partial=True, strict=False):
791
818
  path_ops = parse(path)
792
819
 
793
820
  # Variadic ops (recursive, groups) consume variable-length path
794
- # segments — use the recursive matcher
795
- 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):
796
824
  result = base.match_ops(list(pats), list(path_ops), partial)
797
825
  if result is None:
798
826
  return returns(None, [])
@@ -46,9 +46,13 @@ def match_ops(pats, path_ops, partial):
46
46
  """
47
47
  Match a list of pattern ops against a list of path ops.
48
48
  Returns a list of (value, is_pattern) capture pairs on success, None
49
- on failure. Dispatches to each op's do_match, which decides how
50
- many path segments it consumes.
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
@@ -3,10 +3,10 @@ Traversal engine for dotted path operations.
3
3
 
4
4
  Core traversal functions (walk, gets, updates, removes, expands).
5
5
  """
6
- import copy
7
6
 
8
7
  from . import base
9
8
  from . import matchers
9
+ from . import utils
10
10
  from . import wrappers
11
11
  from .access import Attr, Slot
12
12
  from .results import Dotted
@@ -42,7 +42,7 @@ def build(ops, node, deepcopy=True, **kwargs):
42
42
  built = node.__class__()
43
43
  for k,v in cur.items(node, **kwargs):
44
44
  if not ops:
45
- built = cur.update(built, k, copy.deepcopy(v) if deepcopy else v)
45
+ built = cur.update(built, k, utils.deepcopy(v) if deepcopy else v)
46
46
  else:
47
47
  built = cur.update(built, k, build(ops, v, deepcopy=deepcopy, **kwargs))
48
48
  return built or build_default([cur]+ops)
@@ -128,6 +128,85 @@ class OpGroup(base.TraversalOp):
128
128
  return [(combined, True)] + rest
129
129
  return None
130
130
 
131
+ def do_match_path(self, pats, rest_path, partial):
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
165
+ for branch in base.branches_only(self.branches):
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
193
+ return None
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
+
131
210
  def _render(self, top=True):
132
211
  """
133
212
  Render the group as a string. Subclasses override this.
@@ -429,6 +508,25 @@ class OpGroupAnd(OpGroup):
429
508
  def __repr__(self):
430
509
  return self._render(top=True)
431
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
+
432
530
  def push_children(self, stack, frame, paths):
433
531
  """
434
532
  All branches must match. Collect results per branch;
@@ -566,6 +664,42 @@ class OpGroupNot(OpGroup):
566
664
  seg_val = getattr(getattr(kop, 'op', kop), 'value', kop)
567
665
  return [(seg_val, True)] + rest
568
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)
702
+
569
703
  def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, **kwargs):
570
704
  inner = self.inner
571
705
  if not inner:
@@ -125,9 +125,7 @@ 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:
@@ -136,6 +134,54 @@ class Recursive(BaseOp):
136
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.
@@ -1,6 +1,12 @@
1
1
  """
2
2
  Shared type-checking helpers (duck-typing).
3
3
  """
4
+ try:
5
+ # C implementation of copy.deepcopy with identical semantics;
6
+ # installed by the [copium] extra.
7
+ from copium import deepcopy
8
+ except ImportError:
9
+ from copy import deepcopy
4
10
 
5
11
  class lazyprop:
6
12
  """
@@ -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,3 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotted_notation
3
+ Version: 0.44.9
4
+ Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
+ Author-email: Frey Waid <logophage1@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/freywaid/dotted
8
+ Project-URL: Source, https://github.com/freywaid/dotted
9
+ Project-URL: Changelog, https://github.com/freywaid/dotted/blob/master/CHANGELOG.md
10
+ Keywords: dotted,nested,path,pattern-matching,json,jsonb,sql,traversal
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.6
17
+ Classifier: Programming Language :: Python :: 3.7
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.6
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: pyparsing>=3.0
30
+ Provides-Extra: copium
31
+ Requires-Dist: copium>=0.1.0; (python_version >= "3.10" and platform_python_implementation == "CPython") and extra == "copium"
32
+ Provides-Extra: yaml
33
+ Requires-Dist: PyYAML>=5.0; extra == "yaml"
34
+ Provides-Extra: toml
35
+ Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "toml"
36
+ Requires-Dist: tomli_w>=1.0; extra == "toml"
37
+ Provides-Extra: formats
38
+ Requires-Dist: PyYAML>=5.0; extra == "formats"
39
+ Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "formats"
40
+ Requires-Dist: tomli_w>=1.0; extra == "formats"
41
+ Provides-Extra: all
42
+ Requires-Dist: PyYAML>=5.0; extra == "all"
43
+ Requires-Dist: tomli>=1.0; python_version < "3.11" and extra == "all"
44
+ Requires-Dist: tomli_w>=1.0; extra == "all"
45
+ Dynamic: license-file
46
+
1
47
  # Dotted
2
48
 
3
49
  Sometimes you want to fetch data from a deeply nested data structure. Dotted notation
@@ -21,12 +67,22 @@ Since this package includes the [**`dq`** command-line tool](#cli-dq), several d
21
67
 
22
68
  To install optional format support:
23
69
 
24
- pip install dotted-notation[all]
70
+ pip install dotted-notation[formats]
25
71
 
26
72
  Or pick only what you need:
27
73
 
28
74
  pip install dotted-notation[yaml,toml]
29
75
 
76
+ > **Deprecated:** the `all` extra is an alias for `formats` and will be removed
77
+ > in a future release. Use `formats` instead.
78
+
79
+ For faster `mutable=False` updates and removes, install the
80
+ [copium](https://github.com/Bobronium/copium) extra, a C implementation of
81
+ `copy.deepcopy` (CPython 3.10+). Dotted uses it when present and falls back to
82
+ `copy.deepcopy` otherwise:
83
+
84
+ pip install dotted-notation[copium]
85
+
30
86
  ## Table of Contents
31
87
 
32
88
  - [Safe Traversal (Optional Chaining)](#safe-traversal-optional-chaining)
@@ -479,6 +535,47 @@ single group, and counts as one pattern position:
479
535
  >>> dotted.match('x.(a.b)', 'x.a.b', groups=True)
480
536
  ('x.a.b', ('x', 'a.b'))
481
537
 
538
+ The path may itself be a pattern, in which case `match` asks whether the
539
+ pattern *subsumes* it — covers everything the path denotes:
540
+
541
+ >>> dotted.match('*', '*.*')
542
+ '*.*'
543
+ >>> dotted.match('*.*', '*')
544
+
545
+ A group on the path side denotes all of its expansions, so every branch
546
+ must be covered. It captures as a single segment: itself.
547
+
548
+ >>> dotted.match('references.*', 'references.(a,b)')
549
+ 'references.(a,b)'
550
+ >>> dotted.match('references.*', 'references.(a,b)', groups=True)
551
+ ('references.(a,b)', ('references', '(a,b)'))
552
+
553
+ A branch that outruns the pattern is only covered when partial matching
554
+ is on, and never when the pattern needs more segments than the branch
555
+ has:
556
+
557
+ >>> dotted.match('*', '(a.b,c)')
558
+ '(a.b,c)'
559
+ >>> dotted.match('*', '(a.b,c)', partial=False)
560
+ >>> dotted.match('*.*', '(a.b,c)')
561
+
562
+ Groups on both sides compose the two rules — the pattern matches if
563
+ *some* branch of it covers *every* branch of the path:
564
+
565
+ >>> dotted.match('(a,b)', '(a.b,b)')
566
+ '(a.b,b)'
567
+ >>> dotted.match('(a,b)', '(a.b,b)', partial=False)
568
+ >>> dotted.match('(a,b)', '(a.b,c)')
569
+
570
+ A recursive op on the path side denotes unboundedly many expansions, so
571
+ only a recursive pattern that subsumes it will match:
572
+
573
+ >>> dotted.match('**', 'a.**')
574
+ 'a.**'
575
+ >>> dotted.match('**', '*b')
576
+ '*b'
577
+ >>> dotted.match('*', '**')
578
+
482
579
  <a id="replace"></a>
483
580
  ### Replace
484
581
 
@@ -0,0 +1,29 @@
1
+ pyparsing>=3.0
2
+
3
+ [all]
4
+ PyYAML>=5.0
5
+ tomli_w>=1.0
6
+
7
+ [all:python_version < "3.11"]
8
+ tomli>=1.0
9
+
10
+ [copium]
11
+
12
+ [copium:python_version >= "3.10" and platform_python_implementation == "CPython"]
13
+ copium>=0.1.0
14
+
15
+ [formats]
16
+ PyYAML>=5.0
17
+ tomli_w>=1.0
18
+
19
+ [formats:python_version < "3.11"]
20
+ tomli>=1.0
21
+
22
+ [toml]
23
+ tomli_w>=1.0
24
+
25
+ [toml:python_version < "3.11"]
26
+ tomli>=1.0
27
+
28
+ [yaml]
29
+ PyYAML>=5.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dotted_notation"
7
- version = "0.44.7"
7
+ version = "0.44.9"
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"
@@ -44,8 +44,15 @@ dependencies = [
44
44
  ]
45
45
 
46
46
  [project.optional-dependencies]
47
+ copium = ['copium>=0.1.0;python_version>="3.10" and platform_python_implementation=="CPython"']
47
48
  yaml = ["PyYAML>=5.0"]
48
49
  toml = ['tomli>=1.0;python_version<"3.11"', "tomli_w>=1.0"]
50
+ formats = [
51
+ "PyYAML>=5.0",
52
+ 'tomli>=1.0;python_version<"3.11"',
53
+ "tomli_w>=1.0",
54
+ ]
55
+ # DEPRECATED: legacy alias for formats, to be removed in a future release
49
56
  all = [
50
57
  "PyYAML>=5.0",
51
58
  'tomli>=1.0;python_version<"3.11"',
@@ -1,17 +0,0 @@
1
- pyparsing>=3.0
2
-
3
- [all]
4
- PyYAML>=5.0
5
- tomli_w>=1.0
6
-
7
- [all:python_version < "3.11"]
8
- tomli>=1.0
9
-
10
- [toml]
11
- tomli_w>=1.0
12
-
13
- [toml:python_version < "3.11"]
14
- tomli>=1.0
15
-
16
- [yaml]
17
- PyYAML>=5.0