fancy-template 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.
@@ -0,0 +1,350 @@
1
+ """Everything that can be known about a template without data (SPEC.md sections
2
+ 2, 3, 5, 8, 9, 10 and 11). The answer an admin gets at SAVE time.
3
+
4
+ Issues come in document order so that three runtimes produce comparable lists:
5
+ envelope, declarations, then the document depth-first with an object's ``$``
6
+ keys before its other entries.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from fancy_expr import ExprSyntaxError, references
14
+
15
+ from ._declarations import inspect_decl
16
+ from ._errors import TemplateError
17
+ from ._filters import check_filter
18
+ from ._json import first_too_deep, image_type, pointer, string_bytes, utf8_length
19
+ from ._limits import Limits
20
+ from ._profile import Profile
21
+ from ._scanner import LOOP_HEADS, count_tokens, is_digits, is_name, path_only, tokenize
22
+
23
+ DeclFrame = dict[str, Any] # {"name": str, "decl": Any}
24
+
25
+ _ENVELOPE_REQUIRED = (
26
+ ("fancyTemplate", "TEMPLATE_VERSION"),
27
+ ("writer", "TEMPLATE_WRITER"),
28
+ ("document", "TEMPLATE_SHAPE"),
29
+ )
30
+
31
+
32
+ def declared_variables(template: Any) -> dict[str, Any]:
33
+ """The declared variables, every entry in written order, as the other legs read them."""
34
+ if not isinstance(template, dict):
35
+ return {}
36
+ variables = template.get("variables")
37
+ return dict(variables) if isinstance(variables, dict) else {}
38
+
39
+
40
+ def resolve_decl(steps: list[str], frames: list[DeclFrame], variables: dict[str, Any]) -> tuple[bool, Any]:
41
+ """Walk a path through the declarations (SPEC.md section 6).
42
+
43
+ ``(found, decl)``; decl is None when the shape below is unknown (a broken
44
+ declaration has already been reported, and is not reported twice).
45
+ """
46
+ head = steps[0]
47
+ if head in LOOP_HEADS:
48
+ if not frames or len(steps) > 1:
49
+ return False, None
50
+ return True, {"type": "integer" if head == "$index" else "boolean"}
51
+
52
+ decl: Any = None
53
+ found = False
54
+ for frame in reversed(frames):
55
+ if frame["name"] == head:
56
+ found, decl = True, frame["decl"]
57
+ break
58
+ if not found:
59
+ if head not in variables:
60
+ return False, None
61
+ decl = variables[head]
62
+
63
+ for step in steps[1:]:
64
+ if not isinstance(decl, dict) or decl.get("type") is None:
65
+ return True, None
66
+ properties = decl.get("properties")
67
+ if decl["type"] == "object" and isinstance(properties, dict) and step in properties:
68
+ decl = properties[step]
69
+ continue
70
+ if decl["type"] == "array" and is_digits(step):
71
+ decl = decl.get("items")
72
+ continue
73
+ return False, None
74
+ return True, decl
75
+
76
+
77
+ class Inspector:
78
+ def __init__(self, limits: Limits) -> None:
79
+ self.limits = limits
80
+ self.issues: list[dict[str, str]] = []
81
+ self.variables: dict[str, Any] = {}
82
+ self.profile: Profile | None = None
83
+
84
+ @classmethod
85
+ def run(cls, template: Any, limits: Limits) -> list[dict[str, str]]:
86
+ self = cls(limits)
87
+ self._inspect(template)
88
+ return self.issues
89
+
90
+ def _inspect(self, template: Any) -> None:
91
+ if not isinstance(template, dict):
92
+ self._issue("TEMPLATE_SHAPE", "")
93
+ return
94
+
95
+ # The three bounds that STOP inspect come first, in this order (template/inspect 0020).
96
+ too_deep = first_too_deep(template, self.limits.depth)
97
+ if too_deep is not None:
98
+ self._issue("TEMPLATE_TOO_DEEP", too_deep)
99
+ return
100
+ if string_bytes(template) > self.limits.template_bytes:
101
+ self._issue("TEMPLATE_TOO_LARGE", "")
102
+ return
103
+ if self._count_tokens(template.get("document")) > self.limits.tokens:
104
+ self._issue("TEMPLATE_TOO_MANY_TOKENS", "")
105
+ return
106
+
107
+ for key, value in template.items():
108
+ at = pointer("", key)
109
+ if key == "fancyTemplate":
110
+ # `True == 1` in Python; the version is the integer 1 and nothing else.
111
+ if not (type(value) is int and value == 1) and not (type(value) is float and value == 1.0):
112
+ self._issue("TEMPLATE_VERSION", at)
113
+ elif key == "writer":
114
+ self.profile = Profile.for_writer(value)
115
+ if self.profile is None:
116
+ self._issue("TEMPLATE_WRITER", at)
117
+ elif key == "description":
118
+ if not isinstance(value, str):
119
+ self._issue("TEMPLATE_SHAPE", at)
120
+ elif key in ("variables", "document"):
121
+ if not isinstance(value, dict):
122
+ self._issue("TEMPLATE_SHAPE", at)
123
+ else:
124
+ self._issue("TEMPLATE_SHAPE", at)
125
+ for key, code in _ENVELOPE_REQUIRED:
126
+ if key not in template:
127
+ self._issue(code, pointer("", key))
128
+
129
+ variables = template.get("variables")
130
+ if isinstance(variables, dict):
131
+ for name, decl in variables.items():
132
+ at = pointer("/variables", name)
133
+ if not is_name(name):
134
+ self._issue("DECL_INVALID", at)
135
+ continue
136
+ inspect_decl(decl, at, self.issues)
137
+ self.variables[name] = decl
138
+
139
+ document = template.get("document")
140
+ if self.profile is not None and isinstance(document, dict):
141
+ self._walk(document, "/document", [], [], 0, False)
142
+
143
+ def _count_tokens(self, value: Any) -> int:
144
+ if isinstance(value, str):
145
+ return count_tokens(value, self.limits.token_length)
146
+ if not isinstance(value, (dict, list)):
147
+ return 0
148
+ n = 0
149
+ for v in value.values() if isinstance(value, dict) else value:
150
+ n += self._count_tokens(v)
151
+ if n > self.limits.tokens:
152
+ break
153
+ return n
154
+
155
+ def _walk(
156
+ self,
157
+ value: Any,
158
+ path: str,
159
+ segments: list[str],
160
+ frames: list[DeclFrame],
161
+ directive_depth: int,
162
+ is_element: bool,
163
+ ) -> None:
164
+ if isinstance(value, str):
165
+ self._string(value, path, segments, frames)
166
+ return
167
+ if isinstance(value, list):
168
+ for i, item in enumerate(value):
169
+ self._walk(item, pointer(path, i), [*segments, str(i)], frames, directive_depth, True)
170
+ return
171
+ if not isinstance(value, dict):
172
+ return
173
+
174
+ directives = [k for k in value if k.startswith("$")]
175
+ if directives:
176
+ if not is_element:
177
+ for key in directives:
178
+ self._issue("DIRECTIVE_MISPLACED", pointer(path, key))
179
+ else:
180
+ directive_depth += 1
181
+ if directive_depth > self.limits.directive_depth:
182
+ self._issue("DIRECTIVE_TOO_DEEP", path)
183
+ return
184
+ frames = self._directives(value, path, frames)
185
+ if "$value" in value:
186
+ self._splice(value, path, segments, frames, directive_depth)
187
+ return
188
+
189
+ for key, child in value.items():
190
+ if key.startswith("$"):
191
+ continue
192
+ at = pointer(path, key)
193
+ if "{{" in key:
194
+ self._issue("TOKEN_IN_KEY", at)
195
+ self._walk(child, at, [*segments, key], frames, directive_depth, False)
196
+
197
+ def _splice(
198
+ self, obj: dict[str, Any], path: str, segments: list[str], frames: list[DeclFrame], directive_depth: int
199
+ ) -> None:
200
+ """The rest of a splice (SPEC.md section 8), after its ``$`` keys.
201
+
202
+ Every other key is DIRECTIVE_SPLICE_KEY and is not walked. ``$value`` is
203
+ walked AT THE ELEMENT'S PATH (``$value`` is a pointer step, never a
204
+ profile segment) and as a non-element, so a ``$``-key on its top is
205
+ DIRECTIVE_MISPLACED. Without ``$each`` it is not walked: that is already
206
+ DIRECTIVE_INVALID.
207
+ """
208
+ for key in obj:
209
+ if not key.startswith("$"):
210
+ self._issue("DIRECTIVE_SPLICE_KEY", pointer(path, key))
211
+ if "$each" in obj:
212
+ self._walk(obj["$value"], pointer(path, "$value"), segments, frames, directive_depth, False)
213
+
214
+ def _each_binding(self, obj: dict[str, Any], frames: list[DeclFrame]) -> list[DeclFrame] | None:
215
+ """The frames an element's ``$each`` introduces, or None.
216
+
217
+ Worked out BEFORE the ordered pass so that ``$if`` sees the binding
218
+ whichever key was written first: SPEC.md section 8 says ``$each`` runs
219
+ first, and JSON key order is not something an author should get right.
220
+ """
221
+ each = obj.get("$each")
222
+ if not isinstance(each, str) or utf8_length(each) > self.limits.token_length:
223
+ return None
224
+ steps = path_only(each)
225
+ if steps is None:
226
+ return None
227
+ found, decl = resolve_decl(steps, frames, self.variables)
228
+ if not found or (isinstance(decl, dict) and decl.get("type") != "array"):
229
+ return None
230
+ as_name = obj.get("$as")
231
+ if as_name is None:
232
+ as_name = "item"
233
+ if not isinstance(as_name, str) or not is_name(as_name):
234
+ return None
235
+ return [*frames, {"name": as_name, "decl": decl.get("items") if isinstance(decl, dict) else None}]
236
+
237
+ def _directives(self, obj: dict[str, Any], path: str, frames: list[DeclFrame]) -> list[DeclFrame]:
238
+ has_each = "$each" in obj
239
+ inner = self._each_binding(obj, frames) or frames
240
+
241
+ for key, value in obj.items():
242
+ if not key.startswith("$"):
243
+ continue
244
+ at = pointer(path, key)
245
+
246
+ if key == "$each":
247
+ if not isinstance(value, str):
248
+ self._issue("DIRECTIVE_INVALID", at)
249
+ continue
250
+ if utf8_length(value) > self.limits.token_length:
251
+ self._issue("TOKEN_TOO_LONG", at)
252
+ continue
253
+ steps = path_only(value)
254
+ if steps is None:
255
+ self._issue("DIRECTIVE_INVALID", at)
256
+ continue
257
+ found, decl = resolve_decl(steps, frames, self.variables)
258
+ if not found:
259
+ self._issue("DIRECTIVE_UNDECLARED", at, ".".join(steps))
260
+ continue
261
+ if isinstance(decl, dict) and decl.get("type") != "array":
262
+ self._issue("DIRECTIVE_NOT_ARRAY", at, ".".join(steps))
263
+ continue
264
+
265
+ if key == "$as":
266
+ shadows = isinstance(value, str) and (
267
+ value in self.variables or any(f["name"] == value for f in frames)
268
+ )
269
+ if not has_each or not isinstance(value, str) or not is_name(value) or shadows:
270
+ self._issue("DIRECTIVE_INVALID", at)
271
+ continue
272
+
273
+ if key == "$value":
274
+ # Any JSON is a valid $value; only a splice with nothing to
275
+ # repeat over is wrong (SPEC.md section 8).
276
+ if not has_each:
277
+ self._issue("DIRECTIVE_INVALID", at)
278
+ continue
279
+
280
+ if key == "$if":
281
+ if not isinstance(value, str):
282
+ self._issue("DIRECTIVE_INVALID", at)
283
+ continue
284
+ if utf8_length(value) > self.limits.token_length:
285
+ self._issue("TOKEN_TOO_LONG", at)
286
+ continue
287
+ try:
288
+ roots = references(value)
289
+ except ExprSyntaxError:
290
+ self._issue("DIRECTIVE_EXPR", at)
291
+ continue
292
+ # `$if` sees this element's own loop binding (SPEC.md section 8).
293
+ scope = inner if has_each else frames
294
+ for root in roots:
295
+ if not self._in_scope(root, scope):
296
+ self._issue("DIRECTIVE_UNDECLARED", at, root)
297
+ break
298
+ continue
299
+
300
+ self._issue("DIRECTIVE_UNKNOWN", at)
301
+
302
+ return inner
303
+
304
+ def _in_scope(self, root: str, frames: list[DeclFrame]) -> bool:
305
+ if root in LOOP_HEADS:
306
+ return bool(frames)
307
+ return any(f["name"] == root for f in frames) or root in self.variables
308
+
309
+ def _string(self, value: str, path: str, segments: list[str], frames: list[DeclFrame]) -> None:
310
+ try:
311
+ parts = tokenize(value, self.limits.token_length)
312
+ except TemplateError as e:
313
+ self._issue(e.code, path)
314
+ return
315
+
316
+ tokens = [p for p in parts if "text" not in p]
317
+ assert self.profile is not None
318
+ field = self.profile.field(segments)
319
+
320
+ if not tokens:
321
+ if field is not None and field["encode"] == "image" and image_type(value) not in field["mime"]:
322
+ self._issue("TEMPLATE_IMAGE_INVALID", path)
323
+ return
324
+ if field is None:
325
+ self._issue("TOKEN_FORBIDDEN_FIELD", path)
326
+ return
327
+
328
+ if field["encode"] == "image":
329
+ token = tokens[0]
330
+ is_whole = len(parts) == 1 and "path" in token and token["filters"] == []
331
+ found, decl = resolve_decl(token["path"], frames, self.variables) if is_whole else (False, None)
332
+ if not is_whole or not found or not isinstance(decl, dict) or decl.get("type") != "image":
333
+ self._issue("TOKEN_IMAGE_FIELD", path)
334
+ return
335
+
336
+ for token in tokens:
337
+ if "path" in token:
338
+ found, _ = resolve_decl(token["path"], frames, self.variables)
339
+ if not found:
340
+ self._issue("TOKEN_UNDECLARED", path, ".".join(token["path"]))
341
+ for filt in token["filters"]:
342
+ code = check_filter(filt)
343
+ if code is not None:
344
+ self._issue(code, path)
345
+
346
+ def _issue(self, code: str, path: str, variable: str | None = None) -> None:
347
+ issue = {"code": code, "path": path}
348
+ if variable is not None:
349
+ issue["variable"] = variable
350
+ self.issues.append(issue)
@@ -0,0 +1,207 @@
1
+ """Small JSON-shaped helpers shared by the inspector, validator and renderer.
2
+
3
+ Everything that walks a value of unknown depth here is ITERATIVE. Python's
4
+ recursion limit (1000 by default) sits well below the 4096 levels the other
5
+ legs accept as plain JSON, so a recursive walk would refuse, or crash on, data
6
+ the PHP and TypeScript legs handle.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import math
14
+ from typing import Any, TypeGuard
15
+
16
+ #: The deepest container a value may hold and still be plain JSON. PHP's
17
+ #: ``json_encode`` refuses anything deeper (its depth argument, 4096), and the
18
+ #: three legs must agree on what counts as plain, so this leg refuses it too.
19
+ PLAIN_MAX_DEPTH = 4096
20
+
21
+ _INT64_MIN = -(2**63)
22
+ _INT64_MAX = 2**63 - 1
23
+
24
+
25
+ def pointer(base: str, key: str | int) -> str:
26
+ return base + "/" + str(key).replace("~", "~0").replace("/", "~1")
27
+
28
+
29
+ def is_object(v: Any) -> bool:
30
+ return isinstance(v, dict)
31
+
32
+
33
+ def is_list(v: Any) -> bool:
34
+ return isinstance(v, list)
35
+
36
+
37
+ def is_number(v: Any) -> TypeGuard[int | float]:
38
+ """A JSON number. ``bool`` is an ``int`` in Python and is NOT one here."""
39
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
40
+
41
+
42
+ def utf8_length(s: str) -> int:
43
+ return len(s.encode("utf-8"))
44
+
45
+
46
+ def code_points(s: str) -> int:
47
+ """Unicode code points, JSON Schema's unit for maxLength. A ``str`` IS code points."""
48
+ return len(s)
49
+
50
+
51
+ class _NotPlainError(Exception):
52
+ pass
53
+
54
+
55
+ def _key(k: Any) -> str:
56
+ if isinstance(k, str):
57
+ _check_text(k)
58
+ return str.__str__(k)
59
+ if isinstance(k, int) and not isinstance(k, bool):
60
+ return str(int.__int__(k))
61
+ raise _NotPlainError
62
+
63
+
64
+ def _check_text(s: str) -> None:
65
+ # A lone surrogate is not Unicode text, and no other leg can represent it.
66
+ try:
67
+ str.encode(s, "utf-8")
68
+ except UnicodeEncodeError as exc:
69
+ raise _NotPlainError from exc
70
+
71
+
72
+ def _scalar(v: Any) -> Any:
73
+ if v is None or isinstance(v, bool):
74
+ return v
75
+ if isinstance(v, int):
76
+ v = int.__int__(v)
77
+ if _INT64_MIN <= v <= _INT64_MAX:
78
+ return v
79
+ # Past int64 PHP decodes a JSON integer as a float; so does this leg.
80
+ try:
81
+ f = float(v)
82
+ except OverflowError as exc:
83
+ raise _NotPlainError from exc
84
+ if not math.isfinite(f):
85
+ raise _NotPlainError
86
+ return f
87
+ if isinstance(v, float):
88
+ if not math.isfinite(v):
89
+ raise _NotPlainError
90
+ return float.__float__(v)
91
+ if isinstance(v, str):
92
+ _check_text(v)
93
+ return str.__str__(v)
94
+ raise _NotPlainError
95
+
96
+
97
+ def _is_container(v: Any) -> bool:
98
+ return isinstance(v, (dict, list, tuple))
99
+
100
+
101
+ def plain(value: Any) -> tuple[bool, Any]:
102
+ """Reduce a host value to plain JSON data (SPEC.md section 4).
103
+
104
+ ``(True, copy)`` or ``(False, None)``. Accepts exactly what ``json.dumps``
105
+ accepts without a ``default`` hook -- dict (str or int keys), list, tuple,
106
+ str, int, float, bool, None -- refuses non-finite numbers like
107
+ ``allow_nan=False``, refuses a lone surrogate and a cycle, and never calls a
108
+ method on a host object: a subclass is read through its base type.
109
+ """
110
+ try:
111
+ return True, _convert(value)
112
+ except _NotPlainError:
113
+ return False, None
114
+
115
+
116
+ def _convert(value: Any) -> Any:
117
+ if not _is_container(value):
118
+ return _scalar(value)
119
+
120
+ root: Any = {} if isinstance(value, dict) else []
121
+ # (source, destination, level) frames, with exit markers so the set of
122
+ # containers on the CURRENT path is known: a shared reference is fine
123
+ # (json.dumps allows it), a cycle is not.
124
+ stack: list[tuple[Any, Any, int] | int] = [(value, root, 1)]
125
+ on_path: set[int] = set()
126
+ while stack:
127
+ top = stack.pop()
128
+ if isinstance(top, int):
129
+ on_path.discard(top)
130
+ continue
131
+ src, dst, level = top
132
+ if level > PLAIN_MAX_DEPTH or id(src) in on_path:
133
+ raise _NotPlainError
134
+ on_path.add(id(src))
135
+ stack.append(id(src))
136
+ items = src.items() if isinstance(src, dict) else enumerate(src)
137
+ children: list[tuple[Any, Any, int]] = []
138
+ for k, v in items:
139
+ if _is_container(v):
140
+ child: Any = {} if isinstance(v, dict) else []
141
+ children.append((v, child, level + 1))
142
+ else:
143
+ child = _scalar(v)
144
+ if isinstance(dst, dict):
145
+ dst[_key(k)] = child
146
+ else:
147
+ dst.append(child)
148
+ stack.extend(reversed(children))
149
+ return root
150
+
151
+
152
+ def first_too_deep(value: Any, limit: int) -> str | None:
153
+ """Pointer of the first container, in document order, above ``limit`` levels (the root is level 1)."""
154
+ stack: list[tuple[Any, str, int]] = [(value, "", 1)]
155
+ while stack:
156
+ v, path, level = stack.pop()
157
+ if not isinstance(v, (dict, list)):
158
+ continue
159
+ if level > limit:
160
+ return path
161
+ entries = list(v.items()) if isinstance(v, dict) else list(enumerate(v))
162
+ for k, child in reversed(entries):
163
+ stack.append((child, pointer(path, k), level + 1))
164
+ return None
165
+
166
+
167
+ def string_bytes(value: Any) -> int:
168
+ """UTF-8 bytes of every string, object keys included."""
169
+ total = 0
170
+ stack = [value]
171
+ while stack:
172
+ v = stack.pop()
173
+ if isinstance(v, str):
174
+ total += utf8_length(v)
175
+ elif isinstance(v, list):
176
+ stack.extend(v)
177
+ elif isinstance(v, dict):
178
+ for k, child in v.items():
179
+ total += utf8_length(k)
180
+ stack.append(child)
181
+ return total
182
+
183
+
184
+ _B64 = frozenset(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
185
+ _MAGIC = (("image/png", b"\x89PNG\r\n\x1a\n"), ("image/jpeg", b"\xff\xd8\xff"))
186
+
187
+
188
+ def image_type(value: Any) -> str | None:
189
+ """The MIME type of a valid PNG or JPEG data URL (base64 strict, magic bytes checked), else None."""
190
+ if not isinstance(value, str):
191
+ return None
192
+ for mime, magic in _MAGIC:
193
+ prefix = f"data:{mime};base64,"
194
+ if not value.startswith(prefix):
195
+ continue
196
+ b64 = value[len(prefix) :]
197
+ if not b64.isascii():
198
+ return None
199
+ raw_b64 = b64.encode("ascii")
200
+ if raw_b64 == b"" or len(raw_b64) % 4 != 0 or not set(raw_b64) <= _B64:
201
+ return None
202
+ try:
203
+ raw = base64.b64decode(raw_b64, validate=True)
204
+ except (binascii.Error, ValueError):
205
+ return None
206
+ return mime if raw.startswith(magic) else None
207
+ return None
@@ -0,0 +1,54 @@
1
+ """The bounds from SPEC.md section 10. Every one has a stable error code and
2
+ every one is host-overridable through ``options["limits"]``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ DEFAULT_LIMITS: dict[str, int] = {
10
+ "templateBytes": 262144,
11
+ "tokens": 10000,
12
+ "tokenLength": 512,
13
+ "directiveDepth": 8,
14
+ "iterations": 10000,
15
+ "outputNodes": 50000,
16
+ "outputBytes": 8388608,
17
+ "depth": 64,
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Limits:
23
+ template_bytes: int
24
+ tokens: int
25
+ token_length: int
26
+ directive_depth: int
27
+ iterations: int
28
+ output_nodes: int
29
+ output_bytes: int
30
+ depth: int
31
+
32
+
33
+ def limits_from(options: dict[str, Any] | None) -> Limits:
34
+ given = (options or {}).get("limits") or {}
35
+ if not isinstance(given, dict):
36
+ raise TypeError("options.limits must be an object")
37
+ values = dict(DEFAULT_LIMITS)
38
+ for name, value in given.items():
39
+ if name not in DEFAULT_LIMITS:
40
+ raise ValueError(f"Unknown limit `{name}`")
41
+ # bool is an int in Python; a limit of `True` is not a number.
42
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
43
+ raise ValueError(f"Limit `{name}` must be a non-negative integer")
44
+ values[name] = value
45
+ return Limits(
46
+ template_bytes=values["templateBytes"],
47
+ tokens=values["tokens"],
48
+ token_length=values["tokenLength"],
49
+ directive_depth=values["directiveDepth"],
50
+ iterations=values["iterations"],
51
+ output_nodes=values["outputNodes"],
52
+ output_bytes=values["outputBytes"],
53
+ depth=values["depth"],
54
+ )