dotted-notation 0.48.0__tar.gz → 0.49.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.48.0 → dotted_notation-0.49.0}/CHANGELOG.md +39 -0
  2. {dotted_notation-0.48.0/dotted_notation.egg-info → dotted_notation-0.49.0}/PKG-INFO +89 -2
  3. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/README.md +88 -1
  4. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/filters.py +60 -15
  5. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/grammar.py +48 -13
  6. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/groups.py +117 -0
  7. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/sql/pg.py +6 -0
  8. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/transforms.py +40 -0
  9. {dotted_notation-0.48.0 → dotted_notation-0.49.0/dotted_notation.egg-info}/PKG-INFO +89 -2
  10. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/pyproject.toml +1 -1
  11. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/LICENSE +0 -0
  12. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/MANIFEST.in +0 -0
  13. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/__init__.py +0 -0
  14. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/__main__.py +0 -0
  15. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/access.pxd +0 -0
  16. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/access.py +0 -0
  17. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/api.py +0 -0
  18. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/base.pxd +0 -0
  19. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/base.py +0 -0
  20. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/__init__.py +0 -0
  21. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/_compat.py +0 -0
  22. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/formats.py +0 -0
  23. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/main.py +0 -0
  24. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/containers.py +0 -0
  25. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/engine.pxd +0 -0
  26. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/engine.py +0 -0
  27. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/filters.pxd +0 -0
  28. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/groups.pxd +0 -0
  29. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/matchers.pxd +0 -0
  30. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/matchers.py +0 -0
  31. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/native.py +0 -0
  32. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/predicates.py +0 -0
  33. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/recursive.pxd +0 -0
  34. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/recursive.py +0 -0
  35. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/results.py +0 -0
  36. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/sql/__init__.py +0 -0
  37. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/sql/core.py +0 -0
  38. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/utils.py +0 -0
  39. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/utypes.py +0 -0
  40. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/wrappers.pxd +0 -0
  41. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/wrappers.py +0 -0
  42. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/SOURCES.txt +0 -0
  43. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/dependency_links.txt +0 -0
  44. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/entry_points.txt +0 -0
  45. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/requires.txt +0 -0
  46. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/top_level.txt +0 -0
  47. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/setup.cfg +0 -0
  48. {dotted_notation-0.48.0 → dotted_notation-0.49.0}/setup.py +0 -0
