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 CHANGED
@@ -1,13 +1,21 @@
1
1
  # Import built-in operations so that they register on import.
2
+ from . import casters as _builtin_casters # noqa: F401
3
+ from . import constructs as _builtin_constructs # noqa: F401
4
+ from . import funcs as _builtin_funcs # noqa: F401
2
5
  from . import ops as _builtin_ops # noqa: F401
3
- from .engine import apply_actions, normalize_actions
4
- from .registry import register_op, Handler
5
- from .schema import build_schema
6
+ from .engine import apply_actions, ActionEngine
7
+ from .subst import JpFuncRegistry, CasterRegistry, TemplateSubstitutor
8
+ from .op_handler import OpRegistry, Handlers
9
+ from .special_resolver import SpecialRegistry, SpecialResolver
6
10
 
7
11
  __all__ = [
8
- "Handler",
9
- "register_op",
10
12
  "apply_actions",
11
- "normalize_actions",
12
- "build_schema",
13
+ "ActionEngine",
14
+ "JpFuncRegistry",
15
+ "CasterRegistry",
16
+ "TemplateSubstitutor",
17
+ "OpRegistry",
18
+ "Handlers",
19
+ "SpecialRegistry",
20
+ "SpecialResolver",
13
21
  ]
@@ -0,0 +1,4 @@
1
+ from ._bool import cast_bool
2
+ from ._float import cast_float
3
+ from ._int import cast_int
4
+ from ._str import cast_str
@@ -0,0 +1,9 @@
1
+ from typing import Any
2
+
3
+ from j_perm.subst import CasterRegistry
4
+
5
+
6
+ @CasterRegistry.register("bool")
7
+ def cast_bool(x: Any) -> bool:
8
+ # Preserve original semantics
9
+ return bool(int(x)) if isinstance(x, (int, str)) else bool(x)
@@ -0,0 +1,8 @@
1
+ from typing import Any
2
+
3
+ from j_perm.subst import CasterRegistry
4
+
5
+
6
+ @CasterRegistry.register("float")
7
+ def cast_float(x: Any) -> float:
8
+ return float(x)
j_perm/casters/_int.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import Any
2
+
3
+ from j_perm.subst import CasterRegistry
4
+
5
+
6
+ @CasterRegistry.register("int")
7
+ def cast_int(x: Any) -> int:
8
+ return int(x)
j_perm/casters/_str.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import Any
2
+
3
+ from j_perm.subst import CasterRegistry
4
+
5
+
6
+ @CasterRegistry.register("str")
7
+ def cast_str(x: Any) -> str:
8
+ return str(x)
@@ -0,0 +1,2 @@
1
+ from .eval import sp_eval
2
+ from .ref import of_ref
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Mapping, Any
4
+
5
+ from j_perm.special_resolver import SpecialRegistry
6
+
7
+
8
+ @SpecialRegistry.register("$eval")
9
+ def sp_eval(node: Mapping[str, Any], src: Mapping[str, Any], engine: "ActionEngine") -> Any:
10
+ out = engine.apply_actions(node["$eval"], dest={}, source=src)
11
+
12
+ if "$select" in node:
13
+ sel = resolver.slice(node["$select"], out) # type: ignore[arg-type]
14
+ return sel
15
+
16
+ return out
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Mapping, Any
5
+
6
+ from j_perm.special_resolver import _MISSING, SpecialRegistry
7
+ from j_perm.utils.pointers import maybe_slice
8
+
9
+
10
+ @SpecialRegistry.register("$ref")
11
+ def of_ref(node: Mapping[str, Any], src: Mapping[str, Any], engine: "ActionEngine") -> Any:
12
+ # Expand templates inside "$ref" using configured substitutor
13
+ ptr = engine.substitutor.substitute(node["$ref"], src)
14
+
15
+ dflt = node.get("$default", _MISSING)
16
+ try:
17
+ return copy.deepcopy(maybe_slice(ptr, src))
18
+ except Exception:
19
+ if dflt is not _MISSING:
20
+ return copy.deepcopy(dflt)
21
+ raise
j_perm/engine.py CHANGED
@@ -1,11 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
- from typing import Any, List, Mapping, MutableMapping, Union
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, List, Mapping, TypeAlias, MutableMapping, Union
5
6
 
