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/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")
|