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,144 @@
|
|
|
1
|
+
"""Numbers to text, the same in every runtime (SPEC.md sections 6 and 7).
|
|
2
|
+
|
|
3
|
+
Everything starts from the SHORTEST round-trip decimal digits -- ``repr()`` of a
|
|
4
|
+
float gives exactly those, the same digits JavaScript's ``Number.toString``
|
|
5
|
+
picks -- and then works on the digits as a decimal string. Never ``str()`` of a
|
|
6
|
+
float (``str(3.0)`` is ``"3.0"``, JavaScript writes ``"3"``), never ``round()``
|
|
7
|
+
(banker's rounding, and it rounds the binary double: ``round(2.675, 2)`` is
|
|
8
|
+
2.67), never ``format(x, ",")`` (locale-free but binary-rounded).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
|
|
15
|
+
#: Currency => (minor-unit exponent, prefix symbol or None for "CODE ").
|
|
16
|
+
CURRENCIES: dict[str, tuple[int, str | None]] = {
|
|
17
|
+
"USD": (2, "$"),
|
|
18
|
+
"EUR": (2, "€"),
|
|
19
|
+
"GBP": (2, "£"),
|
|
20
|
+
"JPY": (0, "¥"),
|
|
21
|
+
"INR": (2, "₹"),
|
|
22
|
+
"KRW": (0, "₩"),
|
|
23
|
+
"CAD": (2, None),
|
|
24
|
+
"AUD": (2, None),
|
|
25
|
+
"NZD": (2, None),
|
|
26
|
+
"CHF": (2, None),
|
|
27
|
+
"CNY": (2, None),
|
|
28
|
+
"BRL": (2, None),
|
|
29
|
+
"MXN": (2, None),
|
|
30
|
+
"SEK": (2, None),
|
|
31
|
+
"NOK": (2, None),
|
|
32
|
+
"DKK": (2, None),
|
|
33
|
+
"PLN": (2, None),
|
|
34
|
+
"SGD": (2, None),
|
|
35
|
+
"HKD": (2, None),
|
|
36
|
+
"ZAR": (2, None),
|
|
37
|
+
"CLP": (0, None),
|
|
38
|
+
"VND": (0, None),
|
|
39
|
+
"ISK": (0, None),
|
|
40
|
+
"KWD": (3, None),
|
|
41
|
+
"BHD": (3, None),
|
|
42
|
+
"JOD": (3, None),
|
|
43
|
+
"OMR": (3, None),
|
|
44
|
+
"TND": (3, None),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def shortest(value: int | float) -> tuple[bool, str, int]:
|
|
49
|
+
"""Shortest round-trip decimal of a finite number: (negative, digits, point).
|
|
50
|
+
|
|
51
|
+
The value is 0.DIGITS x 10^point, with no leading or trailing zeros in
|
|
52
|
+
DIGITS ("0" for zero). An int is exact (PHP's int path).
|
|
53
|
+
"""
|
|
54
|
+
if value == 0:
|
|
55
|
+
return False, "0", 1
|
|
56
|
+
negative = value < 0
|
|
57
|
+
if isinstance(value, int):
|
|
58
|
+
text = str(abs(value))
|
|
59
|
+
return negative, text.rstrip("0"), len(text)
|
|
60
|
+
_sign, digit_tuple, exponent = Decimal(repr(abs(value))).as_tuple()
|
|
61
|
+
raw = "".join(str(d) for d in digit_tuple)
|
|
62
|
+
assert isinstance(exponent, int)
|
|
63
|
+
point = len(raw) + exponent
|
|
64
|
+
stripped = raw.lstrip("0")
|
|
65
|
+
point -= len(raw) - len(stripped)
|
|
66
|
+
digits = stripped.rstrip("0")
|
|
67
|
+
return negative, digits or "0", point
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_js_string(value: int | float) -> str:
|
|
71
|
+
"""JavaScript's Number.prototype.toString, for a finite number."""
|
|
72
|
+
negative, digits, point = shortest(value)
|
|
73
|
+
if digits == "0":
|
|
74
|
+
return "0"
|
|
75
|
+
k = len(digits)
|
|
76
|
+
sign = "-" if negative else ""
|
|
77
|
+
if k <= point <= 21:
|
|
78
|
+
return sign + digits + "0" * (point - k)
|
|
79
|
+
if 0 < point <= 21:
|
|
80
|
+
return sign + digits[:point] + "." + digits[point:]
|
|
81
|
+
if -6 < point <= 0:
|
|
82
|
+
return sign + "0." + "0" * (-point) + digits
|
|
83
|
+
e = point - 1
|
|
84
|
+
exp = "e" + ("-" if e < 0 else "+") + str(abs(e))
|
|
85
|
+
return sign + (digits if k == 1 else digits[0] + "." + digits[1:]) + exp
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fixed(value: int | float, decimals: int) -> str:
|
|
89
|
+
"""``number:decimals`` -- decimal rounding, half away from zero, comma groups."""
|
|
90
|
+
negative, digits, point = shortest(value)
|
|
91
|
+
if digits == "0":
|
|
92
|
+
int_part, frac = "0", ""
|
|
93
|
+
elif point <= 0:
|
|
94
|
+
int_part, frac = "0", "0" * (-point) + digits
|
|
95
|
+
elif point >= len(digits):
|
|
96
|
+
int_part, frac = digits + "0" * (point - len(digits)), ""
|
|
97
|
+
else:
|
|
98
|
+
int_part, frac = digits[:point], digits[point:]
|
|
99
|
+
|
|
100
|
+
frac = frac.ljust(decimals + 1, "0")
|
|
101
|
+
round_up = frac[decimals] >= "5"
|
|
102
|
+
kept = int_part + frac[:decimals]
|
|
103
|
+
if round_up:
|
|
104
|
+
kept = _increment(kept)
|
|
105
|
+
|
|
106
|
+
whole = kept[: len(kept) - decimals].lstrip("0") or "0"
|
|
107
|
+
frac_part = kept[len(kept) - decimals :]
|
|
108
|
+
is_zero = (whole + frac_part).strip("0") == ""
|
|
109
|
+
out = _group(whole) + ("." + frac_part if decimals > 0 else "")
|
|
110
|
+
return ("-" if negative and not is_zero else "") + out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def money(minor: int, currency: str) -> str:
|
|
114
|
+
"""``money:CUR`` -- integer minor units, never through a float."""
|
|
115
|
+
exponent, symbol = CURRENCIES[currency]
|
|
116
|
+
negative = minor < 0
|
|
117
|
+
digits = str(abs(minor)).rjust(exponent + 1, "0")
|
|
118
|
+
whole = digits[: len(digits) - exponent]
|
|
119
|
+
frac = "." + digits[len(digits) - exponent :] if exponent > 0 else ""
|
|
120
|
+
prefix = symbol if symbol is not None else currency + " "
|
|
121
|
+
return ("-" if negative else "") + prefix + _group(whole) + frac
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _increment(digits: str) -> str:
|
|
125
|
+
d = list(digits)
|
|
126
|
+
i = len(d) - 1
|
|
127
|
+
while i >= 0:
|
|
128
|
+
if d[i] == "9":
|
|
129
|
+
d[i] = "0"
|
|
130
|
+
i -= 1
|
|
131
|
+
continue
|
|
132
|
+
d[i] = str(int(d[i]) + 1)
|
|
133
|
+
return "".join(d)
|
|
134
|
+
return "1" + "".join(d)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _group(whole: str) -> str:
|
|
138
|
+
out = ""
|
|
139
|
+
n = len(whole)
|
|
140
|
+
for i, ch in enumerate(whole):
|
|
141
|
+
if i > 0 and (n - i) % 3 == 0:
|
|
142
|
+
out += ","
|
|
143
|
+
out += ch
|
|
144
|
+
return out
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""A writer profile (SPEC.md section 9): which string fields may hold tokens and
|
|
2
|
+
how a value is encoded there.
|
|
3
|
+
|
|
4
|
+
The profiles are DATA in the repository's ``profiles/``, shared by every
|
|
5
|
+
implementation. An installed wheel carries a copy inside the package (built
|
|
6
|
+
from that same directory); a source checkout reads the repository's own files,
|
|
7
|
+
so there is no second copy to drift.
|
|
8
|
+
|
|
9
|
+
An allowlist. A field that matches no pattern refuses tokens, so a writer gaining
|
|
10
|
+
a new field (a link, a font, a URL) is closed to data by default.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, ClassVar
|
|
19
|
+
|
|
20
|
+
#: The encodings this implementation has built. A profile needing any other is not offered.
|
|
21
|
+
IMPLEMENTED_ENCODINGS = ("text", "band", "markup", "markdown", "cell", "image")
|
|
22
|
+
|
|
23
|
+
_WRITER = re.compile(r"[a-z][a-z-]*", re.ASCII)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _profiles_dir() -> Path:
|
|
27
|
+
here = Path(__file__).resolve().parent
|
|
28
|
+
packaged = here / "profiles"
|
|
29
|
+
if packaged.is_dir():
|
|
30
|
+
return packaged
|
|
31
|
+
# python/src/fancy_template -> the repository root.
|
|
32
|
+
checkout = here.parents[2] / "profiles"
|
|
33
|
+
if checkout.is_dir():
|
|
34
|
+
return checkout
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"fancy-template: no profiles/ directory, neither packaged nor in the checkout. "
|
|
37
|
+
"This is an error and not an empty profile set: a template engine that silently "
|
|
38
|
+
"knows no writers refuses everything and reads as broken data."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Profile:
|
|
43
|
+
_cache: ClassVar[dict[str, Profile | None]] = {}
|
|
44
|
+
|
|
45
|
+
def __init__(self, writer: str, fields: list[dict[str, Any]]) -> None:
|
|
46
|
+
self.writer = writer
|
|
47
|
+
self._fields = fields
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def for_writer(cls, writer: Any) -> Profile | None:
|
|
51
|
+
"""The profile for a writer, or None when unknown or not implemented here."""
|
|
52
|
+
if not isinstance(writer, str) or _WRITER.fullmatch(writer) is None:
|
|
53
|
+
return None
|
|
54
|
+
if writer in cls._cache:
|
|
55
|
+
return cls._cache[writer]
|
|
56
|
+
file = _profiles_dir() / f"{writer}.json"
|
|
57
|
+
profile: Profile | None = None
|
|
58
|
+
if file.is_file():
|
|
59
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
60
|
+
fields = [{**f, "pattern": f["path"].split(".")} for f in data["fields"]]
|
|
61
|
+
if all(f["encode"] in IMPLEMENTED_ENCODINGS for f in fields):
|
|
62
|
+
profile = cls(writer, fields)
|
|
63
|
+
cls._cache[writer] = profile
|
|
64
|
+
return profile
|
|
65
|
+
|
|
66
|
+
def field(self, segments: list[str]) -> dict[str, Any] | None:
|
|
67
|
+
"""The field rule for a string at ``segments`` inside ``document``. First match wins."""
|
|
68
|
+
for f in self._fields:
|
|
69
|
+
if _matches(f["pattern"], 0, segments, 0):
|
|
70
|
+
return f
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _matches(pattern: list[str], p: int, path: list[str], s: int) -> bool:
|
|
75
|
+
"""Glob match: ``*`` is one segment, ``**`` zero or more. Patterns come from this package."""
|
|
76
|
+
if p == len(pattern):
|
|
77
|
+
return s == len(path)
|
|
78
|
+
if pattern[p] == "**":
|
|
79
|
+
return any(_matches(pattern, p + 1, path, k) for k in range(s, len(path) + 1))
|
|
80
|
+
if s == len(path):
|
|
81
|
+
return False
|
|
82
|
+
return (pattern[p] == "*" or pattern[p] == path[s]) and _matches(pattern, p + 1, path, s + 1)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Template + validated, defaulted data -> writer document (SPEC.md sections 6-10).
|
|
2
|
+
|
|
3
|
+
Runs only on a template that passed inspect and data that passed validate_data,
|
|
4
|
+
so it does not re-check what those guarantee; it checks what only data can
|
|
5
|
+
decide (absent paths, non-scalars, filter inputs, {page} in a band value) and
|
|
6
|
+
the output bounds, counted AS the output is produced so that an oversized result
|
|
7
|
+
is refused before it is built rather than after.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fancy_expr import evaluate, truthy
|
|
16
|
+
|
|
17
|
+
from ._dates import parse_date
|
|
18
|
+
from ._errors import TemplateError
|
|
19
|
+
from ._filters import ABSENT, apply_filter, scalar_text
|
|
20
|
+
from ._inspector import resolve_decl
|
|
21
|
+
from ._json import image_type, is_number, pointer, utf8_length
|
|
22
|
+
from ._limits import Limits
|
|
23
|
+
from ._profile import Profile
|
|
24
|
+
from ._scanner import LOOP_HEADS, is_digits, path_only, tokenize
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def escape_markdown(s: str) -> str:
|
|
28
|
+
"""CommonMark: a backslash before every ASCII punctuation character (SPEC.md section 9)."""
|
|
29
|
+
out = []
|
|
30
|
+
for ch in s:
|
|
31
|
+
o = ord(ch)
|
|
32
|
+
punct = 33 <= o <= 47 or 58 <= o <= 64 or 91 <= o <= 96 or 123 <= o <= 126
|
|
33
|
+
out.append("\\" + ch if punct else ch)
|
|
34
|
+
return "".join(out)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_HTML = {"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def escape_html(s: str) -> str:
|
|
41
|
+
return "".join(_HTML.get(ch, ch) for ch in s)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _identity(s: str) -> str:
|
|
45
|
+
return s
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Renderer:
|
|
49
|
+
def __init__(self, profile: Profile, data: dict[str, Any], variables: dict[str, Any], limits: Limits) -> None:
|
|
50
|
+
self.profile = profile
|
|
51
|
+
self.data = data
|
|
52
|
+
self.variables = variables
|
|
53
|
+
self.limits = limits
|
|
54
|
+
self.iterations = 0
|
|
55
|
+
self.nodes = 0
|
|
56
|
+
self.bytes = 0
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def run(
|
|
60
|
+
cls, document: dict[str, Any], data: dict[str, Any], variables: dict[str, Any], profile: Profile, limits: Limits
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
self = cls(profile, data, variables, limits)
|
|
63
|
+
self._count(None)
|
|
64
|
+
return self._object(document, "/document", [], [])
|
|
65
|
+
|
|
66
|
+
def _object(
|
|
67
|
+
self, obj: dict[str, Any], path: str, segments: list[str], frames: list[dict[str, Any]]
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
out: dict[str, Any] = {}
|
|
70
|
+
for key, value in obj.items():
|
|
71
|
+
if key.startswith("$"):
|
|
72
|
+
continue
|
|
73
|
+
out[key] = self._value(value, pointer(path, key), [*segments, key], frames, obj)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def _value(
|
|
77
|
+
self, value: Any, path: str, segments: list[str], frames: list[dict[str, Any]], parent: dict[str, Any] | None
|
|
78
|
+
) -> Any:
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
out = self._string(value, path, segments, frames, parent)
|
|
81
|
+
if isinstance(out, dict):
|
|
82
|
+
# A cell wrapper: one object and one string (SPEC.md section 10).
|
|
83
|
+
self._count(None)
|
|
84
|
+
self._count(out["value"])
|
|
85
|
+
else:
|
|
86
|
+
self._count(out)
|
|
87
|
+
return out
|
|
88
|
+
if not isinstance(value, (dict, list)):
|
|
89
|
+
self._count(value)
|
|
90
|
+
return value
|
|
91
|
+
self._count(None)
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return self._object(value, path, segments, frames)
|
|
94
|
+
|
|
95
|
+
result: list[Any] = []
|
|
96
|
+
for i, item in enumerate(value):
|
|
97
|
+
at = pointer(path, i)
|
|
98
|
+
item_segments = [*segments, str(i)]
|
|
99
|
+
if not isinstance(item, dict) or not any(k.startswith("$") for k in item):
|
|
100
|
+
result.append(self._value(item, at, item_segments, frames, None))
|
|
101
|
+
continue
|
|
102
|
+
is_splice = "$value" in item
|
|
103
|
+
for inner in self._expand(item, at, frames):
|
|
104
|
+
if is_splice:
|
|
105
|
+
# The splice object is not output: each copy is $value,
|
|
106
|
+
# rendered at the element's path (SPEC.md section 8).
|
|
107
|
+
result.append(self._value(item["$value"], pointer(at, "$value"), item_segments, inner, None))
|
|
108
|
+
continue
|
|
109
|
+
self._count(None)
|
|
110
|
+
result.append(self._object(item, at, item_segments, inner))
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
def _expand(self, item: dict[str, Any], path: str, frames: list[dict[str, Any]]) -> Iterator[list[dict[str, Any]]]:
|
|
114
|
+
"""The frames each copy of a directive element renders with.
|
|
115
|
+
|
|
116
|
+
A generator on purpose: iterations are counted as copies are rendered,
|
|
117
|
+
interleaved, exactly as the other legs count them, so the same template
|
|
118
|
+
fails with the same code.
|
|
119
|
+
"""
|
|
120
|
+
if "$each" not in item:
|
|
121
|
+
if self._condition(item["$if"], frames):
|
|
122
|
+
yield frames
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
at = pointer(path, "$each")
|
|
126
|
+
steps = path_only(item["$each"])
|
|
127
|
+
assert steps is not None
|
|
128
|
+
lst = self._resolve(steps, frames)
|
|
129
|
+
if lst is ABSENT:
|
|
130
|
+
raise TemplateError("TEMPLATE_MISSING", at, ".".join(steps))
|
|
131
|
+
if not isinstance(lst, list):
|
|
132
|
+
raise TemplateError("DIRECTIVE_NOT_ARRAY", at, ".".join(steps))
|
|
133
|
+
|
|
134
|
+
as_name = item.get("$as")
|
|
135
|
+
if as_name is None:
|
|
136
|
+
as_name = "item"
|
|
137
|
+
_, decl = resolve_decl(steps, frames, self.variables)
|
|
138
|
+
items_decl = decl.get("items") if isinstance(decl, dict) else None
|
|
139
|
+
last = len(lst) - 1
|
|
140
|
+
for index, element in enumerate(lst):
|
|
141
|
+
self.iterations += 1
|
|
142
|
+
if self.iterations > self.limits.iterations:
|
|
143
|
+
raise TemplateError("RENDER_TOO_MANY_ITERATIONS", at)
|
|
144
|
+
inner = [
|
|
145
|
+
*frames,
|
|
146
|
+
{
|
|
147
|
+
"name": as_name,
|
|
148
|
+
"decl": items_decl,
|
|
149
|
+
"value": element,
|
|
150
|
+
"$index": index,
|
|
151
|
+
"$first": index == 0,
|
|
152
|
+
"$last": index == last,
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
if "$if" in item and not self._condition(item["$if"], inner):
|
|
156
|
+
continue
|
|
157
|
+
yield inner
|
|
158
|
+
|
|
159
|
+
def _condition(self, expression: str, frames: list[dict[str, Any]]) -> bool:
|
|
160
|
+
context = dict(self.data)
|
|
161
|
+
for frame in frames:
|
|
162
|
+
context[frame["name"]] = frame["value"]
|
|
163
|
+
if frames:
|
|
164
|
+
innermost = frames[-1]
|
|
165
|
+
for head in LOOP_HEADS:
|
|
166
|
+
context[head] = innermost[head]
|
|
167
|
+
# Parsed and bounded at inspect; fancy-expr never reaches the host.
|
|
168
|
+
return bool(truthy(evaluate(expression, context)))
|
|
169
|
+
|
|
170
|
+
def _resolve(self, steps: list[str], frames: list[dict[str, Any]]) -> Any:
|
|
171
|
+
head = steps[0]
|
|
172
|
+
if head in LOOP_HEADS:
|
|
173
|
+
return frames[-1][head] if frames else ABSENT
|
|
174
|
+
|
|
175
|
+
value: Any = ABSENT
|
|
176
|
+
for frame in reversed(frames):
|
|
177
|
+
if frame["name"] == head:
|
|
178
|
+
value = frame["value"]
|
|
179
|
+
break
|
|
180
|
+
if value is ABSENT:
|
|
181
|
+
if head not in self.data:
|
|
182
|
+
return ABSENT
|
|
183
|
+
value = self.data[head]
|
|
184
|
+
|
|
185
|
+
for step in steps[1:]:
|
|
186
|
+
if isinstance(value, list):
|
|
187
|
+
if not is_digits(step):
|
|
188
|
+
return ABSENT
|
|
189
|
+
index = int(step)
|
|
190
|
+
if index >= len(value):
|
|
191
|
+
return ABSENT
|
|
192
|
+
value = value[index]
|
|
193
|
+
continue
|
|
194
|
+
if not isinstance(value, dict) or step not in value:
|
|
195
|
+
return ABSENT
|
|
196
|
+
value = value[step]
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
def _string(
|
|
200
|
+
self, text: str, path: str, segments: list[str], frames: list[dict[str, Any]], parent: dict[str, Any] | None
|
|
201
|
+
) -> Any:
|
|
202
|
+
if "{{" not in text:
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
field = self.profile.field(segments)
|
|
206
|
+
assert field is not None
|
|
207
|
+
parts = tokenize(text, self.limits.token_length)
|
|
208
|
+
escape = self._escaper(field, parent)
|
|
209
|
+
out = ""
|
|
210
|
+
|
|
211
|
+
for part in parts:
|
|
212
|
+
if "text" in part:
|
|
213
|
+
out += part["text"]
|
|
214
|
+
continue
|
|
215
|
+
variable = ".".join(part["path"]) if "path" in part else None
|
|
216
|
+
value = self._resolve(part["path"], frames) if "path" in part else part["literal"]
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
for filt in part["filters"]:
|
|
220
|
+
value = apply_filter(filt, value)
|
|
221
|
+
if value is ABSENT:
|
|
222
|
+
raise TemplateError("TEMPLATE_MISSING", "")
|
|
223
|
+
if field["encode"] == "image":
|
|
224
|
+
if image_type(value) not in field["mime"]:
|
|
225
|
+
raise TemplateError("VALUE_IMAGE_TYPE", "")
|
|
226
|
+
return value
|
|
227
|
+
if field["encode"] == "cell" and len(parts) == 1:
|
|
228
|
+
kept = self._typed_cell(value, part.get("path"), frames)
|
|
229
|
+
if kept is not ABSENT:
|
|
230
|
+
return kept
|
|
231
|
+
piece = scalar_text(value)
|
|
232
|
+
except TemplateError as e:
|
|
233
|
+
raise TemplateError(e.code, path, variable) from None
|
|
234
|
+
|
|
235
|
+
if field["encode"] == "band" and ("{page}" in piece or "{pages}" in piece):
|
|
236
|
+
raise TemplateError("VALUE_PAGE_FIELD", path, variable)
|
|
237
|
+
out += escape(piece)
|
|
238
|
+
|
|
239
|
+
# Every other cell result is wrapped, so user data can never become a
|
|
240
|
+
# formula: holy-sheet promotes a BARE string starting with "=" and takes
|
|
241
|
+
# {value: s} as literal text (template/profiles 0201).
|
|
242
|
+
return {"value": out} if field["encode"] == "cell" else out
|
|
243
|
+
|
|
244
|
+
def _typed_cell(self, value: Any, steps: list[str] | None, frames: list[dict[str, Any]]) -> Any:
|
|
245
|
+
"""A whole token in a cell keeps a number, a boolean, or a valid date DECLARED ``date``.
|
|
246
|
+
|
|
247
|
+
ABSENT when the result is to be wrapped. A valid date cannot begin with
|
|
248
|
+
"=", so nothing kept bare here can become a formula.
|
|
249
|
+
"""
|
|
250
|
+
if is_number(value) or isinstance(value, bool):
|
|
251
|
+
return value
|
|
252
|
+
if not isinstance(value, str) or steps is None or parse_date(value) is None:
|
|
253
|
+
return ABSENT
|
|
254
|
+
found, decl = resolve_decl(steps, frames, self.variables)
|
|
255
|
+
return value if found and isinstance(decl, dict) and decl.get("type") == "date" else ABSENT
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _escaper(field: dict[str, Any], parent: dict[str, Any] | None) -> Callable[[str], str]:
|
|
259
|
+
if field["encode"] == "markdown":
|
|
260
|
+
return escape_markdown
|
|
261
|
+
if field["encode"] == "markup":
|
|
262
|
+
fmt = parent.get("format") if parent is not None else None
|
|
263
|
+
if fmt == "markdown":
|
|
264
|
+
return escape_markdown
|
|
265
|
+
if fmt == "html":
|
|
266
|
+
return escape_html
|
|
267
|
+
return _identity
|
|
268
|
+
|
|
269
|
+
def _count(self, value: Any) -> None:
|
|
270
|
+
"""One output value; a string also adds its bytes (SPEC.md section 10)."""
|
|
271
|
+
self.nodes += 1
|
|
272
|
+
if self.nodes > self.limits.output_nodes:
|
|
273
|
+
raise TemplateError("RENDER_TOO_MANY_NODES", "")
|
|
274
|
+
if isinstance(value, str):
|
|
275
|
+
self.bytes += utf8_length(value)
|
|
276
|
+
if self.bytes > self.limits.output_bytes:
|
|
277
|
+
raise TemplateError("RENDER_TOO_LARGE", "")
|