j-perm 0.1.3.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- j_perm/__init__.py +15 -7
- j_perm/casters/__init__.py +4 -0
- j_perm/casters/_bool.py +9 -0
- j_perm/casters/_float.py +8 -0
- j_perm/casters/_int.py +8 -0
- j_perm/casters/_str.py +8 -0
- j_perm/constructs/__init__.py +2 -0
- j_perm/constructs/eval.py +16 -0
- j_perm/constructs/ref.py +21 -0
- j_perm/engine.py +139 -63
- j_perm/funcs/__init__.py +1 -0
- j_perm/funcs/subtract.py +11 -0
- j_perm/op_handler.py +57 -0
- j_perm/ops/__init__.py +1 -0
- j_perm/ops/{assert.py → _assert.py} +4 -4
- j_perm/ops/_exec.py +8 -9
- j_perm/ops/_if.py +8 -9
- j_perm/ops/copy.py +5 -5
- j_perm/ops/copy_d.py +5 -5
- j_perm/ops/delete.py +4 -4
- j_perm/ops/distinct.py +5 -5
- j_perm/ops/foreach.py +6 -7
- j_perm/ops/replace_root.py +5 -7
- j_perm/ops/set.py +6 -7
- j_perm/ops/update.py +7 -8
- j_perm/schema/__init__.py +109 -109
- j_perm/special_resolver.py +115 -0
- j_perm/subst.py +300 -0
- j_perm-0.2.0.dist-info/METADATA +818 -0
- j_perm-0.2.0.dist-info/RECORD +34 -0
- {j_perm-0.1.3.1.dist-info → j_perm-0.2.0.dist-info}/WHEEL +1 -1
- j_perm/jmes_ext.py +0 -17
- j_perm/registry.py +0 -30
- j_perm/utils/special.py +0 -41
- j_perm/utils/subst.py +0 -133
- j_perm/utils/tuples.py +0 -17
- j_perm-0.1.3.1.dist-info/METADATA +0 -766
- j_perm-0.1.3.1.dist-info/RECORD +0 -26
- {j_perm-0.1.3.1.dist-info → j_perm-0.2.0.dist-info}/top_level.txt +0 -0
j_perm/subst.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable, Mapping
|
|
7
|
+
|
|
8
|
+
import jmespath
|
|
9
|
+
from jmespath import functions as _jp_funcs
|
|
10
|
+
|
|
11
|
+
from j_perm.utils.pointers import maybe_slice
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# Types
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
Caster = Callable[[Any], Any]
|
|
18
|
+
JpMethod = Callable[..., Any] # method-like callable: (self, ...) -> Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# =============================================================================
|
|
22
|
+
# Caster registry (register casters one-by-one)
|
|
23
|
+
# =============================================================================
|
|
24
|
+
|
|
25
|
+
class CasterRegistry:
|
|
26
|
+
"""
|
|
27
|
+
Registry for value casters used in template expressions like:
|
|
28
|
+
|
|
29
|
+
${int:/path}
|
|
30
|
+
${json:${/raw}}
|
|
31
|
+
|
|
32
|
+
Casters are registered by name (prefix before ':').
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_casters: dict[str, Caster] = {}
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def register(cls, name: str) -> Callable[[Caster], Caster]:
|
|
39
|
+
"""
|
|
40
|
+
Decorator to register a caster.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
@CasterRegistry.register("int")
|
|
44
|
+
def cast_int(x): ...
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def decorator(fn: Caster) -> Caster:
|
|
48
|
+
if name in cls._casters:
|
|
49
|
+
raise ValueError(f"caster already registered: {name!r}")
|
|
50
|
+
cls._casters[name] = fn
|
|
51
|
+
return fn
|
|
52
|
+
|
|
53
|
+
return decorator
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def all(cls) -> dict[str, Caster]:
|
|
57
|
+
"""Return a copy of all registered casters."""
|
|
58
|
+
return dict(cls._casters)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# JMESPath function registry (register functions one-by-one)
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
class JpFuncRegistry:
|
|
66
|
+
"""
|
|
67
|
+
Registry for JMESPath custom functions registered one-by-one.
|
|
68
|
+
|
|
69
|
+
JMESPath discovers methods named `_func_<name>` on a
|
|
70
|
+
`jmespath.functions.Functions` instance. We dynamically build such a class
|
|
71
|
+
from registered method callables.
|
|
72
|
+
|
|
73
|
+
IMPORTANT:
|
|
74
|
+
If you rely on 'None => all registered', you must ensure all modules that
|
|
75
|
+
call JpFuncRegistry.register(...) are imported at startup.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_methods: dict[str, JpMethod] = {}
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def register(cls, name: str) -> Callable[[JpMethod], JpMethod]:
|
|
82
|
+
"""
|
|
83
|
+
Decorator to register a JMESPath function under public name `name`.
|
|
84
|
+
|
|
85
|
+
The decorated function:
|
|
86
|
+
- MUST be method-like: (self, ...)
|
|
87
|
+
- SHOULD be decorated with @_jp_funcs.signature(...)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def decorator(fn: JpMethod) -> JpMethod:
|
|
91
|
+
if name in cls._methods:
|
|
92
|
+
raise ValueError(f"JMESPath function already registered: {name!r}")
|
|
93
|
+
cls._methods[name] = fn
|
|
94
|
+
return fn
|
|
95
|
+
|
|
96
|
+
return decorator
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def all(cls) -> dict[str, JpMethod]:
|
|
100
|
+
"""Return a copy of all registered functions."""
|
|
101
|
+
return dict(cls._methods)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def build(cls, funcs: list[str] | Mapping[str, JpMethod] | None) -> _jp_funcs.Functions:
|
|
105
|
+
"""
|
|
106
|
+
Build a single `jmespath.functions.Functions` instance.
|
|
107
|
+
|
|
108
|
+
funcs:
|
|
109
|
+
- None -> use ALL registered
|
|
110
|
+
- list[str] -> use only those names
|
|
111
|
+
- Mapping[str, fn] -> explicit mapping, bypass registry
|
|
112
|
+
"""
|
|
113
|
+
if funcs is None:
|
|
114
|
+
methods = cls._methods
|
|
115
|
+
elif isinstance(funcs, Mapping):
|
|
116
|
+
methods = dict(funcs)
|
|
117
|
+
else:
|
|
118
|
+
methods = {name: cls._methods[name] for name in funcs}
|
|
119
|
+
|
|
120
|
+
namespace = {f"_func_{name}": fn for name, fn in methods.items()}
|
|
121
|
+
Combined = type("UserJMESFunctions", (_jp_funcs.Functions,), namespace)
|
|
122
|
+
return Combined()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_jmespath_options(
|
|
126
|
+
*,
|
|
127
|
+
funcs: list[str] | Mapping[str, JpMethod] | None = None,
|
|
128
|
+
**kwargs: Any,
|
|
129
|
+
) -> jmespath.Options:
|
|
130
|
+
"""
|
|
131
|
+
Convenience helper to build jmespath.Options(custom_functions=...).
|
|
132
|
+
|
|
133
|
+
funcs:
|
|
134
|
+
- None -> all registered
|
|
135
|
+
- list[str] -> subset
|
|
136
|
+
- Mapping[str, fn] -> explicit mapping
|
|
137
|
+
"""
|
|
138
|
+
user_funcs = JpFuncRegistry.build(funcs)
|
|
139
|
+
return jmespath.Options(custom_functions=user_funcs, **kwargs)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# =============================================================================
|
|
143
|
+
# TemplateSubstitutor
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
@dataclass(slots=True)
|
|
147
|
+
class TemplateSubstitutor:
|
|
148
|
+
"""
|
|
149
|
+
Template interpolator with:
|
|
150
|
+
- caster prefixes: ${int:/path}
|
|
151
|
+
- JMESPath: ${? expr}
|
|
152
|
+
- nested templates
|
|
153
|
+
- JSON Pointer fallback
|
|
154
|
+
|
|
155
|
+
Configuration:
|
|
156
|
+
- casters:
|
|
157
|
+
* None -> use all registered casters
|
|
158
|
+
* list[str] -> use only selected casters
|
|
159
|
+
* Mapping[str, Caster] -> explicit mapping
|
|
160
|
+
- jmes_funcs:
|
|
161
|
+
* None -> use all registered JMES funcs
|
|
162
|
+
* list[str] -> subset
|
|
163
|
+
* Mapping[str, JpMethod] -> explicit mapping
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
casters: list[str] | Mapping[str, Caster] | None = None
|
|
167
|
+
jmes_funcs: list[str] | Mapping[str, JpMethod] | None = None
|
|
168
|
+
|
|
169
|
+
_casters: dict[str, Caster] = field(init=False)
|
|
170
|
+
_jp_options: jmespath.Options = field(init=False)
|
|
171
|
+
|
|
172
|
+
def __post_init__(self) -> None:
|
|
173
|
+
# Build casters map
|
|
174
|
+
if self.casters is None:
|
|
175
|
+
self._casters = CasterRegistry.all()
|
|
176
|
+
elif isinstance(self.casters, Mapping):
|
|
177
|
+
self._casters = dict(self.casters)
|
|
178
|
+
else:
|
|
179
|
+
all_c = CasterRegistry.all()
|
|
180
|
+
self._casters = {k: all_c[k] for k in self.casters}
|
|
181
|
+
|
|
182
|
+
# Build JMESPath options
|
|
183
|
+
self._jp_options = build_jmespath_options(funcs=self.jmes_funcs)
|
|
184
|
+
|
|
185
|
+
# -------------------------------------------------------------------------
|
|
186
|
+
# Public API
|
|
187
|
+
# -------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def substitute(self, obj: Any, data: Mapping[str, Any]) -> Any:
|
|
190
|
+
return self.deep_substitute(obj, data)
|
|
191
|
+
|
|
192
|
+
def flat_substitute(self, tmpl: str, data: Mapping[str, Any]) -> Any:
|
|
193
|
+
if "${" not in tmpl:
|
|
194
|
+
return tmpl
|
|
195
|
+
|
|
196
|
+
if tmpl.startswith("${") and tmpl.endswith("}"):
|
|
197
|
+
body = tmpl[2:-1]
|
|
198
|
+
return copy.deepcopy(self._resolve_expr(body, data))
|
|
199
|
+
|
|
200
|
+
out: list[str] = []
|
|
201
|
+
i = 0
|
|
202
|
+
|
|
203
|
+
while i < len(tmpl):
|
|
204
|
+
if tmpl[i: i + 2] == "${":
|
|
205
|
+
depth = 0
|
|
206
|
+
j = i + 2
|
|
207
|
+
|
|
208
|
+
while j < len(tmpl):
|
|
209
|
+
ch = tmpl[j]
|
|
210
|
+
|
|
211
|
+
if ch == "{" and tmpl[j - 1] == "$":
|
|
212
|
+
depth += 1
|
|
213
|
+
elif ch == "}":
|
|
214
|
+
if depth == 0:
|
|
215
|
+
expr = tmpl[i + 2: j]
|
|
216
|
+
val = self._resolve_expr(expr, data)
|
|
217
|
+
|
|
218
|
+
if isinstance(val, (Mapping, list)):
|
|
219
|
+
rendered = json.dumps(val, ensure_ascii=False)
|
|
220
|
+
else:
|
|
221
|
+
rendered = str(val)
|
|
222
|
+
|
|
223
|
+
out.append(rendered)
|
|
224
|
+
i = j + 1
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
depth -= 1
|
|
228
|
+
|
|
229
|
+
j += 1
|
|
230
|
+
else:
|
|
231
|
+
out.append(tmpl[i])
|
|
232
|
+
i += 1
|
|
233
|
+
else:
|
|
234
|
+
out.append(tmpl[i])
|
|
235
|
+
i += 1
|
|
236
|
+
|
|
237
|
+
return "".join(out)
|
|
238
|
+
|
|
239
|
+
def deep_substitute(self, obj: Any, data: Mapping[str, Any], _depth: int = 0) -> Any:
|
|
240
|
+
if _depth > 50:
|
|
241
|
+
raise RecursionError("too deep interpolation")
|
|
242
|
+
|
|
243
|
+
if isinstance(obj, str):
|
|
244
|
+
out = self.flat_substitute(obj, data)
|
|
245
|
+
if isinstance(out, str) and "${" in out:
|
|
246
|
+
return self.deep_substitute(out, data, _depth + 1)
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
if isinstance(obj, list):
|
|
250
|
+
return [self.deep_substitute(item, data, _depth) for item in obj]
|
|
251
|
+
|
|
252
|
+
if isinstance(obj, tuple):
|
|
253
|
+
return [self.deep_substitute(item, data, _depth) for item in obj]
|
|
254
|
+
|
|
255
|
+
if isinstance(obj, Mapping):
|
|
256
|
+
out: dict[Any, Any] = {}
|
|
257
|
+
for k, v in obj.items():
|
|
258
|
+
new_key = self.deep_substitute(k, data, _depth) if isinstance(k, str) else k
|
|
259
|
+
if new_key in out:
|
|
260
|
+
raise KeyError(f"duplicate key after substitution: {new_key!r}")
|
|
261
|
+
out[new_key] = self.deep_substitute(v, data, _depth)
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
return obj
|
|
265
|
+
|
|
266
|
+
# -------------------------------------------------------------------------
|
|
267
|
+
# Internals
|
|
268
|
+
# -------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _resolve_expr(self, expr: str, data: Mapping[str, Any]) -> Any:
|
|
271
|
+
expr = expr.strip()
|
|
272
|
+
|
|
273
|
+
# 1) Casters
|
|
274
|
+
for prefix, fn in self._casters.items():
|
|
275
|
+
tag = f"{prefix}:"
|
|
276
|
+
if expr.startswith(tag):
|
|
277
|
+
inner = expr[len(tag):]
|
|
278
|
+
value = self.flat_substitute(inner, data)
|
|
279
|
+
|
|
280
|
+
if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
|
|
281
|
+
value = self.flat_substitute(value, data)
|
|
282
|
+
|
|
283
|
+
return fn(value)
|
|
284
|
+
|
|
285
|
+
# 2) JMESPath
|
|
286
|
+
if expr.startswith("?"):
|
|
287
|
+
query_raw = expr[1:].lstrip()
|
|
288
|
+
query_expanded = self.flat_substitute(query_raw, data)
|
|
289
|
+
return jmespath.search(query_expanded, data, options=self._jp_options)
|
|
290
|
+
|
|
291
|
+
# 3) Nested template
|
|
292
|
+
if expr.startswith("${") and expr.endswith("}"):
|
|
293
|
+
return self.flat_substitute(expr, data)
|
|
294
|
+
|
|
295
|
+
# 4) JSON Pointer fallback
|
|
296
|
+
pointer = "/" + expr.lstrip("/")
|
|
297
|
+
try:
|
|
298
|
+
return maybe_slice(pointer, data) # type: ignore[arg-type]
|
|
299
|
+
except Exception:
|
|
300
|
+
return None
|