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,121 @@
|
|
|
1
|
+
"""fancy-template -- document templates for the Fancy writers.
|
|
2
|
+
|
|
3
|
+
One specification (``SPEC.md`` in the repository root, normative), three
|
|
4
|
+
implementations: this one, ``@particle-academy/fancy-template`` (TypeScript) and
|
|
5
|
+
``particle-academy/fancy-template`` (PHP). All three assert the same
|
|
6
|
+
``template/*`` rows from ``fancy-conformance``.
|
|
7
|
+
|
|
8
|
+
>>> from fancy_template import inspect, render
|
|
9
|
+
>>> template = {"fancyTemplate": 1, "writer": "fine-print",
|
|
10
|
+
... "variables": {"name": {"type": "string", "required": True}},
|
|
11
|
+
... "document": {"blocks": [{"type": "paragraph", "runs": [{"text": "Dear {{ name }}"}]}]}}
|
|
12
|
+
>>> inspect(template)["ok"]
|
|
13
|
+
True
|
|
14
|
+
>>> render(template, {"name": "Ada"})["blocks"][0]["runs"][0]["text"]
|
|
15
|
+
'Dear Ada'
|
|
16
|
+
|
|
17
|
+
The output is plain writer JSON, so the writers never depend on this package and
|
|
18
|
+
stay zero-dependency. Pass it through the writer's own validate and write.
|
|
19
|
+
|
|
20
|
+
Missing data is LOUD: ``render`` raises :class:`TemplateError` with a stable
|
|
21
|
+
``code``, a JSON Pointer ``path`` into the template and the ``variable`` path --
|
|
22
|
+
never a data value.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ._declarations import apply_defaults, validate_members
|
|
30
|
+
from ._errors import TemplateError
|
|
31
|
+
from ._inspector import Inspector, declared_variables
|
|
32
|
+
from ._json import first_too_deep, plain
|
|
33
|
+
from ._limits import DEFAULT_LIMITS, Limits, limits_from
|
|
34
|
+
from ._profile import Profile
|
|
35
|
+
from ._renderer import Renderer
|
|
36
|
+
from ._scanner import tokenize as _scan
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DEFAULT_LIMITS",
|
|
40
|
+
"TemplateError",
|
|
41
|
+
"inspect",
|
|
42
|
+
"preview",
|
|
43
|
+
"render",
|
|
44
|
+
"tokenize",
|
|
45
|
+
"validate_data",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.2.0"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def inspect(template: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
52
|
+
"""``{"ok": bool, "issues": [{code, path, variable?}]}`` -- at SAVE time, no data."""
|
|
53
|
+
limits = limits_from(options)
|
|
54
|
+
is_plain, value = plain(template)
|
|
55
|
+
issues = Inspector.run(value, limits) if is_plain else [{"code": "TEMPLATE_SHAPE", "path": ""}]
|
|
56
|
+
return {"ok": not issues, "issues": issues}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_data(template: Any, data: Any, options: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
|
60
|
+
"""Data errors in declaration order; empty when valid."""
|
|
61
|
+
return _prepare(template, data, limits_from(options))[1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render(template: Any, data: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
65
|
+
"""Render a template with data: inspect, then validate the data, then render.
|
|
66
|
+
|
|
67
|
+
Raises :class:`TemplateError` with the first failure (SPEC.md section 11).
|
|
68
|
+
"""
|
|
69
|
+
limits = limits_from(options)
|
|
70
|
+
is_plain, value = plain(template)
|
|
71
|
+
if not is_plain or not isinstance(value, dict):
|
|
72
|
+
raise TemplateError("TEMPLATE_SHAPE", "")
|
|
73
|
+
issues = Inspector.run(value, limits)
|
|
74
|
+
if issues:
|
|
75
|
+
first = issues[0]
|
|
76
|
+
raise TemplateError(first["code"], first["path"], first.get("variable"))
|
|
77
|
+
|
|
78
|
+
defaulted, errors = _prepare(value, data, limits)
|
|
79
|
+
if errors:
|
|
80
|
+
raise TemplateError(errors[0]["code"], errors[0]["path"])
|
|
81
|
+
|
|
82
|
+
profile = Profile.for_writer(value["writer"])
|
|
83
|
+
assert profile is not None
|
|
84
|
+
return Renderer.run(value["document"], defaulted, declared_variables(value), profile, limits)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def preview(template: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
88
|
+
"""Render with each variable's ``example``, falling back to its ``default``.
|
|
89
|
+
|
|
90
|
+
A required variable with neither fails, rather than inventing a value the
|
|
91
|
+
real data could not produce (template/resolve 0202).
|
|
92
|
+
"""
|
|
93
|
+
data: dict[str, Any] = {}
|
|
94
|
+
is_plain, value = plain(template)
|
|
95
|
+
variables = value.get("variables") if is_plain and isinstance(value, dict) else None
|
|
96
|
+
if isinstance(variables, dict):
|
|
97
|
+
for name, decl in variables.items():
|
|
98
|
+
if isinstance(decl, dict) and "example" in decl:
|
|
99
|
+
data[name] = decl["example"]
|
|
100
|
+
return render(template, data, options)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def tokenize(text: str, options: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
104
|
+
"""Split one string into text and token parts (SPEC.md section 5)."""
|
|
105
|
+
return _scan(text, limits_from(options).token_length)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _prepare(template: Any, data: Any, limits: Limits) -> tuple[dict[str, Any], list[dict[str, str]]]:
|
|
109
|
+
plain_template, t = plain(template)
|
|
110
|
+
plain_data, d = plain(data)
|
|
111
|
+
if not plain_data:
|
|
112
|
+
return {}, [{"path": "", "code": "DATA_TYPE"}]
|
|
113
|
+
too_deep = first_too_deep(d, limits.depth)
|
|
114
|
+
if too_deep is not None:
|
|
115
|
+
return {}, [{"path": too_deep, "code": "DATA_TOO_DEEP"}]
|
|
116
|
+
if not isinstance(d, dict):
|
|
117
|
+
return {}, [{"path": "", "code": "DATA_TYPE"}]
|
|
118
|
+
|
|
119
|
+
members = declared_variables(t) if plain_template else {}
|
|
120
|
+
errors = validate_members(d, members, "")
|
|
121
|
+
return apply_defaults(d, members), errors
|
fancy_template/_dates.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The ``date`` type and the ``date:"pattern"`` filter (SPEC.md sections 3 and 7).
|
|
2
|
+
|
|
3
|
+
English names only, no time-zone database, no locale, never ``datetime``
|
|
4
|
+
arithmetic: a certificate's date is the date the issuer wrote, and an offset is
|
|
5
|
+
carried but never applied.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_MONTHS = (
|
|
13
|
+
"January",
|
|
14
|
+
"February",
|
|
15
|
+
"March",
|
|
16
|
+
"April",
|
|
17
|
+
"May",
|
|
18
|
+
"June",
|
|
19
|
+
"July",
|
|
20
|
+
"August",
|
|
21
|
+
"September",
|
|
22
|
+
"October",
|
|
23
|
+
"November",
|
|
24
|
+
"December",
|
|
25
|
+
)
|
|
26
|
+
_DAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
|
27
|
+
|
|
28
|
+
#: Pattern letter => allowed run lengths.
|
|
29
|
+
_RUNS: dict[str, tuple[int, ...]] = {
|
|
30
|
+
"y": (2, 4),
|
|
31
|
+
"M": (1, 2, 3, 4),
|
|
32
|
+
"d": (1, 2),
|
|
33
|
+
"E": (3, 4),
|
|
34
|
+
"H": (1, 2),
|
|
35
|
+
"h": (1, 2),
|
|
36
|
+
"m": (2,),
|
|
37
|
+
"s": (2,),
|
|
38
|
+
"a": (1,),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Data, not template text, anchored with no nested quantifier: linear.
|
|
42
|
+
# `[0-9]` and re.ASCII, never a bare `\d`: in Python `\d` matches every Unicode
|
|
43
|
+
# digit, so a date spelled in Arabic-Indic digits would be one here and in no other leg. fullmatch,
|
|
44
|
+
# never `$`, which also matches before a trailing newline.
|
|
45
|
+
_ISO = re.compile(
|
|
46
|
+
r"([0-9]{4})-([0-9]{2})-([0-9]{2})"
|
|
47
|
+
r"(?:T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.[0-9]+)?)?(Z|[+-]([0-9]{2}):([0-9]{2}))?)?",
|
|
48
|
+
re.ASCII,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
CivilDate = dict[str, int]
|
|
52
|
+
Piece = tuple[str, "str | int"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_date(value: object) -> CivilDate | None:
|
|
56
|
+
"""Parse an ISO date or date-time. None when it is not one, or not a real calendar date and time."""
|
|
57
|
+
if not isinstance(value, str):
|
|
58
|
+
return None
|
|
59
|
+
m = _ISO.fullmatch(value)
|
|
60
|
+
if m is None:
|
|
61
|
+
return None
|
|
62
|
+
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
63
|
+
hour = int(m.group(4)) if m.group(4) is not None else 0
|
|
64
|
+
minute = int(m.group(5)) if m.group(5) is not None else 0
|
|
65
|
+
second = int(m.group(6)) if m.group(6) is not None else 0
|
|
66
|
+
if mo < 1 or mo > 12 or d < 1 or d > _days_in(y, mo):
|
|
67
|
+
return None
|
|
68
|
+
if hour > 23 or minute > 59 or second > 59:
|
|
69
|
+
return None
|
|
70
|
+
if m.group(8) is not None and (int(m.group(8)) > 23 or int(m.group(9)) > 59):
|
|
71
|
+
return None
|
|
72
|
+
return {"y": y, "m": mo, "d": d, "H": hour, "i": minute, "s": second}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_ascii_alpha(c: str) -> bool:
|
|
76
|
+
return "A" <= c <= "Z" or "a" <= c <= "z"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def compile_pattern(pattern: str) -> list[Piece] | None:
|
|
80
|
+
"""Compile a pattern to pieces, or None if invalid. Forward-only; quotes are literal, '' is one quote."""
|
|
81
|
+
out: list[Piece] = []
|
|
82
|
+
n = len(pattern)
|
|
83
|
+
i = 0
|
|
84
|
+
while i < n:
|
|
85
|
+
c = pattern[i]
|
|
86
|
+
if c == "'":
|
|
87
|
+
if i + 1 < n and pattern[i + 1] == "'":
|
|
88
|
+
out.append(("lit", "'"))
|
|
89
|
+
i += 2
|
|
90
|
+
continue
|
|
91
|
+
i += 1
|
|
92
|
+
lit = ""
|
|
93
|
+
while True:
|
|
94
|
+
if i >= n:
|
|
95
|
+
return None
|
|
96
|
+
if pattern[i] == "'":
|
|
97
|
+
if i + 1 < n and pattern[i + 1] == "'":
|
|
98
|
+
lit += "'"
|
|
99
|
+
i += 2
|
|
100
|
+
continue
|
|
101
|
+
i += 1
|
|
102
|
+
break
|
|
103
|
+
lit += pattern[i]
|
|
104
|
+
i += 1
|
|
105
|
+
out.append(("lit", lit))
|
|
106
|
+
continue
|
|
107
|
+
if _is_ascii_alpha(c):
|
|
108
|
+
run = 1
|
|
109
|
+
while i + run < n and pattern[i + run] == c:
|
|
110
|
+
run += 1
|
|
111
|
+
if c not in _RUNS or run not in _RUNS[c]:
|
|
112
|
+
return None
|
|
113
|
+
out.append((c, run))
|
|
114
|
+
i += run
|
|
115
|
+
continue
|
|
116
|
+
out.append(("lit", c))
|
|
117
|
+
i += 1
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def format_date(date: CivilDate, pieces: list[Piece]) -> str:
|
|
122
|
+
out = ""
|
|
123
|
+
for kind, value in pieces:
|
|
124
|
+
if kind == "lit":
|
|
125
|
+
out += str(value)
|
|
126
|
+
elif kind == "y":
|
|
127
|
+
out += f"{date['y']:04d}" if value == 4 else f"{date['y'] % 100:02d}"
|
|
128
|
+
elif kind == "M":
|
|
129
|
+
name = _MONTHS[date["m"] - 1]
|
|
130
|
+
out += {4: name, 3: name[:3], 2: f"{date['m']:02d}"}.get(int(value), str(date["m"]))
|
|
131
|
+
elif kind == "d":
|
|
132
|
+
out += f"{date['d']:02d}" if value == 2 else str(date["d"])
|
|
133
|
+
elif kind == "E":
|
|
134
|
+
day = _DAYS[_weekday(date)]
|
|
135
|
+
out += day if value == 4 else day[:3]
|
|
136
|
+
elif kind == "H":
|
|
137
|
+
out += f"{date['H']:02d}" if value == 2 else str(date["H"])
|
|
138
|
+
elif kind == "h":
|
|
139
|
+
h = _twelve(date["H"])
|
|
140
|
+
out += f"{h:02d}" if value == 2 else str(h)
|
|
141
|
+
elif kind == "m":
|
|
142
|
+
out += f"{date['i']:02d}"
|
|
143
|
+
elif kind == "s":
|
|
144
|
+
out += f"{date['s']:02d}"
|
|
145
|
+
elif kind == "a":
|
|
146
|
+
out += "AM" if date["H"] < 12 else "PM"
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _twelve(hour: int) -> int:
|
|
151
|
+
h = hour % 12
|
|
152
|
+
return 12 if h == 0 else h
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _weekday(date: CivilDate) -> int:
|
|
156
|
+
"""0 = Monday, from days since the civil epoch (Howard Hinnant's algorithm)."""
|
|
157
|
+
y = date["y"] - (1 if date["m"] <= 2 else 0)
|
|
158
|
+
# Truncating division, as in C: Python's // floors, which differs for negatives.
|
|
159
|
+
era = int((y if y >= 0 else y - 399) / 400)
|
|
160
|
+
yoe = y - era * 400
|
|
161
|
+
mp = (date["m"] + 9) % 12
|
|
162
|
+
doy = (153 * mp + 2) // 5 + date["d"] - 1
|
|
163
|
+
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
164
|
+
days = era * 146097 + doe - 719468
|
|
165
|
+
# 1970-01-01 was a Thursday (3). Python's % is already non-negative.
|
|
166
|
+
return (days % 7 + 3) % 7
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _days_in(y: int, m: int) -> int:
|
|
170
|
+
if m == 2:
|
|
171
|
+
return 29 if y % 4 == 0 and (y % 100 != 0 or y % 400 == 0) else 28
|
|
172
|
+
return 30 if m in (4, 6, 9, 11) else 31
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Variable declarations: checking them (inspect) and checking data against
|
|
2
|
+
them (validate_data). SPEC.md sections 3 and 4."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import math
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._dates import parse_date
|
|
11
|
+
from ._json import code_points, image_type, is_number, pointer
|
|
12
|
+
from ._scanner import is_name
|
|
13
|
+
|
|
14
|
+
TYPES = ("string", "number", "integer", "boolean", "date", "image", "array", "object")
|
|
15
|
+
|
|
16
|
+
Issue = dict[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_count(v: Any) -> bool:
|
|
20
|
+
"""A non-negative integer as section 3 defines integer: an integral VALUE, so 3.0 counts."""
|
|
21
|
+
return is_number(v) and math.isfinite(v) and float(v).is_integer() and v >= 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def inspect_decl(decl: Any, path: str, issues: list[Issue]) -> None:
|
|
25
|
+
"""Issues in one declaration, its own keys before its nested declarations."""
|
|
26
|
+
if not isinstance(decl, dict) or len(decl) == 0 or "type" not in decl:
|
|
27
|
+
issues.append({"code": "DECL_INVALID", "path": path})
|
|
28
|
+
return
|
|
29
|
+
type_ = decl["type"]
|
|
30
|
+
if not isinstance(type_, str) or type_ not in TYPES:
|
|
31
|
+
issues.append({"code": "DECL_INVALID", "path": pointer(path, "type")})
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
nested: list[tuple[Any, str]] = []
|
|
35
|
+
for key, value in decl.items():
|
|
36
|
+
at = pointer(path, key)
|
|
37
|
+
if key == "type":
|
|
38
|
+
bad = False
|
|
39
|
+
elif key == "required":
|
|
40
|
+
bad = not isinstance(value, bool)
|
|
41
|
+
elif key == "description":
|
|
42
|
+
bad = not isinstance(value, str)
|
|
43
|
+
elif key == "enum":
|
|
44
|
+
bad = (
|
|
45
|
+
type_ not in ("string", "number", "integer")
|
|
46
|
+
or not isinstance(value, list)
|
|
47
|
+
or len(value) == 0
|
|
48
|
+
or not all(type_ok(type_, v) for v in value)
|
|
49
|
+
)
|
|
50
|
+
elif key == "maxLength":
|
|
51
|
+
bad = type_ not in ("string", "date", "image") or not _is_count(value)
|
|
52
|
+
elif key == "maxItems":
|
|
53
|
+
bad = type_ != "array" or not _is_count(value)
|
|
54
|
+
elif key == "items":
|
|
55
|
+
bad = type_ != "array"
|
|
56
|
+
elif key == "properties":
|
|
57
|
+
bad = type_ != "object" or not isinstance(value, dict)
|
|
58
|
+
elif key in ("default", "example"):
|
|
59
|
+
bad = len(validate(value, decl, "")) > 0
|
|
60
|
+
else:
|
|
61
|
+
bad = True
|
|
62
|
+
if bad:
|
|
63
|
+
issues.append({"code": "DECL_INVALID", "path": at})
|
|
64
|
+
continue
|
|
65
|
+
if key == "items":
|
|
66
|
+
nested.append((value, at))
|
|
67
|
+
if key == "properties":
|
|
68
|
+
for name, sub in value.items():
|
|
69
|
+
if not is_name(name):
|
|
70
|
+
issues.append({"code": "DECL_INVALID", "path": pointer(at, name)})
|
|
71
|
+
continue
|
|
72
|
+
nested.append((sub, pointer(at, name)))
|
|
73
|
+
|
|
74
|
+
if (type_ == "array" and "items" not in decl) or (type_ == "object" and "properties" not in decl):
|
|
75
|
+
issues.append({"code": "DECL_INVALID", "path": path})
|
|
76
|
+
|
|
77
|
+
for sub, at in nested:
|
|
78
|
+
inspect_decl(sub, at, issues)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def usable(decl: Any) -> bool:
|
|
82
|
+
"""Whether a declaration passed inspect, so that validation may lean on its shape."""
|
|
83
|
+
issues: list[Issue] = []
|
|
84
|
+
inspect_decl(decl, "", issues)
|
|
85
|
+
return not issues
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate(value: Any, decl: dict[str, Any], path: str) -> list[dict[str, str]]:
|
|
89
|
+
"""Validate one PRESENT value against a declaration."""
|
|
90
|
+
type_ = decl["type"]
|
|
91
|
+
if not type_ok(type_, value):
|
|
92
|
+
return [{"path": path, "code": "DATA_TYPE"}]
|
|
93
|
+
if type_ == "date" and parse_date(value) is None:
|
|
94
|
+
return [{"path": path, "code": "DATA_FORMAT"}]
|
|
95
|
+
if type_ == "image" and image_type(value) is None:
|
|
96
|
+
return [{"path": path, "code": "DATA_FORMAT"}]
|
|
97
|
+
|
|
98
|
+
errors: list[dict[str, str]] = []
|
|
99
|
+
enum = decl.get("enum")
|
|
100
|
+
if enum is not None and not _in_enum(value, enum):
|
|
101
|
+
errors.append({"path": path, "code": "DATA_ENUM"})
|
|
102
|
+
max_length = decl.get("maxLength")
|
|
103
|
+
if is_number(max_length) and isinstance(value, str) and code_points(value) > max_length:
|
|
104
|
+
errors.append({"path": path, "code": "DATA_MAX_LENGTH"})
|
|
105
|
+
if type_ == "array" and isinstance(value, list):
|
|
106
|
+
max_items = decl.get("maxItems")
|
|
107
|
+
if is_number(max_items) and len(value) > max_items:
|
|
108
|
+
errors.append({"path": path, "code": "DATA_MAX_ITEMS"})
|
|
109
|
+
items = decl.get("items")
|
|
110
|
+
if isinstance(items, dict):
|
|
111
|
+
for i, item in enumerate(value):
|
|
112
|
+
errors.extend(validate(item, items, pointer(path, i)))
|
|
113
|
+
if type_ == "object" and isinstance(value, dict):
|
|
114
|
+
properties = decl.get("properties")
|
|
115
|
+
if isinstance(properties, dict):
|
|
116
|
+
errors.extend(validate_members(value, properties, path))
|
|
117
|
+
return errors
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_members(obj: dict[str, Any], members: dict[str, Any], path: str) -> list[dict[str, str]]:
|
|
121
|
+
"""Validate an object's declared members, in declaration order."""
|
|
122
|
+
errors: list[dict[str, str]] = []
|
|
123
|
+
for name, decl in members.items():
|
|
124
|
+
if not usable(decl):
|
|
125
|
+
continue
|
|
126
|
+
at = pointer(path, name)
|
|
127
|
+
if name not in obj:
|
|
128
|
+
if "default" not in decl and decl.get("required") is True:
|
|
129
|
+
errors.append({"path": at, "code": "DATA_REQUIRED"})
|
|
130
|
+
continue
|
|
131
|
+
errors.extend(validate(obj[name], decl, at))
|
|
132
|
+
return errors
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def apply_defaults(obj: dict[str, Any], members: dict[str, Any]) -> dict[str, Any]:
|
|
136
|
+
"""Fill absent members that declare a default, recursively (SPEC.md section 4)."""
|
|
137
|
+
for name, decl in members.items():
|
|
138
|
+
if not isinstance(decl, dict):
|
|
139
|
+
continue
|
|
140
|
+
if name not in obj and "default" in decl:
|
|
141
|
+
# A copy: the default is the template's, and must not be edited in place below.
|
|
142
|
+
obj[name] = copy.deepcopy(decl["default"])
|
|
143
|
+
if name not in obj:
|
|
144
|
+
continue
|
|
145
|
+
current = obj[name]
|
|
146
|
+
properties = decl.get("properties")
|
|
147
|
+
if decl.get("type") == "object" and isinstance(properties, dict) and isinstance(current, dict):
|
|
148
|
+
obj[name] = apply_defaults(current, properties)
|
|
149
|
+
items = decl.get("items")
|
|
150
|
+
if (
|
|
151
|
+
decl.get("type") == "array"
|
|
152
|
+
and isinstance(items, dict)
|
|
153
|
+
and items.get("type") == "object"
|
|
154
|
+
and isinstance(items.get("properties"), dict)
|
|
155
|
+
and isinstance(current, list)
|
|
156
|
+
):
|
|
157
|
+
for i, item in enumerate(current):
|
|
158
|
+
if isinstance(item, dict):
|
|
159
|
+
current[i] = apply_defaults(item, items["properties"])
|
|
160
|
+
return obj
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def type_ok(type_: str, v: Any) -> bool:
|
|
164
|
+
if type_ in ("string", "date", "image"):
|
|
165
|
+
return isinstance(v, str)
|
|
166
|
+
if type_ == "number":
|
|
167
|
+
return is_number(v) and math.isfinite(v)
|
|
168
|
+
if type_ == "integer":
|
|
169
|
+
return is_number(v) and math.isfinite(v) and float(v).is_integer()
|
|
170
|
+
if type_ == "boolean":
|
|
171
|
+
return isinstance(v, bool)
|
|
172
|
+
if type_ == "array":
|
|
173
|
+
return isinstance(v, list)
|
|
174
|
+
if type_ == "object":
|
|
175
|
+
return isinstance(v, dict)
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _in_enum(value: Any, candidates: Any) -> bool:
|
|
180
|
+
if not isinstance(candidates, list):
|
|
181
|
+
return False
|
|
182
|
+
for c in candidates:
|
|
183
|
+
if isinstance(value, str) and isinstance(c, str) and value == c:
|
|
184
|
+
return True
|
|
185
|
+
# Compared by type AND value: `True == 1` is True in Python and must not be here.
|
|
186
|
+
if is_number(value) and is_number(c) and value == c:
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TemplateError(Exception):
|
|
5
|
+
"""Every refusal this package makes (SPEC.md section 6).
|
|
6
|
+
|
|
7
|
+
A stable ``code``, a JSON Pointer ``path`` into the TEMPLATE, and optionally
|
|
8
|
+
the ``variable`` PATH a token named. Never a data value, in the message or
|
|
9
|
+
anywhere else (plan section 5): a certificate's recipient name is personal
|
|
10
|
+
data, and an error is the thing that gets logged, shown to an admin and
|
|
11
|
+
pasted into an issue.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, code: str, path: str, variable: str | None = None) -> None:
|
|
15
|
+
where = "the root" if path == "" else path
|
|
16
|
+
what = "" if variable is None else f" (variable `{variable}`)"
|
|
17
|
+
super().__init__(f"{code} at {where}{what}")
|
|
18
|
+
self.code = code
|
|
19
|
+
self.path = path
|
|
20
|
+
self.variable = variable
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, str]:
|
|
23
|
+
out = {"code": self.code, "path": self.path}
|
|
24
|
+
if self.variable is not None:
|
|
25
|
+
out["variable"] = self.variable
|
|
26
|
+
return out
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The fixed filter list (SPEC.md section 7).
|
|
2
|
+
|
|
3
|
+
Not extensible from a template, and not from a host either in v1: every filter
|
|
4
|
+
is a conformance-pinned behaviour, and a host-registered one would be a fourth
|
|
5
|
+
dialect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Final
|
|
11
|
+
|
|
12
|
+
from ._dates import compile_pattern, format_date, parse_date
|
|
13
|
+
from ._errors import TemplateError
|
|
14
|
+
from ._json import is_number
|
|
15
|
+
from ._numbers import CURRENCIES, fixed, money, to_js_string
|
|
16
|
+
from ._scanner import is_digits
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Absent:
|
|
20
|
+
"""The absent value. A private singleton rather than a magic string because
|
|
21
|
+
data can contain any string -- a sentinel spelled in characters is one a
|
|
22
|
+
hostile value can forge -- and nothing that went through ``plain()`` is ever
|
|
23
|
+
an instance of this class."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ()
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return "ABSENT"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ABSENT: Final = _Absent()
|
|
32
|
+
|
|
33
|
+
FILTER_NAMES = ("default", "upper", "lower", "number", "money", "date", "join")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_filter(filt: dict[str, str]) -> str | None:
|
|
37
|
+
"""Static check of one filter. None when fine, else the issue code."""
|
|
38
|
+
name = filt["name"]
|
|
39
|
+
if name not in FILTER_NAMES:
|
|
40
|
+
return "FILTER_UNKNOWN"
|
|
41
|
+
arg = filt.get("arg")
|
|
42
|
+
if name == "default":
|
|
43
|
+
ok = arg is not None
|
|
44
|
+
elif name in ("upper", "lower"):
|
|
45
|
+
ok = arg is None
|
|
46
|
+
elif name == "number":
|
|
47
|
+
ok = arg is not None and is_digits(arg) and len(arg) <= 2 and int(arg) <= 10
|
|
48
|
+
elif name == "money":
|
|
49
|
+
ok = arg is not None and arg in CURRENCIES
|
|
50
|
+
elif name == "date":
|
|
51
|
+
ok = arg is not None and compile_pattern(arg) is not None
|
|
52
|
+
else: # join
|
|
53
|
+
ok = True
|
|
54
|
+
return None if ok else "FILTER_ARG"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _input() -> TemplateError:
|
|
58
|
+
return TemplateError("FILTER_INPUT", "")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scalar_text(value: Any) -> str:
|
|
62
|
+
"""A scalar as text (SPEC.md section 6). Arrays and objects are not scalars."""
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return value
|
|
65
|
+
if isinstance(value, bool): # before int: bool IS an int in Python
|
|
66
|
+
return "true" if value else "false"
|
|
67
|
+
if is_number(value):
|
|
68
|
+
return to_js_string(value)
|
|
69
|
+
raise TemplateError("TEMPLATE_NOT_SCALAR", "")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ascii_case(s: str, upper: bool) -> str:
|
|
73
|
+
# str.upper() is Unicode-aware ("ß".upper() is "SS"); the spec maps ASCII only.
|
|
74
|
+
if upper:
|
|
75
|
+
return "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in s)
|
|
76
|
+
return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in s)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_filter(filt: dict[str, str], value: Any) -> Any:
|
|
80
|
+
"""Apply one filter. ``value`` may be ABSENT. Raises TemplateError with an empty path."""
|
|
81
|
+
name = filt["name"]
|
|
82
|
+
arg = filt.get("arg")
|
|
83
|
+
|
|
84
|
+
if name == "default":
|
|
85
|
+
return arg if value is ABSENT or value is None else value
|
|
86
|
+
if value is ABSENT:
|
|
87
|
+
raise TemplateError("TEMPLATE_MISSING", "")
|
|
88
|
+
|
|
89
|
+
if name == "upper":
|
|
90
|
+
return _ascii_case(scalar_text(value), True)
|
|
91
|
+
if name == "lower":
|
|
92
|
+
return _ascii_case(scalar_text(value), False)
|
|
93
|
+
if name == "number":
|
|
94
|
+
if not is_number(value):
|
|
95
|
+
raise _input()
|
|
96
|
+
return fixed(value, int(arg or "0"))
|
|
97
|
+
if name == "money":
|
|
98
|
+
minor = value
|
|
99
|
+
if isinstance(minor, float) and minor.is_integer() and abs(minor) < 2**53:
|
|
100
|
+
minor = int(minor)
|
|
101
|
+
if isinstance(minor, bool) or not isinstance(minor, int):
|
|
102
|
+
raise _input()
|
|
103
|
+
return money(minor, str(arg))
|
|
104
|
+
if name == "date":
|
|
105
|
+
date = parse_date(value)
|
|
106
|
+
pieces = compile_pattern(str(arg))
|
|
107
|
+
if date is None or pieces is None:
|
|
108
|
+
raise _input()
|
|
109
|
+
return format_date(date, pieces)
|
|
110
|
+
# join
|
|
111
|
+
if not isinstance(value, list):
|
|
112
|
+
raise _input()
|
|
113
|
+
parts = []
|
|
114
|
+
for item in value:
|
|
115
|
+
if item is None or isinstance(item, (dict, list)):
|
|
116
|
+
raise _input()
|
|
117
|
+
parts.append(scalar_text(item))
|
|
118
|
+
return (", " if arg is None else arg).join(parts)
|