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
pyweb/serve.py ADDED
@@ -0,0 +1,335 @@
1
+ """Production server: threaded stdlib HTTP over a built ``dist/``.
2
+
3
+ ``pyweb build`` copies the app source into ``dist/app.pyweb``; ``serve``
4
+ loads it (:class:`pyweb.app_loader.LoadedApp`), so ``@server`` functions
5
+ are live RPC endpoints and pages render per request with real data. It
6
+ serves hashed static assets with immutable caching and answers
7
+ ``/healthz`` for orchestrators.
8
+
9
+ For process managers and HTTP/2, prefer the ASGI adapter
10
+ (:mod:`pyweb.asgi`) under uvicorn/gunicorn; this server is dependency-free
11
+ and fine behind a reverse proxy for small and medium deployments.
12
+ ``--app mod:attr`` (legacy) mounts RPC implementations from a factory
13
+ returning ``(compiled, rpc_impls)``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import http.server
19
+ import json
20
+ import os
21
+ import socketserver
22
+ import threading
23
+ import time
24
+
25
+ def _version():
26
+ try:
27
+ from pyweb import __version__ as _v
28
+ return _v
29
+ except ImportError: # pragma: no cover - serve imported standalone
30
+ return "0.0.0+unknown"
31
+
32
+
33
+ VERSION = _version()
34
+
35
+
36
+ class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
37
+ daemon_threads = True
38
+ allow_reuse_address = True
39
+
40
+
41
+ def load_dist(dist):
42
+ manifest_path = os.path.join(dist, "manifest.json")
43
+ with open(manifest_path) as fh:
44
+ manifest = json.load(fh)
45
+ static_dir = os.path.join(dist, "static")
46
+ server_dir = os.path.join(dist, "server")
47
+ return manifest, static_dir, server_dir
48
+
49
+
50
+ def parse_cookies(header_value):
51
+ """Parse a ``Cookie`` header into a dict. Malformed pairs are skipped
52
+ (never raise on attacker-controlled input)."""
53
+ cookies = {}
54
+ for pair in (header_value or "").split(";"):
55
+ name, sep, value = pair.partition("=")
56
+ name = name.strip()
57
+ if sep and name:
58
+ cookies[name] = value.strip().strip('"')
59
+ return cookies
60
+
61
+
62
+ #: Default cap for request bodies (1 MiB). Override per-call with
63
+ #: ``serve(..., max_body=...)`` or ``make_handler(..., max_body=...)``.
64
+ MAX_BODY = 1_048_576
65
+
66
+ #: Security headers applied to every response. The app's own values win
67
+ #: on conflict (merged with ``setdefault`` semantics per header).
68
+ #: Content-Security-Policy for HTML responses (override with PYWEB_CSP).
69
+ #: Scripts only from this origin; the page-state JSON block is data, not code.
70
+ DEFAULT_CSP = ("default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; "
71
+ "frame-ancestors 'self'; img-src 'self' data: https:; "
72
+ "style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:; "
73
+ "connect-src 'self'")
74
+
75
+ SECURITY_HEADERS = {
76
+ "X-Content-Type-Options": "nosniff",
77
+ "Referrer-Policy": "same-origin",
78
+ "X-Frame-Options": "SAMEORIGIN",
79
+ }
80
+
81
+
82
+ def make_handler(*, static_dir, server_runtime=None, logger=None,
83
+ started_at=None, max_body=MAX_BODY):
84
+ started = started_at or time.time()
85
+
86
+ class H(http.server.BaseHTTPRequestHandler):
87
+ server_version = f"PyWeb/{VERSION}"
88
+
89
+ def _bytes(self, body, status=200, ctype="text/plain", extra=None):
90
+ raw = body.encode() if isinstance(body, str) else body
91
+ self.send_response(status)
92
+ self.send_header("Content-Type", ctype)
93
+ self.send_header("Content-Length", str(len(raw)))
94
+ merged = dict(SECURITY_HEADERS)
95
+ if ctype.startswith("text/html"):
96
+ merged["Content-Security-Policy"] = os.environ.get("PYWEB_CSP", DEFAULT_CSP)
97
+ merged.update(extra or {})
98
+ for k, v in merged.items():
99
+ for item in (v if isinstance(v, list) else [v]):
100
+ self.send_header(k, item)
101
+ self.end_headers()
102
+ if self.command != "HEAD":
103
+ self.wfile.write(raw)
104
+
105
+ def _static(self, rel):
106
+ # Contain path traversal: rel must resolve inside static_dir.
107
+ base = os.path.realpath(static_dir)
108
+ target = os.path.realpath(os.path.join(base, rel))
109
+ if target != base and not target.startswith(base + os.sep):
110
+ return self._bytes("forbidden", 403)
111
+ if not os.path.isfile(target):
112
+ return self._bytes("not found", 404)
113
+ ctype = "text/javascript" if target.endswith(".js") else (
114
+ "text/css" if target.endswith(".css") else
115
+ "application/json" if target.endswith(".map") else
116
+ "application/octet-stream")
117
+ extra = {}
118
+ if "?" in self.path or target.endswith((".js", ".css", ".map")):
119
+ # Production assets are content-hashed; dev ``?v=`` query
120
+ # strings version the rest. Both are safe to cache forever.
121
+ extra["Cache-Control"] = "public, max-age=31536000, immutable"
122
+ with open(target, "rb") as fh:
123
+ return self._bytes(fh.read(), 200, ctype, extra)
124
+
125
+ def do_GET(self): # noqa: N802
126
+ path = self.path.split("?")[0]
127
+ if path in ("/healthz", "/health", "/readyz"):
128
+ uptime = round(time.time() - started, 3)
129
+ return self._bytes(
130
+ json.dumps({"ok": True, "version": VERSION,
131
+ "uptime_s": uptime}),
132
+ 200, "application/json")
133
+ if path.startswith("/static/"):
134
+ return self._static(path[len("/static/"):].split("?")[0])
135
+ if server_runtime is not None:
136
+ from pyweb.runtime.server import Request
137
+ resp = server_runtime.handle(Request(
138
+ "GET", self.path, dict(self.headers),
139
+ cookies=parse_cookies(self.headers.get("Cookie"))))
140
+ return self._bytes(resp.body, resp.status,
141
+ resp.headers.get("Content-Type", "text/html"),
142
+ {k: v for k, v in resp.headers.items()
143
+ if k != "Content-Type"})
144
+ # Static fallback: pre-rendered page shell if present.
145
+ return self._bytes("not found", 404)
146
+
147
+ def _drain(self, n, limit=16_777_216):
148
+ """Discard up to ``n`` request-body bytes in bounded chunks.
149
+
150
+ HTTP/1.1 keep-alive requires the server to consume the declared
151
+ body before responding on the same connection; answering 413
152
+ while bytes are still in flight RSTs the socket and the client
153
+ sees a broken pipe instead of the rejection. Discarding (not
154
+ buffering) keeps memory flat. Beyond ``limit`` (16 MiB) the
155
+ client is told to go away with the connection closed.
156
+ """
157
+ left = min(n, limit)
158
+ while left > 0:
159
+ chunk = self.rfile.read(min(65536, left))
160
+ if not chunk:
161
+ break
162
+ left -= len(chunk)
163
+ return n <= limit
164
+
165
+ def _read_body(self):
166
+ """Read the request body, enforcing ``max_body``. Returns bytes,
167
+ or sends 413 and returns None."""
168
+ try:
169
+ n = int(self.headers.get("Content-Length", 0))
170
+ except (TypeError, ValueError):
171
+ n = 0
172
+ if n < 0:
173
+ n = 0
174
+ if n > max_body:
175
+ clean = self._drain(n)
176
+ extra = {} if clean else {"Connection": "close"}
177
+ self._bytes(json.dumps(
178
+ {"ok": False, "error": "body-too-large",
179
+ "message": f"request body exceeds {max_body} bytes"}),
180
+ 413, "application/json", extra)
181
+ if not clean:
182
+ try:
183
+ self.close_connection = True
184
+ except AttributeError:
185
+ pass
186
+ return None
187
+ return self.rfile.read(n) if n else b""
188
+
189
+ def do_HEAD(self): # noqa: N802
190
+ # Same routing as GET; _bytes suppresses the body but keeps
191
+ # Content-Length so link checkers/crawlers get real metadata.
192
+ self.do_GET()
193
+
194
+ def do_POST(self): # noqa: N802
195
+ body = self._read_body()
196
+ if body is None:
197
+ return
198
+ if server_runtime is None:
199
+ return self._bytes(json.dumps(
200
+ {"ok": False, "error": "rpc-unavailable",
201
+ "message": "this dist has no app.pyweb; rebuild with `pyweb build`"}),
202
+ 501, "application/json")
203
+ from pyweb.runtime.server import Request
204
+ resp = server_runtime.handle(Request(
205
+ "POST", self.path.split("?")[0], dict(self.headers), body,
206
+ cookies=parse_cookies(self.headers.get("Cookie"))))
207
+ return self._bytes(resp.body, resp.status,
208
+ resp.headers.get("Content-Type", "application/json"),
209
+ {k: v for k, v in resp.headers.items()
210
+ if k not in ("Content-Type", "Content-Length")})
211
+
212
+ def log_message(self, fmt, *args): # noqa: N802
213
+ if logger is not None:
214
+ logger.info("http %s %s" % (self.command, self.path))
215
+
216
+ return H
217
+
218
+
219
+ def load_factory(app_factory):
220
+ """Import ``module:attr`` and return the factory. Raises RuntimeError
221
+ with an actionable message instead of a bare traceback."""
222
+ mod_name, sep, attr = (app_factory or "").partition(":")
223
+ if not sep or not mod_name or not attr:
224
+ raise RuntimeError(
225
+ f"bad --app {app_factory!r}: expected module:attr, "
226
+ "e.g. --app myapp:factory where factory(manifest) returns "
227
+ "(compiled, rpc_impls)")
228
+ import importlib
229
+ try:
230
+ mod = importlib.import_module(mod_name)
231
+ except ImportError as exc:
232
+ raise RuntimeError(
233
+ f"bad --app {app_factory!r}: cannot import {mod_name!r} "
234
+ f"({exc}); is it on PYTHONPATH?") from exc
235
+ factory = getattr(mod, attr, None)
236
+ if not callable(factory):
237
+ raise RuntimeError(
238
+ f"bad --app {app_factory!r}: {mod_name}.{attr} is not callable; "
239
+ "it must be factory(manifest) -> (compiled, rpc_impls)")
240
+ return factory
241
+
242
+
243
+ def serve(dist, *, host="0.0.0.0", port=8000, app_factory=None, logger=None,
244
+ rate_limit=None, rpc_timeout=30.0, auth_secret=None,
245
+ csrf_secret=None, max_body=1_048_576):
246
+ """Serve ``dist/`` forever. Returns the server (for tests, use
247
+ ``serve_in_thread``).
248
+
249
+ RPC protection is on by default: 120 calls/min/IP unless
250
+ ``rate_limit=`` overrides (``False`` disables — tests only).
251
+ ``auth_secret``/``csrf_secret`` default to ``PYWEB_AUTH_SECRET`` /
252
+ ``PYWEB_CSRF_SECRET`` env vars; without a secret, auth decorators
253
+ stay advisory (sessions cannot verify). ``max_body`` caps request
254
+ bodies (413 beyond it).
255
+ """
256
+ from pyweb import rpc as _rpc
257
+ from pyweb.runtime.server import Server
258
+ manifest, static_dir, _ = load_dist(dist)
259
+ if rate_limit is False:
260
+ rate_limit = None
261
+ elif rate_limit is None:
262
+ rate_limit = _rpc.RateLimiter(max_calls=120, window=60.0)
263
+ if auth_secret is None:
264
+ auth_secret = os.environ.get("PYWEB_AUTH_SECRET")
265
+ if csrf_secret is None:
266
+ csrf_secret = os.environ.get("PYWEB_CSRF_SECRET")
267
+ if logger is not None and auth_secret:
268
+ from pyweb import auth as _auth
269
+ warning = _auth.warn_if_insecure_cookies(
270
+ os.environ.get("PYWEB_COOKIE_SECURE", "").lower() in ("1", "true"),
271
+ host=host)
272
+ if warning is not None:
273
+ logger.info(f"security warning: {warning}")
274
+ runtime = None
275
+ app_source = os.path.join(dist, manifest.get("app") or "app.pyweb")
276
+ if not app_factory and os.path.isfile(app_source):
277
+ from pyweb.app_loader import LoadedApp
278
+ asset_urls = {name: f"/static/{p['js']}" for name, p in manifest.get("pages", {}).items()
279
+ if p.get("js")}
280
+ app = LoadedApp(app_source, asset_urls=asset_urls)
281
+ secure = os.environ.get("PYWEB_COOKIE_SECURE", "").lower() in ("1", "true")
282
+ runtime = Server(app=app, rate_limit=rate_limit, rpc_timeout=rpc_timeout,
283
+ auth_secret=auth_secret, csrf_secret=csrf_secret,
284
+ max_body=max_body, logger=logger, secure_cookies=secure)
285
+ elif app_factory:
286
+ factory = load_factory(app_factory)
287
+ compiled, impls = factory(manifest) if callable(factory) else (None, {})
288
+ runtime = Server(compiled or {"pages": {}, "rpc": []},
289
+ rate_limit=rate_limit, rpc_timeout=rpc_timeout,
290
+ auth_secret=auth_secret, csrf_secret=csrf_secret,
291
+ max_body=max_body)
292
+ for fn in (impls or {}).values():
293
+ runtime.register_rpc(fn)
294
+ handler = make_handler(static_dir=static_dir, server_runtime=runtime,
295
+ logger=logger, max_body=max_body)
296
+ httpd = ThreadedServer((host, port), handler)
297
+ if logger is not None:
298
+ logger.info(f"serving {dist}", host=host, port=httpd.server_address[1])
299
+ return httpd
300
+
301
+
302
+ def install_shutdown_handlers(httpd, *, logger=None, timeout=25.0):
303
+ """Handle SIGTERM/SIGINT by draining in-flight requests, then exiting.
304
+
305
+ Container orchestrators (k8s, Docker, Fly) send SIGTERM before SIGKILL;
306
+ without this, in-flight RPCs die mid-write. Returns the previous
307
+ handlers so tests can restore them. Main-thread only — silently skips
308
+ elsewhere (threads cannot receive process signals).
309
+ """
310
+ import signal
311
+ prev = {}
312
+
313
+ def _drain(signum, _frame):
314
+ name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
315
+ if logger is not None:
316
+ logger.info(f"received {name}: draining in-flight requests")
317
+ t = threading.Thread(target=httpd.shutdown, daemon=True,
318
+ name="pyweb-shutdown")
319
+ t.start()
320
+ t.join(timeout)
321
+
322
+ try:
323
+ for sig in (signal.SIGTERM, signal.SIGINT):
324
+ prev[sig] = signal.getsignal(sig)
325
+ signal.signal(sig, _drain)
326
+ except ValueError:
327
+ pass # not the main thread: process signals unavailable
328
+ return prev
329
+
330
+
331
+ def serve_in_thread(dist, **kwargs):
332
+ httpd = serve(dist, host="127.0.0.1", port=0, **kwargs)
333
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
334
+ thread.start()
335
+ return httpd, thread
pyweb/ssr.py ADDED
@@ -0,0 +1,260 @@
1
+ """Server-side rendering: UI tree + Python values → HTML.
2
+
3
+ The same UI tree the compiler turns into browser JS is rendered here with
4
+ real Python semantics, so the first paint is complete, crawlable HTML.
5
+ Rendering rules match the browser runtime:
6
+
7
+ * ``{expr}`` renders ``str(value)``; ``None`` renders nothing.
8
+ * text and attribute values are HTML-escaped; ``javascript:`` URLs in
9
+ ``href``/``src``/``action`` are neutralised.
10
+ * ``attr={True}`` renders a bare attribute, ``False``/``None`` omit it.
11
+ * ``class={{"done": flag}}`` and ``style={{"color": c}}`` accept dicts.
12
+ * ``on*`` handlers are dropped; ``bind={x}`` renders ``x`` as the value.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import builtins
19
+ import html as _html
20
+ import json
21
+ import re
22
+
23
+ from .compiler.ast import ControlFor, ControlIf, Element, ExprNode, TextNode
24
+
25
+ VOID = {"input", "img", "br", "hr", "meta", "link", "source", "wbr",
26
+ "col", "base", "area", "embed", "track", "param"}
27
+ URL_ATTRS = {"href", "src", "action", "formaction", "xlink:href"}
28
+ BOOL_PROPS = {"checked", "selected", "disabled", "readonly", "required", "multiple", "hidden"}
29
+ _BAD_URL = re.compile(r"^\s*(javascript|vbscript|data:text/html)", re.I)
30
+
31
+
32
+ class Markup(str):
33
+ """Pre-rendered HTML (component children). Not escaped again."""
34
+
35
+
36
+ class RenderError(Exception):
37
+ def __init__(self, msg, lineno=None):
38
+ super().__init__(f"line {lineno}: {msg}" if lineno else msg)
39
+ self.lineno = lineno
40
+
41
+
42
+ def safe_url(v):
43
+ s = str(v)
44
+ return "#" if _BAD_URL.match(re.sub(r"[\x00-\x1f]", "", s)) else s
45
+
46
+
47
+ def text_of(v):
48
+ if v is None:
49
+ return ""
50
+ if isinstance(v, Markup):
51
+ return v
52
+ return _html.escape(str(v), quote=False)
53
+
54
+
55
+ def clsx(v):
56
+ if v is None or v is False:
57
+ return ""
58
+ if isinstance(v, dict):
59
+ return " ".join(k for k, on in v.items() if on)
60
+ if isinstance(v, (list, tuple)):
61
+ return " ".join(c for c in (clsx(x) for x in v) if c)
62
+ return str(v)
63
+
64
+
65
+ def _kebab(k):
66
+ return re.sub(r"[A-Z]", lambda m: "-" + m.group(0).lower(), k).replace("_", "-")
67
+
68
+
69
+ def style_of(v):
70
+ if isinstance(v, dict):
71
+ return "; ".join(f"{_kebab(k)}: {val}" for k, val in v.items() if val is not None and val is not False)
72
+ return str(v)
73
+
74
+
75
+ def to_jsonable(v):
76
+ """Convert server values to JSON for the page state payload."""
77
+ import dataclasses
78
+ import datetime
79
+ import decimal
80
+ if v is None or isinstance(v, (bool, int, float, str)):
81
+ return v
82
+ if isinstance(v, (list, tuple, set, frozenset)):
83
+ return [to_jsonable(x) for x in v]
84
+ if isinstance(v, dict):
85
+ return {str(k): to_jsonable(x) for k, x in v.items()}
86
+ if dataclasses.is_dataclass(v) and not isinstance(v, type):
87
+ return to_jsonable(dataclasses.asdict(v))
88
+ if isinstance(v, (datetime.datetime, datetime.date, datetime.time)):
89
+ return v.isoformat()
90
+ if isinstance(v, decimal.Decimal):
91
+ return float(v)
92
+ for attr in ("to_dict", "dict", "model_dump"):
93
+ fn = getattr(v, attr, None)
94
+ if callable(fn):
95
+ return to_jsonable(fn())
96
+ if hasattr(v, "__dict__"):
97
+ return {k: to_jsonable(x) for k, x in vars(v).items() if not k.startswith("_")}
98
+ raise TypeError(f"value of type {type(v).__name__} cannot be sent to the browser")
99
+
100
+
101
+ def state_json(state):
102
+ """JSON for ``<script id="pw-state">`` — safe inside HTML."""
103
+ raw = json.dumps(to_jsonable(state), separators=(",", ":"))
104
+ return raw.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
105
+
106
+
107
+ class Renderer:
108
+ """Render UI nodes.
109
+
110
+ ``component_state(name, props) -> (ui_nodes, env)`` is supplied by the
111
+ caller: the compiler uses static evaluation, the server calls the real
112
+ component function.
113
+ """
114
+
115
+ def __init__(self, globals_=None, component_state=None, strict=True):
116
+ self.globals = dict(globals_ or {})
117
+ self.globals.setdefault("__builtins__", builtins)
118
+ self.component_state = component_state
119
+ self.strict = strict
120
+
121
+ def eval(self, code, env, lineno=None):
122
+ scope = dict(self.globals)
123
+ scope.update(env)
124
+ try:
125
+ return eval(compile(code, "<pyweb-ssr>", "eval"), scope) # noqa: S307 - app's own code
126
+ except Exception as exc: # noqa: BLE001
127
+ if self.strict:
128
+ raise RenderError(f"{{{code}}} raised {type(exc).__name__}: {exc}", lineno) from exc
129
+ return None
130
+
131
+ def render(self, nodes, env):
132
+ return "".join(self.node(n, env) for n in nodes)
133
+
134
+ def node(self, n, env):
135
+ if isinstance(n, TextNode):
136
+ return _html.escape(n.text, quote=False)
137
+ if isinstance(n, ExprNode):
138
+ return text_of(self.eval(n.code, env, n.line))
139
+ if isinstance(n, ControlFor):
140
+ items = self.eval(n.iterable, env, n.line)
141
+ if items is None:
142
+ return ""
143
+ target = ast.parse(n.target, mode="eval").body
144
+ out = []
145
+ for item in items:
146
+ scope = dict(env)
147
+ _bind(target, item, scope)
148
+ out.append(self.render(n.body, scope))
149
+ return "".join(out)
150
+ if isinstance(n, ControlIf):
151
+ if self.eval(n.test, env, n.line):
152
+ return self.render(n.body, env)
153
+ return self.render(n.orelse, env)
154
+ if isinstance(n, Element):
155
+ if n.is_component:
156
+ return self.component(n, env)
157
+ return self.element(n, env)
158
+ return ""
159
+
160
+ def attrs_html(self, n, env):
161
+ parts = []
162
+ for key, val in n.attrs.items():
163
+ if key.startswith("on"):
164
+ continue
165
+ if key == "bind":
166
+ if not isinstance(val, tuple):
167
+ continue
168
+ v = self.eval(val[1], env, val[2])
169
+ typ = n.attrs.get("type")
170
+ typ = typ[1] if isinstance(typ, tuple) else None
171
+ if typ == "checkbox":
172
+ if v:
173
+ parts.append(" checked")
174
+ elif typ == "radio":
175
+ own = n.attrs.get("value")
176
+ if isinstance(own, tuple) and str(v) == own[1]:
177
+ parts.append(" checked")
178
+ elif n.tag.lower() not in ("select", "textarea"):
179
+ parts.append(f' value="{_html.escape("" if v is None else str(v))}"')
180
+ continue
181
+ name = "class" if key in ("class_", "className") else key
182
+ if val is True:
183
+ parts.append(f" {name}")
184
+ continue
185
+ kind, body, line = val
186
+ v = body if kind == "lit" else self.eval(body, env, line)
187
+ if name == "class":
188
+ v = clsx(v)
189
+ if not v:
190
+ continue
191
+ elif name == "style" and isinstance(v, dict):
192
+ v = style_of(v)
193
+ if v is None or v is False:
194
+ continue
195
+ if v is True:
196
+ parts.append(f" {name}")
197
+ continue
198
+ if name in URL_ATTRS:
199
+ v = safe_url(v)
200
+ parts.append(f' {name}="{_html.escape(str(v))}"')
201
+ return "".join(parts)
202
+
203
+ def element(self, n, env):
204
+ tag = n.tag
205
+ attrs = self.attrs_html(n, env)
206
+ if tag.lower() in VOID:
207
+ return f"<{tag}{attrs}>"
208
+ inner = self.render(n.children, env)
209
+ if tag.lower() == "textarea" and "bind" in n.attrs and isinstance(n.attrs["bind"], tuple):
210
+ v = self.eval(n.attrs["bind"][1], env, n.line)
211
+ inner = _html.escape("" if v is None else str(v), quote=False)
212
+ if tag.lower() == "select" and "bind" in n.attrs and isinstance(n.attrs["bind"], tuple):
213
+ v = self.eval(n.attrs["bind"][1], env, n.line)
214
+ inner = re.sub(r'<option value="([^"]*)"',
215
+ lambda m: m.group(0) + (" selected" if _html.unescape(m.group(1)) == str(v) else ""),
216
+ inner)
217
+ return f"<{tag}{attrs}>{inner}</{tag}>"
218
+
219
+ def component(self, n, env):
220
+ if self.component_state is None:
221
+ raise RenderError(f"unknown component <{n.tag}>", n.line)
222
+ props = {}
223
+ for key, val in n.attrs.items():
224
+ if val is True:
225
+ props[key] = True
226
+ elif val[0] == "lit":
227
+ props[key] = val[1]
228
+ else:
229
+ props[key] = self.eval(val[1], env, val[2])
230
+ if n.children:
231
+ props["children"] = Markup(self.render(n.children, env))
232
+ ui, cenv = self.component_state(n.tag, props)
233
+ return self.render(ui, cenv)
234
+
235
+
236
+ def _bind(target, value, scope):
237
+ if isinstance(target, ast.Name):
238
+ scope[target.id] = value
239
+ return
240
+ if isinstance(target, (ast.Tuple, ast.List)):
241
+ vals = list(value)
242
+ if len(vals) != len(target.elts):
243
+ raise RenderError(f"cannot unpack {len(vals)} values into {len(target.elts)} names")
244
+ for t, v in zip(target.elts, vals):
245
+ _bind(t, v, scope)
246
+ return
247
+ raise RenderError("unsupported loop target")
248
+
249
+
250
+ def page_html(*, name, title, body, state, js_url=None, css_urls=(), head_extra="", lang="en"):
251
+ """The full HTML document for one page."""
252
+ css = "".join(f'<link rel="stylesheet" href="{_html.escape(u)}">' for u in css_urls)
253
+ script = ""
254
+ if js_url:
255
+ script = (f'<script id="pw-state" type="application/json">{state_json(state)}</script>'
256
+ f'<script type="module" src="{_html.escape(js_url)}"></script>')
257
+ return (f"<!doctype html>\n<html lang=\"{_html.escape(lang)}\"><head><meta charset=\"utf-8\">"
258
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
259
+ f"<title>{_html.escape(title)}</title>{css}{head_extra}</head>"
260
+ f"<body><div data-pw-root=\"{_html.escape(name)}\">{body}</div>{script}</body></html>\n")