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,733 @@
1
+ """Lowering: module analysis, state classification, browser JS emission.
2
+
3
+ This is where PyWeb decides *where code runs*:
4
+
5
+ * ``@server`` functions run on the server; browser calls become typed RPC.
6
+ * Page/component locals are classified per name:
7
+
8
+ ======== ==========================================================
9
+ signal mutated by a handler or bound to an input → reactive state
10
+ computed derived from reactive state, never assigned → cached derivation
11
+ const never changes after the page loads
12
+ ======== ==========================================================
13
+
14
+ and per *initializer*: a literal, an expression the browser can compute,
15
+ or a **server value** (anything touching server functions, models,
16
+ imports, or page-level Python logic). Server values are computed per
17
+ request and only those the browser actually reads are sent to it.
18
+ * Event handlers and ``{expressions}`` are translated to JS
19
+ (:mod:`pyweb.compiler.pyjs`); module helpers are compiled on demand.
20
+ Code that cannot run in a browser is a compile error, not a runtime
21
+ surprise.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ import json
28
+ import re
29
+
30
+ from . import pyjs
31
+ from .ast import ControlFor, ControlIf, Element, ExprNode
32
+ from .errors import CompileError
33
+ from .pyjs import (COMPUTED, CONST, HANDLER, PROP, SERVER, SIGNAL, VALUE,
34
+ ModuleContext, Translator, jsname)
35
+
36
+ RUNTIME_IMPORT = ("import { h as $h, list as $list, when as $when, signal as $signal, "
37
+ "computed as $computed, mount as $mount, onMount as $onMount, py as $py, rpc as $rpc } "
38
+ "from \"./runtime.js\";")
39
+
40
+ SECRET_NAME = re.compile(r"(?i)(secret|password|passwd|api_?key|token|private_?key|credential)")
41
+
42
+ PAGE_DECORATOR_ATTRS = ("page",)
43
+ SERVER_DECORATORS = ("server", "worker", "edge", "task")
44
+
45
+
46
+ # ------------------------------------------------------------ module scan
47
+
48
+ def _deco_name(d):
49
+ if isinstance(d, ast.Call):
50
+ d = d.func
51
+ if isinstance(d, ast.Name):
52
+ return d.id
53
+ if isinstance(d, ast.Attribute):
54
+ return d.attr
55
+ return ""
56
+
57
+
58
+ def _literal_kwargs(call):
59
+ out = {}
60
+ for k in call.keywords:
61
+ if k.arg is None:
62
+ continue
63
+ try:
64
+ out[k.arg] = ast.literal_eval(k.value)
65
+ except (ValueError, SyntaxError, TypeError):
66
+ pass
67
+ return out
68
+
69
+
70
+ def _page_kwargs(fn):
71
+ for d in fn.decorator_list:
72
+ if isinstance(d, ast.Call) and _deco_name(d) in PAGE_DECORATOR_ATTRS:
73
+ return _literal_kwargs(d)
74
+ return {}
75
+
76
+
77
+ def _route_of(fn):
78
+ for d in fn.decorator_list:
79
+ if isinstance(d, ast.Call) and _deco_name(d) in PAGE_DECORATOR_ATTRS and d.args:
80
+ a = d.args[0]
81
+ if isinstance(a, ast.Constant) and isinstance(a.value, str):
82
+ return a.value
83
+ return None
84
+
85
+
86
+ def is_ui_stmt(stmt):
87
+ """Statements that exist only because of markup placeholders."""
88
+ if pyjs.ui_placeholder(stmt):
89
+ return True
90
+ if isinstance(stmt, (ast.For, ast.If)):
91
+ body = stmt.body + stmt.orelse
92
+ return bool(body) and all(is_ui_stmt(s) for s in body)
93
+ return False
94
+
95
+
96
+ def has_ui(fn):
97
+ return any(is_ui_stmt(s) for s in fn.body)
98
+
99
+
100
+ class PageInfo:
101
+ def __init__(self, node, kind, route=None):
102
+ self.node = node
103
+ self.name = node.name
104
+ self.kind = kind # "page" | "component"
105
+ self.route = route
106
+ self.title = _page_kwargs(node).get("title") if kind == "page" else None
107
+ self.ui = []
108
+ self.params = [a.arg for a in node.args.args]
109
+ self.defaults = {}
110
+ args = node.args
111
+ for a, d in zip(args.args[len(args.args) - len(args.defaults):], args.defaults):
112
+ self.defaults[a.arg] = d
113
+ self.handlers = {} # name -> FunctionDef
114
+ self.inits = {} # name -> value node (single simple assignment)
115
+ self.order = [] # state names in assignment order
116
+ self.server_logic = [] # non-assignment statements (run on the server)
117
+ self.server_assigned = set()
118
+ self.kinds = {} # name -> SIGNAL/COMPUTED/CONST
119
+ self.origin = {} # name -> "literal" | "browser" | "server"
120
+ self.reasons = {} # name -> (location, reason) for `inspect`
121
+ self.literal = {} # name -> python literal value
122
+ self.js_init = {} # name -> js expression for browser-computable init
123
+ self.deps = {} # name -> names read by initializer
124
+ self.mutated = {} # name -> reason
125
+ self.sent = [] # names sent in the page state payload
126
+ self.needs_js = False
127
+ self.js_body = ""
128
+
129
+
130
+ def scan_module(tree, ui_all, filename):
131
+ """Collect module facts and the pages/components it defines."""
132
+ import importlib
133
+ browser_api = importlib.import_module("pyweb.browser")
134
+ ctx = ModuleContext(filename)
135
+ pages, components = [], []
136
+ browser_bindings = browser_api.bindings()
137
+ for node in tree.body:
138
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
139
+ mod = getattr(node, "module", None) or ""
140
+ for alias in node.names:
141
+ local = alias.asname or alias.name.split(".")[0]
142
+ if mod == "pyweb.browser" and alias.name in browser_bindings:
143
+ ctx.browser_globals[local] = browser_bindings[alias.name]
144
+ elif mod == "pyweb.browser" and alias.name in pyjs.JS_GLOBALS:
145
+ ctx.browser_globals[local] = alias.name
146
+ else:
147
+ where = f"from {mod} import" if isinstance(node, ast.ImportFrom) else "import"
148
+ ctx.server_only[local] = f"imported with `{where} {alias.name}`"
149
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
150
+ decos = [_deco_name(d) for d in node.decorator_list]
151
+ route = _route_of(node)
152
+ if route is not None or any(d in PAGE_DECORATOR_ATTRS for d in decos):
153
+ pages.append(PageInfo(node, "page", route or "/"))
154
+ elif any(d in SERVER_DECORATORS for d in decos):
155
+ a = node.args
156
+ if a.vararg or a.kwarg:
157
+ raise CompileError(f"@server function {node.name!r} cannot take *args/**kwargs "
158
+ "(RPC arguments are named)", node.lineno, filename)
159
+ ctx.server_fns[node.name] = [p.arg for p in a.args + a.kwonlyargs]
160
+ elif "component" in decos or (not decos and has_ui(node)):
161
+ if not node.name[:1].isupper():
162
+ raise CompileError(f"component {node.name!r} must start with a capital letter "
163
+ "(lowercase tags are HTML elements)", node.lineno, filename)
164
+ components.append(PageInfo(node, "component"))
165
+ ctx.components[node.name] = components[-1]
166
+ elif decos:
167
+ ctx.server_only[node.name] = f"decorated with @{decos[0]}"
168
+ else:
169
+ ctx.helpers[node.name] = node
170
+ elif isinstance(node, ast.ClassDef):
171
+ ctx.server_only[node.name] = "a class (classes and models live on the server)"
172
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
173
+ if isinstance(node.value, ast.Call) and _deco_name(node.value) == "App":
174
+ ctx.app_config = _literal_kwargs(node.value)
175
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
176
+ value = node.value
177
+ for t in targets:
178
+ for name in _names(t):
179
+ try:
180
+ if value is None:
181
+ raise ValueError
182
+ ctx.modconsts[name] = ast.literal_eval(value)
183
+ except (ValueError, SyntaxError, TypeError):
184
+ ctx.server_only[name] = "a module-level object created on the server"
185
+ ctx.modconsts.pop(name, None)
186
+ if not pages:
187
+ # Single-page fallback: the first function with markup is "/".
188
+ for node in tree.body:
189
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and has_ui(node) \
190
+ and node.name not in ctx.components:
191
+ pages.append(PageInfo(node, "page", "/"))
192
+ break
193
+ for info in pages + components:
194
+ span = (info.node.lineno, getattr(info.node, "end_lineno", info.node.lineno))
195
+ info.ui = [n for n in ui_all if span[0] <= getattr(n, "line", 0) <= span[1]]
196
+ return ctx, pages, components
197
+
198
+
199
+ def _names(t):
200
+ if isinstance(t, ast.Name):
201
+ return [t.id]
202
+ if isinstance(t, (ast.Tuple, ast.List)):
203
+ out = []
204
+ for e in t.elts:
205
+ out += _names(e)
206
+ return out
207
+ return []
208
+
209
+
210
+ # ---------------------------------------------------------- UI analysis
211
+
212
+ def _expr_names(code):
213
+ try:
214
+ tree = ast.parse(code, mode="eval")
215
+ except SyntaxError:
216
+ return set()
217
+ return {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
218
+
219
+
220
+ def ui_refs(nodes):
221
+ """(names read by markup, bind targets with their lines)."""
222
+ names, binds = set(), {}
223
+
224
+ def walk(ns):
225
+ for n in ns:
226
+ if isinstance(n, ExprNode):
227
+ names.update(_expr_names(n.code))
228
+ elif isinstance(n, Element):
229
+ for key, val in n.attrs.items():
230
+ if not isinstance(val, tuple) or val[0] != "expr":
231
+ continue
232
+ if key == "bind":
233
+ binds[val[1]] = val[2]
234
+ names.update(_expr_names(val[1]))
235
+ walk(n.children)
236
+ elif isinstance(n, ControlFor):
237
+ names.update(_expr_names(n.iterable))
238
+ walk(n.body)
239
+ elif isinstance(n, ControlIf):
240
+ names.update(_expr_names(n.test))
241
+ walk(n.body)
242
+ walk(n.orelse)
243
+
244
+ walk(nodes)
245
+ return names, binds
246
+
247
+
248
+ def used_components(nodes):
249
+ out = []
250
+
251
+ def walk(ns):
252
+ for n in ns:
253
+ if isinstance(n, Element):
254
+ if n.is_component and n.tag not in out:
255
+ out.append(n.tag)
256
+ walk(n.children)
257
+ elif isinstance(n, (ControlFor, ControlIf)):
258
+ walk(n.body)
259
+ walk(getattr(n, "orelse", []))
260
+
261
+ walk(nodes)
262
+ return out
263
+
264
+
265
+ def _has_events(nodes):
266
+ for n in nodes:
267
+ if isinstance(n, Element):
268
+ if any(k.startswith("on") or k == "bind" for k in n.attrs) or _has_events(n.children):
269
+ return True
270
+ if n.is_component:
271
+ return True
272
+ elif isinstance(n, (ControlFor, ControlIf)):
273
+ if _has_events(n.body) or _has_events(getattr(n, "orelse", [])):
274
+ return True
275
+ return False
276
+
277
+
278
+ def _mutations(fn, state):
279
+ """Map state name -> description of how handler ``fn`` mutates it."""
280
+ out = {}
281
+ local = pyjs._local_names(fn) - {a.arg for a in fn.args.args}
282
+ for name in local & state:
283
+ out[name] = f"assigned in {fn.name}()"
284
+ for sub in ast.walk(fn):
285
+ root = None
286
+ how = ""
287
+ if isinstance(sub, ast.AugAssign):
288
+ root, how = pyjs._root_name(sub.target), "updated"
289
+ elif isinstance(sub, (ast.Assign, ast.Delete)):
290
+ for t in getattr(sub, "targets", []):
291
+ if isinstance(t, (ast.Subscript, ast.Attribute)):
292
+ root, how = pyjs._root_name(t), "item assigned"
293
+ elif isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) \
294
+ and sub.func.attr in pyjs.MUTATING:
295
+ root, how = pyjs._root_name(sub.func.value), f".{sub.func.attr}()"
296
+ if root in state and root not in out:
297
+ out[root] = f"{how} in {fn.name}()"
298
+ return out
299
+
300
+
301
+ # ------------------------------------------------------- classification
302
+
303
+ def classify(info, ctx):
304
+ """Decide signal/computed/const and browser/server origin for each local."""
305
+ fn = info.node
306
+ state_names = set()
307
+ seen = {}
308
+ for stmt in fn.body:
309
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
310
+ if stmt.decorator_list:
311
+ raise CompileError(f"decorators are not supported on handler {stmt.name!r}; "
312
+ "define @server functions at module level", stmt.lineno, ctx.filename)
313
+ info.handlers[stmt.name] = stmt
314
+ continue
315
+ if is_ui_stmt(stmt):
316
+ continue
317
+ if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) \
318
+ and isinstance(stmt.value.value, str):
319
+ continue
320
+ target = None
321
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
322
+ target, value = stmt.targets[0].id, stmt.value
323
+ elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.value is not None:
324
+ target, value = stmt.target.id, stmt.value
325
+ if target is not None:
326
+ if target in seen:
327
+ info.server_assigned.add(target)
328
+ seen[target] = stmt
329
+ info.inits[target] = value
330
+ if target not in info.order:
331
+ info.order.append(target)
332
+ state_names.add(target)
333
+ continue
334
+ info.server_logic.append(stmt)
335
+ for sub in ast.walk(stmt):
336
+ if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Store):
337
+ info.server_assigned.add(sub.id)
338
+ state_names.add(sub.id)
339
+ if sub.id not in info.order:
340
+ info.order.append(sub.id)
341
+ if info.kind == "component" and info.server_logic:
342
+ raise CompileError(f"component {info.name!r} can only contain state assignments, handlers "
343
+ "and markup; move server logic into the page or an @server function",
344
+ info.server_logic[0].lineno, ctx.filename)
345
+
346
+ ui_names, binds = ui_refs(info.ui)
347
+ for name, line in binds.items():
348
+ if name not in state_names:
349
+ raise CompileError(f"bind={{{name}}} must name a local variable of {info.name}()",
350
+ line, ctx.filename)
351
+ info.mutated.setdefault(name, f"bound to an input (line {line})")
352
+ for h in info.handlers.values():
353
+ for name, why in _mutations(h, state_names).items():
354
+ info.mutated.setdefault(name, why)
355
+
356
+ params = set(info.params)
357
+ tr = Translator(ctx)
358
+ scope = ctx.module_scope().child(kind="page")
359
+ for p in info.params:
360
+ scope.names[p] = PROP if info.kind == "component" else CONST
361
+ for h in info.handlers:
362
+ scope.names[h] = HANDLER
363
+ scope.names["$fn:" + h] = info.handlers[h]
364
+ for name in info.order:
365
+ value = info.inits.get(name)
366
+ mutated = name in info.mutated
367
+ if name in params:
368
+ raise CompileError(f"{name!r} is a parameter of {info.name}() and cannot be reassigned",
369
+ getattr(value, "lineno", None), ctx.filename)
370
+ origin, js = "server", None
371
+ if name not in info.server_assigned and value is not None:
372
+ info.deps[name] = {n.id for n in ast.walk(value) if isinstance(n, ast.Name)}
373
+ try:
374
+ info.literal[name] = ast.literal_eval(value)
375
+ origin = "literal"
376
+ except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
377
+ pass
378
+ try:
379
+ tr.fn_stack = [pyjs._Fn("<init>")]
380
+ js = tr.expr(value, scope)
381
+ if tr.fn_stack[-1].is_async:
382
+ js = None
383
+ elif origin != "literal":
384
+ origin = "browser"
385
+ except CompileError:
386
+ js = None
387
+ finally:
388
+ tr.fn_stack = []
389
+ calls_helper = value is not None and any(
390
+ isinstance(c, ast.Call) and isinstance(c.func, ast.Name) and c.func.id in ctx.helpers
391
+ for c in ast.walk(value))
392
+ if origin == "browser" and calls_helper and not tr.is_reactive(value, scope):
393
+ # Loading data (`rows = load_rows()`) runs on the server even when
394
+ # the helper happens to be browser-compatible: only the result ships.
395
+ origin, js = "server", None
396
+ if origin == "browser" and any(info.origin.get(d) == "server" for d in info.deps.get(name, ())):
397
+ # Derived from server data: compute it on the server and send only
398
+ # the result, so the inputs (e.g. a whole session/user record) stay private.
399
+ origin, js = "server", None
400
+ info.origin[name] = origin
401
+ info.js_init[name] = js
402
+ reactive_deps = js is not None and tr.is_reactive(value, scope)
403
+ if mutated:
404
+ kind = SIGNAL
405
+ elif origin == "browser" and reactive_deps:
406
+ kind = COMPUTED
407
+ else:
408
+ kind = CONST
409
+ if info.kind == "component" and origin == "server":
410
+ raise CompileError(f"component state {name!r} must be computable in the browser; "
411
+ "pass server data in as a prop", getattr(value, "lineno", fn.lineno),
412
+ ctx.filename)
413
+ info.kinds[name] = kind
414
+ scope.names[name] = kind
415
+ pyjs.mark_async_handlers(info.handlers, scope)
416
+ info.scope = scope
417
+ info.ui_names = ui_names
418
+
419
+ # What the browser reads: markup + handlers + derived initializers.
420
+ refs = set(ui_names)
421
+ for h in info.handlers.values():
422
+ refs |= {n.id for n in ast.walk(h) if isinstance(n, ast.Name)}
423
+ changed = True
424
+ while changed:
425
+ changed = False
426
+ for name in list(refs):
427
+ if info.kinds.get(name) == COMPUTED or (info.kinds.get(name) == CONST and info.origin.get(name) == "browser"):
428
+ extra = info.deps.get(name, set()) - refs
429
+ if extra:
430
+ refs |= extra
431
+ changed = True
432
+ info.refs = refs
433
+ info.needs_js = bool(info.handlers) or any(k == SIGNAL for k in info.kinds.values()) \
434
+ or _has_events(info.ui)
435
+
436
+ sent = []
437
+ if info.kind == "page" and info.needs_js:
438
+ for p in info.params:
439
+ if p in refs:
440
+ sent.append(p)
441
+ for name in info.order:
442
+ if name not in refs:
443
+ continue
444
+ k, o = info.kinds[name], info.origin[name]
445
+ if k == SIGNAL or (k == CONST and o == "server"):
446
+ sent.append(name)
447
+ # Secrets: anything browser code or markup reads ends up in the browser
448
+ # (HTML or JSON state), so secret-looking values are a compile error.
449
+ # Empty form state (`password = ""` bound to an input) is user input.
450
+ for name in sorted(refs & set(info.order)):
451
+ if not SECRET_NAME.search(name):
452
+ continue
453
+ lit = info.literal.get(name, None)
454
+ if info.origin.get(name) == "server" or lit not in (None, "", 0, False):
455
+ raise CompileError(
456
+ f"server secret {name!r} would be sent to the browser (markup or browser code "
457
+ "reads it). Keep secrets inside @server functions",
458
+ getattr(info.inits.get(name), "lineno", fn.lineno), ctx.filename)
459
+ info.sent = sent
460
+
461
+ # Placement report for `pyweb inspect`.
462
+ for name in info.order:
463
+ k, o = info.kinds[name], info.origin[name]
464
+ if name not in refs and o == "server":
465
+ info.reasons[name] = ("server", "computed per request; never read by browser code, never sent")
466
+ elif k == SIGNAL:
467
+ src = {"literal": "literal initial value", "browser": "initial value computed in the browser",
468
+ "server": "initial value computed on the server and sent"}[o]
469
+ info.reasons[name] = ("browser", f"reactive state: {info.mutated[name]}; {src}")
470
+ elif k == COMPUTED:
471
+ deps = sorted(d for d in info.deps.get(name, ()) if info.kinds.get(d) in (SIGNAL, COMPUTED))
472
+ info.reasons[name] = ("browser", f"derived from {', '.join(deps)}; recomputed when they change")
473
+ elif o == "server":
474
+ info.reasons[name] = ("server", "computed per request on the server; value sent because browser code reads it")
475
+ else:
476
+ info.reasons[name] = ("browser", "constant")
477
+ for h in info.handlers:
478
+ info.reasons[h] = ("browser", "event handler (compiled to JavaScript)")
479
+ return info
480
+
481
+
482
+ # --------------------------------------------------------------- codegen
483
+
484
+ class Emitter:
485
+ def __init__(self, ctx):
486
+ self.ctx = ctx
487
+ self.tr = Translator(ctx)
488
+ self.component_js = {}
489
+
490
+ def page_js(self, info):
491
+ """The browser module for one page (``""`` if the page is static)."""
492
+ if not info.needs_js:
493
+ info.js_body = ""
494
+ return ""
495
+ fn_js = self.function_js(info)
496
+ comps = self.components_for(info.ui)
497
+ parts = [RUNTIME_IMPORT]
498
+ for name in sorted(self.ctx.used_consts):
499
+ parts.append(f"const {jsname(name)} = {json.dumps(self.ctx.modconsts[name])};")
500
+ for name in self.ctx.used_helpers:
501
+ parts.append(self.ctx.helper_js[name])
502
+ for name in comps:
503
+ parts.append(self.component_js[name])
504
+ parts.append(fn_js)
505
+ parts.append(f"$mount({json.dumps(info.name)}, {jsname(info.name)});")
506
+ info.js_body = fn_js
507
+ return "\n".join(parts) + "\n"
508
+
509
+ def components_for(self, nodes, out=None):
510
+ out = [] if out is None else out
511
+ for name in used_components(nodes):
512
+ comp = self.ctx.components.get(name)
513
+ if comp is None:
514
+ continue
515
+ if name not in self.component_js:
516
+ self.component_js[name] = "" # recursion guard
517
+ self.component_js[name] = self.function_js(comp)
518
+ self.components_for(comp.ui, out)
519
+ if name not in out:
520
+ out.append(name)
521
+ return out
522
+
523
+ def function_js(self, info):
524
+ scope = info.scope
525
+ lines = []
526
+ arg = "$p" if info.kind == "component" else "$s"
527
+ lines.append(f"function {jsname(info.name)}({arg}) {{")
528
+ for p in info.params:
529
+ js = jsname(p)
530
+ if info.kind == "component":
531
+ d = info.defaults.get(p)
532
+ default = self.tr.expr(d, scope.parent) if d is not None else "null"
533
+ lines.append(f" const {js} = {arg}[{json.dumps(p)}] || ({_thunk(default)});")
534
+ elif p in info.refs:
535
+ lines.append(f" const {js} = {arg}[{json.dumps(p)}];")
536
+ for name in info.order:
537
+ if name not in info.refs and info.kinds[name] != SIGNAL:
538
+ continue
539
+ js = jsname(name)
540
+ kind, origin, init = info.kinds[name], info.origin[name], info.js_init[name]
541
+ key = json.dumps(name)
542
+ if kind == COMPUTED:
543
+ lines.append(f" const {js} = $computed({_thunk(init)});")
544
+ continue
545
+ if origin == "server" or init is None:
546
+ fallback = "null"
547
+ else:
548
+ fallback = init
549
+ if kind == SIGNAL:
550
+ if info.kind == "page":
551
+ lines.append(f" const {js} = $signal({key} in $s ? $s[{key}] : {fallback});")
552
+ else:
553
+ lines.append(f" const {js} = $signal({fallback});")
554
+ elif origin == "server":
555
+ lines.append(f" const {js} = $s[{key}];")
556
+ else:
557
+ lines.append(f" const {js} = {fallback};")
558
+ for hname, hnode in info.handlers.items():
559
+ lines.append(self.tr.function(hnode, scope, " "))
560
+ if "on_mount" in info.handlers:
561
+ mh = info.handlers["on_mount"]
562
+ if mh.args.args:
563
+ raise CompileError("on_mount() takes no parameters", mh.lineno, self.ctx.filename)
564
+ lines.append(" $onMount(on_mount);")
565
+ lines.append(f" return {self.ui_js(info.ui, scope, ' ')};")
566
+ lines.append("}")
567
+ return "\n".join(lines)
568
+
569
+ # markup → JS -------------------------------------------------------
570
+ def ui_js(self, nodes, scope, ind):
571
+ items = [self.node_js(n, scope, ind + " ") for n in nodes]
572
+ items = [i for i in items if i]
573
+ if not items:
574
+ return "[]"
575
+ if len(items) == 1:
576
+ return f"[{items[0]}]"
577
+ return "[\n" + ",\n".join(ind + " " + i for i in items) + "\n" + ind + "]"
578
+
579
+ def _parse(self, code, line):
580
+ try:
581
+ return ast.parse(code, mode="eval").body
582
+ except SyntaxError as exc:
583
+ raise CompileError(f"invalid expression {{{code}}}: {exc.msg}", line, self.ctx.filename) from None
584
+
585
+ def _expr(self, code, line, scope):
586
+ node = self._parse(code, line)
587
+ _relocate(node, line)
588
+ if self.tr._calls_server(node, scope):
589
+ raise CompileError(f"{{{code}}} calls a server function; markup expressions must be "
590
+ "synchronous. Load the value in a page variable or a handler",
591
+ line, self.ctx.filename)
592
+ self.tr.fn_stack = [pyjs._Fn("<markup>")]
593
+ try:
594
+ js = self.tr.expr(node, scope)
595
+ finally:
596
+ self.tr.fn_stack = []
597
+ return node, js
598
+
599
+ def node_js(self, n, scope, ind):
600
+ t = type(n).__name__
601
+ if t == "TextNode":
602
+ return json.dumps(n.text)
603
+ if t == "ExprNode":
604
+ node, js = self._expr(n.code, n.line, scope)
605
+ if self.tr.is_reactive(node, scope):
606
+ return _thunk(js)
607
+ return f"$py.text({js})"
608
+ if t == "ControlFor":
609
+ it_node, it_js = self._expr(n.iterable, n.line, scope)
610
+ target = self._parse(n.target, n.line)
611
+ inner = scope.child({name: VALUE for name in pyjs._target_names(target)})
612
+ try:
613
+ pattern = self.tr.target_pattern(target)
614
+ except CompileError as exc:
615
+ raise exc.with_file(self.ctx.filename)
616
+ body = self.ui_js(n.body, inner, ind)
617
+ return f"$list({_thunk(it_js)}, ({pattern}) => {body})"
618
+ if t == "ControlIf":
619
+ test_node = self._parse(n.test, n.line)
620
+ _relocate(test_node, n.line)
621
+ self.tr.fn_stack = [pyjs._Fn("<markup>")]
622
+ try:
623
+ test = self.tr.test(test_node, scope)
624
+ finally:
625
+ self.tr.fn_stack = []
626
+ yes = self.ui_js(n.body, scope, ind)
627
+ no = self.ui_js(n.orelse, scope, ind) if n.orelse else None
628
+ return f"$when(() => {test}, () => {yes}" + (f", () => {no})" if no else ")")
629
+ if t == "Element":
630
+ if n.is_component:
631
+ return self.component_call(n, scope, ind)
632
+ return self.element_js(n, scope, ind)
633
+ return ""
634
+
635
+ def element_js(self, n, scope, ind):
636
+ props = []
637
+ for key, val in n.attrs.items():
638
+ name = "class" if key in ("class_", "className") else key
639
+ if val is True:
640
+ props.append(f"{json.dumps(name)}: true")
641
+ continue
642
+ kind, body, line = val
643
+ if kind == "lit":
644
+ props.append(f"{json.dumps(name)}: {json.dumps(body)}")
645
+ continue
646
+ if key == "bind":
647
+ if scope.lookup(body) != SIGNAL:
648
+ raise CompileError(f"bind={{{body}}} must name a page variable", line, self.ctx.filename)
649
+ props.append(f"\"$bind\": {jsname(body)}")
650
+ continue
651
+ if key.startswith("on"):
652
+ props.append(f"{json.dumps(name)}: {self.handler_js(body, line, scope)}")
653
+ continue
654
+ node, js = self._expr(body, line, scope)
655
+ if self.tr.is_reactive(node, scope):
656
+ props.append(f"{json.dumps(name)}: {_thunk(js)}")
657
+ else:
658
+ props.append(f"{json.dumps(name)}: {js}")
659
+ children = self.ui_js(n.children, scope, ind) if n.children else None
660
+ p = "{" + ", ".join(props) + "}" if props else "null"
661
+ if children:
662
+ return f"$h({json.dumps(n.tag)}, {p}, {children})"
663
+ return f"$h({json.dumps(n.tag)}, {p})"
664
+
665
+ def handler_js(self, code, line, scope):
666
+ node = self._parse(code, line)
667
+ _relocate(node, line)
668
+ if isinstance(node, ast.Name):
669
+ kind = scope.lookup(node.id)
670
+ if kind == HANDLER:
671
+ return jsname(node.id)
672
+ if kind == PROP:
673
+ return f"(e) => {jsname(node.id)}()(e)"
674
+ if kind == SERVER:
675
+ raise CompileError(f"event handler {node.id!r} is a server function; call it from a "
676
+ "handler or use a lambda: onclick={lambda: " + node.id + "(...)}",
677
+ line, self.ctx.filename)
678
+ if isinstance(node, ast.Lambda):
679
+ self.tr.fn_stack = []
680
+ return self.tr.expr(node, scope)
681
+ # Any other expression runs when the event fires: onclick={remove(todo)}
682
+ fn = pyjs._Fn("<event>")
683
+ self.tr.fn_stack = [fn]
684
+ try:
685
+ js = self.tr.expr(node, scope)
686
+ finally:
687
+ self.tr.fn_stack = []
688
+ return f"({'async ' if fn.is_async else ''}() => {js})"
689
+
690
+ def component_call(self, n, scope, ind):
691
+ if n.tag not in self.ctx.components:
692
+ raise CompileError(f"unknown component <{n.tag}>: define `def {n.tag}(...)` with markup "
693
+ "in this file", n.line, self.ctx.filename)
694
+ comp = self.ctx.components[n.tag]
695
+ props = []
696
+ for key, val in n.attrs.items():
697
+ if key not in comp.params:
698
+ raise CompileError(f"<{n.tag}> has no prop {key!r} (props: {', '.join(comp.params) or 'none'})",
699
+ n.line, self.ctx.filename)
700
+ if val is True:
701
+ props.append(f"{json.dumps(key)}: () => true")
702
+ elif val[0] == "lit":
703
+ props.append(f"{json.dumps(key)}: () => {json.dumps(val[1])}")
704
+ else:
705
+ if key.startswith("on"):
706
+ props.append(f"{json.dumps(key)}: {_thunk(self.handler_js(val[1], val[2], scope))}")
707
+ else:
708
+ _node, js = self._expr(val[1], val[2], scope)
709
+ props.append(f"{json.dumps(key)}: {_thunk(js)}")
710
+ if n.children:
711
+ if "children" not in comp.params:
712
+ raise CompileError(f"<{n.tag}> does not accept children (add a `children` parameter)",
713
+ n.line, self.ctx.filename)
714
+ props.append(f"\"children\": () => {self.ui_js(n.children, scope, ind)}")
715
+ missing = [p for p in comp.params if p not in comp.defaults and p not in n.attrs
716
+ and not (p == "children" and n.children)]
717
+ if missing:
718
+ raise CompileError(f"<{n.tag}> is missing required prop(s): {', '.join(missing)}",
719
+ n.line, self.ctx.filename)
720
+ return f"{jsname(n.tag)}({{{', '.join(props)}}})"
721
+
722
+
723
+ def _thunk(js):
724
+ """``() => js``, parenthesised when ``js`` would parse as a block."""
725
+ return f"() => ({js})" if js.lstrip().startswith("{") else f"() => {js}"
726
+
727
+
728
+ def _relocate(node, line):
729
+ """Give parsed markup expressions their real `.pyweb` line number."""
730
+ for sub in ast.walk(node):
731
+ if hasattr(sub, "lineno"):
732
+ sub.lineno = line
733
+ sub.end_lineno = line