6
- from .registry import get_handler
7
- from .utils.tuples import tuples_to_lists
7
+ from .op_handler import Handlers
8
+ from .special_resolver import SpecialResolver
9
+ from .subst import TemplateSubstitutor
8
10
 
11
+ JsonLikeMapping: TypeAlias = MutableMapping[str, Any]
12
+ JsonLikeSource: TypeAlias = Union[Mapping[str, Any], List[Any]]
13
+ JsonLikeDest: TypeAlias = Union[MutableMapping[str, Any], List[Any]]
14
+
15
+
16
+ # =============================================================================
17
+ # Small utilities
18
+ # =============================================================================
9
19
 
10
20
  def _is_pointer_string(v: Any) -> bool:
11
21
  return isinstance(v, str) and v.startswith("/")
@@ -15,72 +25,138 @@ def _to_list(x: Any) -> List[Any]:
15
25
  return x if isinstance(x, list) else [x]
16
26
 
17
27
 
18
- def _expand_shorthand(obj: Mapping[str, Any]) -> List[dict]:
19
- """Expand shorthand mapping form into explicit op steps."""
20
- steps: List[dict] = []
21
-
22
- for key, val in obj.items():
23
- if key == "~delete":
24
- for p in _to_list(val):
25
- steps.append({"op": "delete", "path": p})
26
- continue
27
-
28
- if key == "~assert":
29
- if isinstance(val, Mapping):
30
- for p, eq in val.items():
31
- steps.append({"op": "assert", "path": p, "equals": eq})
32
- else:
28
+ def tuples_to_lists(obj: Any) -> Any:
29
+ """Recursively convert all tuples into lists so JMESPath indexers work reliably."""
30
+ if isinstance(obj, tuple):
31
+ return [tuples_to_lists(x) for x in obj]
32
+
33
+ if isinstance(obj, list):
34
+ return [tuples_to_lists(x) for x in obj]
35
+
36
+ if isinstance(obj, Mapping):
37
+ return {k: tuples_to_lists(v) for k, v in obj.items()}
38
+
39
+ return obj
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class ActionEngine:
44
+ """
45
+ Executes a DSL script against dest with a given source context.
46
+
47
+ Configuration:
48
+ - handlers: a Handlers object (has get_handler)
49
+ - special: a SpecialResolver (resolves $ref/$eval/... inside the actions spec, if you want)
50
+ - substitutor: a TemplateSubstitutor (optional: expand ${...} in action specs)
51
+ - resolve_special_in_actions: enable resolving special constructs inside the actions spec
52
+ - substitute_templates_in_actions: enable template expansion inside the actions spec
53
+
54
+ Notes on order:
55
+ 1) Optionally substitute templates in 'actions' using 'source' as context
56
+ 2) Optionally resolve special constructs in 'actions' using 'source' as context
57
+ 3) Normalize to flat step list
58
+ 4) Execute handlers
59
+ """
60
+
61
+ handlers: Handlers = field(default_factory=Handlers)
62
+ special: SpecialResolver = field(default_factory=SpecialResolver)
63
+ substitutor: TemplateSubstitutor = field(default_factory=TemplateSubstitutor)
64
+
65
+ resolve_special_in_actions: bool = True
66
+ substitute_templates_in_actions: bool = True
67
+
68
+ def apply_actions(
69
+ self,
70
+ actions: Any,
71
+ *,
72
+ dest: JsonLikeDest,
73
+ source: JsonLikeSource,
74
+ ) -> Any:
75
+ """External API: apply a DSL script."""
76
+ # Work on copies to keep the function referentially safe.
77
+ result = copy.deepcopy(dest)
78
+
79
+ # Normalize actions into a flat list of step dicts.
80
+ steps = self.normalize_actions(actions)
81
+
82
+ # Convert tuples in source to lists.
83
+ source_norm = tuples_to_lists(source)
84
+
85
+ try:
86
+ for step in steps:
87
+ op = step.get("op")
88
+ if not op:
89
+ raise ValueError(f"Invalid step without 'op': {step!r}")
90
+
91
+ handler = self.handlers.get_handler(op)
92
+ result = handler(step, result, source_norm, self)
93
+ except ValueError:
94
+ raise
95
+ except Exception:
96
+ raise
97
+
98
+ return copy.deepcopy(result)
99
+
100
+ # -------------------------------------------------------------------------
101
+ # Normalization (ported from your original code)
102
+ # -------------------------------------------------------------------------
103
+
104
+ def normalize_actions(self, spec: Any) -> List[dict]:
105
+ """Normalize DSL script into a flat list of step dicts."""
106
+ if isinstance(spec, list):
107
+ out: List[dict] = []
108
+ for item in spec:
109
+ if isinstance(item, Mapping) and "op" not in item:
110
+ out.extend(self._expand_shorthand(item))
111
+ else:
112
+ out.append(item)
113
+ return out
114
+
115
+ if isinstance(spec, Mapping):
116
+ return self._expand_shorthand(spec)
117
+
118
+ raise TypeError("spec must be dict or list")
119
+
120
+ @staticmethod
121
+ def _expand_shorthand(obj: Mapping[str, Any]) -> List[dict]:
122
+ """Expand shorthand mapping form into explicit op steps."""
123
+ steps: List[dict] = []
124
+
125
+ for key, val in obj.items():
126
+ if key == "~delete":
33
127
  for p in _to_list(val):
