j-perm 0.2.1.1__py3-none-any.whl → 0.2.3__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/constructs/ref.py CHANGED
@@ -4,17 +4,16 @@ import copy
4
4
  from typing import Mapping, Any
5
5
 
6
6
  from ..special_resolver import _MISSING, SpecialRegistry
7
- from ..utils.pointers import maybe_slice
8
7
 
9
8
 
10
9
  @SpecialRegistry.register("$ref")
11
10
  def of_ref(node: Mapping[str, Any], src: Mapping[str, Any], engine: "ActionEngine") -> Any:
12
11
  # Expand templates inside "$ref" using configured substitutor
13
- ptr = engine.substitutor.substitute(node["$ref"], src)
12
+ ptr = engine.substitutor.substitute(node["$ref"], src, engine)
14
13
 
15
14
  dflt = node.get("$default", _MISSING)
16
15
  try:
17
- return copy.deepcopy(maybe_slice(ptr, src))
16
+ return copy.deepcopy(engine.pointer_manager.maybe_slice(ptr, src))
18
17
  except Exception:
19
18
  if dflt is not _MISSING:
20
19
  return copy.deepcopy(dflt)
j_perm/engine.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any, List, Mapping, TypeAlias, MutableMapping, Union
6
6
 
7
7
  from .normalizer import Normalizer
8
8
  from .op_handler import Handlers
9
+ from .pointers import PointerManager
9
10
  from .special_resolver import SpecialResolver
10
11
  from .subst import TemplateSubstitutor
11
12
 
@@ -48,6 +49,9 @@ class ActionEngine:
48
49
  special: SpecialResolver = field(default_factory=SpecialResolver)
49
50
  substitutor: TemplateSubstitutor = field(default_factory=TemplateSubstitutor)
50
51
  normalizer: Normalizer = field(default_factory=Normalizer)
52
+ pointer_manager: PointerManager = field(default_factory=PointerManager)
53
+
54
+ max_depth: int = 50
51
55
 
