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,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 = {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}
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", "")