pyweb-stack 0.1.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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,532 @@
1
+ """Parse `.pyweb` source: Python via CPython `ast`, markup via a scanner.
2
+
3
+ A `.pyweb` file is Python plus *markup statements*. A markup statement is
4
+ a line that starts with a tag (``<main>``), a content line inside an open
5
+ tag (``Count: {count}``), or a ``for``/``if``/``elif``/``else`` line that
6
+ governs markup.
7
+
8
+ Parsing happens in two passes:
9
+
10
+ 1. :func:`split_sources` classifies every physical line. Markup lines are
11
+ replaced by ``__pyweb_ui__(<lineno>)`` placeholder statements so CPython
12
+ parses the remaining logic exactly (scoping, types, errors). The output
13
+ has **the same number of lines as the input**, so every line number the
14
+ Python parser reports is a real `.pyweb` line. A tag or ``{expression}``
15
+ may span several physical lines; the continuation lines become blank.
16
+ 2. :func:`parse_ui_block` turns the markup lines into a UI tree using a
17
+ character scanner that understands balanced braces and Python string
18
+ literals inside ``{...}`` holes.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ import re
25
+
26
+ from .ast import ControlFor, ControlIf, Element, ExprNode, TextNode
27
+
28
+ VOID = {"input", "img", "br", "hr", "meta", "link", "source", "wbr",
29
+ "col", "base", "area", "embed", "track", "param"}
30
+
31
+ CTL_RE = re.compile(r"^(for\s+.+\s+in\s+.+:|if\s+.+:|elif\s+.+:|else\s*:)\s*(#.*)?$")
32
+ _TAG_START = re.compile(r"<(?:/?[A-Za-z]|!--)")
33
+ _NAME = re.compile(r"[A-Za-z][A-Za-z0-9._:-]*")
34
+ _ATTR_NAME = re.compile(r"[A-Za-z_@:][\w:.@-]*")
35
+
36
+
37
+ class PyWebSyntaxError(SyntaxError):
38
+ """A markup error, reported with the `.pyweb` line number."""
39
+
40
+ def __init__(self, msg, lineno=None, filename=None):
41
+ super().__init__(f"pyweb: {msg}")
42
+ self.lineno = lineno
43
+ self.filename = filename
44
+ self.pyweb_msg = msg
45
+
46
+
47
+ class _Incomplete(Exception):
48
+ """The scanned text ends inside a tag, expression or string."""
49
+
50
+
51
+ # ---------------------------------------------------------------- scanner
52
+
53
+ def _skip_string(s, i):
54
+ """``s[i]`` is a quote. Return the index just past the string literal."""
55
+ q = s[i]
56
+ if s.startswith(q * 3, i):
57
+ end = s.find(q * 3, i + 3)
58
+ if end < 0:
59
+ raise _Incomplete
60
+ return end + 3
61
+ j = i + 1
62
+ while j < len(s):
63
+ c = s[j]
64
+ if c == "\\":
65
+ j += 2
66
+ continue
67
+ if c == q:
68
+ return j + 1
69
+ j += 1
70
+ raise _Incomplete
71
+
72
+
73
+ def _scan_braced(s, i):
74
+ """``s[i] == '{'``. Return ``(inner_code, index_after_closing_brace)``.
75
+
76
+ Nested ``()[]{}`` and Python string literals (including f-strings and
77
+ triple quotes) are skipped, so ``{len({"a": 1})}`` is one expression.
78
+ """
79
+ depth = 0
80
+ j = i
81
+ while j < len(s):
82
+ c = s[j]
83
+ if c in "\"'":
84
+ j = _skip_string(s, j)
85
+ continue
86
+ if c in "([{":
87
+ depth += 1
88
+ elif c in ")]}":
89
+ depth -= 1
90
+ if depth == 0:
91
+ if c != "}":
92
+ raise PyWebSyntaxError(f"unbalanced {c!r} in expression")
93
+ return s[i + 1:j], j + 1
94
+ j += 1
95
+ raise _Incomplete
96
+
97
+
98
+ def _scan_tag(s, i):
99
+ """``s[i] == '<'`` and a tag starts here. Return ``(token, next_index)``."""
100
+ if s.startswith("<!--", i):
101
+ end = s.find("-->", i + 4)
102
+ if end < 0:
103
+ raise _Incomplete
104
+ return ("comment", s[i + 4:end]), end + 3
105
+ j = i + 1
106
+ closing = s.startswith("/", j)
107
+ if closing:
108
+ j += 1
109
+ m = _NAME.match(s, j)
110
+ if not m:
111
+ raise PyWebSyntaxError(f"invalid tag near {s[i:i + 12]!r}")
112
+ tag = m.group(0)
113
+ j = m.end()
114
+ attrs = {}
115
+ order = []
116
+ while True:
117
+ while j < len(s) and s[j].isspace():
118
+ j += 1
119
+ if j >= len(s):
120
+ raise _Incomplete
121
+ if s.startswith("/>", j):
122
+ if closing:
123
+ raise PyWebSyntaxError(f"malformed closing tag </{tag}/>")
124
+ return ("open", tag, attrs, True), j + 2
125
+ if s[j] == ">":
126
+ if closing:
127
+ return ("close", tag), j + 1
128
+ return ("open", tag, attrs, False), j + 1
129
+ if closing:
130
+ raise PyWebSyntaxError(f"unexpected attributes on </{tag}>")
131
+ m = _ATTR_NAME.match(s, j)
132
+ if not m:
133
+ raise PyWebSyntaxError(f"invalid attribute in <{tag}> near {s[j:j + 12]!r}")
134
+ key = m.group(0)
135
+ j = m.end()
136
+ while j < len(s) and s[j].isspace():
137
+ j += 1
138
+ if j < len(s) and s[j] == "=":
139
+ j += 1
140
+ while j < len(s) and s[j].isspace():
141
+ j += 1
142
+ if j >= len(s):
143
+ raise _Incomplete
144
+ c = s[j]
145
+ if c == "{":
146
+ code, j = _scan_braced(s, j)
147
+ val = ("expr", code.strip(), 0)
148
+ elif c in "\"'":
149
+ end = s.find(c, j + 1)
150
+ if end < 0:
151
+ raise _Incomplete
152
+ val = ("lit", s[j + 1:end], 0)
153
+ j = end + 1
154
+ else:
155
+ k = j
156
+ while k < len(s) and not s[k].isspace() and s[k] != ">" and not s.startswith("/>", k):
157
+ k += 1
158
+ val = ("lit", s[j:k], 0)
159
+ j = k
160
+ else:
161
+ val = True
162
+ if key in attrs:
163
+ raise PyWebSyntaxError(f"duplicate attribute {key!r} on <{tag}>")
164
+ attrs[key] = val
165
+ order.append(key)
166
+
167
+
168
+ def scan(s):
169
+ """Tokenize one logical markup line.
170
+
171
+ Yields ``("text", str)``, ``("expr", code)``, ``("open", tag, attrs,
172
+ self_closing)``, ``("close", tag)`` and ``("comment", str)``. Raises
173
+ :class:`_Incomplete` if the line ends inside a tag or expression.
174
+ """
175
+ out = []
176
+ i = 0
177
+ buf = []
178
+ while i < len(s):
179
+ c = s[i]
180
+ if c == "{":
181
+ if buf:
182
+ out.append(("text", "".join(buf)))
183
+ buf = []
184
+ code, i = _scan_braced(s, i)
185
+ if not code.strip():
186
+ raise PyWebSyntaxError("empty {} expression")
187
+ out.append(("expr", code.strip()))
188
+ continue
189
+ if c == "<" and _TAG_START.match(s, i):
190
+ if buf:
191
+ out.append(("text", "".join(buf)))
192
+ buf = []
193
+ tok, i = _scan_tag(s, i)
194
+ out.append(tok)
195
+ continue
196
+ if c == "}":
197
+ raise PyWebSyntaxError("unmatched '}' in markup (write {'}'} for a literal brace)")
198
+ buf.append(c)
199
+ i += 1
200
+ if buf:
201
+ out.append(("text", "".join(buf)))
202
+ return out
203
+
204
+
205
+ def _complete(s):
206
+ try:
207
+ scan(s)
208
+ return True
209
+ except _Incomplete:
210
+ return False
211
+
212
+
213
+ # ------------------------------------------------------- line classifier
214
+
215
+ def _indent(line):
216
+ return len(line) - len(line.lstrip())
217
+
218
+
219
+ def _is_tag_line(s):
220
+ return bool(_TAG_START.match(s))
221
+
222
+
223
+ def split_sources(source):
224
+ """Return ``(python_source, ui_lines)``.
225
+
226
+ ``python_source`` has exactly as many lines as ``source``.
227
+ ``ui_lines`` maps the first line number of every logical markup line to
228
+ its text (continuations joined with a space, original indent kept).
229
+ """
230
+ lines = source.splitlines()
231
+ n = len(lines)
232
+ stripped = [l.strip() for l in lines]
233
+ kind = ["py"] * n # py | tag | content | ctl | cont | blank
234
+ logical = {} # head line index -> joined text
235
+
236
+ def next_sig(i):
237
+ j = i + 1
238
+ while j < n and not stripped[j]:
239
+ j += 1
240
+ return j if j < n else None
241
+
242
+ def leads_to_markup(i):
243
+ j = next_sig(i)
244
+ if j is None or _indent(lines[j]) <= _indent(lines[i]):
245
+ return False
246
+ if _is_tag_line(stripped[j]):
247
+ return True
248
+ if CTL_RE.match(stripped[j]):
249
+ return leads_to_markup(j)
250
+ return False
251
+
252
+ def join_from(i):
253
+ """Join physical lines starting at ``i`` until the markup is complete."""
254
+ text = stripped[i]
255
+ j = i
256
+ while not _complete(text):
257
+ if j + 1 >= n:
258
+ raise PyWebSyntaxError("unterminated tag or expression", lineno=i + 1)
259
+ j += 1
260
+ text = text + " " + stripped[j]
261
+ for k in range(i + 1, j + 1):
262
+ kind[k] = "cont"
263
+ logical[i] = " " * _indent(lines[i]) + text
264
+ return j
265
+
266
+ depth = 0
267
+ open_ctl_indents = [] # indents of markup ctl lines still open
268
+ i = 0
269
+ while i < n:
270
+ s = stripped[i]
271
+ if not s:
272
+ kind[i] = "blank"
273
+ i += 1
274
+ continue
275
+ ind = _indent(lines[i])
276
+ if ind == 0 and not _is_tag_line(s):
277
+ depth = 0
278
+ open_ctl_indents = []
279
+ while open_ctl_indents and ind <= open_ctl_indents[-1] and not (
280
+ CTL_RE.match(s) and s.split()[0].rstrip(":") in ("elif", "else")
281
+ and ind == open_ctl_indents[-1]):
282
+ open_ctl_indents.pop()
283
+ if _is_tag_line(s):
284
+ kind[i] = "tag"
285
+ last = join_from(i)
286
+ try:
287
+ toks = scan(logical[i].strip())
288
+ except PyWebSyntaxError as exc:
289
+ raise PyWebSyntaxError(exc.pyweb_msg, lineno=i + 1) from None
290
+ for tok in toks:
291
+ if tok[0] == "open" and not tok[3] and tok[1].lower() not in VOID:
292
+ depth += 1
293
+ elif tok[0] == "close":
294
+ depth = max(0, depth - 1)
295
+ i = last + 1
296
+ continue
297
+ if CTL_RE.match(s) and (depth > 0 or open_ctl_indents or leads_to_markup(i)):
298
+ kind[i] = "ctl"
299
+ open_ctl_indents.append(ind)
300
+ i += 1
301
+ continue
302
+ if depth > 0 or open_ctl_indents:
303
+ if _is_content(s):
304
+ kind[i] = "content"
305
+ last = join_from(i) if "{" in s else i
306
+ if last == i:
307
+ logical[i] = lines[i]
308
+ i = last + 1
309
+ continue
310
+ i += 1
311
+
312
+ # Placeholders go at the indent of the enclosing Python block: the
313
+ # indent of the first line of each contiguous markup run, clamped to
314
+ # an indentation level Python will accept there.
315
+ def block_indent(i):
316
+ m = _indent(lines[i])
317
+ for j in range(i - 1, -1, -1):
318
+ if kind[j] != "py":
319
+ continue
320
+ pj = _indent(lines[j])
321
+ if pj < m:
322
+ return m if stripped[j].endswith(":") else pj
323
+ if pj == m:
324
+ return m
325
+ return 0
326
+
327
+ py_lines = []
328
+ ui_lines = {}
329
+ run_indent = None
330
+ for i, line in enumerate(lines):
331
+ k = kind[i]
332
+ if k == "py":
333
+ run_indent = None
334
+ py_lines.append(line)
335
+ continue
336
+ if k in ("blank", "cont"):
337
+ py_lines.append("")
338
+ continue
339
+ if run_indent is None:
340
+ run_indent = block_indent(i)
341
+ pad = " " * run_indent
342
+ ln = i + 1
343
+ if k == "ctl":
344
+ ui_lines[ln] = line
345
+ s = CTL_RE.match(stripped[i]).group(1)
346
+ head = s.split()[0].rstrip(":")
347
+ if head == "else":
348
+ py_lines.append(f"{pad}__pyweb_ui__({ln})")
349
+ elif head == "elif":
350
+ py_lines.append(f"{pad}if {s[4:].strip()} __pyweb_ui__({ln})")
351
+ else:
352
+ py_lines.append(f"{pad}{s} __pyweb_ui__({ln})")
353
+ else:
354
+ ui_lines[ln] = logical.get(i, line)
355
+ py_lines.append(f"{pad}__pyweb_ui__({ln})")
356
+ return "\n".join(py_lines), ui_lines
357
+
358
+
359
+ def _is_content(s):
360
+ """Inside an open tag: is this line markup content rather than Python?"""
361
+ if s.startswith("{"):
362
+ return True
363
+ try:
364
+ tree = ast.parse(s, mode="exec")
365
+ except SyntaxError:
366
+ return True
367
+ sole = tree.body[0] if len(tree.body) == 1 else None
368
+ if isinstance(sole, ast.Expr):
369
+ # A bare call (`refresh()`) inside markup is still Python; any
370
+ # other bare expression (`Add`, `Hello world`) is text.
371
+ return not isinstance(sole.value, (ast.Call, ast.Await))
372
+ if isinstance(sole, ast.AnnAssign) and sole.value is None:
373
+ return True # `Count: {x}` parses as an annotation
374
+ return False
375
+
376
+
377
+ # ------------------------------------------------------------ UI tree
378
+
379
+ class _CtlBox:
380
+ """Stack entry for an open ``for``/``if`` chain.
381
+
382
+ ``node`` is the node placed in the tree; ``cur`` is the ``if``/``elif``
383
+ whose branch is being filled (``elif`` nests as ``orelse=[ControlIf]``).
384
+ """
385
+
386
+ def __init__(self, node, indent):
387
+ self.node = node
388
+ self.cur = node
389
+ self.indent = indent
390
+ self.in_else = False
391
+
392
+
393
+ def _cur_list(stack, root):
394
+ if not stack:
395
+ return root
396
+ top = stack[-1]
397
+ if isinstance(top, _CtlBox):
398
+ return top.cur.orelse if top.in_else else top.cur.body
399
+ return top.children
400
+
401
+
402
+ def _norm_text(text, first, last):
403
+ t = re.sub(r"\s+", " ", text)
404
+ if first:
405
+ t = t.lstrip()
406
+ if last:
407
+ t = t.rstrip()
408
+ return t
409
+
410
+
411
+ def _check_expr(code, lineno):
412
+ try:
413
+ ast.parse(code.strip(), mode="eval")
414
+ except SyntaxError as exc:
415
+ raise PyWebSyntaxError(f"invalid expression {{{code}}}: {exc.msg}", lineno=lineno) from None
416
+
417
+
418
+ def _append_tokens(toks, lineno, stack, root, closes):
419
+ for idx, tok in enumerate(toks):
420
+ first, last = idx == 0, idx == len(toks) - 1
421
+ kind = tok[0]
422
+ if kind == "text":
423
+ t = _norm_text(tok[1], first, last)
424
+ if t:
425
+ _cur_list(stack, root).append(TextNode(text=t, line=lineno))
426
+ elif kind == "expr":
427
+ _check_expr(tok[1], lineno)
428
+ _cur_list(stack, root).append(ExprNode(code=tok[1], line=lineno))
429
+ elif kind == "comment":
430
+ continue
431
+ elif kind == "close":
432
+ tag = tok[1]
433
+ if not stack or not isinstance(stack[-1], Element) or stack[-1].tag != tag:
434
+ opened = stack[-1].tag if stack and isinstance(stack[-1], Element) else None
435
+ hint = f" (expected </{opened}>)" if opened else ""
436
+ raise PyWebSyntaxError(f"mismatched </{tag}>{hint}", lineno=lineno)
437
+ stack.pop()
438
+ else:
439
+ _, tag, attrs, selfclose = tok
440
+ fixed = {}
441
+ for k, v in attrs.items():
442
+ if isinstance(v, tuple):
443
+ if v[0] == "expr":
444
+ _check_expr(v[1], lineno)
445
+ v = (v[0], v[1], lineno)
446
+ fixed[k] = v
447
+ el = Element(tag=tag, attrs=fixed, children=[], line=lineno)
448
+ _cur_list(stack, root).append(el)
449
+ if not selfclose and tag.lower() not in VOID:
450
+ stack.append(el)
451
+
452
+
453
+ def parse_ui_block(lines: dict[int, str]):
454
+ """Build the UI tree from ``{lineno: logical_markup_line}``."""
455
+ ordered = sorted(lines.items())
456
+ scanned = {}
457
+ closes: set[str] = set()
458
+ for ln, code in ordered:
459
+ s = code.strip()
460
+ if CTL_RE.match(s):
461
+ continue
462
+ try:
463
+ toks = scan(s)
464
+ except _Incomplete:
465
+ raise PyWebSyntaxError("unterminated tag or expression", lineno=ln) from None
466
+ except PyWebSyntaxError as exc:
467
+ raise PyWebSyntaxError(exc.pyweb_msg, lineno=ln) from None
468
+ scanned[ln] = toks
469
+ closes.update(t[1] for t in toks if t[0] == "close")
470
+ root: list = []
471
+ stack: list = []
472
+ for lineno, raw in ordered:
473
+ code = raw.strip()
474
+ indent = _indent(raw)
475
+ if lineno not in scanned:
476
+ code = CTL_RE.match(code).group(1)
477
+ mf = re.match(r"^for\s+(.+?)\s+in\s+(.+):$", code)
478
+ mi = re.match(r"^if\s+(.+):$", code)
479
+ ml = re.match(r"^elif\s+(.+):$", code)
480
+ if ml or code.startswith("else"):
481
+ while stack and isinstance(stack[-1], _CtlBox) and indent < stack[-1].indent:
482
+ stack.pop()
483
+ box = stack[-1] if stack else None
484
+ if (not isinstance(box, _CtlBox) or not isinstance(box.node, ControlIf)
485
+ or box.in_else or box.indent != indent):
486
+ raise PyWebSyntaxError(f"stray {code!r}", lineno=lineno)
487
+ if ml:
488
+ _check_expr(ml.group(1), lineno)
489
+ nested = ControlIf(test=ml.group(1).strip(), body=[], line=lineno)
490
+ box.cur.orelse.append(nested)
491
+ box.cur = nested
492
+ else:
493
+ box.in_else = True
494
+ continue
495
+ while stack and isinstance(stack[-1], _CtlBox) and indent <= stack[-1].indent:
496
+ stack.pop()
497
+ if mf:
498
+ _check_expr(mf.group(2), lineno)
499
+ node = ControlFor(target=mf.group(1).strip(), iterable=mf.group(2).strip(),
500
+ body=[], line=lineno)
501
+ else:
502
+ _check_expr(mi.group(1), lineno)
503
+ node = ControlIf(test=mi.group(1).strip(), body=[], line=lineno)
504
+ _cur_list(stack, root).append(node)
505
+ stack.append(_CtlBox(node, indent))
506
+ continue
507
+ toks = scanned[lineno]
508
+ if toks and toks[0][0] in ("open", "close", "comment"):
509
+ while stack and isinstance(stack[-1], _CtlBox) and indent <= stack[-1].indent:
510
+ stack.pop()
511
+ _append_tokens(toks, lineno, stack, root, closes)
512
+ for item in stack:
513
+ if isinstance(item, Element):
514
+ raise PyWebSyntaxError(f"<{item.tag}> is never closed", lineno=item.line)
515
+ return root
516
+
517
+
518
+ def parse_source(source, filename="<pyweb>"):
519
+ try:
520
+ py_source, ui_lines = split_sources(source)
521
+ ui = parse_ui_block(ui_lines) if ui_lines else []
522
+ except PyWebSyntaxError as exc:
523
+ exc.filename = filename
524
+ raise
525
+ tree = ast.parse(py_source, filename=filename)
526
+ pages = []
527
+ for node in ast.walk(tree):
528
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
529
+ span = (node.lineno, getattr(node, "end_lineno", node.lineno))
530
+ body_ui = [x for x in ui if span[0] <= getattr(x, "line", 0) <= span[1]]
531
+ pages.append((node, body_ui))
532
+ return tree, ui, pages
@@ -0,0 +1,158 @@
1
+ """Top-level pipeline: `.pyweb` source → app graph + per-page HTML/JS.
2
+
3
+ ``compile_source`` is pure: it never imports or executes the app. Page
4
+ HTML produced here is a *static prerender* (literal and browser-computable
5
+ values only). ``pyweb dev``/``serve``/ASGI render pages per request with
6
+ real server values via :mod:`pyweb.app_loader`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import builtins
13
+ import hashlib
14
+
15
+ from . import parser as P
16
+ from .codegen.ir import build_graph, to_text
17
+ from .errors import CompileError
18
+ from .lower import Emitter, classify, scan_module
19
+ from .rpc import rpc_specs
20
+
21
+
22
+ def static_state(info, ctx, args=None):
23
+ """Evaluate a page/component's state at compile time.
24
+
25
+ Literals and expressions over them are evaluated with Python; anything
26
+ needing the server (or failing) is ``None``.
27
+ """
28
+ env = {"__builtins__": builtins}
29
+ env.update(static_globals(ctx))
30
+ env.update(args or {})
31
+ for name in info.order:
32
+ value = info.inits.get(name)
33
+ if value is None or info.origin.get(name) == "server":
34
+ env.setdefault(name, None)
35
+ continue
36
+ try:
37
+ env[name] = eval(compile(ast.Expression(value), "<pyweb-static>", "eval"), env) # noqa: S307
38
+ except Exception: # noqa: BLE001
39
+ env[name] = None
40
+ env.pop("__builtins__", None)
41
+ return env
42
+
43
+
44
+ def static_globals(ctx):
45
+ """Module constants plus browser-safe helpers (pure Python) for prerendering."""
46
+ cached = getattr(ctx, "_static_globals", None)
47
+ if cached is not None:
48
+ return cached
49
+ env = {"__builtins__": builtins}
50
+ env.update(ctx.modconsts)
51
+ safe = [ctx.helpers[n] for n, js in ctx.helper_js.items() if js]
52
+ if safe:
53
+ mod = ast.Module(body=safe, type_ignores=[])
54
+ try:
55
+ exec(compile(mod, ctx.filename, "exec"), env) # noqa: S102 - pure helpers
56
+ except Exception: # noqa: BLE001
57
+ pass
58
+ env.pop("__builtins__", None)
59
+ ctx._static_globals = env
60
+ return env
61
+
62
+
63
+ def _renderer(ctx, globals_=None, strict=False):
64
+ from pyweb.ssr import Renderer
65
+
66
+ def component_state(name, props):
67
+ comp = ctx.components[name]
68
+ args = {}
69
+ for p in comp.params:
70
+ if p in props:
71
+ args[p] = props[p]
72
+ elif p in comp.defaults:
73
+ try:
74
+ args[p] = ast.literal_eval(comp.defaults[p])
75
+ except ValueError:
76
+ args[p] = None
77
+ else:
78
+ args[p] = None
79
+ return comp.ui, static_state(comp, ctx, args)
80
+
81
+ return Renderer(globals_ or static_globals(ctx), component_state=component_state, strict=strict)
82
+
83
+
84
+ def compile_source(source, filename="<pyweb>", route="/", title="PyWeb"):
85
+ try:
86
+ return _compile(source, filename, route, title)
87
+ except CompileError as exc:
88
+ if not exc.filename or exc.filename == "<pyweb>":
89
+ exc.with_file(filename)
90
+ raise
91
+
92
+
93
+ def _compile(source, filename, route, title):
94
+ from pyweb.ssr import page_html
95
+ tree, ui_all, _pages = P.parse_source(source, filename)
96
+ rpc = rpc_specs(tree)
97
+ ctx, pages, components = scan_module(tree, ui_all, filename)
98
+ for comp in components:
99
+ classify(comp, ctx)
100
+ for info in pages:
101
+ classify(info, ctx)
102
+ emitter = Emitter(ctx)
103
+ artifacts = {}
104
+ page_infos, all_signals, all_computeds, all_place, all_edges = [], {}, {}, {}, []
105
+ for info in pages:
106
+ js = emitter.page_js(info)
107
+ args = {p: None for p in info.params}
108
+ env = static_state(info, ctx, args)
109
+ body = _renderer(ctx).render(info.ui, env)
110
+ state = {k: env.get(k) for k in info.sent}
111
+ version = hashlib.sha256(js.encode()).hexdigest()[:10]
112
+ js_url = f"/static/{info.name}.js?v={version}" if js else None
113
+ app_cfg = ctx.app_config
114
+ page_title = info.title or app_cfg.get("title") or title
115
+ html = page_html(name=info.name, title=page_title, body=body, state=state, js_url=js_url,
116
+ css_urls=app_cfg.get("stylesheets") or (), lang=app_cfg.get("lang") or "en")
117
+ signals = [n for n in info.order if info.kinds[n] == "signal"]
118
+ computeds = {n: {"code": ast.unparse(info.inits[n]),
119
+ "deps": sorted(d for d in info.deps.get(n, ()) if d in info.kinds),
120
+ "line": info.inits[n].lineno}
121
+ for n in info.order if info.kinds[n] == "computed"}
122
+ edges = [(s, "__dom__") for s in signals if s in info.ui_names]
123
+ for n, c in computeds.items():
124
+ edges += [(d, n) for d in c["deps"]]
125
+ edges.append((n, "__dom__"))
126
+ placement = dict(info.reasons)
127
+ placement["__page__"] = ("browser+server",
128
+ "server renders HTML per request; browser takes over interactivity"
129
+ if js else "static HTML (no JavaScript shipped)")
130
+ sourcemap = [(f"sig {s}", info.inits[s].lineno if s in info.inits else info.node.lineno)
131
+ for s in signals]
132
+ sourcemap += [(f"handler {h}", node.lineno) for h, node in info.handlers.items()]
133
+ initial = {n: env.get(n) for n in info.order}
134
+ artifacts[info.name] = {
135
+ "ui": info.ui, "signals": signals, "computeds": computeds,
136
+ "placement": placement, "edges": edges, "initial": initial,
137
+ "js": js, "html": html, "html_body": body, "lineno": info.node.lineno,
138
+ "params": info.params, "route": info.route or route, "sourcemap": sourcemap,
139
+ "handlers": {h: {"line": n.lineno} for h, n in info.handlers.items()},
140
+ "source": ast.get_source_segment(source, info.node) or "",
141
+ "dynamic": bool(info.params) or any(info.origin[n] == "server" for n in info.order),
142
+ "state_keys": list(info.sent), "title": page_title, "info": info, "js_url": js_url,
143
+ }
144
+ all_signals.update({s: initial.get(s) for s in signals})
145
+ all_computeds.update(computeds)
146
+ all_place.update(placement)
147
+ all_edges.extend(edges)
148
+ page_infos.append({"name": info.name, "route": info.route or route, "signals": signals})
149
+ for name, callers in ctx.rpc_calls.items():
150
+ callers = sorted(c for c in callers if not c.startswith("<"))
151
+ if callers:
152
+ all_place[name] = ("server", f"@server function; browser calls it over RPC from "
153
+ f"{', '.join(callers)}")
154
+ for spec in rpc:
155
+ all_place.setdefault(spec["name"], ("server", "@server function (not called from browser code)"))
156
+ graph = build_graph(page_infos, rpc, all_signals, all_computeds, all_place, all_edges)
157
+ return {"graph": graph, "ir_text": to_text(graph), "rpc": rpc, "pages": artifacts,
158
+ "components": {c.name: c for c in components}, "context": ctx}