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.
- fancy_template/__init__.py +121 -0
- fancy_template/_dates.py +172 -0
- fancy_template/_declarations.py +188 -0
- fancy_template/_errors.py +26 -0
- fancy_template/_filters.py +118 -0
- fancy_template/_inspector.py +350 -0
- fancy_template/_json.py +207 -0
- fancy_template/_limits.py +54 -0
- fancy_template/_numbers.py +144 -0
- fancy_template/_profile.py +82 -0
- fancy_template/_renderer.py +277 -0
- fancy_template/_scanner.py +284 -0
- fancy_template/profiles/dark-slide.json +16 -0
- fancy_template/profiles/fine-print.json +18 -0
- fancy_template/profiles/holy-sheet.json +11 -0
- fancy_template/profiles/last-word.json +18 -0
- fancy_template/py.typed +0 -0
- fancy_template-0.2.0.dist-info/METADATA +67 -0
- fancy_template-0.2.0.dist-info/RECORD +21 -0
- fancy_template-0.2.0.dist-info/WHEEL +4 -0
- fancy_template-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
fancy_template/_json.py
ADDED
|
@@ -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
|
+
)
|