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/parser.py
ADDED
|
@@ -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}
|