34
- steps.append({"op": "assert", "path": p})
35
- continue
36
-
37
- append = key.endswith("[]")
38
- dst = f"{key[:-2]}/-" if append else key
39
-
40
- if _is_pointer_string(val):
41
- steps.append({"op": "copy", "from": val, "path": dst, "ignore_missing": True})
42
- else:
43
- steps.append({"op": "set", "path": dst, "value": val})
44
-
45
- return steps
46
-
47
-
48
- def normalize_actions(spec: Any) -> List[dict]:
49
- """Normalize DSL script into a flat list of step dicts."""
50
- if isinstance(spec, list):
51
- out: List[dict] = []
52
- for item in spec:
53
- if isinstance(item, Mapping) and "op" not in item:
54
- out.extend(_expand_shorthand(item))
128
+ steps.append({"op": "delete", "path": p})
129
+ continue
130
+
131
+ if key == "~assert":
132
+ if isinstance(val, Mapping):
133
+ for p, eq in val.items():
134
+ steps.append({"op": "assert", "path": p, "equals": eq})
135
+ else:
136
+ for p in _to_list(val):
137
+ steps.append({"op": "assert", "path": p})
138
+ continue
139
+
140
+ append = isinstance(key, str) and key.endswith("[]")
141
+ dst = f"{key[:-2]}/-" if append else key
142
+
143
+ if _is_pointer_string(val):
144
+ steps.append({"op": "copy", "from": val, "path": dst, "ignore_missing": True})
55
145
  else:
56
- out.append(item)
57
- return out
146
+ steps.append({"op": "set", "path": dst, "value": val})
147
+
148
+ return steps
58
149
 
59
- if isinstance(spec, Mapping):
60
- return _expand_shorthand(spec)
61
150
 
62
- raise TypeError("spec must be dict or list")
151
+ # Create a default engine instance for convenience
152
+ default_engine = ActionEngine()
63
153
 
64
154
 
