j-perm 0.1.3.1__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 ADDED
@@ -0,0 +1,13 @@
1
+ # Import built-in operations so that they register on import.
2
+ 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
+
7
+ __all__ = [
8
+ "Handler",
9
+ "register_op",
10
+ "apply_actions",
11
+ "normalize_actions",
12
+ "build_schema",
13
+ ]
j_perm/engine.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any, List, Mapping, MutableMapping, Union
5
+
6
+ from .registry import get_handler
7
+ from .utils.tuples import tuples_to_lists
8
+
9
+
10
+ def _is_pointer_string(v: Any) -> bool:
11
+ return isinstance(v, str) and v.startswith("/")
12
+
13
+
14
+ def _to_list(x: Any) -> List[Any]:
15
+ return x if isinstance(x, list) else [x]
16
+
17
+
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:
33
+ 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))
55
+ else:
56
+ out.append(item)
57
+ return out
58
+
59
+ if isinstance(spec, Mapping):
60
+ return _expand_shorthand(spec)
61
+
62
+ raise TypeError("spec must be dict or list")
63
+
64
+
65
+ def apply_actions(
66
+ actions: Any,
67
+ *,
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)
j_perm/jmes_ext.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import jmespath
4
+ from jmespath import functions as _jp_funcs
5
+
6
+
7
+ class _UserFunctions(_jp_funcs.Functions):
8
+ """Container for custom JMESPath functions used by the DSL."""
9
+
10
+ @_jp_funcs.signature({'types': ['number']}, {'types': ['number']})
11
+ def _func_subtract(self, a: float, b: float) -> float:
12
+ """JMESPath function that subtracts two numbers (a - b)."""
13
+ return a - b
14
+
15
+
16
+ USER_FUNCTIONS = _UserFunctions()
17
+ JP_OPTIONS = jmespath.Options(custom_functions=USER_FUNCTIONS)
j_perm/ops/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from ._exec import op_exec
2
+ from ._if import op_if
3
+ from .copy import op_copy
4
+ from .copy_d import op_copy_d
5
+ from .delete import op_delete
6
+ from .distinct import op_distinct
7
+ from .foreach import op_foreach
8
+ from .replace_root import op_replace_root
9
+ from .set import op_set
10
+ from .update import op_update
j_perm/ops/_exec.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import MutableMapping, Any, Mapping
4
+
5
+ from ..registry import register_op
6
+ from ..engine import apply_actions
7
+ from ..utils.pointers import maybe_slice
8
+ from ..utils.special import resolve_special
9
+ from ..utils.subst import substitute
10
+
11
+
12
+ @register_op("exec")
13
+ def op_exec(
14
+ step: dict,
15
+ dest: MutableMapping[str, Any],
16
+ src: Mapping[str, Any],
17
+ ) -> MutableMapping[str, Any]:
18
+ """Execute nested actions stored either in source or inline in the op."""
19
+ has_from = "from" in step
20
+ has_actions = "actions" in step
21
+
22
+ if has_from and has_actions:
23
+ raise ValueError("exec operation cannot have both 'from' and 'actions' parameters")
24
+
25
+ if not has_from and not has_actions:
26
+ raise ValueError("exec operation requires either 'from' or 'actions' parameter")
27
+
28
+ if has_from:
29
+ actions_ptr = substitute(step["from"], src)
30
+ try:
31
+ actions = maybe_slice(actions_ptr, src)
32
+ except Exception:
33
+ if "default" in step:
34
+ actions = resolve_special(step["default"], src)
35
+ if isinstance(actions, (str, list, dict)):
36
+ actions = substitute(actions, src)
37
+ else:
38
+ raise ValueError(f"Cannot find actions at {actions_ptr}")
39
+ else:
40
+ actions = resolve_special(step["actions"], src)
41
+ if isinstance(actions, (str, list, dict)):
42
+ actions = substitute(actions, src)
43
+
44
+ merge = bool(step.get("merge", False))
45
+
46
+ if merge:
47
+ result = apply_actions(actions, dest=dest, source=src)
48
+ return result
49
+ else:
50
+ result = apply_actions(actions, dest={}, source=src)
51
+ dest.clear()
52
+ if isinstance(dest, list):
53
+ dest.extend(result) # type: ignore[arg-type]
54
+ else:
55
+ dest.update(result) # type: ignore[arg-type]
56
+ return dest
j_perm/ops/_if.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import MutableMapping, Any, Mapping
5
+
6
+ from ..registry import register_op
7
+ from ..engine import normalize_actions, apply_actions
8
+ from ..utils.pointers import maybe_slice
9
+ from ..utils.subst import substitute
10
+
11
+
12
+ @register_op("if")
13
+ def op_if(
14
+ step: dict,
15
+ dest: MutableMapping[str, Any],
16
+ src: Mapping[str, Any] | list,
17
+ ) -> MutableMapping[str, Any]:
18
+ """Conditionally execute nested actions based on a path or expression."""
19
+ if "path" in step:
20
+ try:
21
+ ptr = substitute(step["path"], src)
22
+ current = maybe_slice(ptr, dest)
23
+ missing = False
24
+ except Exception:
25
+ current = None
26
+ missing = True
27
+
28
+ if "equals" in step:
29
+ expected = substitute(step["equals"], src)
30
+ cond_val = current == expected and not missing
31
+ elif step.get("exists"):
32
+ cond_val = not missing
33
+ else:
34
+ cond_val = bool(current) and not missing
35
+ else:
36
+ raw_cond = substitute(step.get("cond"), src)
37
+ cond_val = bool(raw_cond)
38
+
39
+ branch_key = "then" if cond_val else "else"
40
+ branch_key = branch_key if branch_key in step else "do" if cond_val else None
41
+ actions = step.get(branch_key)
42
+
43
+ if not actions:
44
+ return dest
45
+
46
+ actions_norm = normalize_actions(actions)
47
+ snapshot = copy.deepcopy(dest)
48
+
49
+ try:
50
+ return apply_actions(actions_norm, dest=dest, source=src)
51
+ except Exception:
52
+ dest.clear()
53
+ dest.update(snapshot)
54
+ raise
j_perm/ops/assert.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import MutableMapping, Any, Mapping
4
+
5
+ from ..registry import register_op
6
+ from ..utils.pointers import jptr_get
7
+ from ..utils.subst import substitute
8
+
9
+
10
+ @register_op("assert")
11
+ def op_assert(
12
+ step: dict,
13
+ dest: MutableMapping[str, Any],
14
+ src: Mapping[str, Any],
15
+ ) -> MutableMapping[str, Any]:
16
+ """Assert node existence and/or value at JSON Pointer path in dest."""
17
+ path = substitute(step["path"], src)
18
+
19
+ try:
20
+ current = jptr_get(dest, path)
21
+ except Exception:
22
+ raise AssertionError(f"{path} does not exist")
23
+
24
+ if "equals" in step and current != step["equals"]:
25
+ raise AssertionError(f"{path} != {step['equals']!r}")
26
+
27
+ return dest
j_perm/ops/copy.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import MutableMapping, Any, Mapping
5
+
6
+ from .set import op_set
7
+ from ..registry import register_op
8
+ from ..utils.pointers import maybe_slice
9
+ from ..utils.subst import substitute
10
+
11
+
12
+ @register_op("copy")
13
+ def op_copy(
14
+ step: dict,
15
+ dest: MutableMapping[str, Any],
16
+ src: Mapping[str, Any],
17
+ ) -> MutableMapping[str, Any]:
18
+ """Copy value from source pointer into dest path."""
19
+ path = substitute(step["path"], src)
20
+ create = bool(step.get("create", True))
21
+ extend_list = bool(step.get("extend", True))
22
+
23
+ ptr = substitute(step["from"], src)
24
+ ignore = bool(step.get("ignore_missing", False))
25
+
26
+ try:
27
+ value = copy.deepcopy(maybe_slice(ptr, src))
28
+ except Exception:
29
+ if "default" in step:
30
+ value = copy.deepcopy(step["default"])
31
+ elif ignore:
32
+ return dest
33
+ else:
34
+ raise
35
+
36
+ return op_set(
37
+ {"op": "set", "path": path, "value": value, "create": create, "extend": extend_list},
38
+ dest,
39
+ src,
40
+ )
j_perm/ops/copy_d.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import MutableMapping, Any, Mapping
5
+
6
+ from .set import op_set
7
+ from ..registry import register_op
8
+ from ..utils.pointers import maybe_slice
9
+ from ..utils.subst import substitute
10
+
11
+
12
+ @register_op("copyD")
13
+ def op_copy_d(
14
+ step: dict,
15
+ dest: MutableMapping[str, Any],
16
+ src: Mapping[str, Any],
17
+ ) -> MutableMapping[str, Any]:
18
+ """Copy value from dest (self) into another dest path."""
19
+ path = substitute(step["path"], src)
20
+ create = bool(step.get("create", True))
21
+
22
+ ptr = substitute(step["from"], dest)
23
+ ignore = bool(step.get("ignore_missing", False))
24
+
25
+ try:
26
+ value = copy.deepcopy(maybe_slice(ptr, dest))
27
+ except Exception:
28
+ if "default" in step:
29
+ value = copy.deepcopy(step["default"])
30
+ elif ignore:
31
+ return dest
32
+ else:
33
+ raise
34
+
35
+ return op_set({"op": "set", "path": path, "value": value, "create": create}, dest, src)
j_perm/ops/delete.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import MutableMapping, Any, Mapping
4
+
5
+ from ..registry import register_op
6
+ from ..utils.pointers import jptr_ensure_parent
7
+ from ..utils.subst import substitute
8
+
9
+
10
+ @register_op("delete")
11
+ def op_delete(
12
+ step: dict,
13
+ dest: MutableMapping[str, Any],
14
+ src: Mapping[str, Any],
15
+ ) -> MutableMapping[str, Any]:
16
+ """Delete node at the given JSON Pointer path in dest."""
17
+ path = substitute(step["path"], src)
18
+ ignore = bool(step.get("ignore_missing", True))
19
+
20
+ try:
21
+ parent, leaf = jptr_ensure_parent(dest, path, create=False)
22
+
23
+ if leaf == "-":
24
+ raise ValueError("'-' not allowed in delete")
25
+
26
+ if isinstance(parent, list):
27
+ del parent[int(leaf)]
28
+ else:
29
+ del parent[leaf]
30
+
31
+ except (KeyError, IndexError):
32
+ if not ignore:
33
+ raise
34
+
35
+ return dest
j_perm/ops/distinct.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import MutableMapping, Any, Mapping
4
+
5
+ from ..registry import register_op
6
+ from ..utils.pointers import jptr_get
7
+ from ..utils.subst import substitute
8
+
9
+
10
+ @register_op("distinct")
11
+ def op_distinct(
12
+ step: dict,
13
+ dest: MutableMapping[str, Any],
14
+ src: Mapping[str, Any],
15
+ ) -> MutableMapping[str, Any]:
16
+ """Remove duplicates from a list at the given path, preserving order."""
17
+ path = substitute(step["path"], src)
18
+ lst = jptr_get(dest, path)
19
+
20
+ if not isinstance(lst, list):
21
+ raise TypeError(f"{path} is not a list (distinct)")
22
+
23
+ key = step.get("key", None)
24
+ key_path = substitute(key, src)
25
+
26
+ seen = set()
27
+ unique = []
28
+ for item in lst:
29
+ if key is not None:
30
+ filter_item = jptr_get(item, key_path)
31
+ else:
32
+ filter_item = item
33
+
34
+ if filter_item not in seen:
35
+ seen.add(filter_item)
36
+ unique.append(item)
37
+
38
+ lst[:] = unique
39
+ return dest
j_perm/ops/foreach.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import MutableMapping, Any, Mapping
5
+
6
+ from ..registry import register_op
7
+ from ..engine import normalize_actions, apply_actions
8
+ from ..utils.pointers import maybe_slice
9
+ from ..utils.subst import substitute
10
+
11
+
12
+ @register_op("foreach")
13
+ def op_foreach(
14
+ step: dict,
15
+ dest: MutableMapping[str, Any],
16
+ src: Mapping[str, Any] | list,
17
+ ) -> MutableMapping[str, Any]:
18
+ """Iterate over array in source and execute nested actions for each element."""
19
+ arr_ptr = substitute(step["in"], src)
20
+
21
+ default = copy.deepcopy(step.get("default", []))
22
+ skip_empty = bool(step.get("skip_empty", True))
23
+
24
+ try:
25
+ arr = maybe_slice(arr_ptr, src)
26
+ except Exception:
27
+ arr = default
28
+
29
+ if not arr and skip_empty:
30
+ return dest
31
+
32
+ if isinstance(arr, dict):
33
+ arr = list(arr.items())
34
+
35
+ var = step.get("as", "item")
36
+ body = normalize_actions(step["do"])
37
+ snapshot = copy.deepcopy(dest)
38
+
39
+ try:
40
+ for elem in arr:
41
+ if isinstance(src, Mapping):
42
+ extended = dict(src)
43
+ else:
44
+ extended = {"_": src}
45
+
46
+ extended[var] = elem
47
+ dest = apply_actions(body, dest=dest, source=extended)
48
+ except Exception:
49
+ dest.clear()
50
+ if isinstance(dest, list):
51
+ dest = snapshot
52
+ else:
53
+ dest.update(snapshot)
54
+ raise
55
+
56
+ return dest
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+
5
+ from ..registry import register_op
6
+ from ..utils.special import resolve_special
7
+ from ..utils.subst import substitute
8
+
9
+
10
+ @register_op("replace_root")
11
+ def op_replace_root(step, dest, src):
12
+ """Replace the whole dest root value with the resolved special value."""
13
+ value = resolve_special(step["value"], src)
14
+ if isinstance(value, (str, list, dict)):
15
+ value = substitute(value, src)
16
+ return copy.deepcopy(value)
j_perm/ops/set.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, MutableMapping
4
+
5
+ from ..registry import register_op
6
+ from ..utils.pointers import jptr_ensure_parent
7
+ from ..utils.special import resolve_special
8
+ from ..utils.subst import substitute
9
+
10
+
11
+ @register_op("set")
12
+ def op_set(
13
+ step: dict,
14
+ dest: MutableMapping[str, Any],
15
+ src: Mapping[str, Any],
16
+ ) -> MutableMapping[str, Any]:
17
+ """Set or append a value at JSON Pointer path in dest."""
18
+ path = substitute(step["path"], src)
19
+ create = bool(step.get("create", True))
20
+ extend_list = bool(step.get("extend", True))
21
+
22
+ value = resolve_special(step["value"], src)
23
+ if isinstance(value, (str, list, Mapping)):
24
+ value = substitute(value, src)
25
+
26
+ parent, leaf = jptr_ensure_parent(dest, path, create=create)
27
+
28
+ if leaf == "-":
29
+ if not isinstance(parent, list):
30
+ if create:
31
+ grand, last = jptr_ensure_parent(dest, path.rsplit("/", 1)[0], create=True)
32
+ if not isinstance(grand[last], list):
33
+ if grand[last] == {}:
34
+ grand[last] = []
35
+ else:
36
+ grand[last] = [grand[last]]
37
+ parent = grand[last]
38
+ else:
39
+ raise TypeError(f"{path} is not a list (append)")
40
+
41
+ if isinstance(value, list) and extend_list:
42
+ parent.extend(value)
43
+ else:
44
+ parent.append(value)
45
+ else:
46
+ if isinstance(parent, list):
47
+ idx = int(leaf)
48
+ if idx >= len(parent):
49
+ if create:
50
+ while idx >= len(parent):
51
+ parent.append(None)
52
+ else:
53
+ raise IndexError(f"{path}: index {idx} out of range")
54
+ parent[idx] = value
55
+ else:
56
+ parent[leaf] = value
57
+
58
+ return dest
j_perm/ops/update.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import MutableMapping, Any, Mapping
5
+
6
+ from ..registry import register_op
7
+ from ..utils.pointers import maybe_slice, jptr_ensure_parent
8
+ from ..utils.special import resolve_special
9
+ from ..utils.subst import substitute
10
+
11
+
12
+ @register_op("update")
13
+ def op_update(
14
+ step: dict,
15
+ dest: MutableMapping[str, Any],
16
+ src: Mapping[str, Any],
17
+ ) -> MutableMapping[str, Any]:
18
+ """Update a mapping at the given path using a mapping from source or inline value."""
19
+ path = substitute(step["path"], src)
20
+ create = bool(step.get("create", True))
21
+ deep = bool(step.get("deep", False))
22
+
23
+ if "from" in step:
24
+ ptr = substitute(step["from"], src)
25
+ try:
26
+ update_value = copy.deepcopy(maybe_slice(ptr, src))
27
+ except Exception:
28
+ if "default" in step:
29
+ update_value = copy.deepcopy(step["default"])
30
+ else:
31
+ raise
32
+ elif "value" in step:
33
+ update_value = resolve_special(step["value"], src)
34
+ if isinstance(update_value, (str, list, Mapping)):
35
+ update_value = substitute(update_value, src)
36
+ else:
37
+ raise ValueError("update operation requires either 'from' or 'value' parameter")
38
+
39
+ if not isinstance(update_value, Mapping):
40
+ raise TypeError(f"update value must be a dict, got {type(update_value).__name__}")
41
+
42
+ parent, leaf = jptr_ensure_parent(dest, path, create=create)
43
+
44
+ if leaf:
45
+ if isinstance(parent, list):
46
+ idx = int(leaf)
47
+ target = parent[idx]
48
+ else:
49
+ if leaf not in parent:
50
+ if create:
51
+ parent[leaf] = {}
52
+ else:
53
+ raise KeyError(f"{path} does not exist")
54
+ target = parent[leaf]
55
+ else:
56
+ target = dest
57
+
58
+ if not isinstance(target, MutableMapping):
59
+ raise TypeError(f"{path} is not a dict, cannot update")
60
+
61
+ if deep:
62
+ def deep_update(dst: MutableMapping, src_val: Mapping) -> None:
63
+ for key, value in src_val.items():
64
+ if key in dst and isinstance(dst[key], MutableMapping) and isinstance(value, Mapping):
65
+ deep_update(dst[key], value) # type: ignore[index]
66
+ else:
67
+ dst[key] = copy.deepcopy(value)
68
+
69
+ deep_update(target, update_value)
70
+ else:
71
+ target.update(update_value)
72
+
73
+ return dest
j_perm/registry.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, MutableMapping, Mapping, TypeAlias
4
+
5
+ Handler: TypeAlias = Callable[
6
+ [dict, MutableMapping[str, Any], Mapping[str, Any]],
7
+ MutableMapping[str, Any],
8
+ ]
9
+
10
+ _OP_HANDLERS: Dict[str, Handler] = {}
11
+
12
+
13
+ def register_op(name: str) -> Callable[[Handler], Handler]:
14
+ """Decorator that registers a DSL operation handler under a given name."""
15
+
16
+ def decorator(func: Handler) -> Handler:
17
+ if name in _OP_HANDLERS:
18
+ raise ValueError(f"Handler for op '{name}' is already registered")
19
+ _OP_HANDLERS[name] = func
20
+ return func
21
+
22
+ return decorator
23
+
24
+
25
+ def get_handler(name: str) -> Handler:
26
+ """Return a registered handler or raise ValueError if it does not exist."""
27
+ try:
28
+ return _OP_HANDLERS[name]
29
+ except KeyError:
30
+ raise ValueError(f"Unknown op '{name}'") from None