52
56
  def apply_actions(
53
57
  self,
j_perm/normalizer.py CHANGED
@@ -19,6 +19,7 @@ class ShorthandRegistry:
19
19
  cls._rules[name] = fn
20
20
  cls._default_priority[name] = priority
21
21
  return fn
22
+
22
23
  return deco
23
24
 
24
25
  @classmethod
j_perm/ops/_assert.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
5
  from ..op_handler import OpRegistry
6
- from ..utils.pointers import jptr_get
7
6
 
8
7
 
9
8
  @OpRegistry.register("assert")
@@ -14,10 +13,10 @@ def op_assert(
14
13
  engine: "ActionEngine",
15
14
  ) -> MutableMapping[str, Any]:
16
15
  """Assert node existence and/or value at JSON Pointer path in dest."""
17
- path = engine.substitutor.substitute(step["path"], src)
16
+ path = engine.substitutor.substitute(step["path"], src, engine)
18
17
 
19
18
  try:
20
- current = jptr_get(src, path)
19
+ current = engine.pointer_manager.get_pointer(src, path)
21
20
  except Exception:
22
21
  raise AssertionError(f"'{path}' does not exist in source")
23
22
 
j_perm/ops/_assert_d.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
5
  from ..op_handler import OpRegistry
6
- from ..utils.pointers import jptr_get
7
6
 
8
7
 
9
8
  @OpRegistry.register("assertD")
@@ -14,10 +13,10 @@ def op_assert_d(
14
13
  engine: "ActionEngine",
15
14
  ) -> MutableMapping[str, Any]:
16
15
  """Assert node existence and/or value at JSON Pointer path in dest."""
17
- path = engine.substitutor.substitute(step["path"], dest)
16
+ path = engine.substitutor.substitute(step["path"], dest, engine)
18
17
 
19
18
  try:
20
- current = jptr_get(dest, path)
19
+ current = engine.pointer_manager.get_pointer(dest, path)
21
20
  except Exception:
22
21
  raise AssertionError(f"'{path}' does not exist in destination")
23
22
 
j_perm/ops/_exec.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
5
  from ..op_handler import OpRegistry
6
- from ..utils.pointers import maybe_slice
7
6
 
8
7
 
9
8
  @OpRegistry.register("exec")
@@ -24,20 +23,20 @@ def op_exec(
24
23
  raise ValueError("exec operation requires either 'from' or 'actions' parameter")
25
24
 
26
25
  if has_from:
27
- actions_ptr = engine.substitutor.substitute(step["from"], src)
26
+ actions_ptr = engine.substitutor.substitute(step["from"], src, engine)
28
27
  try:
29
- actions = maybe_slice(actions_ptr, src)
28
+ actions = engine.pointer_manager.maybe_slice(actions_ptr, src)
30
29
  except Exception:
31
30
  if "default" in step:
32
31
  actions = engine.special.resolve(step["default"], src, engine)
33
32
  if isinstance(actions, (str, list, dict)):
34
- actions = engine.substitutor.substitute(actions, src)
33
+ actions = engine.substitutor.substitute(actions, src, engine)
35
34
  else:
36
35
  raise ValueError(f"Cannot find actions at {actions_ptr}")
37
36
  else:
38
37
  actions = engine.special.resolve(step["actions"], src, engine)
39
38
  if isinstance(actions, (str, list, dict)):
40
- actions = engine.substitutor.substitute(actions, src)
39
+ actions = engine.substitutor.substitute(actions, src, engine)
41
40
 
42
41
  merge = bool(step.get("merge", False))
43
42
 
j_perm/ops/_if.py CHANGED
@@ -4,7 +4,6 @@ import copy
4
4
  from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from ..op_handler import OpRegistry
7
- from ..utils.pointers import maybe_slice
8
7
 
9
8
 
10
9
  @OpRegistry.register("if")
@@ -17,22 +16,22 @@ def op_if(
17
16
  """Conditionally execute nested actions based on a path or expression."""
18
17
  if "path" in step:
19
18
  try:
20
- ptr = engine.substitutor.substitute(step["path"], src)
21
- current = maybe_slice(ptr, dest)
19
+ ptr = engine.substitutor.substitute(step["path"], src, engine)
20
+ current = engine.pointer_manager.maybe_slice(ptr, dest)
22
21
  missing = False
23
22
  except Exception:
24
23
  current = None
25
24
  missing = True
26
25
 
27
26
  if "equals" in step:
28
- expected = engine.substitutor.substitute(step["equals"], src)
27
+ expected = engine.substitutor.substitute(step["equals"], src, engine)
29
28
  cond_val = current == expected and not missing
30
29
  elif step.get("exists"):
31
30
  cond_val = not missing
32
31
  else:
33
32
  cond_val = bool(current) and not missing
34
33
  else:
35
- raw_cond = engine.substitutor.substitute(step.get("cond"), src)
34
+ raw_cond = engine.substitutor.substitute(step.get("cond"), src, engine)
36
35
  cond_val = bool(raw_cond)
37
36
 
38
37
  branch_key = "then" if cond_val else "else"
j_perm/ops/copy.py CHANGED
@@ -5,7 +5,6 @@ from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from .set import op_set
7
7
  from ..op_handler import OpRegistry
8
- from ..utils.pointers import maybe_slice
9
8
 
10
9
 
11
10
  @OpRegistry.register("copy")
@@ -16,15 +15,15 @@ def op_copy(
16
15
  engine: "ActionEngine",
17
16
  ) -> MutableMapping[str, Any]:
18
17
  """Copy value from source pointer into dest path."""
19
- path = engine.substitutor.substitute(step["path"], src)
18
+ path = engine.substitutor.substitute(step["path"], src, engine)
20
19
  create = bool(step.get("create", True))
21
20
  extend_list = bool(step.get("extend", True))
22
21
 
23
- ptr = engine.substitutor.substitute(step["from"], src)
22
+ ptr = engine.substitutor.substitute(step["from"], src, engine)
24
23
  ignore = bool(step.get("ignore_missing", False))
25
24
 
26
25
  try:
27
- value = copy.deepcopy(maybe_slice(ptr, src))
26
+ value = copy.deepcopy(engine.pointer_manager.maybe_slice(ptr, src))
28
27
  except Exception:
29
28
  if "default" in step:
30
29
  value = copy.deepcopy(step["default"])
j_perm/ops/copy_d.py CHANGED
@@ -5,7 +5,6 @@ from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from .set import op_set
7
7
  from ..op_handler import OpRegistry
8
- from ..utils.pointers import maybe_slice
9
8
 
10
9
 
11
10
  @OpRegistry.register("copyD")
@@ -16,14 +15,14 @@ def op_copy_d(
16
15
  engine: "ActionEngine",
17
16
  ) -> MutableMapping[str, Any]:
18
17
  """Copy value from dest (self) into another dest path."""
19
- path = engine.substitutor.substitute(step["path"], src)
18
+ path = engine.substitutor.substitute(step["path"], src, engine)
20
19
  create = bool(step.get("create", True))
21
20
 
22
- ptr = engine.substitutor.substitute(step["from"], dest)
21
+ ptr = engine.substitutor.substitute(step["from"], dest, engine)
23
22
  ignore = bool(step.get("ignore_missing", False))
24
23
 
25
24
  try:
26
- value = copy.deepcopy(maybe_slice(ptr, dest))
25
+ value = copy.deepcopy(engine.pointer_manager.maybe_slice(ptr, dest))
27
26
  except Exception:
28
27
  if "default" in step:
29
28
  value = copy.deepcopy(step["default"])
j_perm/ops/delete.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
5
  from ..op_handler import OpRegistry
6
- from ..utils.pointers import jptr_ensure_parent
7
6
 
8
7
 
9
8
  @OpRegistry.register("delete")
@@ -14,11 +13,11 @@ def op_delete(
14
13
  engine: "ActionEngine",
15
14
  ) -> MutableMapping[str, Any]:
16
15
  """Delete node at the given JSON Pointer path in dest."""
17
- path = engine.substitutor.substitute(step["path"], src)
16
+ path = engine.substitutor.substitute(step["path"], src, engine)
18
17
  ignore = bool(step.get("ignore_missing", True))
19
18
 
20
19
  try:
21
- parent, leaf = jptr_ensure_parent(dest, path, create=False)
20
+ parent, leaf = engine.pointer_manager.ensure_parent(dest, path, create=False)
22
21
 
23
22
  if leaf == "-":
24
23
  raise ValueError("'-' not allowed in delete")
j_perm/ops/distinct.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
5
  from ..op_handler import OpRegistry
6
- from ..utils.pointers import jptr_get
7
6
 
8
7
 
9
8
  @OpRegistry.register("distinct")
@@ -14,20 +13,20 @@ def op_distinct(
14
13
  engine: "ActionEngine",
15
14
  ) -> MutableMapping[str, Any]:
16
15
  """Remove duplicates from a list at the given path, preserving order."""
17
- path = engine.substitutor.substitute(step["path"], src)
18
- lst = jptr_get(dest, path)
16
+ path = engine.substitutor.substitute(step["path"], src, engine)
17
+ lst = engine.pointer_manager.get_pointer(dest, path)
19
18
 
20
19
  if not isinstance(lst, list):
21
20
  raise TypeError(f"{path} is not a list (distinct)")
22
21
 
23
22
  key = step.get("key", None)
24
- key_path = engine.substitutor.substitute(key, src)
23
+ key_path = engine.substitutor.substitute(key, src, engine)
25
24
 
26
25
  seen = set()
27
26
  unique = []
28
27
  for item in lst:
29
28
  if key is not None:
30
- filter_item = jptr_get(item, key_path)
29
+ filter_item = engine.pointer_manager.get_pointer(item, key_path)
31
30
  else:
32
31
  filter_item = item
33
32
 
j_perm/ops/foreach.py CHANGED
@@ -4,7 +4,6 @@ import copy
4
4
  from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from ..op_handler import OpRegistry
7
- from ..utils.pointers import maybe_slice
8
7
 
9
8
 
10
9
  @OpRegistry.register("foreach")
@@ -15,13 +14,13 @@ def op_foreach(
15
14
  engine: "ActionEngine",
16
15
  ) -> MutableMapping[str, Any]:
17
16
  """Iterate over array in source and execute nested actions for each element."""
18
- arr_ptr = engine.substitutor.substitute(step["in"], src)
17
+ arr_ptr = engine.substitutor.substitute(step["in"], src, engine)
19
18
 
20
19
  default = copy.deepcopy(step.get("default", []))
21
20
  skip_empty = bool(step.get("skip_empty", True))
22
21
 
23
22
  try:
24
- arr = maybe_slice(arr_ptr, src)
23
+ arr = engine.pointer_manager.maybe_slice(arr_ptr, src)
25
24
  except Exception:
26
25
  arr = default
27
26
 
@@ -10,5 +10,5 @@ def op_replace_root(step, dest, src, engine):
10
10
  """Replace the whole dest root value with the resolved special value."""
11
11
  value = engine.special.resolve(step["value"], src, engine)
12
12
  if isinstance(value, (str, list, dict)):
13
- value = engine.substitutor.substitute(value, src)
13
+ value = engine.substitutor.substitute(value, src, engine)
14
14
  return copy.deepcopy(value)
j_perm/ops/set.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import Any, Mapping, MutableMapping
4
4
 
5
5
  from ..op_handler import OpRegistry
6
- from ..utils.pointers import jptr_ensure_parent
7
6
 
8
7
 
9
8
  @OpRegistry.register("set")
@@ -14,20 +13,20 @@ def op_set(
14
13
  engine: "ActionEngine",
15
14
  ) -> MutableMapping[str, Any]:
16
15
  """Set or append a value at JSON Pointer path in dest."""
17
- path = engine.substitutor.substitute(step["path"], src)
16
+ path = engine.substitutor.substitute(step["path"], src, engine)
18
17
  create = bool(step.get("create", True))
19
18
  extend_list = bool(step.get("extend", True))
20
19
 
21
20
  value = engine.special.resolve(step["value"], src, engine)
22
21
  if isinstance(value, (str, list, Mapping)):
23
- value = engine.substitutor.substitute(value, src)
22
+ value = engine.substitutor.substitute(value, src, engine)
24
23
 
25
- parent, leaf = jptr_ensure_parent(dest, path, create=create)
24
+ parent, leaf = engine.pointer_manager.ensure_parent(dest, path, create=create)
26
25
 
27
26
  if leaf == "-":
28
27
  if not isinstance(parent, list):
29
28
  if create:
30
- grand, last = jptr_ensure_parent(dest, path.rsplit("/", 1)[0], create=True)
29
+ grand, last = engine.pointer_manager.ensure_parent(dest, path.rsplit("/", 1)[0], create=True)
31
30
  if not isinstance(grand[last], list):
32
31
  if grand[last] == {}:
33
32
  grand[last] = []
j_perm/ops/update.py CHANGED
@@ -4,7 +4,6 @@ import copy
4
4
  from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from ..op_handler import OpRegistry
7
- from ..utils.pointers import maybe_slice, jptr_ensure_parent
8
7
 
9
8
 
10
9
  @OpRegistry.register("update")
@@ -15,14 +14,14 @@ def op_update(
15
14
  engine: "ActionEngine",
16
15
  ) -> MutableMapping[str, Any]:
17
16
  """Update a mapping at the given path using a mapping from source or inline value."""
18
- path = engine.substitutor.substitute(step["path"], src)
17
+ path = engine.substitutor.substitute(step["path"], src, engine)
19
18
  create = bool(step.get("create", True))
20
19
  deep = bool(step.get("deep", False))
21
20
 
22
21
  if "from" in step:
23
- ptr = engine.substitutor.substitute(step["from"], src)
22
+ ptr = engine.substitutor.substitute(step["from"], src, engine)
24
23
  try:
25
- update_value = copy.deepcopy(maybe_slice(ptr, src))
24
+ update_value = copy.deepcopy(engine.pointer_manager.maybe_slice(ptr, src))
26
25
  except Exception:
27
26
  if "default" in step:
28
27
  update_value = copy.deepcopy(step["default"])
@@ -31,7 +30,7 @@ def op_update(
31
30
  elif "value" in step:
32
31
  update_value = engine.special.resolve(step["value"], src, engine)
33
32
  if isinstance(update_value, (str, list, Mapping)):
34
- update_value = engine.substitutor.substitute(update_value, src)
33
+ update_value = engine.substitutor.substitute(update_value, src, engine)
35
34
  else:
36
35
  raise ValueError("update operation requires either 'from' or 'value' parameter")
37
36
 
j_perm/pointers.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Mapping, MutableMapping, List, Tuple
5
+
6
+
7
+ class PointerManager:
8
+ _SLICE_RE = re.compile(r"(.+)\[(-?\d*):(-?\d*)]$")
9
+
10
+ def _decode(self, tok: str) -> str:
11
+ """Decode a single JSON Pointer token, handling RFC6901-style escapes."""
12
+ return (
13
+ tok.replace("~0", "~")
14
+ .replace("~1", "/")
15
+ .replace("~2", "$")
16
+ .replace("~3", ".")
17
+ )
18
+
19
+ def get_pointer(self, doc: Any, ptr: str) -> Any:
20
+ """Read value by JSON Pointer, supporting root and '..' segments."""
21
+ if ptr in ("", "/", "."):
22
+ return doc
23
+
24
+ tokens = ptr.lstrip("/").split("/")
25
+ cur: Any = doc
26
+ parents: List[Tuple[Any, Any]] = []
27
+
28
+ for raw_tok in tokens:
29
+ if raw_tok == "..":
30
+ if parents:
31
+ cur, _ = parents.pop()
32
+ else:
33
+ cur = doc
34
+ continue
35
+
36
+ key = self._decode(raw_tok)
37
+
38
+ if isinstance(cur, (list, tuple)):
39
+ idx = int(key)
40
+ parents.append((cur, idx))
41
+ cur = cur[idx]
42
+ else:
43
+ parents.append((cur, key))
44
+ cur = cur[key]
45
+
46
+ return cur
47
+
48
+ def maybe_slice(self, ptr: str, src: Mapping[str, Any]) -> Any:
49
+ """Resolve a pointer and optional Python-style slice suffix '[start:end]' for arrays."""
50
+ m = self._SLICE_RE.match(ptr)
51
+ if m:
52
+ base, s, e = m.groups()
53
+ seq = self.get_pointer(src, base)
54
+ if not isinstance(seq, (list, tuple)):
55
+ raise TypeError(f"{base} is not a list (slice requested)")
56
+
57
+ start = int(s) if s else None
58
+ end = int(e) if e else None
59
+ return seq[start:end]
60
+
61
+ return self.get_pointer(src, ptr)
62
+
63
+ def ensure_parent(
64
+ self,
65
+ doc: MutableMapping[str, Any],
66
+ ptr: str,
67
+ *,
68
+ create: bool = False,
69
+ ):
70
+ """Return (container, leaf_key) for ptr, optionally creating intermediate nodes."""
71
+ raw_parts = ptr.lstrip("/").split("/")
72
+ parts: List[str] = []
73
+
74
+ for raw in raw_parts:
75
+ if raw == "..":
76
+ if parts:
77
+ parts.pop()
78
+ continue
79
+ parts.append(raw)
80
+
81
+ if not parts:
82
+ return doc, ""
83
+
84
+ cur: Any = doc
85
+
86
+ for raw in parts[:-1]:
87
+ token = self._decode(raw)
88
+
89
+ if isinstance(cur, list):
90
+ idx = int(token)
91
+ if idx >= len(cur):
92
+ if create:
93
+ while idx >= len(cur):
94
+ cur.append({})
95
+ else:
96
+ raise IndexError(f"{ptr}: index {idx} out of range")
97
+ cur = cur[idx]
98
+ else:
99
+ if token not in cur:
100
+ if create:
101
+ cur[token] = {}
102
+ else:
103
+ raise KeyError(f"{ptr}: missing key '{token}'")
104
+ cur = cur[token]
105
+
106
+ leaf = self._decode(parts[-1])
107
+ return cur, leaf
j_perm/subst.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import copy
4
3
  import json
5
4
  from dataclasses import dataclass, field
6
5
  from typing import Any, Callable, Mapping
@@ -8,8 +7,6 @@ from typing import Any, Callable, Mapping
8
7
  import jmespath
9
8
  from jmespath import functions as _jp_funcs
10
9
 
11
- from j_perm.utils.pointers import maybe_slice
12
-
13
10
  # =============================================================================
14
11
  # Types
15
12
  # =============================================================================
@@ -186,21 +183,30 @@ class TemplateSubstitutor:
186
183
  # Public API
187
184
  # -------------------------------------------------------------------------
188
185
 
189
- def substitute(self, obj: Any, data: Mapping[str, Any]) -> Any:
190
- return self.deep_substitute(obj, data)
186
+ def substitute(self, obj: Any, data: Mapping[str, Any], engine: "ActionEngine") -> Any:
187
+ res = self.deep_substitute(obj, data, engine)
188
+ return self._final_unescape(res)
191
189
 
192
- def flat_substitute(self, tmpl: str, data: Mapping[str, Any]) -> Any:
193
- if "${" not in tmpl:
190
+ def flat_substitute(self, tmpl: str, data: Mapping[str, Any], engine: "ActionEngine") -> Any:
191
+ if not self._has_unescaped_placeholder(tmpl):
194
192
  return tmpl
195
193
 
196
- if tmpl.startswith("${") and tmpl.endswith("}"):
197
- body = tmpl[2:-1]
198
- return copy.deepcopy(self._resolve_expr(body, data))
199
-
200
194
  out: list[str] = []
201
195
  i = 0
202
196
 
203
197
  while i < len(tmpl):
198
+ # literal "${"
199
+ if tmpl[i:i + 3] == "$${":
200
+ out.append("$${")
201
+ i += 3
202
+ continue
203
+
204
+ # literal "$"
205
+ if tmpl[i:i + 2] == "$$":
206
+ out.append("$$")
207
+ i += 2
208
+ continue
209
+
204
210
  if tmpl[i: i + 2] == "${":
205
211
  depth = 0
206
212
  j = i + 2
@@ -213,7 +219,7 @@ class TemplateSubstitutor:
213
219
  elif ch == "}":
214
220
  if depth == 0:
215
221
  expr = tmpl[i + 2: j]
216
- val = self._resolve_expr(expr, data)
222
+ val = self._resolve_expr(expr, data, engine)
217
223
 
218
224
  if isinstance(val, (Mapping, list)):
219
225
  rendered = json.dumps(val, ensure_ascii=False)
@@ -236,29 +242,29 @@ class TemplateSubstitutor:
236
242
 
237
243
  return "".join(out)
238
244
 
239
- def deep_substitute(self, obj: Any, data: Mapping[str, Any], _depth: int = 0) -> Any:
240
- if _depth > 50:
245
+ def deep_substitute(self, obj: Any, data: Mapping[str, Any], engine: "ActionEngine", _depth: int = 0) -> Any:
246
+ if _depth > engine.max_depth:
241
247
  raise RecursionError("too deep interpolation")
242
248
 
243
249
  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)
250
+ out = self.flat_substitute(obj, data, engine)
251
+ if isinstance(out, str) and self._has_unescaped_placeholder(out):
252
+ return self.deep_substitute(out, data, engine, _depth + 1)
247
253
  return out
248
254
 
249
255
  if isinstance(obj, list):
250
- return [self.deep_substitute(item, data, _depth) for item in obj]
256
+ return [self.deep_substitute(item, data, engine, _depth) for item in obj]
251
257
 
252
258
  if isinstance(obj, tuple):
253
- return [self.deep_substitute(item, data, _depth) for item in obj]
259
+ return [self.deep_substitute(item, data, engine, _depth) for item in obj]
254
260
 
255
261
  if isinstance(obj, Mapping):
256
262
  out: dict[Any, Any] = {}
257
263
  for k, v in obj.items():
258
- new_key = self.deep_substitute(k, data, _depth) if isinstance(k, str) else k
264
+ new_key = self.deep_substitute(k, data, engine, _depth) if isinstance(k, str) else k
259
265
  if new_key in out:
260
266
  raise KeyError(f"duplicate key after substitution: {new_key!r}")
261
- out[new_key] = self.deep_substitute(v, data, _depth)
267
+ out[new_key] = self.deep_substitute(v, data, engine, _depth)
262
268
  return out
263
269
 
264
270
  return obj
@@ -267,7 +273,33 @@ class TemplateSubstitutor:
267
273
  # Internals
268
274
  # -------------------------------------------------------------------------
269
275
 
270
- def _resolve_expr(self, expr: str, data: Mapping[str, Any]) -> Any:
276
+ def _final_unescape(self, obj: Any) -> Any:
277
+ if isinstance(obj, str):
278
+ return obj.replace("$${", "${").replace("$$", "$")
279
+ if isinstance(obj, list):
280
+ return [self._final_unescape(x) for x in obj]
281
+ if isinstance(obj, tuple):
282
+ return tuple(self._final_unescape(x) for x in obj)
283
+ if isinstance(obj, Mapping):
284
+ return {self._final_unescape(k) if isinstance(k, str) else k: self._final_unescape(v)
285
+ for k, v in obj.items()}
286
+ return obj
287
+
288
+ @staticmethod
289
+ def _has_unescaped_placeholder(s: str) -> bool:
290
+ i = 0
291
+ while True:
292
+ j = s.find("${", i)
293
+ if j == -1:
294
+ return False
295
+ if j > 0 and s[j - 1] == "$":
296
+ i = j + 2
297
+ continue
298
+ return True
299
+
300
+ return False
301
+
302
+ def _resolve_expr(self, expr: str, data: Mapping[str, Any], engine: "ActionEngine") -> Any:
271
303
  expr = expr.strip()
272
304
 
273
305
  # 1) Casters
@@ -275,26 +307,26 @@ class TemplateSubstitutor:
275
307
  tag = f"{prefix}:"
276
308
  if expr.startswith(tag):
277
309
  inner = expr[len(tag):]
278
- value = self.flat_substitute(inner, data)
310
+ value = self.flat_substitute(inner, data, engine)
279
311
 
280
- if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
281
- value = self.flat_substitute(value, data)
312
+ if isinstance(value, str) and self._has_unescaped_placeholder(value):
313
+ value = self.flat_substitute(value, data, engine)
282
314
 
283
315
  return fn(value)
284
316
 
285
317
  # 2) JMESPath
286
318
  if expr.startswith("?"):
287
319
  query_raw = expr[1:].lstrip()
288
- query_expanded = self.flat_substitute(query_raw, data)
320
+ query_expanded = self.flat_substitute(query_raw, data, engine)
289
321
  return jmespath.search(query_expanded, data, options=self._jp_options)
290
322
 
291
323
  # 3) Nested template
292
- if expr.startswith("${") and expr.endswith("}"):
293
- return self.flat_substitute(expr, data)
324
+ if self._has_unescaped_placeholder(expr):
325
+ return self.flat_substitute(expr, data, engine)
294
326
 
295
327
  # 4) JSON Pointer fallback
296
328
  pointer = "/" + expr.lstrip("/")
297
329
  try:
298
- return maybe_slice(pointer, data) # type: ignore[arg-type]
330
+ return engine.pointer_manager.maybe_slice(pointer, data)
299
331
  except Exception:
300
332
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: j-perm
3
- Version: 0.2.1.1
3
+ Version: 0.2.3
4
4
  Summary: json permutation library
5
5
  Author-email: Roman <kuschanow@gmail.com>
6
6
  License: MIT
@@ -799,8 +799,8 @@ def my_op(step, dest, src, engine):
799
799
  from j_perm import SpecialRegistry
800
800
 
801
801
  @SpecialRegistry.register("$upper")
802
- def sp_upper(node, src, resolver):
803
- value = resolver.substitutor.substitute(node["$upper"], src)
802
+ def sp_upper(node, src, resolver, engine):
803
+ value = resolver.substitutor.substitute(node["$upper"], src, engine)
804
804
  return str(value).upper()
805
805
  ```
806
806
 
@@ -0,0 +1,39 @@
1
+ j_perm/__init__.py,sha256=81aLdcmlUg51R6xR-VXVDDUeM87ezy3ZuiLpblHZ_wk,873
2
+ j_perm/engine.py,sha256=ETrLMr2VecSpiT8Ai9AL_-TW6sAlSQ-1Jpf7lttHNPY,4061
3
+ j_perm/normalizer.py,sha256=zxWtARRNuzS-zgFuB40dqU0oaUUk8PLKJNOPNb5wbRw,2672
4
+ j_perm/op_handler.py,sha256=h28thz_18DwZTb9IZtTd6kfsZj7hN4UsmU4uf3aMYpo,1649
5
+ j_perm/pointers.py,sha256=Czhq9vre_HnxhT1iOs8cDcKdCvEum5_YdvaEzoZ3iTY,3205
6
+ j_perm/special_resolver.py,sha256=khRVWXrE3Vch1gbxsfFwj9JQ37pZ-_ojMeh-G2Vv7bs,4032
7
+ j_perm/subst.py,sha256=I1O9qdmJp9TYjpfnqOgyIgL064hWGCmsGoxCbFWCBL4,11026
8
+ j_perm/casters/__init__.py,sha256=mD49oMEyj6oDfSKXEJ8DbnmqkSypfEEg3VMI_rfaK-4,114
9
+ j_perm/casters/_bool.py,sha256=qRsT7IcFBApD09elKUyUUaZmfePSIsOsKQHQHsgnHsw,230
10
+ j_perm/casters/_float.py,sha256=C4r-h6zbkjmQnoEdE2RDMhSkW9oGrvxbQngJZ2T7_tU,153
11
+ j_perm/casters/_int.py,sha256=f1BKqsULiG6Puov1qcGS_inSnv0hZtQ0deQEMP0v0i4,145
12
+ j_perm/casters/_str.py,sha256=v1_BD1_hPbOtvO5CKYAlNEGbMhpR56eKCnV2a2Q4kGU,145
13
+ j_perm/constructs/__init__.py,sha256=TTUPSMyvj8HSfPikrFqXaFsechc9aOIbjtVjy4TOolE,50
14
+ j_perm/constructs/eval.py,sha256=UzjZK27UAzsl3s26Y3RWBPhuKX1Az0fK6xk-cgpS8pQ,452
15
+ j_perm/constructs/ref.py,sha256=WgzEo54IhGWvBVSwPGTeqCRLDyK5uPJ01p5Wjd1YdNM,634
16
+ j_perm/funcs/__init__.py,sha256=8yIafxhCH5B7jXrsautQEtl4Qt8RcUK7IWNniORj9ow,31
17
+ j_perm/funcs/subtract.py,sha256=8W49TsrnIU4EVjS8tWQY2GXlX2nAaQ1O9O1JlrVYdIw,288
18
+ j_perm/ops/__init__.py,sha256=j_U4Aq0iwEvZOF0jqMmlQA0NisJsHNJWzqkfyMWuHFA,364
19
+ j_perm/ops/_assert.py,sha256=vXsQBNIBQYLNSrO6u-vg4_oavvK5N_u-MAc8AqnISBk,769
20
+ j_perm/ops/_assert_d.py,sha256=sG1DC-ElJi54EN2LPzzbvw_5jtOoR1w6j_Ml3mO5Gwo,779
21
+ j_perm/ops/_exec.py,sha256=e3TCFN7bzJ_mjPbpm4ZZq0Dbz-YoJhOiHH1Dt4PwY3k,1919
22
+ j_perm/ops/_if.py,sha256=QziuiEOSkcOMMbyHm_C9IYRhybdtqiLdtQSBbyEL6To,1596
23
+ j_perm/ops/copy.py,sha256=XA5EysIAbbX7hrS1aeN7MidiX_8YyaeTjsZCKoMYbT0,1108
24
+ j_perm/ops/copy_d.py,sha256=iTlZ6lE1gMMcCaUjVWhbXHpBxd97OLXM9Ml5SP0Xvbw,1015
25
+ j_perm/ops/delete.py,sha256=YwPyMxPZ2LnCVC__bivvDLxlsvT2mnrmNIH7Qgw7bjk,894
26
+ j_perm/ops/distinct.py,sha256=eL7Ja4YNVCpsrgiuMxew2xqO3pLitJyAI3ygzy3hSVs,1066
27
+ j_perm/ops/foreach.py,sha256=zbELdguV5He_7nBoekaFsn6dsqBQuRzCoPIB44weUbE,1415
28
+ j_perm/ops/replace_root.py,sha256=klPY9E3KsPfnswIM4CdX_JPXGE1WWjr0sXSXSwn3ooo,453
29
+ j_perm/ops/set.py,sha256=YJjS1BXEEdPxFbUcJGu4YZPt0HHsk9cixpk0OnVQB4E,1886
30
+ j_perm/ops/update.py,sha256=9ouXm4ICNBqr03gJXUvV78AiytgkA4K2Fwb648r8rhE,2457
31
+ j_perm/schema/__init__.py,sha256=mhEbGdi1MgLeEm1IL8-tkTtbIdrdlvQi22sBr7vCUqg,3392
32
+ j_perm/shorthands/__init__.py,sha256=zvsu15iExYtUrct6Ob4IRs6Ao46PoZaCKVIUhMBY4Dk,117
33
+ j_perm/shorthands/_assert.py,sha256=iRvuUGlN5Bj9G5Bo-aXUa4MQujgmrR3N-PhDeee6xcY,555
34
+ j_perm/shorthands/assign_or_append.py,sha256=TsSzaQXb5qoKtY6tvx_skHob-695Zii5roUfXltznAQ,621
35
+ j_perm/shorthands/delete.py,sha256=PZeTRcP6-Q3SdbdyruWWXtMJ9mTnz1CBCHiOpHNI06Q,311
36
+ j_perm-0.2.3.dist-info/METADATA,sha256=VC5n1B4bYvfhZ2Yl6m-MPlVknNUZESOjt9z4R9MgkkU,17674
37
+ j_perm-0.2.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
38
+ j_perm-0.2.3.dist-info/top_level.txt,sha256=yveBxREqVn9DliNwmen1QWoxR0uta7bU5giS1CCffRI,7
39
+ j_perm-0.2.3.dist-info/RECORD,,
j_perm/utils/__init__.py DELETED
File without changes
j_perm/utils/pointers.py DELETED
@@ -1,108 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from typing import Any, Mapping, MutableMapping, List, Tuple
5
-
6
- _SLICE_RE = re.compile(r"(.+)\[(-?\d*):(-?\d*)]$")
7
-
8
-
9
- def _decode(tok: str) -> str:
10
- """Decode a single JSON Pointer token, handling RFC6901-style escapes."""
11
- return (
12
- tok.replace("~0", "~")
13
- .replace("~1", "/")
14
- .replace("~2", "$")
15
- .replace("~3", ".")
16
- )
17
-
18
-
19
- def jptr_get(doc: Any, ptr: str) -> Any:
20
- """Read value by JSON Pointer, supporting root and '..' segments."""
21
- if ptr in ("", "/", "."):
22
- return doc
23
-
24
- tokens = ptr.lstrip("/").split("/")
25
- cur: Any = doc
26
- parents: List[Tuple[Any, Any]] = []
27
-
28
- for raw_tok in tokens:
29
- if raw_tok == "..":
30
- if parents:
31
- cur, _ = parents.pop()
32
- else:
33
- cur = doc
34
- continue
35
-
36
- key = _decode(raw_tok)
37
-
38
- if isinstance(cur, (list, tuple)):
39
- idx = int(key)
40
- parents.append((cur, idx))
41
- cur = cur[idx]
42
- else:
43
- parents.append((cur, key))
44
- cur = cur[key]
45
-
46
- return cur
47
-
48
-
49
- def maybe_slice(ptr: str, src: Mapping[str, Any]) -> Any:
50
- """Resolve a pointer and optional Python-style slice suffix '[start:end]' for arrays."""
51
- m = _SLICE_RE.match(ptr)
52
- if m:
53
- base, s, e = m.groups()
54
- seq = jptr_get(src, base)
55
- if not isinstance(seq, (list, tuple)):
56
- raise TypeError(f"{base} is not a list (slice requested)")
57
-
58
- start = int(s) if s else None
59
- end = int(e) if e else None
60
- return seq[start:end]
61
-
62
- return jptr_get(src, ptr)
63
-
64
-
65
- def jptr_ensure_parent(
66
- doc: MutableMapping[str, Any],
67
- ptr: str,
68
- *,
69
- create: bool = False,
70
- ):
71
- """Return (container, leaf_key) for ptr, optionally creating intermediate nodes."""
72
- raw_parts = ptr.lstrip("/").split("/")
73
- parts: List[str] = []
74
-
75
- for raw in raw_parts:
76
- if raw == "..":
77
- if parts:
78
- parts.pop()
79
- continue
80
- parts.append(raw)
81
-
82
- if not parts:
83
- return doc, ""
84
-
85
- cur: Any = doc
86
-
87
- for raw in parts[:-1]:
88
- token = _decode(raw)
89
-
90
- if isinstance(cur, list):
91
- idx = int(token)
92
- if idx >= len(cur):
93
- if create:
94
- while idx >= len(cur):
95
- cur.append({})
96
- else:
97
- raise IndexError(f"{ptr}: index {idx} out of range")
98
- cur = cur[idx]
99
- else:
100
- if token not in cur:
101
- if create:
102
- cur[token] = {}
103
- else:
104
- raise KeyError(f"{ptr}: missing key '{token}'")
105
- cur = cur[token]
106
-
107
- leaf = _decode(parts[-1])
108
- return cur, leaf
@@ -1,40 +0,0 @@
1
- j_perm/__init__.py,sha256=81aLdcmlUg51R6xR-VXVDDUeM87ezy3ZuiLpblHZ_wk,873
2
- j_perm/engine.py,sha256=iaUxB7EWcqvWsP4Z8z49a6-atX9br1tTC-WlZVrVo-A,3923
3
- j_perm/normalizer.py,sha256=50pff5j3sTrCu2Fp_SW6m99qwt88CladpYlWpQ3c-ac,2671
4
- j_perm/op_handler.py,sha256=h28thz_18DwZTb9IZtTd6kfsZj7hN4UsmU4uf3aMYpo,1649
5
- j_perm/special_resolver.py,sha256=khRVWXrE3Vch1gbxsfFwj9JQ37pZ-_ojMeh-G2Vv7bs,4032
6
- j_perm/subst.py,sha256=oW21Gs2nTEQLcOC_xMKzeuZFtfNvnB2QZAIWl7U4TSg,9802
7
- j_perm/casters/__init__.py,sha256=mD49oMEyj6oDfSKXEJ8DbnmqkSypfEEg3VMI_rfaK-4,114
8
- j_perm/casters/_bool.py,sha256=qRsT7IcFBApD09elKUyUUaZmfePSIsOsKQHQHsgnHsw,230
9
- j_perm/casters/_float.py,sha256=C4r-h6zbkjmQnoEdE2RDMhSkW9oGrvxbQngJZ2T7_tU,153
10
- j_perm/casters/_int.py,sha256=f1BKqsULiG6Puov1qcGS_inSnv0hZtQ0deQEMP0v0i4,145
11
- j_perm/casters/_str.py,sha256=v1_BD1_hPbOtvO5CKYAlNEGbMhpR56eKCnV2a2Q4kGU,145
12
- j_perm/constructs/__init__.py,sha256=TTUPSMyvj8HSfPikrFqXaFsechc9aOIbjtVjy4TOolE,50
13
- j_perm/constructs/eval.py,sha256=UzjZK27UAzsl3s26Y3RWBPhuKX1Az0fK6xk-cgpS8pQ,452
14
- j_perm/constructs/ref.py,sha256=GtR2hpENev9k1lsx3mHGM8956vBfrPUgHm3IydYr-_I,644
15
- j_perm/funcs/__init__.py,sha256=8yIafxhCH5B7jXrsautQEtl4Qt8RcUK7IWNniORj9ow,31
16
- j_perm/funcs/subtract.py,sha256=8W49TsrnIU4EVjS8tWQY2GXlX2nAaQ1O9O1JlrVYdIw,288
17
- j_perm/ops/__init__.py,sha256=j_U4Aq0iwEvZOF0jqMmlQA0NisJsHNJWzqkfyMWuHFA,364
18
- j_perm/ops/_assert.py,sha256=O7MFxW3FDCglPMx-tBwi67k2Wd-Mds996AQdeGLbM6Q,773
19
- j_perm/ops/_assert_d.py,sha256=xEjqBryCbzBpkzYhwRZht7-uyXv7XaCkEcRt6sjwE7k,783
20
- j_perm/ops/_exec.py,sha256=HaFy__49QXnxYNbh1IdIu8ZQoWlQsrms5HgyRN5lV6o,1913
21
- j_perm/ops/_if.py,sha256=DNYp0j3-3YYMIR_CcDM3Ai0CpW2Qt4SYtKxk-9onu8c,1590
22
- j_perm/ops/copy.py,sha256=fV2rE92JyTRqo91GjXwWucklDR1T1ZcFHHbxG1F8S9g,1110
23
- j_perm/ops/copy_d.py,sha256=2SGHoq4vaD79diJos_oO3pvZvC_XrQorxhXAgZ5l09E,1017
24
- j_perm/ops/delete.py,sha256=7SzLE9W_dlmcLOVzKjOzIFLSAHf9nGlagbhbwIH_PmI,916
25
- j_perm/ops/distinct.py,sha256=RoVIIRNm7C9iAjcPZHpoMfgKw6pGx7nuLkYwruhHeYY,1036
26
- j_perm/ops/foreach.py,sha256=6tEQ9PyiWBvrm0eANg18p5hV-OvIiL2O1rdqWOC18tQ,1425
27
- j_perm/ops/replace_root.py,sha256=FFyc-95n3kTzcOXrnjwDdvwOnKNitXKb_1F25MMnXd8,445
28
- j_perm/ops/set.py,sha256=-kFFgHxeq65K5vLzDe6PhUoLT-ksoZYUVL1Jxf8bRA0,1882
29
- j_perm/ops/update.py,sha256=gvJTh3vS4zD-lpaqN23w5OO3hTtkSdu8RPy0V8r8Rwg,2471
30
- j_perm/schema/__init__.py,sha256=mhEbGdi1MgLeEm1IL8-tkTtbIdrdlvQi22sBr7vCUqg,3392
31
- j_perm/shorthands/__init__.py,sha256=zvsu15iExYtUrct6Ob4IRs6Ao46PoZaCKVIUhMBY4Dk,117
32
- j_perm/shorthands/_assert.py,sha256=iRvuUGlN5Bj9G5Bo-aXUa4MQujgmrR3N-PhDeee6xcY,555
33
- j_perm/shorthands/assign_or_append.py,sha256=TsSzaQXb5qoKtY6tvx_skHob-695Zii5roUfXltznAQ,621
34
- j_perm/shorthands/delete.py,sha256=PZeTRcP6-Q3SdbdyruWWXtMJ9mTnz1CBCHiOpHNI06Q,311
35
- j_perm/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
- j_perm/utils/pointers.py,sha256=X1HSCag4t-qq5zTvBMRAlBKXwpHVNNZYJrdJRtyEr4g,2788
37
- j_perm-0.2.1.1.dist-info/METADATA,sha256=zwwrX08mxWxT5sMGLkh4PaLBVs7FMspDzwhcqj8sfpg,17660
38
- j_perm-0.2.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
39
- j_perm-0.2.1.1.dist-info/top_level.txt,sha256=yveBxREqVn9DliNwmen1QWoxR0uta7bU5giS1CCffRI,7
40
- j_perm-0.2.1.1.dist-info/RECORD,,