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.
- pyweb/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/compiler/lower.py
ADDED
|
@@ -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
|