@@ -3,6 +3,45 @@
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.49.0]
7
+
8
+ ### Added
9
+ - Packing: a `;` just inside either paren of a group, `(a,b;)` or
10
+ `(;a,b)`, makes the group yield its matches as one tuple instead of
11
+ one by one. The group keeps its kind, `(a&b;)` packs both or nothing,
12
+ `(a#,b;)` what the cut lets through, `(a.*;)` a one-branch group, and
13
+ nothing matched packs to `()`. The tuple is one value, so a packed
14
+ path is not a pattern and a transform after it applies once, which is
15
+ how an aggregate over a pattern is written: `(a.*;)|sum` is one
16
+ number, `items[*].(p[*];)|sum` one per item. Guards compare the
17
+ tuple, `(a.*;)|sum>2`, and the path may continue into it,
18
+ `(a.*;)[0]`. A packed group may lead a filter key, running from each
19
+ item: `items[(p[*];)|sum>2]` filters by an aggregate over the item.
20
+ `update` and `remove` through a pack raise `ValueError`; the SQL
21
+ translator raises `TranslationError`.
22
+ - `|sum` and `|mean` transforms, on any container value.
23
+
24
+ ### Changed
25
+ - **Breaking**: `;` is a reserved character. Quote a key that contains
26
+ one: `"a;b"`.
27
+
28
+ ### Fixed
29
+ - On the compiled engine, groups and filters compared by identity, so
30
+ `parse('(a,b)') == parse('(a, b)')` was `False` there and `True` on
31
+ the Python engine. A compiled class that defines `__hash__` alone
32
+ does not inherit `__eq__`; `OpGroup` and the filter classes now
33
+ spell theirs out.
34
+
35
+ ## [0.48.1]
36
+
37
+ ### Fixed
38
+ - A sigil token in a transform argument, guard value or filter value may
39
+ carry a dotted path: `|min:$amount.value`, `=^lots.taken`. It is one
40
+ string, offered whole to a resolver with `literals = True`, and the
41
+ literal string without one. `$amount.value` was a `ParseError` there
42
+ and `^lots.taken` a raw string no resolver saw, so the transform
43
+ failed silently (#60). In a segment the dot still separates segments.
44
+
6
45
  ## [0.48.0]
7
46
 
8
47
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.48.0
3
+ Version: 0.49.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
@@ -274,6 +274,11 @@ Several Python libraries handle nested data access. Here's how dotted compares:
274
274
  <a id="breaking-changes"></a>
275
275
  ## Breaking Changes
276
276
 
277
+ ### v0.49.0
278
+ - **`;` is now a reserved character**: it packs a group, `(a,b;)` (see
279
+ [Packing](#packing)). If you have keys containing a literal `;`, quote
280
+ them: `"a;b"`.
281
+
277
282
  ### v0.47.0
278
283
  - **`!` binds tighter than guards, filters and transforms.** `!a=5` is now
279
284
  `(!a)=5`, the values other than `a`'s that equal 5; the old meaning,
@@ -1728,6 +1733,14 @@ A `$` before a word is not a substitution at all: `$str` is the plain string
1728
1733
  >>> dotted.get({'$amount': {'value': 2}}, '$amount.value')
1729
1734
  2
1730
1735
 
1736
+ In a guard value or transform argument the token may carry dots,
1737
+ `|min:$amount.value` or `=^lots.taken`, and is still one string; a
1738
+ [custom resolver](#custom-resolvers) reads it whole. In a segment the dot
1739
+ separates segments, so there `$amount.value` is two:
1740
+
1741
+ >>> dotted.get({'a': None}, 'a|coalesce:$amount.value')
1742
+ '$amount.value'
1743
+
1731
1744
  <a id="type-restrictions"></a>
1732
1745
  ## Type Restrictions
1733
1746
 
@@ -2139,6 +2152,77 @@ fallback as disjunction—first concrete path (last to first):
2139
2152
  >>> dotted.update({'a': {}}, 'a(.x,.y)?', 99) # nothing matches → creates last (.y)
2140
2153
  {'a': {'y': 99}}
2141
2154
 
2155
+ #### Packing
2156
+
2157
+ A group yields its matches one by one. A `;` just inside either paren
2158
+ **packs** them: the group yields one value, the tuple of what it would
2159
+ have yielded. `(a,b;)` and `(;a,b)` are the same pack, and the group
2160
+ keeps its own kind—`(a,b;)` packs the branches that match, `(a&b;)` packs
2161
+ both or nothing, `(a#,b;)` packs what the cut lets through, `(a,b;)?`
2162
+ packs the first match—and `(a.*;)` packs a one-branch group. Nothing
2163
+ matched packs to `()`:
2164
+
2165
+ >>> d = {'a': {'x': 1, 'y': 2}, 'b': 3}
2166
+ >>> dotted.get(d, '(a.*;)')
2167
+ (1, 2)
2168
+ >>> dotted.get(d, '(b,zz;)')
2169
+ (3,)
2170
+ >>> dotted.get(d, '(b&zz;)')
2171
+ ()
2172
+ >>> dotted.get(d, '(zz.*;)')
2173
+ ()
2174
+
2175
+ The tuple is one value, so a packed path is not a pattern: `get` returns
2176
+ the value bare, and a transform after the pack applies to the tuple once.
2177
+ This is how an aggregate over a pattern is written—the pack gathers, an
2178
+ ordinary [transform](#transforms) reduces:
2179
+
2180
+ >>> dotted.get(d, '(a.*;)|sum')
2181
+ 3
2182
+ >>> dotted.get(d, '(a.*;)|mean')
2183
+ 1.5
2184
+ >>> dotted.get(d, '(a.*;)|set|len')
2185
+ 2
2186
+ >>> dotted.get({'a': 1, 'b': 2}, '(a,b;)|sum') # not (a|sum,b|sum)
2187
+ 3
2188
+
2189
+ The group runs from the node the pack sits at, so a pack under a pattern
2190
+ gives one tuple per match—a group-by—while a pack around the whole path
2191
+ gathers across every level inside it:
2192
+
2193
+ >>> d = {'items': [{'p': [1, 2]}, {'p': [3]}, {'p': []}]}
2194
+ >>> dotted.get(d, 'items[*].(p[*];)|sum')
2195
+ (3, 3, 0)
2196
+ >>> dotted.get(d, '(items[*].p[*];)|sum')
2197
+ 6
2198
+
2199
+ The path may continue into the tuple, and a guard compares it, with
2200
+ [guard transforms](#guard-transforms) before it:
2201
+
2202
+ >>> d = {'a': {'x': 1, 'y': 2}}
2203
+ >>> dotted.get(d, '(a.*;)[0]')
2204
+ 1
2205
+ >>> dotted.get(d, '(a.*;)|sum>2')
2206
+ (1, 2)
2207
+
2208
+ A packed group may also lead a [filter](#filters) key, where it runs
2209
+ from each item and its tuple is the value compared—filter by an
2210
+ aggregate over the item:
2211
+
2212
+ >>> d = {'items': [{'p': [1, 2]}, {'p': [3]}, {'p': []}]}
2213
+ >>> dotted.get(d, 'items[(p[*];)|sum>2]')
2214
+ [{'p': [1, 2]}, {'p': [3]}]
2215
+ >>> dotted.get(d, 'items[*&(p[*];)|len=0]')
2216
+ ({'p': []},)
2217
+
2218
+ A pack is its own path segment: `walk` and `expand` give it back as
2219
+ written, `(a.*;)|sum`. There is nothing under a tuple of matches to write
2220
+ to, so `update` and `remove` through a pack raise `ValueError`, and a pack
2221
+ cannot be negated. `;` is a reserved character; quote a key that contains
2222
+ one, `"a;b"`.
2223
+
2224
+ `(a.*;)[*]` yields the matches again: `[*]` is the inverse of packing.
2225
+
2142
2226
  #### Negation (NOT)
2143
2227
 
2144
2228
  Use `!` prefix to exclude keys matching a pattern:
@@ -2190,7 +2274,8 @@ A guard, filter or transform applies to the whole expression on its left:
2190
2274
  an access path (on its last segment), a `!` expression (on what it
2191
2275
  yields), or a parenthesized group, where it is shorthand for writing it on
2192
2276
  every branch: `(a,b)=5` is `(a=5,b=5)` and `(a,b)|int` is `(a|int,b|int)`,
2193
- which is also how `assemble` writes them.
2277
+ which is also how `assemble` writes them. A [packed](#packing) group
2278
+ yields one value, so `(a,b;)|sum` applies to that tuple once.
2194
2279
 
2195
2280
  >>> d = {'a': 1, 'b': 2, 'c': 3}
2196
2281
  >>> dotted.get(d, '!a&b,c') # ((!a)&b), c
@@ -3060,6 +3145,8 @@ decorator.
3060
3145
  | `coalesce` | fallbacks... | The first of the value and the fallbacks that is not `None`: `\|coalesce:0` |
3061
3146
  | `strip` | `chars`, `raises` | Strip whitespace or specified chars |
3062
3147
  | `len` | `default` | Get length. Optional default if not sized: `\|len:0` |
3148
+ | `sum` | `start`, `raises` | Sum a container: `\|sum`, `\|sum:10`; over a pattern, `(a.*;)\|sum` |
3149
+ | `mean` | `raises` | Arithmetic mean of a container: `(a.*;)\|mean` |
3063
3150
  | `lowercase` | `raises` | Convert string to lowercase |
3064
3151
  | `uppercase` | `raises` | Convert string to uppercase |
3065
3152
  | `add` | `rhs`, `raises` | Add value: `\|add:10` |
@@ -227,6 +227,11 @@ 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.49.0
231
+ - **`;` is now a reserved character**: it packs a group, `(a,b;)` (see
232
+ [Packing](#packing)). If you have keys containing a literal `;`, quote
233
+ them: `"a;b"`.
234
+
230
235
  ### v0.47.0
231
236
  - **`!` binds tighter than guards, filters and transforms.** `!a=5` is now
232
237
  `(!a)=5`, the values other than `a`'s that equal 5; the old meaning,
@@ -1681,6 +1686,14 @@ A `$` before a word is not a substitution at all: `$str` is the plain string
1681
1686
  >>> dotted.get({'$amount': {'value': 2}}, '$amount.value')
1682
1687
  2
1683
1688
 
1689
+ In a guard value or transform argument the token may carry dots,
1690
+ `|min:$amount.value` or `=^lots.taken`, and is still one string; a
1691
+ [custom resolver](#custom-resolvers) reads it whole. In a segment the dot
1692
+ separates segments, so there `$amount.value` is two:
1693
+
1694
+ >>> dotted.get({'a': None}, 'a|coalesce:$amount.value')
1695
+ '$amount.value'
1696
+
1684
1697
  <a id="type-restrictions"></a>
1685
1698
  ## Type Restrictions
1686
1699
 
@@ -2092,6 +2105,77 @@ fallback as disjunction—first concrete path (last to first):
2092
2105
  >>> dotted.update({'a': {}}, 'a(.x,.y)?', 99) # nothing matches → creates last (.y)
2093
2106
  {'a': {'y': 99}}
2094
2107
 
2108
+ #### Packing
2109
+
2110
+ A group yields its matches one by one. A `;` just inside either paren
2111
+ **packs** them: the group yields one value, the tuple of what it would
2112
+ have yielded. `(a,b;)` and `(;a,b)` are the same pack, and the group
2113
+ keeps its own kind—`(a,b;)` packs the branches that match, `(a&b;)` packs
2114
+ both or nothing, `(a#,b;)` packs what the cut lets through, `(a,b;)?`
2115
+ packs the first match—and `(a.*;)` packs a one-branch group. Nothing
2116
+ matched packs to `()`:
2117
+
2118
+ >>> d = {'a': {'x': 1, 'y': 2}, 'b': 3}
2119
+ >>> dotted.get(d, '(a.*;)')
2120
+ (1, 2)
2121
+ >>> dotted.get(d, '(b,zz;)')
2122
+ (3,)
2123
+ >>> dotted.get(d, '(b&zz;)')
2124
+ ()
2125
+ >>> dotted.get(d, '(zz.*;)')
2126
+ ()
2127
+
2128
+ The tuple is one value, so a packed path is not a pattern: `get` returns
2129
+ the value bare, and a transform after the pack applies to the tuple once.
2130
+ This is how an aggregate over a pattern is written—the pack gathers, an
2131
+ ordinary [transform](#transforms) reduces:
2132
+
2133
+ >>> dotted.get(d, '(a.*;)|sum')
2134
+ 3
2135
+ >>> dotted.get(d, '(a.*;)|mean')
2136
+ 1.5
2137
+ >>> dotted.get(d, '(a.*;)|set|len')
2138
+ 2
2139
+ >>> dotted.get({'a': 1, 'b': 2}, '(a,b;)|sum') # not (a|sum,b|sum)
2140
+ 3
2141
+
2142
+ The group runs from the node the pack sits at, so a pack under a pattern
2143
+ gives one tuple per match—a group-by—while a pack around the whole path
2144
+ gathers across every level inside it:
2145
+
2146
+ >>> d = {'items': [{'p': [1, 2]}, {'p': [3]}, {'p': []}]}
2147
+ >>> dotted.get(d, 'items[*].(p[*];)|sum')
2148
+ (3, 3, 0)
2149
+ >>> dotted.get(d, '(items[*].p[*];)|sum')
2150
+ 6
2151
+
2152
+ The path may continue into the tuple, and a guard compares it, with
2153
+ [guard transforms](#guard-transforms) before it:
2154
+
2155
+ >>> d = {'a': {'x': 1, 'y': 2}}
2156
+ >>> dotted.get(d, '(a.*;)[0]')
2157
+ 1
2158
+ >>> dotted.get(d, '(a.*;)|sum>2')
2159
+ (1, 2)
2160
+
2161
+ A packed group may also lead a [filter](#filters) key, where it runs
2162
+ from each item and its tuple is the value compared—filter by an
2163
+ aggregate over the item:
2164
+
2165
+ >>> d = {'items': [{'p': [1, 2]}, {'p': [3]}, {'p': []}]}
2166
+ >>> dotted.get(d, 'items[(p[*];)|sum>2]')
2167
+ [{'p': [1, 2]}, {'p': [3]}]
2168
+ >>> dotted.get(d, 'items[*&(p[*];)|len=0]')
2169
+ ({'p': []},)
2170
+
2171
+ A pack is its own path segment: `walk` and `expand` give it back as
2172
+ written, `(a.*;)|sum`. There is nothing under a tuple of matches to write
2173
+ to, so `update` and `remove` through a pack raise `ValueError`, and a pack
2174
+ cannot be negated. `;` is a reserved character; quote a key that contains
2175
+ one, `"a;b"`.
2176
+
2177
+ `(a.*;)[*]` yields the matches again: `[*]` is the inverse of packing.
2178
+
2095
2179
  #### Negation (NOT)
2096
2180
 
2097
2181
  Use `!` prefix to exclude keys matching a pattern:
@@ -2143,7 +2227,8 @@ A guard, filter or transform applies to the whole expression on its left:
2143
2227
  an access path (on its last segment), a `!` expression (on what it
2144
2228
  yields), or a parenthesized group, where it is shorthand for writing it on
2145
2229
  every branch: `(a,b)=5` is `(a=5,b=5)` and `(a,b)|int` is `(a|int,b|int)`,
2146
- which is also how `assemble` writes them.
2230
+ which is also how `assemble` writes them. A [packed](#packing) group
2231
+ yields one value, so `(a,b;)|sum` applies to that tuple once.
2147
2232
 
2148
2233
  >>> d = {'a': 1, 'b': 2, 'c': 3}
2149
2234
  >>> dotted.get(d, '!a&b,c') # ((!a)&b), c
@@ -3013,6 +3098,8 @@ decorator.
3013
3098
  | `coalesce` | fallbacks... | The first of the value and the fallbacks that is not `None`: `\|coalesce:0` |
3014
3099
  | `strip` | `chars`, `raises` | Strip whitespace or specified chars |
3015
3100
  | `len` | `default` | Get length. Optional default if not sized: `\|len:0` |
3101
+ | `sum` | `start`, `raises` | Sum a container: `\|sum`, `\|sum:10`; over a pattern, `(a.*;)\|sum` |
3102
+ | `mean` | `raises` | Arithmetic mean of a container: `(a.*;)\|mean` |
3016
3103
  | `lowercase` | `raises` | Convert string to lowercase |
3017
3104
  | `uppercase` | `raises` | Convert string to uppercase |
3018
3105
  | `add` | `rhs`, `raises` | Add value: `\|add:10` |
@@ -52,8 +52,9 @@ class FilterKey(base.MatchOp):
52
52
  self.parts = tuple(self.args)
53
53
  # Override args with tuple of parts for proper hashing
54
54
  self.args = self.parts
55
- # For simple keys (single part), expose value for backwards compat
56
- self.value = self.parts[0].value if len(self.parts) == 1 else None
55
+ # For simple keys (single part), expose value for backwards compat;
56
+ # a packed group, (a,b;), has none
57
+ self.value = getattr(self.parts[0], 'value', None) if len(self.parts) == 1 else None
57
58
 
58
59
  def __repr__(self):
59
60
  out = []
@@ -66,9 +67,20 @@ class FilterKey(base.MatchOp):
66
67
  def __hash__(self):
67
68
  return hash(self.parts)
68
69
 
70
+ def __eq__(self, other):
71
+ # spelled out: a compiled class with __hash__ alone compares by identity
72
+ return self.__class__ == other.__class__ and self.parts == other.parts
73
+
69
74
  def is_dotted(self):
70
75
  return len(self.parts) > 1
71
76
 
77
+ def is_packed(self):
78
+ """
79
+ True if the key starts with a packed group, [(p[*];)|sum>2]: the
80
+ group runs from the item and its tuple is the key's value.
81
+ """
82
+ return bool(self.parts) and hasattr(self.parts[0], 'gather')
83
+
72
84
  def is_template(self):
73
85
  """
74
86
  True if any part of the filter key contains a substitution reference.
@@ -81,24 +93,29 @@ class FilterKey(base.MatchOp):
81
93
  """
82
94
  return _any_reference(self.parts)
83
95
 
84
- def get_values(self, node):
96
+ def get_values(self, node, settings=base.SETTINGS):
85
97
  """
86
98
  Get all values from node matching this key, traversing dotted path if needed.
87
- Supports slot parts (e.g. tags[*]) to yield each element of a list for matching.
99
+ Supports slot parts (e.g. tags[*]) to yield each element of a list for matching,
100
+ and a leading packed group, (p[*];), whose tuple is the value; settings
101
+ are the traversal's, for references inside the group.
88
102
  Yields (value, True) for each match, or (None, False) if no matches.
89
103
  """
90
- yield from self._get_values(node, list(self.parts))
104
+ yield from self._get_values(node, list(self.parts), settings)
91
105
 
92
- def _get_values(self, node, parts):
106
+ def _get_values(self, node, parts, settings=base.SETTINGS):
93
107
  if not parts:
94
108
  yield node, True
95
109
  return
96
110
  part = parts[0]
97
111
  rest = parts[1:]
112
+ if hasattr(part, 'gather'):
113
+ yield from self._get_values(part.gather(node, settings), rest, settings)
114
+ return
98
115
  if isinstance(part, Slot):
99
116
  try:
100
117
  for v in part.values(node):
101
- yield from self._get_values(v, rest)
118
+ yield from self._get_values(v, rest, settings)
102
119
  except (TypeError, AttributeError):
103
120
  yield None, False
104
121
  return
@@ -106,7 +123,7 @@ class FilterKey(base.MatchOp):
106
123
  try:
107
124
  s = part.slice(node)
108
125
  val = node[s]
109
- yield from self._get_values(val, rest)
126
+ yield from self._get_values(val, rest, settings)
110
127
  except (TypeError, AttributeError, KeyError, IndexError):
111
128
  yield None, False
112
129
  return
@@ -127,7 +144,7 @@ class FilterKey(base.MatchOp):
127
144
  found = False
128
145
  for km in part.matches(node.keys()):
129
146
  child = node[km]
130
- yield from self._get_values(child, rest)
147
+ yield from self._get_values(child, rest, settings)
131
148
  found = True
132
149
  break
133
150
  if not found:
@@ -145,9 +162,9 @@ class FilterKey(base.MatchOp):
145
162
  """
146
163
  For simple keys, delegate to the inner part's matches
147
164
  """
148
- if not self.is_dotted():
165
+ if not self.is_dotted() and not self.is_packed():
149
166
  return self.parts[0].matches(keys)
150
- return ()
167
+ return iter(())
151
168
 
152
169
  def matchable(self, op):
153
170
  """
@@ -214,6 +231,10 @@ class FilterKeyValue(FilterOp):
214
231
  def __hash__(self):
215
232
  return hash((self._eq_str, self.key, self.val, self.guard_transforms))
216
233
 
234
+ def __eq__(self, other):
235
+ # spelled out: a compiled class with __hash__ alone compares by identity
236
+ return self.__class__ == other.__class__ and (self._eq_str, self.key, self.val, self.guard_transforms) == (other._eq_str, other.key, other.val, other.guard_transforms)
237
+
217
238
  def __repr__(self):
218
239
  t_str = ''.join('|' + t.operator() for t in self.guard_transforms) if self.guard_transforms else ''
219
240
  return f'{self.key}{t_str}{self._eq_str}{self.val}'
@@ -252,11 +273,11 @@ class FilterKeyValue(FilterOp):
252
273
  self.val sees the value as ^, node as ^^ and node's tracked parents
253
274
  above that; one that does not resolve matches nothing.
254
275
  """
255
- if not hasattr(node, 'keys'):
276
+ if not hasattr(node, 'keys') and not self.key.is_packed():
256
277
  return False
257
278
  from . import results
258
279
  parents = (node,) + (settings.parents or ())
259
- for val, found in self.key.get_values(node):
280
+ for val, found in self.key.get_values(node, settings):
260
281
  if not found:
261
282
  continue
262
283
  try:
@@ -292,9 +313,13 @@ class FilterKeyValue(FilterOp):
292
313
  return None
293
314
  if not self.key.matchable(op.key) or not self.val.matchable(op.val):
294
315
  return None
295
- mk = next(self.key.matches((op.key.value,)), base.marker)
316
+ if self.key.is_packed():
317
+ # a packed key has no single value: it matches an equal key
318
+ mk = self.key.match(op.key)
319
+ else:
320
+ mk = next(self.key.matches((op.key.value,)), base.marker)
296
321
  mv = next(self.val.matches((op.val.value,)), base.marker)
297
- if base.marker in (mk, mv):
322
+ if mk is None or base.marker in (mk, mv):
298
323
  return None
299
324
  return type(op)(op.key, op.val)
300
325
 
@@ -384,6 +409,10 @@ class FilterGroup(FilterOp):
384
409
  def __hash__(self):
385
410
  return hash(self.inner)
386
411
 
412
+ def __eq__(self, other):
413
+ # spelled out: a compiled class with __hash__ alone compares by identity
414
+ return self.__class__ == other.__class__ and self.inner == other.inner
415
+
387
416
  def __repr__(self):
388
417
  return f'({self.inner})'
389
418
 
@@ -434,6 +463,10 @@ class FilterAnd(FilterOp):
434
463
  def __hash__(self):
435
464
  return hash(self.filters)
436
465
 
466
+ def __eq__(self, other):
467
+ # spelled out: a compiled class with __hash__ alone compares by identity
468
+ return self.__class__ == other.__class__ and self.filters == other.filters
469
+
437
470
  def __repr__(self):
438
471
  return '&'.join(str(f) for f in self.filters)
439
472
 
@@ -492,6 +525,10 @@ class FilterOr(FilterOp):
492
525
  def __hash__(self):
493
526
  return hash(self.filters)
494
527
 
528
+ def __eq__(self, other):
529
+ # spelled out: a compiled class with __hash__ alone compares by identity
530
+ return self.__class__ == other.__class__ and self.filters == other.filters
531
+
495
532
  def __repr__(self):
496
533
  return ','.join(str(f) for f in self.filters)
497
534
 
@@ -538,6 +575,10 @@ class FilterKeyValueFirst(FilterOp):
538
575
  def __hash__(self):
539
576
  return hash(self.inner)
540
577
 
578
+ def __eq__(self, other):
579
+ # spelled out: a compiled class with __hash__ alone compares by identity
580
+ return self.__class__ == other.__class__ and self.inner == other.inner
581
+
541
582
  def __repr__(self):
542
583
  return f'{self.inner}?'
543
584
 
@@ -598,6 +639,10 @@ class FilterNot(FilterOp):
598
639
  def __hash__(self):
599
640
  return hash(('not', self.inner))
600
641
 
642
+ def __eq__(self, other):
643
+ # spelled out: a compiled class with __hash__ alone compares by identity
644
+ return self.__class__ == other.__class__ and self.inner == other.inner
645
+
601
646
  def __repr__(self):
602
647
  return f'!{self.inner}'
603
648
 
@@ -43,6 +43,8 @@ def inner_not_action(t):
43
43
  item = t[0]
44
44
  if isinstance(item, (list, tuple, pp.ParseResults)) and len(item) == 1:
45
45
  item = item[0] # an op_seq of one op
46
+ if isinstance(item, groups.Pack):
47
+ raise ValueError(f'a packed group cannot be negated: !{item!r}')
46
48
  if isinstance(item, groups.OpGroupOr):
47
49
  # a parenthesized single path, !(a.b): the group adds nothing, so
48
50
  # !(a.b) is the same op as !a.b
@@ -92,8 +94,8 @@ def inner_to_opgroup(parsed_result):
92
94
  items = list(parsed_result)
93
95
  if not items:
94
96
  return groups.OpGroupOr()
95
- # If there's a single OpGroup result, use it directly
96
- if len(items) == 1 and isinstance(items[0], groups.OpGroup):
97
+ # If there's a single OpGroup (or packed group) result, use it directly
98
+ if len(items) == 1 and isinstance(items[0], (groups.OpGroup, groups.Pack)):
97
99
  inner = items[0]
98
100
  # Only unwrap redundant OpGroupOr wrapping a single OpGroupAnd/OpGroupNot
99
101
  if isinstance(inner, groups.OpGroupOr):
@@ -220,7 +222,7 @@ none = (pp.Literal('None') | pp.Literal('null')).set_parse_action(matchers.NoneV
220
222
  true = (pp.Literal('True') | pp.Literal('true')).set_parse_action(matchers.Boolean)
221
223
  false = (pp.Literal('False') | pp.Literal('false')).set_parse_action(matchers.Boolean)
222
224
 
223
- reserved = '.[]*:|+?/=,@&()!~#{}$<>' # {} for container syntax, $ for substitution, <> for comparisons
225
+ reserved = '.[]*:|+?/=,@&()!~#{}$<>;' # {} for container syntax, $ for substitution, <> for comparisons, ; for packing
224
226
  breserved = ''.join('\\' + i for i in reserved)
225
227
  tilde = L('~')
226
228
 
@@ -279,6 +281,10 @@ _raw_subst = pp.Regex(r'\$[0-9]+').set_parse_action(
279
281
  # $str, a $ before a word: a plain string, as ^str is. Not $0, $(name) or $$(path).
280
282
  _dollar_word = pp.Regex(r'\$(?![0-9($])[^' + re.escape(reserved) + r' \t\n\r]+').set_parse_action(matchers.Word)
281
283
  var = _escaped_dollar | _reference | _paren_subst | _raw_subst | _dollar_word
284
+ # In an argument or value position a sigil token may carry a dotted path,
285
+ # |min:$amount.value or =^lots.taken: one string, for a resolver to read
286
+ # whole. In a segment position the . separates segments, so not there.
287
+ _arg_sigil = pp.Regex(r'(?:\$(?![0-9($])|\^)[^' + re.escape(reserved.replace('.', '')) + r' \t\n\r]+').set_parse_action(matchers.Word)
282
288
  slice = pp.Optional(integer | plus) + ':' + pp.Optional(integer | plus) \
283
289
  + pp.Optional(':') + pp.Optional(integer | plus)
284
290
 
@@ -535,7 +541,7 @@ concrete_value <<= (
535
541
  value = pp.Forward()
536
542
  _value_group_inner = value + OM(_ccomma + value)
537
543
  value_group = (S('(') + _value_group_inner + S(')')).set_parse_action(lambda t: [ct.ValueGroup(*t)])
538
- value <<= value_group | container_value | string_glob | bytes_literal | string | wildcard | var | regex | numeric_quoted | none | true | false | numeric_extended | numeric_slot
544
+ value <<= value_group | container_value | string_glob | bytes_literal | string | wildcard | _arg_sigil | var | regex | numeric_quoted | none | true | false | numeric_extended | numeric_slot
539
545
  # ---------------------------------------------------------------------------
540
546
  # Concat: part+part for key construction using Python's native + operator.
541
547
  # Each part can carry per-part transforms: part|xform+part|xform
@@ -545,7 +551,7 @@ _key_atom = _commons | numeric_extended | non_integer | numeric_key | word
545
551
  _slot_atom = _commons | numeric_extended | numeric_slot
546
552
 
547
553
  # Transform: |name or |name:param — defined early so filters and guards can reference it
548
- targ = concrete_value | var | quoted | ppc.number | none | true | false | pp.CharsNotIn('|:')
554
+ targ = concrete_value | _arg_sigil | var | quoted | ppc.number | none | true | false | pp.CharsNotIn('|:')
549
555
  param = (colon + targ) | colon.copy().set_parse_action(lambda: [None])
550
556
  transform = (transform_name.copy() + ZM(param)).set_parse_action(lambda s, loc, t: base.Transform(*t))
551
557
  transforms = ZM(pipe + transform)
@@ -597,7 +603,10 @@ key = _concat_key | _key_atom
597
603
  _filter_key_part = string | _common_pats | numeric_extended | non_integer | numeric_key | word
598
604
  filter_key_slice = (lb + slice + rb).set_parse_action(lambda t: access.Slice(*t))
599
605
  filter_key_slot = (lb + (_commons | numeric_extended | numeric_slot) + rb).set_parse_action(lambda t: access.Slot(t[0]))
600
- _filter_key_segment = _filter_key_part | filter_key_slice | filter_key_slot
606
+ # A packed group may lead a filter key, [(p[*];)|sum>2]; set below, once
607
+ # the group grammar exists.
608
+ _filter_key_group = pp.Forward()
609
+ _filter_key_segment = _filter_key_group | _filter_key_part | filter_key_slice | filter_key_slot
601
610
  filter_key = pp.Group(
602
611
  _filter_key_segment + ZM(filter_key_slice | filter_key_slot | (dot + _filter_key_part))
603
612
  ).set_parse_action(flt.FilterKey)
@@ -725,6 +734,10 @@ slicefilter = (lb + filters + ZM(amp + filters) + rb).set_parse_action(access.Sl
725
734
  softcut_marker = L('##')
726
735
  cut_marker = softcut_marker | L('#')
727
736
 
737
+ # Pack marker: a ; just inside either paren, (a,b;) or (;a,b), packs the
738
+ # group's matches into one tuple (see groups.Pack).
739
+ pack_marker = L(';')
740
+
728
741
  # Slot grouping: [(*&filter, +)] for disjunction inside slots; [(*&filter#, +)] for cut
729
742
  def _slot_item_action(t):
730
743
  _, filters, _, _, args = _classify_tokens(list(t))
@@ -750,8 +763,12 @@ def _grouped_with_type_restriction(parsed_result, first=False):
750
763
  """
751
764
  Parse action for grouped expressions with optional type restriction.
752
765
  Last token may be a TypeSpec; if so, wrap the OpGroup in TypeRestriction.
766
+ A ; token at either end packs the group (see groups.Pack); the type
767
+ restriction then guards the tuple.
753
768
  """
754
769
  items = list(parsed_result)
770
+ packed = any(isinstance(i, str) and i == ';' for i in items)
771
+ items = [i for i in items if not (isinstance(i, str) and i == ';')]
755
772
  tr = None
756
773
  if items and isinstance(items[-1], utypes.TypeSpec):
757
774
  tr = items.pop()
@@ -759,32 +776,50 @@ def _grouped_with_type_restriction(parsed_result, first=False):
759
776
  grp = inner_to_opgroup_first(items)
760
777
  else:
761
778
  grp = inner_to_opgroup(items)
779
+ if packed:
780
+ grp = groups.Pack(grp)
762
781
  if tr:
763
782
  grp = tr.wrap(grp)
764
783
  return grp
765
784
 
766
- inner_grouped = (lparen + inner_expr + rparen).set_parse_action(
785
+ _packable = lambda expr: Opt(pack_marker) + expr + Opt(pack_marker)
786
+
787
+
788
+ def _filter_key_pack_action(s, loc, t):
789
+ """
790
+ Parse action for a group leading a filter key: it must be packed,
791
+ the tuple being the value compared; an unpacked group has no single
792
+ value there, so it fails to parse as it always did.
793
+ """
794
+ grp = _grouped_with_type_restriction(t)
795
+ if not isinstance(grp, groups.Pack):
796
+ raise pp.ParseException(s, loc, 'a group in a filter key must be packed, (a,b;)')
797
+ return grp
798
+
799
+ _filter_key_group <<= (lparen + _packable(inner_expr) + rparen).set_parse_action(_filter_key_pack_action)
800
+
801
+ inner_grouped = (lparen + _packable(inner_expr) + rparen).set_parse_action(
767
802
  lambda t: _grouped_with_type_restriction(t))
768
- inner_grouped_first = (lparen + inner_expr + rparen + S('?')).set_parse_action(
803
+ inner_grouped_first = (lparen + _packable(inner_expr) + rparen + S('?')).set_parse_action(
769
804
  lambda t: _grouped_with_type_restriction(t, first=True))
770
805
  # Explicit-op grouped: used mid-path where bare-key inference is not allowed.
771
806
  # Uses a separate precedence tower (_explicit_inner_expr) that only allows
772
807
  # op_seqs built from _op_seq_cont (no bare keys even in first position).
773
808
  # The FollowedBy is a fast-fail optimization — if the first char after ( can't
774
809
  # start an explicit branch, don't even try the tower.
775
- _explicit_op_start = pp.FollowedBy(pp.Char('.@[!~('))
810
+ _explicit_op_start = pp.FollowedBy(pp.Char('.@[!~(;'))
776
811
  _explicit_inner_expr = pp.Forward()
777
- _inner_grouped_explicit = (lparen + _explicit_op_start + _explicit_inner_expr + rparen).set_parse_action(
812
+ _inner_grouped_explicit = (lparen + _explicit_op_start + _packable(_explicit_inner_expr) + rparen).set_parse_action(
778
813
  lambda t: _grouped_with_type_restriction(t))
779
- _inner_grouped_explicit_first = (lparen + _explicit_op_start + _explicit_inner_expr + rparen + S('?')).set_parse_action(
814
+ _inner_grouped_explicit_first = (lparen + _explicit_op_start + _packable(_explicit_inner_expr) + rparen + S('?')).set_parse_action(
780
815
  lambda t: _grouped_with_type_restriction(t, first=True))
781
816
  # Bare-key grouped: first branch does NOT start with an access op.
782
817
  # Used with prefix shorthand (.(a,b), @(a,b)) where the prefix distributes.
783
818
  # Allows ! and ~ (modifiers) but rejects . @ [ ( (access ops / nested groups).
784
819
  _bare_key_start = ~pp.FollowedBy(pp.Char('.@[('))
785
- _inner_grouped_bare = (lparen + _bare_key_start + inner_expr + rparen).set_parse_action(
820
+ _inner_grouped_bare = (lparen + _bare_key_start + _packable(inner_expr) + rparen).set_parse_action(
786
821
  lambda t: _grouped_with_type_restriction(t))
787
- _inner_grouped_bare_first = (lparen + _bare_key_start + inner_expr + rparen + S('?')).set_parse_action(
822
+ _inner_grouped_bare_first = (lparen + _bare_key_start + _packable(inner_expr) + rparen + S('?')).set_parse_action(
788
823
  lambda t: _grouped_with_type_restriction(t, first=True))
789
824
 
790
825
  # Recursive operator: ** (recursive wildcard) and *pattern (recursive chain-following)
@@ -92,6 +92,12 @@ class OpGroup(base.TraversalOp):
92
92
  def __hash__(self):
93
93
  return hash(self.branches)
94
94
 
95
+ def __eq__(self, other):
96
+ # spelled out, not inherited: a compiled class that defines
97
+ # __hash__ alone compares by identity (CPython inherits
98
+ # tp_richcompare only together with tp_hash)
99
+ return self.__class__ == other.__class__ and self.branches == other.branches
100
+
95
101
  def is_pattern(self):
96
102
  return True
97
103
 
@@ -841,6 +847,117 @@ class OpGroupNot(OpGroup):
841
847
  # =============================================================================
842
848
 
843
849
 
850
+ class Pack(base.TraversalOp):
851
+ """
852
+ A packed group, (a,b;) or (;a,b): the group's matches as one tuple.
853
+ The group keeps its own kind, so (a,b;) packs whatever branches
854
+ match, (a&b;) packs both or nothing, (a#,b;) packs what the cut
855
+ lets through, and (a.*;) packs a one-branch group; no matches pack
856
+ to (). Yielding one value, it is not a pattern, and a transform
857
+ after it applies to the tuple once: (a.*;)|sum. The group is run
858
+ from the node the pack sits at, so a[*].(b.*;) packs per a. Writes
859
+ through a pack raise. Not a Wrap: the pack is the op, the group
860
+ its argument, so an unwrapping stops here.
861
+ """
862
+
863
+ def __init__(self, inner):
864
+ super().__init__(inner)
865
+ self.inner = inner
866
+
867
+ def __repr__(self):
868
+ return self.operator(top=True)
869
+
870
+ def is_template(self):
871
+ return self.inner.is_template()
872
+
873
+ def is_reference(self):
874
+ return self.inner.is_reference()
875
+
876
+ def matchable(self, op):
877
+ return op == self
878
+
879
+ def match(self, op, specials=False):
880
+ """
881
+ A pack denotes only itself: it matches an equal pack.
882
+ """
883
+ if not self.matchable(op):
884
+ return None
885
+ return base.MatchResult(self)
886
+
887
+ def do_match(self, rest_pats, path_ops, partial):
888
+ """
889
+ One segment, matched by equality (see match).
890
+ """
891
+ if not path_ops or not self.match(path_ops[0]):
892
+ return None
893
+ rest = base.match_ops(rest_pats, path_ops[1:], partial)
894
+ if rest is None:
895
+ return None
896
+ return [(self, False)] + rest
897
+
898
+ def operator(self, top=False):
899
+ """
900
+ The group's text with the marker before its closing paren:
901
+ (a,b;), (a,b;)?.
902
+ """
903
+ s = self.inner.operator(top)
904
+ i = s.rindex(')')
905
+ return s[:i] + ';' + s[i:]
906
+
907
+ def is_pattern(self):
908
+ return False
909
+
910
+ def as_attrs(self):
911
+ return Pack(self.inner.as_attrs())
912
+
913
+ def resolve(self, bindings, partial=False):
914
+ new_inner = self.inner.resolve(bindings, partial)
915
+ if new_inner is self.inner:
916
+ return self
917
+ return Pack(new_inner)
918
+
919
+ def concrete(self, val):
920
+ """
921
+ A pack is its own path segment: a walked path reads back as
922
+ written, (a.*;).
923
+ """
924
+ return self
925
+
926
+ def gather(self, node, settings=base.SETTINGS):
927
+ """
928
+ The tuple of the group's matches from node, stopping at a cut.
929
+ """
930
+ return tuple(engine.values((self.inner,), node, settings=settings))
931
+
932
+ def items(self, node, settings=base.SETTINGS, filtered=True):
933
+ yield None, self.gather(node, settings)
934
+
935
+ def keys(self, node, settings=base.SETTINGS):
936
+ yield None
937
+
938
+ def values(self, node, settings=base.SETTINGS):
939
+ yield self.gather(node, settings)
940
+
941
+ def is_empty(self, node):
942
+ return False
943
+
944
+ def push_children(self, stack, frame, paths):
945
+ """
946
+ One frame, the tuple, with this pack as its path segment; the
947
+ node the group ran from is its parent for ^^ references.
948
+ """
949
+ packed = self.gather(frame.node, frame.settings)
950
+ cp = frame.prefix + (self,) if paths else frame.prefix
951
+ stack.push(base.Frame(frame.ops, packed, cp, settings=frame.settings.below(frame.node)))
952
+ return ()
953
+
954
+ def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, settings=base.SETTINGS):
955
+ raise ValueError(f'cannot update through a packed group: {self!r}')
956
+
957
+ def do_remove(self, ops, node, val, nop, settings=base.SETTINGS):
958
+ raise ValueError(f'cannot remove through a packed group: {self!r}')
959
+
960
+
844
961
  def _attr_branch(branch):
845
962
  """
846
963
  Convert leading Key to Attr in a branch tuple.
@@ -336,6 +336,8 @@ class PostgresMixin:
336
336
  op = op.inner
337
337
  if isinstance(op, wrappers.TypeRestriction):
338
338
  raise TranslationError('type restriction not supported')
339
+ if isinstance(op.most_inner, _groups.Pack):
340
+ raise TranslationError('packed group not supported')
339
341
  # Groups: each branch is walked independently, so dispatch first
340
342
  # (OpGroup has is_pattern=True but shouldn't take the pattern-mode
341
343
  # path wholesale).
@@ -459,6 +461,8 @@ class PostgresMixin:
459
461
  return self._op_to_jsonpath(op.inner, jvars)
460
462
  if isinstance(op, wrappers.TypeRestriction):
461
463
  raise TranslationError('type restriction not supported')
464
+ if isinstance(op.most_inner, _groups.Pack):
465
+ raise TranslationError('packed group not supported')
462
466
  # NB: Slot is a subclass of Key, so check Slot first.
463
467
  if isinstance(op, access.Slot):
464
468
  return self._key_slot_to_jsonpath(op, is_key=False), None
@@ -674,6 +678,8 @@ class PostgresMixin:
674
678
  if isinstance(key, _filters.FilterKey):
675
679
  parts = ['@']
676
680
  for p in key.parts:
681
+ if isinstance(p, _groups.Pack):
682
+ raise TranslationError('packed group in filter key not supported')
677
683
  if isinstance(p, access.Slot):
678
684
  m = p.op
679
685
  if isinstance(m, matchers.Wildcard):
@@ -3,6 +3,7 @@ Default transforms
3
3
  """
4
4
  import decimal
5
5
  import math
6
+ import statistics
6
7
  from .results import Dotted
7
8
 
8
9
 
@@ -502,6 +503,45 @@ def transform_not_in(val, rhs, *modes):
502
503
  return val
503
504
 
504
505
 
506
+ @transform('sum')
507
+ def transform_sum(val, start=None, *modes):
508
+ """
509
+ Sum the elements of val
510
+ <dotted>|sum sum(val)
511
+ <dotted>|sum:<start> sum(val, <start>)
512
+ <dotted>|sum::raises sum(val) or raises
513
+ >>> import dotted
514
+ >>> dotted.get({'a': {'x': 1, 'y': 2}}, '(a.*;)|sum')
515
+ 3
516
+ """
517
+ try:
518
+ if start is None:
519
+ return sum(val)
520
+ return sum(val, start)
521
+ except TypeError:
522
+ if 'raises' in modes:
523
+ raise
524
+ return val
525
+
526
+
527
+ @transform('mean')
528
+ def transform_mean(val, *modes):
529
+ """
530
+ Arithmetic mean of the elements of val
531
+ <dotted>|mean statistics.mean(val)
532
+ <dotted>|mean:raises statistics.mean(val) or raises
533
+ >>> import dotted
534
+ >>> dotted.get({'a': {'x': 1, 'y': 2}}, '(a.*;)|mean')
535
+ 1.5
536
+ """
537
+ try:
538
+ return statistics.mean(val)
539
+ except (TypeError, statistics.StatisticsError):
540
+ if 'raises' in modes:
541
+ raise
542
+ return val
543
+
544
+
505
545
  @transform('list')
506
546
  def transform_list(val, *modes):
507
547
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.48.0
3
+ Version: 0.49.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
@@ -274,6 +274,11 @@ Several Python libraries handle nested data access. Here's how dotted compares:
274
274
  <a id="breaking-changes"></a>
275
275
  ## Breaking Changes
276
276
 
277
+ ### v0.49.0
278
+ - **`;` is now a reserved character**: it packs a group, `(a,b;)` (see
279
+ [Packing](#packing)). If you have keys containing a literal `;`, quote
280
+ them: `"a;b"`.
281
+
277
282
  ### v0.47.0
278
283
  - **`!` binds tighter than guards, filters and transforms.** `!a=5` is now
279
284
  `(!a)=5`, the values other than `a`'s that equal 5; the old meaning,
@@ -1728,6 +1733,14 @@ A `$` before a word is not a substitution at all: `$str` is the plain string
1728
1733
  >>> dotted.get({'$amount': {'value': 2}}, '$amount.value')
1729
1734
  2
1730
1735
 
1736
+ In a guard value or transform argument the token may carry dots,
1737
+ `|min:$amount.value` or `=^lots.taken`, and is still one string; a
1738
+ [custom resolver](#custom-resolvers) reads it whole. In a segment the dot
1739
+ separates segments, so there `$amount.value` is two:
1740
+
1741
+ >>> dotted.get({'a': None}, 'a|coalesce:$amount.value')
1742
+ '$amount.value'
1743
+
1731
1744
  <a id="type-restrictions"></a>
1732
1745
  ## Type Restrictions
1733
1746
 
@@ -2139,6 +2152,77 @@ fallback as disjunction—first concrete path (last to first):
2139
2152
  >>> dotted.update({'a': {}}, 'a(.x,.y)?', 99) # nothing matches → creates last (.y)
2140
2153
  {'a': {'y': 99}}
2141
2154
 
2155
+ #### Packing
2156
+
2157
+ A group yields its matches one by one. A `;` just inside either paren
2158
+ **packs** them: the group yields one value, the tuple of what it would
2159
+ have yielded. `(a,b;)` and `(;a,b)` are the same pack, and the group
2160
+ keeps its own kind—`(a,b;)` packs the branches that match, `(a&b;)` packs
2161
+ both or nothing, `(a#,b;)` packs what the cut lets through, `(a,b;)?`
2162
+ packs the first match—and `(a.*;)` packs a one-branch group. Nothing
2163
+ matched packs to `()`:
2164
+
2165
+ >>> d = {'a': {'x': 1, 'y': 2}, 'b': 3}
2166
+ >>> dotted.get(d, '(a.*;)')
2167
+ (1, 2)
2168
+ >>> dotted.get(d, '(b,zz;)')
2169
+ (3,)
2170
+ >>> dotted.get(d, '(b&zz;)')
2171
+ ()
2172
+ >>> dotted.get(d, '(zz.*;)')
2173
+ ()
2174
+
2175
+ The tuple is one value, so a packed path is not a pattern: `get` returns
2176
+ the value bare, and a transform after the pack applies to the tuple once.
2177
+ This is how an aggregate over a pattern is written—the pack gathers, an
2178
+ ordinary [transform](#transforms) reduces:
2179
+
2180
+ >>> dotted.get(d, '(a.*;)|sum')
2181
+ 3
2182
+ >>> dotted.get(d, '(a.*;)|mean')
2183
+ 1.5
2184
+ >>> dotted.get(d, '(a.*;)|set|len')
2185
+ 2
2186
+ >>> dotted.get({'a': 1, 'b': 2}, '(a,b;)|sum') # not (a|sum,b|sum)
2187
+ 3
2188
+
2189
+ The group runs from the node the pack sits at, so a pack under a pattern
2190
+ gives one tuple per match—a group-by—while a pack around the whole path
2191
+ gathers across every level inside it:
2192
+
2193
+ >>> d = {'items': [{'p': [1, 2]}, {'p': [3]}, {'p': []}]}
2194
+ >>> dotted.get(d, 'items[*].(p[*];)|sum')
2195
+ (3, 3, 0)
2196
+ >>> dotted.get(d, '(items[*].p[*];)|sum')
2197
+ 6
2198
+
2199
+ The path may continue into the tuple, and a guard compares it, with
2200
+ [guard transforms](#guard-transforms) before it:
2201
+
2202
+ >>> d = {'a': {'x': 1, 'y': 2}}
2203
+ >>> dotted.get(d, '(a.*;)[0]')
2204
+ 1
2205
+ >>> dotted.get(d, '(a.*;)|sum>2')
2206
+ (1, 2)
2207
+
2208
+ A packed group may also lead a [filter](#filters) key, where it runs
2209
+ from each item and its tuple is the value compared—filter by an
2210
+ aggregate over the item:
2211
+
2212
+ >>> d = {'items': [{'p': [1, 2]}, {'p': [3]}, {'p': []}]}
2213
+ >>> dotted.get(d, 'items[(p[*];)|sum>2]')
2214
+ [{'p': [1, 2]}, {'p': [3]}]
2215
+ >>> dotted.get(d, 'items[*&(p[*];)|len=0]')
2216
+ ({'p': []},)
2217
+
2218
+ A pack is its own path segment: `walk` and `expand` give it back as
2219
+ written, `(a.*;)|sum`. There is nothing under a tuple of matches to write
2220
+ to, so `update` and `remove` through a pack raise `ValueError`, and a pack
2221
+ cannot be negated. `;` is a reserved character; quote a key that contains
2222
+ one, `"a;b"`.
2223
+
2224
+ `(a.*;)[*]` yields the matches again: `[*]` is the inverse of packing.
2225
+
2142
2226
  #### Negation (NOT)
2143
2227
 
2144
2228
  Use `!` prefix to exclude keys matching a pattern:
@@ -2190,7 +2274,8 @@ A guard, filter or transform applies to the whole expression on its left:
2190
2274
  an access path (on its last segment), a `!` expression (on what it
2191
2275
  yields), or a parenthesized group, where it is shorthand for writing it on
2192
2276
  every branch: `(a,b)=5` is `(a=5,b=5)` and `(a,b)|int` is `(a|int,b|int)`,
2193
- which is also how `assemble` writes them.
2277
+ which is also how `assemble` writes them. A [packed](#packing) group
2278
+ yields one value, so `(a,b;)|sum` applies to that tuple once.
2194
2279
 
2195
2280
  >>> d = {'a': 1, 'b': 2, 'c': 3}
2196
2281
  >>> dotted.get(d, '!a&b,c') # ((!a)&b), c
@@ -3060,6 +3145,8 @@ decorator.
3060
3145
  | `coalesce` | fallbacks... | The first of the value and the fallbacks that is not `None`: `\|coalesce:0` |
3061
3146
  | `strip` | `chars`, `raises` | Strip whitespace or specified chars |
3062
3147
  | `len` | `default` | Get length. Optional default if not sized: `\|len:0` |
3148
+ | `sum` | `start`, `raises` | Sum a container: `\|sum`, `\|sum:10`; over a pattern, `(a.*;)\|sum` |
3149
+ | `mean` | `raises` | Arithmetic mean of a container: `(a.*;)\|mean` |
3063
3150
  | `lowercase` | `raises` | Convert string to lowercase |
3064
3151
  | `uppercase` | `raises` | Convert string to uppercase |
3065
3152
  | `add` | `rhs`, `raises` | Add value: `\|add:10` |
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "dotted_notation"
9
- version = "0.48.0"
9
+ version = "0.49.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"