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,284 @@
1
+ """Splits one string into text and ``{{ }}`` tokens (SPEC.md section 5).
2
+
3
+ Hand-written and single-pass: every index only moves forward, and no regular
4
+ expression ever runs over template text (plan section 5). Template text is
5
+ authored by admins and agents and arrives over the wire, and a backtracking
6
+ regex over it is a denial of service waiting for the right string.
7
+
8
+ It scans UTF-8 BYTES, because the token-length bound is defined in bytes
9
+ (SPEC.md section 10) and the three legs must refuse the same token at the same
10
+ place. Every delimiter is ASCII, so a split never lands inside a character.
11
+
12
+ Character classes are ASCII by hand: ``str.isalpha`` and ``str.isdigit`` are
13
+ Unicode-aware, so ``"é".isalpha()`` and ``"٣".isdigit()`` are True and a name
14
+ like ``café`` would parse here and nowhere else.
15
+
16
+ Errors are raised as TemplateError with an empty path; the caller knows which
17
+ field it was scanning and re-raises with the pointer.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from ._errors import TemplateError
26
+
27
+ LOOP_HEADS = ("$index", "$first", "$last")
28
+
29
+ _OPEN = ord("{")
30
+ _CLOSE = ord("}")
31
+ _QUOTE = ord('"')
32
+ _BACKSLASH = ord("\\")
33
+ _SPACE = ord(" ")
34
+ _TAB = ord("\t")
35
+
36
+
37
+ def _is_alpha(c: int) -> bool:
38
+ return 0x41 <= c <= 0x5A or 0x61 <= c <= 0x7A
39
+
40
+
41
+ def _is_digit(c: int) -> bool:
42
+ return 0x30 <= c <= 0x39
43
+
44
+
45
+ def _is_name_char(c: int) -> bool:
46
+ return _is_alpha(c) or _is_digit(c) or c == 0x5F
47
+
48
+
49
+ def _is_lower(c: int) -> bool:
50
+ return 0x61 <= c <= 0x7A
51
+
52
+
53
+ def _is_bare(c: int) -> bool:
54
+ return _is_name_char(c) or c in (0x2E, 0x2B, 0x2D)
55
+
56
+
57
+ def _syntax() -> TemplateError:
58
+ return TemplateError("TOKEN_SYNTAX", "")
59
+
60
+
61
+ class _Cursor:
62
+ """A forward-only position in a byte string. Nothing here can move backwards."""
63
+
64
+ __slots__ = ("b", "i", "n")
65
+
66
+ def __init__(self, b: bytes) -> None:
67
+ self.b = b
68
+ self.i = 0
69
+ self.n = len(b)
70
+
71
+ def peek(self) -> int | None:
72
+ return self.b[self.i] if self.i < self.n else None
73
+
74
+ def advance(self) -> None:
75
+ self.i += 1
76
+
77
+ def done(self) -> bool:
78
+ return self.i >= self.n
79
+
80
+ def skip_ws(self) -> None:
81
+ # Spaces and tabs only. A newline is never whitespace inside a token.
82
+ while self.i < self.n and self.b[self.i] in (_SPACE, _TAB):
83
+ self.i += 1
84
+
85
+ def take_while(self, accept: Callable[[int], bool]) -> str:
86
+ start = self.i
87
+ while self.i < self.n and accept(self.b[self.i]):
88
+ self.i += 1
89
+ return self.b[start : self.i].decode("utf-8")
90
+
91
+
92
+ def tokenize(text: str, token_length: int) -> list[dict[str, Any]]:
93
+ if "{{" not in text:
94
+ return [] if text == "" else [{"text": text}]
95
+
96
+ b = text.encode("utf-8")
97
+ n = len(b)
98
+ parts: list[dict[str, Any]] = []
99
+ buffer = ""
100
+ i = 0
101
+ while i < n:
102
+ open_at = b.find(b"{{", i)
103
+ if open_at == -1:
104
+ buffer += b[i:].decode("utf-8")
105
+ break
106
+ buffer += b[i:open_at].decode("utf-8")
107
+
108
+ start = open_at + 2
109
+ close = _find_close(b, start, n, token_length)
110
+ token = _parse_token(b[start:close])
111
+
112
+ if buffer != "":
113
+ parts.append({"text": buffer})
114
+ buffer = ""
115
+ parts.append(token)
116
+ i = close + 2
117
+
118
+ if buffer != "":
119
+ parts.append({"text": buffer})
120
+ return parts
121
+
122
+
123
+ def count_tokens(text: str, token_length: int) -> int:
124
+ """How many tokens a string opens, stopping quietly at the first error (reported elsewhere)."""
125
+ if "{{" not in text:
126
+ return 0
127
+ b = text.encode("utf-8")
128
+ count = 0
129
+ i = 0
130
+ while True:
131
+ open_at = b.find(b"{{", i)
132
+ if open_at == -1:
133
+ break
134
+ count += 1
135
+ try:
136
+ i = _find_close(b, open_at + 2, len(b), token_length) + 2
137
+ except TemplateError:
138
+ break
139
+ return count
140
+
141
+
142
+ def _find_close(b: bytes, start: int, n: int, limit: int) -> int:
143
+ """Index of the ``}}`` that closes a token whose inner text starts at ``start``."""
144
+ j = start
145
+ while True:
146
+ if j - start > limit:
147
+ raise TemplateError("TOKEN_TOO_LONG", "")
148
+ if j >= n:
149
+ raise TemplateError("TOKEN_UNCLOSED", "")
150
+ c = b[j]
151
+ if c == _QUOTE:
152
+ # A string literal: ``}}`` inside it does not close the token. An escape skips one byte.
153
+ j += 1
154
+ while j < n and b[j] != _QUOTE:
155
+ if j - start > limit:
156
+ raise TemplateError("TOKEN_TOO_LONG", "")
157
+ j += 2 if b[j] == _BACKSLASH else 1
158
+ if j >= n:
159
+ if j - start > limit:
160
+ raise TemplateError("TOKEN_TOO_LONG", "")
161
+ raise TemplateError("TOKEN_UNCLOSED", "")
162
+ j += 1
163
+ continue
164
+ if c == _CLOSE and j + 1 < n and b[j + 1] == _CLOSE:
165
+ return j
166
+ j += 1
167
+
168
+
169
+ def _parse_token(inner: bytes) -> dict[str, Any]:
170
+ if all(c in (_SPACE, _TAB) for c in inner):
171
+ raise TemplateError("TOKEN_EMPTY", "")
172
+
173
+ p = _Cursor(inner)
174
+ p.skip_ws()
175
+ token: dict[str, Any] = {"literal": _string(p)} if p.peek() == _QUOTE else {"path": _path(p)}
176
+
177
+ filters: list[dict[str, str]] = []
178
+ while True:
179
+ p.skip_ws()
180
+ if p.done():
181
+ break
182
+ if p.peek() != ord("|"):
183
+ raise _syntax()
184
+ p.advance()
185
+ p.skip_ws()
186
+ name = p.take_while(_is_lower)
187
+ if name == "":
188
+ raise _syntax()
189
+ filt = {"name": name}
190
+ p.skip_ws()
191
+ if p.peek() == ord(":"):
192
+ p.advance()
193
+ p.skip_ws()
194
+ if p.peek() == _QUOTE:
195
+ filt["arg"] = _string(p)
196
+ else:
197
+ arg = p.take_while(_is_bare)
198
+ if arg == "":
199
+ raise _syntax()
200
+ filt["arg"] = arg
201
+ filters.append(filt)
202
+
203
+ token["filters"] = filters
204
+ return token
205
+
206
+
207
+ def _path(p: _Cursor) -> list[str]:
208
+ segments: list[str] = []
209
+ if p.peek() == ord("$"):
210
+ p.advance()
211
+ head = "$" + p.take_while(_is_name_char)
212
+ if head not in LOOP_HEADS:
213
+ raise _syntax()
214
+ segments.append(head)
215
+ else:
216
+ name = _name(p)
217
+ if name is None:
218
+ raise _syntax()
219
+ segments.append(name)
220
+
221
+ while p.peek() == ord("."):
222
+ p.advance()
223
+ digits = p.take_while(_is_digit)
224
+ if digits != "":
225
+ segments.append(digits)
226
+ continue
227
+ name = _name(p)
228
+ if name is None:
229
+ raise _syntax()
230
+ segments.append(name)
231
+ return segments
232
+
233
+
234
+ def path_only(text: str) -> list[str] | None:
235
+ """Parse a whole string as a bare path, as ``$each`` takes one. None if it is not one."""
236
+ p = _Cursor(text.encode("utf-8"))
237
+ p.skip_ws()
238
+ try:
239
+ steps = _path(p)
240
+ except TemplateError:
241
+ return None
242
+ p.skip_ws()
243
+ return steps if p.done() else None
244
+
245
+
246
+ def is_name(s: str) -> bool:
247
+ if s == "" or not s.isascii():
248
+ return False
249
+ b = s.encode("ascii")
250
+ if not (_is_alpha(b[0]) or b[0] == 0x5F):
251
+ return False
252
+ return all(_is_name_char(c) for c in b[1:])
253
+
254
+
255
+ def is_digits(s: str) -> bool:
256
+ return s != "" and s.isascii() and all(_is_digit(c) for c in s.encode("ascii"))
257
+
258
+
259
+ def _name(p: _Cursor) -> str | None:
260
+ c = p.peek()
261
+ if c is None or not (_is_alpha(c) or c == 0x5F):
262
+ return None
263
+ return p.take_while(_is_name_char)
264
+
265
+
266
+ def _string(p: _Cursor) -> str:
267
+ p.advance() # opening quote
268
+ out = bytearray()
269
+ while True:
270
+ c = p.peek()
271
+ if c is None:
272
+ raise _syntax()
273
+ p.advance()
274
+ if c == _QUOTE:
275
+ return out.decode("utf-8")
276
+ if c == _BACKSLASH:
277
+ nxt = p.peek()
278
+ if nxt not in (_QUOTE, _BACKSLASH):
279
+ raise _syntax()
280
+ assert nxt is not None
281
+ p.advance()
282
+ out.append(nxt)
283
+ continue
284
+ out.append(c)
@@ -0,0 +1,16 @@
1
+ {
2
+ "writer": "dark-slide",
3
+ "schema": "dark-slide deck",
4
+ "notes": "Implemented in all three legs (phase 6). src and background.image are image fields so that a LITERAL local path is refused: dark-slide's ImageResolver reads local paths.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "slides.*.elements.*.content", "encode": "markup" },
8
+ { "path": "slides.*.notes", "encode": "text" },
9
+ { "path": "slides.*.elements.*.alt", "encode": "text" },
10
+ { "path": "slides.*.elements.*.items.*", "encode": "text" },
11
+ { "path": "slides.*.elements.*.columns.*.label", "encode": "text" },
12
+ { "path": "slides.*.elements.*.rows.**", "encode": "text" },
13
+ { "path": "slides.*.elements.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
14
+ { "path": "slides.*.background.image", "encode": "image", "mime": ["image/png", "image/jpeg"] }
15
+ ]
16
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "writer": "fine-print",
3
+ "schema": "fine-print 0.3",
4
+ "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: fine-print replaces them with str_replace and has no escape.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "**.runs.*.text", "encode": "text" },
8
+ { "path": "**.blocks.*.text", "encode": "text" },
9
+ { "path": "**.blocks.*.alt", "encode": "text" },
10
+ { "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
11
+ { "path": "page.header.left", "encode": "band" },
12
+ { "path": "page.header.center", "encode": "band" },
13
+ { "path": "page.header.right", "encode": "band" },
14
+ { "path": "page.footer.left", "encode": "band" },
15
+ { "path": "page.footer.center", "encode": "band" },
16
+ { "path": "page.footer.right", "encode": "band" }
17
+ ]
18
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "writer": "holy-sheet",
3
+ "schema": "holy-sheet workbook",
4
+ "notes": "Implemented in all three legs (phase 6). Every string result in a cell is written {value: s}, so user data can never become a formula. formula is deliberately absent.",
5
+ "fields": [
6
+ { "path": "sheets.*.name", "encode": "text" },
7
+ { "path": "sheets.*.rows.*.*", "encode": "cell" },
8
+ { "path": "sheets.*.cells.*", "encode": "cell" },
9
+ { "path": "sheets.*.cells.*.value", "encode": "text" }
10
+ ]
11
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "writer": "last-word",
3
+ "schema": "last-word 0.x (Doc model shared with fine-print)",
4
+ "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, chart option, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: last-word writes them as PAGE and NUMPAGES fields with str_replace and has no escape.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "**.runs.*.text", "encode": "text" },
8
+ { "path": "**.blocks.*.text", "encode": "text" },
9
+ { "path": "**.blocks.*.alt", "encode": "text" },
10
+ { "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
11
+ { "path": "page.header.left", "encode": "band" },
12
+ { "path": "page.header.center", "encode": "band" },
13
+ { "path": "page.header.right", "encode": "band" },
14
+ { "path": "page.footer.left", "encode": "band" },
15
+ { "path": "page.footer.center", "encode": "band" },
16
+ { "path": "page.footer.right", "encode": "band" }
17
+ ]
18
+ }
File without changes
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: fancy-template
3
+ Version: 0.2.0
4
+ Summary: Document templates for the Fancy document writers: a writer document with {{ tokens }} and JSON directives, plus a declaration of the data it needs. inspect() at save time, validate_data() before render, render() to plain writer JSON. No code execution, no includes, bounded everywhere. One spec, three runtimes, held to a shared conformance table.
5
+ Project-URL: Homepage, https://github.com/Particle-Academy/fancy-template
6
+ Project-URL: Issues, https://github.com/Particle-Academy/fancy-template/issues
7
+ Project-URL: Source, https://github.com/Particle-Academy/fancy-template
8
+ Author: Particle Academy
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agentic,certificate,cross-language,document,docx,particle-academy,pdf,pptx,template,xlsx
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Text Processing :: Markup
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: fancy-expr<2.0,>=0.2.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # fancy-template (Python)
26
+
27
+ Document templates for the Fancy document writers: a writer document
28
+ (fine-print, last-word, dark-slide, holy-sheet JSON) with `{{ tokens }}` in some
29
+ string values and JSON directives on some array items, plus a declaration of the
30
+ data it needs. **One specification, three implementations** (Python, TypeScript,
31
+ PHP), all asserted against the same `template/*` table in `fancy-conformance`.
32
+
33
+ ```python
34
+ from fancy_template import inspect, validate_data, render
35
+
36
+ inspect(template)["issues"] # at SAVE time, no data
37
+ validate_data(template, data) # before rendering
38
+ doc = render(template, data) # plain writer JSON, or raises TemplateError
39
+
40
+ import holy_sheet
41
+
42
+ holy_sheet.to_bytes(doc) # the writer validates it again
43
+ ```
44
+
45
+ ## What it guarantees
46
+
47
+ - **Missing data is loud.** An absent value raises `TemplateError` with a stable
48
+ `code`, a JSON Pointer `path` into the template and the `variable` path --
49
+ never the data value itself.
50
+ - **No code execution, no includes.** Tokens are paths plus a fixed filter list;
51
+ `$if` is a sandboxed fancy-expr expression; data is reduced to plain JSON
52
+ before anything reads it, and no method of a host object is ever called.
53
+ - **Profiles are allowlists.** Only listed fields take tokens. holy-sheet cells
54
+ write every string result as `{"value": s}`, so user data can never become a
55
+ formula; dark-slide escapes values for the element's markdown or html.
56
+ - **Bounded everywhere**, with a stable error code per bound.
57
+
58
+ ## Where Python differs from its own instincts
59
+
60
+ - `True == 1` is true in Python; here a boolean is never a number, and
61
+ `{"fancyTemplate": True}` is refused.
62
+ - `str.isalpha`, `str.isdigit` and `re`'s `\d` are Unicode-aware; names, dates
63
+ and digits here are ASCII only, as in the other legs.
64
+ - The recursion limit sits below the 4096 levels the other legs accept as JSON,
65
+ so every walk of unknown depth is iterative.
66
+
67
+ The specification is `SPEC.md` in the repository root.
@@ -0,0 +1,21 @@
1
+ fancy_template/__init__.py,sha256=R1ltUO7ZH3TCEZ_-MHZJnGFvK6T70TwnYRvx7S7_US0,4776
2
+ fancy_template/_dates.py,sha256=dlKm7PVppftAEWRKp2jzsjy78fZOWjcWP5iPpdDcVHM,5508
3
+ fancy_template/_declarations.py,sha256=aFOuvwd2JeDHbTb22Q9pWCOdSJtrp35Dv2cI0kX76cE,7353
4
+ fancy_template/_errors.py,sha256=1kHsoyHMFSElDxDG29kkWc9sjAnzEWLMRfJ2FsyvGno,1018
5
+ fancy_template/_filters.py,sha256=xEh5tX5H5Cn4ToqNGYfu-m8sCnts_FazUb-EmMLSao8,3923
6
+ fancy_template/_inspector.py,sha256=6_yp8W43cl0o3EXhsIZVu1Bc5vAUWmwYhDVR_To1G_M,14031
7
+ fancy_template/_json.py,sha256=SnQP4e6reXBg-AV3Z4oojsKm2iQzQ_RAImRS82LsIXw,6616
8
+ fancy_template/_limits.py,sha256=BOGIQE2pEw7ChoXE3xbI1ah_pqIQvz2q88tp0dlgGaw,1664
9
+ fancy_template/_numbers.py,sha256=rsSh8LOUmX-q7CGUANJbovHI3Ja1_wiV53ZC6wUeq_c,4687
10
+ fancy_template/_profile.py,sha256=pyhdFptrpdP3yvAC0NSfV9xF-nc9pQc1Y3jsn4gjZOc,3223
11
+ fancy_template/_renderer.py,sha256=6SU5m6ZtMb2NjYXNbvF6cIJvzguEMKIdXiZT5ipaJvg,11027
12
+ fancy_template/_scanner.py,sha256=-tzEUROCk0yV0tors7CWX43eKDtLmH6UGepXw2VCuL8,7979
13
+ fancy_template/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ fancy_template/profiles/dark-slide.json,sha256=9ve4c6BkILK4T_0spwSbEfJkeHmYO1gTTPHrSdWirsU,888
15
+ fancy_template/profiles/fine-print.json,sha256=AEbv8WEziX5wNI0thMhtaszFOt0bgPLKGrPoOYhBKg0,977
16
+ fancy_template/profiles/holy-sheet.json,sha256=ZUdvftUaVinQ6RVmBKVzykWVGVW3OnIeCvu2VIW9Bjg,483
17
+ fancy_template/profiles/last-word.json,sha256=pSRQH3YFXrOiH0lZ3cc9fFKUdu4lRUMhzEWCWon106Q,1049
18
+ fancy_template-0.2.0.dist-info/METADATA,sha256=MD4IWyld3XOObXg63rtkpZByfPwdjLZbmlhm6e6JEl4,3293
19
+ fancy_template-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
20
+ fancy_template-0.2.0.dist-info/licenses/LICENSE,sha256=nBlLZPUsduZ364Sj4w82nIJyfqWKvlBnDV3j-czSMnA,1073
21
+ fancy_template-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.