65
155
  def apply_actions(
66
156
  actions: Any,
67
157
  *,
68
- dest: Union[MutableMapping[str, Any], List[Any]],
69
- source: Union[Mapping[str, Any], List[Any]],
70
- ) -> Mapping[str, Any]:
71
- """Execute a DSL script against dest with a given source context."""
72
- steps = normalize_actions(actions)
73
- result = copy.deepcopy(dest)
74
-
75
- source = tuples_to_lists(source)
76
-
77
- try:
78
- for step in steps:
79
- handler = get_handler(step["op"])
80
- result = handler(step, result, source) # type: ignore[arg-type]
81
- except ValueError:
82
- raise
83
- except Exception:
84
- raise
85
-
86
- return copy.deepcopy(result)
158
+ dest: JsonLikeDest,
159
+ source: JsonLikeSource,
160
+ ) -> Any:
161
+ """Convenience function: apply a DSL script using the default engine."""
162
+ return default_engine.apply_actions(actions, dest=dest, source=source)
@@ -0,0 +1 @@
1
+ from .subtract import subtract
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from jmespath import functions as _jp_funcs
4
+
5
+ from j_perm.subst import JpFuncRegistry
6
+
7
+
8
+ @JpFuncRegistry.register("subtract")
9
+ @_jp_funcs.signature({"types": ["number"]}, {"types": ["number"]})
10
+ def subtract(self, a: float, b: float) -> float:
11
+ return a - b
j_perm/op_handler.py ADDED
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Mapping, Any, Callable
3
+
4
+ OpHandler = Callable[[dict, Any, Any, "ActionEngine"], Any]
5
+
6
+
7
+ class OpRegistry:
8
+ _ops: dict[str, OpHandler] = {}
9
+
10
+ @classmethod
11
+ def register(cls, name: str) -> Callable[[OpHandler], OpHandler]:
12
+ def deco(fn: OpHandler) -> OpHandler:
13
+ if name in cls._ops:
14
+ raise ValueError(f"op already registered: {name!r}")
15
+ cls._ops[name] = fn
16
+ return fn
17
+
18
+ return deco
19
+
20
+ @classmethod
21
+ def get(cls, name: str) -> OpHandler:
22
+ try:
23
+ return cls._ops[name]
24
+ except KeyError:
25
+ raise ValueError(f"Unknown op {name!r}") from None
26
+
27
+ @classmethod
28
+ def all(cls) -> dict[str, OpHandler]:
29
+ return dict(cls._ops)
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class Handlers:
34
+ """
35
+ If ops is None -> use all registered.
36
+ If ops is list[str] -> use only those names.
37
+ If ops is Mapping[str, handler] -> use that explicit mapping (no registry).
38
+ """
39
+ ops: list[str] | Mapping[str, OpHandler] | None = None
40
+ on_conflict: str = "error" # "error" | "override"
41
+ _ops: dict[str, OpHandler] = field(init=False, repr=False)
42
+
43
+ def get_handler(self, name: str) -> OpHandler:
44
+ return self._ops[name]
45
+
46
+ def __post_init__(self) -> None:
47
+ if self.ops is None:
48
+ ops_map = OpRegistry.all()
49
+ elif isinstance(self.ops, Mapping):
50
+ ops_map = dict(self.ops)
51
+ else:
52
+ all_ops = OpRegistry.all()
53
+ ops_map = {}
54
+ for n in self.ops:
55
+ ops_map[n] = all_ops[n]
56
+
57
+ self._ops = ops_map
j_perm/ops/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from ._assert import op_assert
1
2
  from ._exec import op_exec
2
3
  from ._if import op_if
3
4
  from .copy import op_copy
@@ -2,19 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
- from ..registry import register_op
5
+ from ..op_handler import OpRegistry
6
6
  from ..utils.pointers import jptr_get
7
- from ..utils.subst import substitute
8
7
 
9
8
 
10
- @register_op("assert")
9
+ @OpRegistry.register("assert")
11
10
  def op_assert(
12
11
  step: dict,
13
12
  dest: MutableMapping[str, Any],
14
13
  src: Mapping[str, Any],
14
+ engine: "ActionEngine",
15
15
  ) -> MutableMapping[str, Any]:
16
16
  """Assert node existence and/or value at JSON Pointer path in dest."""
17
- path = substitute(step["path"], src)
17
+ path = engine.substitutor.substitute(step["path"], src)
18
18
 
19
19
  try:
20
20
  current = jptr_get(dest, path)
j_perm/ops/_exec.py CHANGED
@@ -2,18 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
- from ..registry import register_op
5
+ from ..op_handler import OpRegistry
6
6
  from ..engine import apply_actions
7
7
  from ..utils.pointers import maybe_slice
8
- from ..utils.special import resolve_special
9
- from ..utils.subst import substitute
10
8
 
11
9
 
12
- @register_op("exec")
10
+ @OpRegistry.register("exec")
13
11
  def op_exec(
14
12
  step: dict,
15
13
  dest: MutableMapping[str, Any],
16
14
  src: Mapping[str, Any],
15
+ engine: "ActionEngine",
17
16
  ) -> MutableMapping[str, Any]:
