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/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