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.
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/CHANGELOG.md +39 -0
- {dotted_notation-0.48.0/dotted_notation.egg-info → dotted_notation-0.49.0}/PKG-INFO +89 -2
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/README.md +88 -1
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/filters.py +60 -15
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/grammar.py +48 -13
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/groups.py +117 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/sql/pg.py +6 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/transforms.py +40 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0/dotted_notation.egg-info}/PKG-INFO +89 -2
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/pyproject.toml +1 -1
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/LICENSE +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/MANIFEST.in +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/__init__.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/__main__.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/access.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/access.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/api.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/base.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/base.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/__init__.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/_compat.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/formats.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/cli/main.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/containers.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/engine.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/engine.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/filters.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/groups.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/matchers.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/matchers.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/native.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/predicates.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/recursive.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/recursive.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/results.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/sql/__init__.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/sql/core.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/utils.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/utypes.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/wrappers.pxd +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted/wrappers.py +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/SOURCES.txt +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/dependency_links.txt +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/entry_points.txt +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/requires.txt +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/top_level.txt +0 -0
- {dotted_notation-0.48.0 → dotted_notation-0.49.0}/setup.cfg +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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 = '.[]*:|+?/=,@&()!~#{}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dotted_notation-0.48.0 → dotted_notation-0.49.0}/dotted_notation.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|