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 +13 -0
- j_perm/engine.py +86 -0
- j_perm/jmes_ext.py +17 -0
- j_perm/ops/__init__.py +10 -0
- j_perm/ops/_exec.py +56 -0
- j_perm/ops/_if.py +54 -0
- j_perm/ops/assert.py +27 -0
- j_perm/ops/copy.py +40 -0
- j_perm/ops/copy_d.py +35 -0
- j_perm/ops/delete.py +35 -0
- j_perm/ops/distinct.py +39 -0
- j_perm/ops/foreach.py +56 -0
- j_perm/ops/replace_root.py +16 -0
- j_perm/ops/set.py +58 -0
- j_perm/ops/update.py +73 -0
- j_perm/registry.py +30 -0
- j_perm/schema/__init__.py +109 -0
- j_perm/utils/__init__.py +0 -0
- j_perm/utils/pointers.py +108 -0
- j_perm/utils/special.py +41 -0
- j_perm/utils/subst.py +133 -0
- j_perm/utils/tuples.py +17 -0
- j_perm-0.1.3.1.dist-info/METADATA +766 -0
- j_perm-0.1.3.1.dist-info/RECORD +26 -0
- j_perm-0.1.3.1.dist-info/WHEEL +5 -0
- j_perm-0.1.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, List, Mapping
|
|
5
|
+
|
|
6
|
+
from ..engine import normalize_actions
|
|
7
|
+
|
|
8
|
+
_JSON_TYPE = {
|
|
9
|
+
str: "string",
|
|
10
|
+
int: "integer",
|
|
11
|
+
float: "number",
|
|
12
|
+
bool: "boolean",
|
|
13
|
+
list: "array",
|
|
14
|
+
dict: "object",
|
|
15
|
+
type(None): "null",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _split_pointer(p: str) -> List[str]:
|
|
20
|
+
return [t.replace("~1", "/").replace("~0", "~") for t in p.lstrip("/").split("/")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _merge(dst: dict, patch: dict) -> None:
|
|
24
|
+
for key, val in patch.items():
|
|
25
|
+
if isinstance(dst.get(key), dict) and isinstance(val, dict):
|
|
26
|
+
_merge(dst[key], val)
|
|
27
|
+
else:
|
|
28
|
+
dst[key] = val
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_schema(spec: Any) -> dict:
|
|
32
|
+
"""Build a JSON Schema approximation from a DSL script.
|
|
33
|
+
|
|
34
|
+
The algorithm scans all steps that write to paths, infers basic JSON types from
|
|
35
|
+
literal values and respects replace_root / foreach / $eval nesting.
|
|
36
|
+
"""
|
|
37
|
+
root: Dict[str, Any] = {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {},
|
|
40
|
+
"required": [],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
steps = normalize_actions(spec)
|
|
44
|
+
|
|
45
|
+
def _wild(pointer: str) -> str:
|
|
46
|
+
"""Replace any ${...} template segments in pointer with '*' to avoid collisions."""
|
|
47
|
+
return re.sub(r"\$\{[^}]+}", "*", pointer)
|
|
48
|
+
|
|
49
|
+
def _ensure(pointer: str, is_append: bool) -> Dict[str, Any]:
|
|
50
|
+
"""Ensure that the schema tree contains the path described by pointer."""
|
|
51
|
+
cur = root
|
|
52
|
+
for tok in _split_pointer(pointer):
|
|
53
|
+
cur.setdefault("required", [])
|
|
54
|
+
if tok not in cur["required"]:
|
|
55
|
+
cur["required"].append(tok)
|
|
56
|
+
|
|
57
|
+
cur = cur.setdefault("properties", {}).setdefault(
|
|
58
|
+
tok,
|
|
59
|
+
{"type": "object", "properties": {}},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if is_append:
|
|
63
|
+
cur["type"] = "array"
|
|
64
|
+
|
|
65
|
+
return cur
|
|
66
|
+
|
|
67
|
+
def _scan_value(val: Any) -> None:
|
|
68
|
+
if isinstance(val, Mapping):
|
|
69
|
+
if "$eval" in val:
|
|
70
|
+
nested = normalize_actions(val["$eval"])
|
|
71
|
+
_scan(nested)
|
|
72
|
+
|
|
73
|
+
for v in val.values():
|
|
74
|
+
_scan_value(v)
|
|
75
|
+
|
|
76
|
+
elif isinstance(val, (list, tuple)):
|
|
77
|
+
for item in val:
|
|
78
|
+
_scan_value(item)
|
|
79
|
+
|
|
80
|
+
def _scan(items: List[dict]) -> None:
|
|
81
|
+
for step in items:
|
|
82
|
+
op = step.get("op")
|
|
83
|
+
|
|
84
|
+
if op == "replace_root":
|
|
85
|
+
val = step.get("value")
|
|
86
|
+
if not (isinstance(val, Mapping) and "$ref" in val):
|
|
87
|
+
root["type"] = _JSON_TYPE.get(type(val), "object")
|
|
88
|
+
_scan_value(val)
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
if "do" in step and isinstance(step["do"], list):
|
|
92
|
+
_scan(step["do"])
|
|
93
|
+
|
|
94
|
+
if "path" in step:
|
|
95
|
+
path_raw = step["path"]
|
|
96
|
+
path = _wild(path_raw)
|
|
97
|
+
|
|
98
|
+
is_append = path.endswith("/-")
|
|
99
|
+
leaf = _ensure(path[:-2] if is_append else path, is_append)
|
|
100
|
+
|
|
101
|
+
if "value" in step and not isinstance(step["value"], Mapping):
|
|
102
|
+
leaf["type"] = _JSON_TYPE.get(type(step["value"]), "object")
|
|
103
|
+
|
|
104
|
+
_scan_value(step.get("value"))
|
|
105
|
+
else:
|
|
106
|
+
_scan_value(step.get("value"))
|
|
107
|
+
|
|
108
|
+
_scan(steps)
|
|
109
|
+
return root
|
j_perm/utils/__init__.py
ADDED
|
File without changes
|
j_perm/utils/pointers.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
j_perm/utils/special.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from .pointers import maybe_slice
|
|
7
|
+
from .subst import substitute
|
|
8
|
+
from ..engine import apply_actions
|
|
9
|
+
|
|
10
|
+
_MISSING = object()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_special(val: Any, src: Mapping[str, Any]):
|
|
14
|
+
"""Resolve $ref / $eval constructs inside an arbitrary value tree."""
|
|
15
|
+
if isinstance(val, dict):
|
|
16
|
+
if "$ref" in val:
|
|
17
|
+
ptr = substitute(val["$ref"], src)
|
|
18
|
+
dflt = val.get("$default", _MISSING)
|
|
19
|
+
try:
|
|
20
|
+
return copy.deepcopy(maybe_slice(ptr, src))
|
|
21
|
+
except Exception:
|
|
22
|
+
if dflt is not _MISSING:
|
|
23
|
+
return copy.deepcopy(dflt)
|
|
24
|
+
raise
|
|
25
|
+
|
|
26
|
+
if "$eval" in val:
|
|
27
|
+
out = apply_actions(val["$eval"], dest={}, source=src)
|
|
28
|
+
if "$select" in val:
|
|
29
|
+
sel = maybe_slice(val["$select"], out) # type: ignore[arg-type]
|
|
30
|
+
return sel
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
return {k: resolve_special(v, src) for k, v in val.items()}
|
|
34
|
+
|
|
35
|
+
if isinstance(val, list):
|
|
36
|
+
return [resolve_special(x, src) for x in val]
|
|
37
|
+
|
|
38
|
+
if isinstance(val, tuple):
|
|
39
|
+
return tuple(resolve_special(x, src) for x in val)
|
|
40
|
+
|
|
41
|
+
return val
|
j_perm/utils/subst.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Mapping, List
|
|
6
|
+
|
|
7
|
+
import jmespath
|
|
8
|
+
|
|
9
|
+
from ..jmes_ext import JP_OPTIONS
|
|
10
|
+
|
|
11
|
+
_CASTERS = {
|
|
12
|
+
"int": int,
|
|
13
|
+
"float": float,
|
|
14
|
+
"str": str,
|
|
15
|
+
"bool": lambda x: bool(int(x)) if isinstance(x, (int, str)) else bool(x),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_expr(expr: str, data: Mapping[str, Any]) -> Any:
|
|
20
|
+
"""Resolve a single ${...} expression body."""
|
|
21
|
+
from .pointers import maybe_slice # local import to avoid cycles
|
|
22
|
+
|
|
23
|
+
expr = expr.strip()
|
|
24
|
+
|
|
25
|
+
# 1) simple casters like int:/path
|
|
26
|
+
for prefix, fn in _CASTERS.items():
|
|
27
|
+
tag = f"{prefix}:"
|
|
28
|
+
if expr.startswith(tag):
|
|
29
|
+
inner = expr[len(tag):]
|
|
30
|
+
value = flat_substitute(inner, data)
|
|
31
|
+
if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
|
|
32
|
+
value = flat_substitute(value, data)
|
|
33
|
+
return fn(value)
|
|
34
|
+
|
|
35
|
+
# 2) JMESPath expression ? <expr>
|
|
36
|
+
if expr.startswith("?"):
|
|
37
|
+
query_raw = expr[1:].lstrip()
|
|
38
|
+
query_expanded = flat_substitute(query_raw, data)
|
|
39
|
+
return jmespath.search(query_expanded, data, options=JP_OPTIONS)
|
|
40
|
+
|
|
41
|
+
# 3) nested template ${...}
|
|
42
|
+
if expr.startswith("${") and expr.endswith("}"):
|
|
43
|
+
return flat_substitute(expr, data)
|
|
44
|
+
|
|
45
|
+
# 4) default: treat as JSON Pointer (relative to root)
|
|
46
|
+
pointer = "/" + expr.lstrip("/")
|
|
47
|
+
try:
|
|
48
|
+
return maybe_slice(pointer, data) # type: ignore[arg-type]
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def flat_substitute(tmpl: str, data: Mapping[str, Any]) -> Any:
|
|
54
|
+
"""One-pass interpolation that replaces all ${...} occurrences in a string."""
|
|
55
|
+
if "${" not in tmpl:
|
|
56
|
+
return tmpl
|
|
57
|
+
|
|
58
|
+
# Entire string is a single ${...}
|
|
59
|
+
if tmpl.startswith("${") and tmpl.endswith("}"):
|
|
60
|
+
body = tmpl[2:-1]
|
|
61
|
+
return copy.deepcopy(_resolve_expr(body, data))
|
|
62
|
+
|
|
63
|
+
out: List[str] = []
|
|
64
|
+
i = 0
|
|
65
|
+
while i < len(tmpl):
|
|
66
|
+
if tmpl[i:i + 2] == "${":
|
|
67
|
+
depth = 0
|
|
68
|
+
j = i + 2
|
|
69
|
+
|
|
70
|
+
while j < len(tmpl):
|
|
71
|
+
ch = tmpl[j]
|
|
72
|
+
|
|
73
|
+
if ch == "{" and tmpl[j - 1] == "$":
|
|
74
|
+
depth += 1
|
|
75
|
+
elif ch == "}":
|
|
76
|
+
if depth == 0:
|
|
77
|
+
expr = tmpl[i + 2:j]
|
|
78
|
+
val = _resolve_expr(expr, data)
|
|
79
|
+
|
|
80
|
+
if isinstance(val, (Mapping, list)):
|
|
81
|
+
rendered = json.dumps(val, ensure_ascii=False)
|
|
82
|
+
else:
|
|
83
|
+
rendered = str(val)
|
|
84
|
+
|
|
85
|
+
out.append(rendered)
|
|
86
|
+
i = j + 1
|
|
87
|
+
break
|
|
88
|
+
depth -= 1
|
|
89
|
+
|
|
90
|
+
j += 1
|
|
91
|
+
else:
|
|
92
|
+
# no closing brace found, treat '${' as a literal
|
|
93
|
+
out.append(tmpl[i])
|
|
94
|
+
i += 1
|
|
95
|
+
else:
|
|
96
|
+
out.append(tmpl[i])
|
|
97
|
+
i += 1
|
|
98
|
+
|
|
99
|
+
return "".join(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def deep_substitute(obj: Any, data: Mapping[str, Any], _depth: int = 0) -> Any:
|
|
103
|
+
"""Recursively apply interpolation to strings, mapping keys/values and sequences."""
|
|
104
|
+
if _depth > 50:
|
|
105
|
+
raise RecursionError("too deep interpolation")
|
|
106
|
+
|
|
107
|
+
if isinstance(obj, str):
|
|
108
|
+
out = flat_substitute(obj, data)
|
|
109
|
+
if isinstance(out, str) and "${" in out:
|
|
110
|
+
return deep_substitute(out, data, _depth + 1)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
if isinstance(obj, list):
|
|
114
|
+
return [deep_substitute(item, data, _depth) for item in obj]
|
|
115
|
+
|
|
116
|
+
if isinstance(obj, tuple):
|
|
117
|
+
return [deep_substitute(item, data, _depth) for item in obj]
|
|
118
|
+
|
|
119
|
+
if isinstance(obj, Mapping):
|
|
120
|
+
out: dict[Any, Any] = {}
|
|
121
|
+
for k, v in obj.items():
|
|
122
|
+
new_key = deep_substitute(k, data, _depth) if isinstance(k, str) else k
|
|
123
|
+
if new_key in out:
|
|
124
|
+
raise KeyError(f"duplicate key after substitution: {new_key!r}")
|
|
125
|
+
out[new_key] = deep_substitute(v, data, _depth)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
return obj
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Public alias mirroring the original _substitute name
|
|
132
|
+
substitute = deep_substitute
|
|
133
|
+
substitute.__name__ = "substitute"
|
j_perm/utils/tuples.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def tuples_to_lists(obj: Any) -> Any:
|
|
7
|
+
"""Recursively convert all tuples into lists so JMESPath indexers work reliably."""
|
|
8
|
+
if isinstance(obj, tuple):
|
|
9
|
+
return [tuples_to_lists(x) for x in obj]
|
|
10
|
+
|
|
11
|
+
if isinstance(obj, list):
|
|
12
|
+
return [tuples_to_lists(x) for x in obj]
|
|
13
|
+
|
|
14
|
+
if isinstance(obj, Mapping):
|
|
15
|
+
return {k: tuples_to_lists(v) for k, v in obj.items()}
|
|
16
|
+
|
|
17
|
+
return obj
|