18
17
  """Execute nested actions stored either in source or inline in the op."""
19
18
  has_from = "from" in step
@@ -26,20 +25,20 @@ def op_exec(
26
25
  raise ValueError("exec operation requires either 'from' or 'actions' parameter")
27
26
 
28
27
  if has_from:
29
- actions_ptr = substitute(step["from"], src)
28
+ actions_ptr = engine.substitutor.substitute(step["from"], src)
30
29
  try:
31
30
  actions = maybe_slice(actions_ptr, src)
32
31
  except Exception:
33
32
  if "default" in step:
34
- actions = resolve_special(step["default"], src)
33
+ actions = engine.special.resolve(step["default"], src, engine)
35
34
  if isinstance(actions, (str, list, dict)):
36
- actions = substitute(actions, src)
35
+ actions = engine.substitutor.substitute(actions, src)
37
36
  else:
38
37
  raise ValueError(f"Cannot find actions at {actions_ptr}")
39
38
  else:
40
- actions = resolve_special(step["actions"], src)
39
+ actions = engine.special.resolve(step["actions"], src, engine)
41
40
  if isinstance(actions, (str, list, dict)):
42
- actions = substitute(actions, src)
41
+ actions = engine.substitutor.substitute(actions, src)
43
42
 
44
43
  merge = bool(step.get("merge", False))
45
44
 
j_perm/ops/_if.py CHANGED
@@ -3,22 +3,21 @@ from __future__ import annotations
3
3
  import copy
4
4
  from typing import MutableMapping, Any, Mapping
5
5
 
6
- from ..registry import register_op
7
- from ..engine import normalize_actions, apply_actions
6
+ from ..op_handler import OpRegistry
8
7
  from ..utils.pointers import maybe_slice
9
- from ..utils.subst import substitute
10
8
 
11
9
 
12
- @register_op("if")
10
+ @OpRegistry.register("if")
13
11
  def op_if(
14
12
  step: dict,
15
13
  dest: MutableMapping[str, Any],
16
14
  src: Mapping[str, Any] | list,
15
+ engine: "ActionEngine",
17
16
  ) -> MutableMapping[str, Any]:
18
17
  """Conditionally execute nested actions based on a path or expression."""
19
18
  if "path" in step:
20
19
  try:
21
- ptr = substitute(step["path"], src)
20
+ ptr = engine.substitutor.substitute(step["path"], src)
22
21
  current = maybe_slice(ptr, dest)
23
22
  missing = False
24
23
  except Exception:
@@ -26,14 +25,14 @@ def op_if(
26
25
  missing = True
27
26
 
28
27
  if "equals" in step:
29
- expected = substitute(step["equals"], src)
28
+ expected = engine.substitutor.substitute(step["equals"], src)
30
29
  cond_val = current == expected and not missing
31
30
  elif step.get("exists"):
32
31
  cond_val = not missing
33
32
  else:
34
33
  cond_val = bool(current) and not missing
35
34
  else:
36
- raw_cond = substitute(step.get("cond"), src)
35
+ raw_cond = engine.substitutor.substitute(step.get("cond"), src)
37
36
  cond_val = bool(raw_cond)
38
37
 
39
38
  branch_key = "then" if cond_val else "else"
@@ -43,11 +42,11 @@ def op_if(
43
42
  if not actions:
44
43
  return dest
45
44
 
46
- actions_norm = normalize_actions(actions)
45
+ actions_norm = engine.normalize_actions(actions)
47
46
  snapshot = copy.deepcopy(dest)
48
47
 
49
48
  try:
50
- return apply_actions(actions_norm, dest=dest, source=src)
49
+ return engine.apply_actions(actions_norm, dest=dest, source=src)
51
50
  except Exception:
52
51
  dest.clear()
53
52
  dest.update(snapshot)
j_perm/ops/copy.py CHANGED
@@ -4,23 +4,23 @@ import copy
4
4
  from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from .set import op_set
7
- from ..registry import register_op
7
+ from ..op_handler import OpRegistry
8
8
  from ..utils.pointers import maybe_slice
9
- from ..utils.subst import substitute
10
9
 
11
10
 
12
- @register_op("copy")
11
+ @OpRegistry.register("copy")
13
12
  def op_copy(
14
13
  step: dict,
15
14
  dest: MutableMapping[str, Any],
16
15
  src: Mapping[str, Any],
16
+ engine: "ActionEngine",
17
17
  ) -> MutableMapping[str, Any]:
18
18
  """Copy value from source pointer into dest path."""
19
- path = substitute(step["path"], src)
19
+ path = engine.substitutor.substitute(step["path"], src)
20
20
  create = bool(step.get("create", True))
21
21
  extend_list = bool(step.get("extend", True))
22
22
 
23
- ptr = substitute(step["from"], src)
23
+ ptr = engine.substitutor.substitute(step["from"], src)
24
24
  ignore = bool(step.get("ignore_missing", False))
25
25
 
26
26
  try:
j_perm/ops/copy_d.py CHANGED
@@ -4,22 +4,22 @@ import copy
4
4
  from typing import MutableMapping, Any, Mapping
5
5
 
6
6
  from .set import op_set
7
- from ..registry import register_op
7
+ from ..op_handler import OpRegistry
8
8
  from ..utils.pointers import maybe_slice
9
- from ..utils.subst import substitute
10
9
 
11
10
 
12
- @register_op("copyD")
11
+ @OpRegistry.register("copyD")
13
12
  def op_copy_d(
14
13
  step: dict,
15
14
  dest: MutableMapping[str, Any],
16
15
  src: Mapping[str, Any],
16
+ engine: "ActionEngine",
17
17
  ) -> MutableMapping[str, Any]:
18
18
  """Copy value from dest (self) into another dest path."""
19
- path = substitute(step["path"], src)
19
+ path = engine.substitutor.substitute(step["path"], src)
20
20
  create = bool(step.get("create", True))
21
21
 
22
- ptr = substitute(step["from"], dest)
22
+ ptr = engine.substitutor.substitute(step["from"], dest)
23
23
  ignore = bool(step.get("ignore_missing", False))
24
24
 
25
25
  try:
j_perm/ops/delete.py CHANGED
@@ -2,19 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
- from ..registry import register_op
5
+ from ..op_handler import OpRegistry
6
6
  from ..utils.pointers import jptr_ensure_parent
7
- from ..utils.subst import substitute
8
7
 
9
8
 
10
- @register_op("delete")
9
+ @OpRegistry.register("delete")
11
10
  def op_delete(
12
11
  step: dict,
13
12
  dest: MutableMapping[str, Any],
14
13
  src: Mapping[str, Any],
14
+ engine: "ActionEngine",
15
15
  ) -> MutableMapping[str, Any]:
16
16
  """Delete node at the given JSON Pointer path in dest."""
17
- path = substitute(step["path"], src)
17
+ path = engine.substitutor.substitute(step["path"], src)
18
18
  ignore = bool(step.get("ignore_missing", True))
19
19
 
20
20
  try:
j_perm/ops/distinct.py CHANGED
@@ -2,26 +2,26 @@ from __future__ import annotations
2
2
 
3
3
  from typing import MutableMapping, Any, Mapping
4
4
 
5
- from ..registry import register_op
5
+ from ..op_handler import OpRegistry
6
6
  from ..utils.pointers import jptr_get
7
- from ..utils.subst import substitute
8
7
 
9
8
 
10
- @register_op("distinct")
9
+ @OpRegistry.register("distinct")
11
10
  def op_distinct(
12
11
  step: dict,
13
12
  dest: MutableMapping[str, Any],
14
13
  src: Mapping[str, Any],
14
+ engine: "ActionEngine",
15
15
  ) -> MutableMapping[str, Any]:
16
16
  """Remove duplicates from a list at the given path, preserving order."""
17
- path = substitute(step["path"], src)
17
+ path = engine.substitutor.substitute(step["path"], src)
18
18
  lst = jptr_get(dest, path)
19
19
 
20
20
  if not isinstance(lst, list):
21
21
  raise TypeError(f"{path} is not a list (distinct)")
22
22
 
23
23
  key = step.get("key", None)
24
- key_path = substitute(key, src)
24
+ key_path = engine.substitutor.substitute(key, src)
25
25
 
26
26
  seen = set()
27
27
  unique = []