pyweft 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.
pyweft/parser.py ADDED
@@ -0,0 +1,426 @@
1
+ """Parse a ``.weft`` file into a template tree plus a code block.
2
+
3
+ File shape::
4
+
5
+ <template>
6
+ <button @click="increment">{{ count }}</button>
7
+ </template>
8
+
9
+ <code>
10
+ count: int = 0
11
+
12
+ def increment(self):
13
+ self.count += 1
14
+ </code>
15
+
16
+ The ``<code>`` and ``<style>`` blocks are cut out by string scanning *before*
17
+ any XML parsing, because Python source is full of ``<``, ``>`` and ``&`` (and
18
+ CSS of ``>``) and would blow up an XML parser. Only the ``<template>`` block
19
+ is parsed as XML.
20
+
21
+ The template must be well-formed XML (close your tags, quote your attributes).
22
+ That buys us the stdlib ``xml.etree`` parser instead of a bespoke HTML5
23
+ tokenizer, which is a large amount of complexity to skip for a PoC.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ import textwrap
30
+ import xml.etree.ElementTree as ET
31
+ from dataclasses import dataclass, field
32
+ from typing import Any
33
+
34
+ from .events import parse_bind_modifiers, parse_event_spec
35
+ from .expressions import parse_interpolation
36
+
37
+
38
+ class TemplateError(RuntimeError):
39
+ pass
40
+
41
+
42
+ # ---------------------------------------------------------------- node types
43
+
44
+
45
+ @dataclass
46
+ class TStatic:
47
+ """Literal text, possibly with {{ }} holes."""
48
+
49
+ parts: list[tuple[str, str]]
50
+
51
+
52
+ @dataclass
53
+ class TElement:
54
+ tag: str
55
+ attrs: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
56
+ # Event spec ("click", "keydown.enter") -> handler source; see events.py.
57
+ events: dict[str, str] = field(default_factory=dict)
58
+ bind: str | None = None
59
+ bind_mods: tuple[str, ...] = ()
60
+ key_expr: str | None = None
61
+ children: list[Any] = field(default_factory=list)
62
+ # ``:name="expr"`` bindings -- evaluated as Python, unlike ``attrs`` which
63
+ # interpolate to strings. On components these become typed props.
64
+ expr_attrs: dict[str, str] = field(default_factory=dict)
65
+
66
+
67
+ @dataclass
68
+ class TIf:
69
+ """branches is [(cond_src, children), ...]; a None cond is the else."""
70
+
71
+ branches: list[tuple[str | None, list[Any]]]
72
+
73
+
74
+ @dataclass
75
+ class TFor:
76
+ var: str
77
+ iter_src: str
78
+ index_var: str | None = None
79
+ key_expr: str | None = None
80
+ children: list[Any] = field(default_factory=list)
81
+
82
+
83
+ @dataclass
84
+ class TSlot:
85
+ """``<Slot name="footer" :item="row">fallback</Slot>`` in a component's
86
+ template: where the content its parent passed is rendered. ``attrs`` and
87
+ ``expr_attrs`` are slot props, handed to that content as variables."""
88
+
89
+ name: str = "default"
90
+ attrs: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
91
+ expr_attrs: dict[str, str] = field(default_factory=dict)
92
+ children: list[Any] = field(default_factory=list)
93
+
94
+
95
+ @dataclass
96
+ class TFill:
97
+ """``<Fill name="footer">...</Fill>`` directly inside a component tag:
98
+ content for one of its named slots."""
99
+
100
+ name: str
101
+ children: list[Any] = field(default_factory=list)
102
+
103
+
104
+ # ------------------------------------------------------------ source slicing
105
+
106
+ _BLOCK_OPEN = re.compile(r"<(template|code|style)\s*>")
107
+ _TEMPLATE_TAG = re.compile(r"<(/?)template\b[^>]*?(/?)>")
108
+ _AFTER_BLOCK = re.compile(r"\s*(?:$|<(?:template|code|style)\s*>|<!--)")
109
+
110
+
111
+ def _template_end(src: str, start: int) -> re.Match[str] | None:
112
+ # Balanced, so a <template> element inside the markup can't end it.
113
+ depth = 1
114
+ for tag in _TEMPLATE_TAG.finditer(src, start):
115
+ if tag.group(1):
116
+ depth -= 1
117
+ if depth == 0:
118
+ return tag
119
+ elif not tag.group(2):
120
+ depth += 1
121
+ return None
122
+
123
+
124
+ def _raw_end(src: str, start: int, name: str) -> re.Match[str] | None:
125
+ # Python and CSS aren't markup, so nothing can be balanced: the block
126
+ # ends at the first closing tag followed by another block or the end,
127
+ # so "</code>" inside a string mostly stays in. Failing that, the last.
128
+ closes = list(re.finditer(rf"</{name}\s*>", src[start:]))
129
+ for close in closes:
130
+ if _AFTER_BLOCK.match(src, start + close.end()):
131
+ break
132
+ else:
133
+ if not closes:
134
+ return None
135
+ close = closes[-1]
136
+ return re.compile(rf"</{name}\s*>").match(src, start + close.start())
137
+
138
+
139
+ def block_spans(src: str) -> dict[str, tuple[int, int]]:
140
+ """Where the top-level ``<template>``, ``<code>`` and ``<style>`` blocks'
141
+ contents are: ``{"code": (start, end), ...}``. Blocks are found by
142
+ scanning, not by regex over the whole file, so markup inside the
143
+ template (an HTML ``<code>`` element, say) is not taken for a block."""
144
+ spans: dict[str, tuple[int, int]] = {}
145
+ pos = 0
146
+ while (opening := _BLOCK_OPEN.search(src, pos)) is not None:
147
+ comment = src.find("<!--", pos, opening.start())
148
+ if comment != -1:
149
+ close = src.find("-->", comment + 4)
150
+ pos = len(src) if close == -1 else close + 3
151
+ continue
152
+ name = opening.group(1)
153
+ end = (
154
+ _template_end(src, opening.end())
155
+ if name == "template"
156
+ else _raw_end(src, opening.end(), name)
157
+ )
158
+ if end is None:
159
+ raise TemplateError(f"<{name}> block is not closed")
160
+ spans.setdefault(name, (opening.end(), end.start()))
161
+ pos = end.end()
162
+ return spans
163
+
164
+
165
+ def split_source(src: str) -> tuple[str, str, str]:
166
+ """Return (template_source, code_source, style_source)."""
167
+ spans = block_spans(src)
168
+ if "template" not in spans:
169
+ raise TemplateError("no <template> block found")
170
+
171
+ def content(name: str) -> str:
172
+ start, end = spans.get(name, (0, 0))
173
+ return src[start:end]
174
+
175
+ return content("template"), textwrap.dedent(content("code")), textwrap.dedent(content("style"))
176
+
177
+
178
+ _AT_ATTR = re.compile(r"@([A-Za-z_][\w.\-]*)\s*=")
179
+ _COLON_ATTR = re.compile(r":([A-Za-z_][\w.\-]*)\s*=")
180
+
181
+
182
+ def rewrite_at_directives(src: str) -> str:
183
+ """Rewrite ``@click="..."`` to ``py-at-click="..."`` and ``:prop="..."``
184
+ to ``py-colon-prop="..."``.
185
+
186
+ XML attribute names cannot start with ``@`` or ``:``, so the Blazor-ish /
187
+ Vue-ish sugar has to be normalised before the XML parser sees it. This is a
188
+ small state machine rather than a bare regex so that ``@`` or ``:`` inside
189
+ text or attribute values (an email address, a URL) is left alone.
190
+ """
191
+ out: list[str] = []
192
+ i, n = 0, len(src)
193
+ in_tag = False
194
+ quote: str | None = None
195
+ while i < n:
196
+ ch = src[i]
197
+ if not in_tag:
198
+ if ch == "<":
199
+ in_tag = True
200
+ out.append(ch)
201
+ i += 1
202
+ continue
203
+ if quote:
204
+ if ch == quote:
205
+ quote = None
206
+ out.append(ch)
207
+ i += 1
208
+ continue
209
+ if ch in "\"'":
210
+ quote = ch
211
+ out.append(ch)
212
+ i += 1
213
+ continue
214
+ if ch == ">":
215
+ in_tag = False
216
+ out.append(ch)
217
+ i += 1
218
+ continue
219
+ if ch == "@" and out and (out[-1].isspace() or out[-1] == "<"):
220
+ match = _AT_ATTR.match(src, i)
221
+ if match:
222
+ out.append("py-at-" + match.group(1) + "=")
223
+ i = match.end()
224
+ continue
225
+ # Only after whitespace: an attribute-name position. A ':' elsewhere in
226
+ # a tag (a namespaced attribute name, say) is not our sugar.
227
+ if ch == ":" and out and out[-1].isspace():
228
+ match = _COLON_ATTR.match(src, i)
229
+ if match:
230
+ out.append("py-colon-" + match.group(1) + "=")
231
+ i = match.end()
232
+ continue
233
+ out.append(ch)
234
+ i += 1
235
+ return "".join(out)
236
+
237
+
238
+ # --------------------------------------------------------------- conversion
239
+
240
+
241
+ def parse_template(template_src: str) -> list[Any]:
242
+ src = rewrite_at_directives(template_src)
243
+ try:
244
+ root = ET.fromstring("<pyweft-root>" + src + "</pyweft-root>")
245
+ except ET.ParseError as exc:
246
+ raise TemplateError(f"template is not well-formed XML: {exc}") from exc
247
+ return _convert_children(root)
248
+
249
+
250
+ def _text_nodes(text: str | None) -> list[Any]:
251
+ """Whitespace-only text containing a newline is formatting; drop it."""
252
+ if not text:
253
+ return []
254
+ if not text.strip():
255
+ return [] if "\n" in text else [TStatic([("static", text)])]
256
+ return [TStatic(parse_interpolation(text))]
257
+
258
+
259
+ _EACH_RE = re.compile(r"^\s*([A-Za-z_]\w*)\s+in\s+(.+)$", re.S)
260
+
261
+
262
+ def _convert_children(el: ET.Element) -> list[Any]:
263
+ out: list[Any] = []
264
+ out += _text_nodes(el.text)
265
+ kids = [k for k in el if not callable(k.tag)] # drop comments / PIs
266
+ i = 0
267
+ while i < len(kids):
268
+ child = kids[i]
269
+ if child.tag in ("ElseIf", "Else"):
270
+ raise TemplateError(f"<{child.tag}> must directly follow an <If>")
271
+ if child.tag == "If":
272
+ node, i = _convert_if(kids, i)
273
+ out.append(node)
274
+ out += _text_nodes(kids[i - 1].tail)
275
+ continue
276
+ if child.tag == "For":
277
+ out.append(_convert_for(child))
278
+ out += _text_nodes(child.tail)
279
+ i += 1
280
+ continue
281
+ if child.tag in ("Slot", "Fill"):
282
+ out.append(_convert_slot(child) if child.tag == "Slot" else _convert_fill(child))
283
+ out += _text_nodes(child.tail)
284
+ i += 1
285
+ continue
286
+ out.append(_convert_element(child))
287
+ out += _text_nodes(child.tail)
288
+ i += 1
289
+ return out
290
+
291
+
292
+ def _convert_if(kids: list[ET.Element], i: int) -> tuple[TIf, int]:
293
+ node = kids[i]
294
+ cond = node.get("cond")
295
+ if cond is None:
296
+ raise TemplateError('<If> requires a cond attribute, e.g. cond="count > 5"')
297
+ branches: list[tuple[str | None, list[Any]]] = [(cond, _convert_children(node))]
298
+ j = i + 1
299
+ while j < len(kids) and kids[j].tag in ("ElseIf", "Else"):
300
+ if (kids[j - 1].tail or "").strip():
301
+ break # real content between the branches; stop folding
302
+ nxt = kids[j]
303
+ if nxt.tag == "ElseIf":
304
+ cond2 = nxt.get("cond")
305
+ if cond2 is None:
306
+ raise TemplateError("<ElseIf> requires a cond attribute")
307
+ branches.append((cond2, _convert_children(nxt)))
308
+ j += 1
309
+ else:
310
+ branches.append((None, _convert_children(nxt)))
311
+ j += 1
312
+ break
313
+ return TIf(branches), j
314
+
315
+
316
+ def _convert_for(el: ET.Element) -> TFor:
317
+ each = el.get("each")
318
+ if not each:
319
+ raise TemplateError('<For> requires each="item in items"')
320
+ match = _EACH_RE.match(each)
321
+ if not match:
322
+ raise TemplateError(f'bad each expression {each!r}, expected "item in items"')
323
+ return TFor(
324
+ var=match.group(1),
325
+ iter_src=match.group(2).strip(),
326
+ index_var=el.get("index"),
327
+ key_expr=el.get("key"),
328
+ children=_convert_children(el),
329
+ )
330
+
331
+
332
+ _SLOT_NAME = re.compile(r"^[A-Za-z_][\w-]*$")
333
+
334
+
335
+ def _slot_name(el: ET.Element, required: bool) -> str:
336
+ name = el.get("name")
337
+ if name is None:
338
+ if required:
339
+ raise TemplateError('<Fill> requires name="...", the slot it fills')
340
+ return "default"
341
+ if not _SLOT_NAME.match(name):
342
+ raise TemplateError(f"<{el.tag} name={name!r}>: not a valid slot name")
343
+ return name
344
+
345
+
346
+ def _convert_slot(el: ET.Element) -> TSlot:
347
+ attrs: dict[str, list[tuple[str, str]]] = {}
348
+ expr_attrs: dict[str, str] = {}
349
+ for name, value in el.attrib.items():
350
+ if name == "name":
351
+ continue
352
+ if name.startswith("py-at-"):
353
+ raise TemplateError(f"<Slot> takes no events (@{name[6:]})")
354
+ if name.startswith("py-colon-"):
355
+ name, target = name[len("py-colon-") :], expr_attrs
356
+ else:
357
+ target = attrs
358
+ if not name.isidentifier():
359
+ raise TemplateError(
360
+ f"<Slot> prop {name!r} must be a Python name: the content that "
361
+ "fills the slot sees it as a variable"
362
+ )
363
+ if target is expr_attrs:
364
+ expr_attrs[name] = value
365
+ else:
366
+ attrs[name] = parse_interpolation(value)
367
+ return TSlot(
368
+ name=_slot_name(el, required=False),
369
+ attrs=attrs,
370
+ expr_attrs=expr_attrs,
371
+ children=_convert_children(el),
372
+ )
373
+
374
+
375
+ def _convert_fill(el: ET.Element) -> TFill:
376
+ extra = sorted(set(el.attrib) - {"name"})
377
+ if extra:
378
+ raise TemplateError(f"<Fill> takes only name=, not {', '.join(extra)}")
379
+ return TFill(name=_slot_name(el, required=True), children=_convert_children(el))
380
+
381
+
382
+ def _convert_element(el: ET.Element) -> TElement:
383
+ attrs: dict[str, list[tuple[str, str]]] = {}
384
+ events: dict[str, str] = {}
385
+ expr_attrs: dict[str, str] = {}
386
+ bind: str | None = None
387
+ bind_mods: tuple[str, ...] = ()
388
+ key_expr: str | None = None
389
+
390
+ for name, value in el.attrib.items():
391
+ if name == "key":
392
+ key_expr = value
393
+ elif name.startswith("py-colon-"):
394
+ expr_attrs[name[len("py-colon-") :]] = value
395
+ elif name.startswith("py-at-"):
396
+ directive = name[len("py-at-") :]
397
+ try:
398
+ if directive == "bind" or directive.startswith("bind."):
399
+ if not value.isidentifier():
400
+ raise TemplateError(
401
+ f"@bind takes a plain state name, got {value!r}"
402
+ )
403
+ bind = value
404
+ bind_mods = parse_bind_modifiers(directive)
405
+ else:
406
+ events[parse_event_spec(directive)] = value
407
+ except ValueError as exc:
408
+ raise TemplateError(f"<{el.tag}> {exc}") from None
409
+ else:
410
+ attrs[name] = parse_interpolation(value)
411
+
412
+ if el.tag == "Dynamic" and "is" not in expr_attrs:
413
+ raise TemplateError(
414
+ '<Dynamic> needs :is="..." naming the component class to render'
415
+ )
416
+
417
+ return TElement(
418
+ tag=el.tag,
419
+ attrs=attrs,
420
+ events=events,
421
+ bind=bind,
422
+ bind_mods=bind_mods,
423
+ key_expr=key_expr,
424
+ children=_convert_children(el),
425
+ expr_attrs=expr_attrs,
426
+ )
pyweft/prerender.py ADDED
@@ -0,0 +1,121 @@
1
+ """Render an app's first screen ahead of time, on the developer's machine.
2
+
3
+ ``pyweft run`` and ``pyweft build`` put the result in the page, so visitors
4
+ see the real app while Python starts in the browser instead of a loading
5
+ screen; the browser's first render then takes over the same markup. The CLI
6
+ runs this as ``python -m pyweft.prerender APP_DIR`` in a separate process:
7
+ the app's code runs here too (up to the first ``await`` of an async
8
+ ``on_init``), and must not be able to break or slow down the CLI itself.
9
+
10
+ Host-only, like cli.py: never shipped to the browser.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import html
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import routing
23
+ from .component import load_component
24
+ from .hotreload import format_error
25
+ from .renderer import Renderer
26
+ from .styles import collect_styles
27
+ from .vdom import Element, Node
28
+
29
+ MARK = "PYWEFT-PRERENDER " # prefixes the result line, so the app may print
30
+
31
+ # Elements with no closing tag in HTML.
32
+ _VOID = frozenset(
33
+ "area base br col embed hr img input link meta source track wbr".split()
34
+ )
35
+
36
+
37
+ def to_html(nodes: list[Node]) -> str:
38
+ """The vdom as HTML, as the runtime would build it: same elements, same
39
+ attributes. Events don't exist until Python runs, and scripts are left
40
+ out, since the runtime never runs them either."""
41
+ out: list[str] = []
42
+ _write(nodes, out, selected=None)
43
+ return "".join(out)
44
+
45
+
46
+ def _write(nodes: list[Node], out: list[str], selected: str | None) -> None:
47
+ for node in nodes:
48
+ if not isinstance(node, Element):
49
+ out.append(html.escape(node.text, quote=False))
50
+ continue
51
+ if node.tag == "script":
52
+ continue
53
+ attrs = dict(node.attrs)
54
+ inner = selected
55
+ # value is a live property for these, not markup: say it in HTML.
56
+ if node.tag == "textarea":
57
+ text = attrs.pop("value", "")
58
+ elif node.tag == "select":
59
+ inner = attrs.pop("value", None)
60
+ elif node.tag == "option" and selected is not None and attrs.get("value") == selected:
61
+ attrs["selected"] = ""
62
+ out.append(f"<{node.tag}")
63
+ for name, value in attrs.items():
64
+ out.append(f' {name}="{html.escape(value)}"' if value else f" {name}")
65
+ out.append(">")
66
+ if node.tag in _VOID:
67
+ continue
68
+ if node.tag == "textarea":
69
+ out.append(html.escape(text, quote=False))
70
+ else:
71
+ _write(node.children, out, inner)
72
+ out.append(f"</{node.tag}>")
73
+
74
+
75
+ def prerender(app_dir: Path) -> dict[str, Any]:
76
+ """``{"html", "css", "location"}`` for the app's first screen.
77
+ ``location`` is the address it was rendered at, or ``""`` when the app
78
+ doesn't route, so any address may show it."""
79
+ app_dir = app_dir.resolve()
80
+ sys.path.insert(0, str(app_dir)) # the app's own modules, as in the browser
81
+ routing.set_host(routing.MemoryHistory("/"))
82
+ App = load_component(app_dir / "app.weft")
83
+
84
+ async def first_screen() -> list[Node]:
85
+ # What the browser shows once its first render has settled: effects
86
+ # and the synchronous start of an async on_init included.
87
+ renderer = Renderer(App())
88
+ renderer.init_root()
89
+ tree = renderer.render()
90
+ for _ in range(100):
91
+ if not renderer.after_render():
92
+ break
93
+ tree = renderer.render()
94
+ for task in renderer.pending_tasks():
95
+ task.cancel()
96
+ return tree
97
+
98
+ tree = asyncio.run(first_screen())
99
+ css = collect_styles(App)
100
+ return {
101
+ "html": to_html(tree),
102
+ # Inside <style>, only "</style" could end the element early.
103
+ "css": css.replace("</style", "<\\/style"),
104
+ "location": routing._current() if routing._url is not None else "",
105
+ }
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> int:
109
+ args = sys.argv[1:] if argv is None else argv
110
+ app_dir = Path(args[0])
111
+ try:
112
+ result = prerender(app_dir)
113
+ except BaseException as exc: # noqa: BLE001 - reported, never raised
114
+ result = {"error": format_error(exc, root=str(app_dir.resolve()))}
115
+ sys.__stdout__.write(MARK + json.dumps(result) + "\n")
116
+ sys.__stdout__.flush()
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())