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/db/migrate.py ADDED
@@ -0,0 +1,143 @@
1
+ """Versioned migrations: journal + ordered apply + CLI.
2
+
3
+ ``migrations/`` holds ``NNN_name.up.sql`` / ``NNN_name.down.sql`` pairs.
4
+ Applied versions are recorded in the ``pyweb_migrations`` journal table,
5
+ so ``migrate`` is idempotent and ``status`` shows pending work. Works on
6
+ any driver exposing ``execute()`` + ``transaction()`` (SQLite, Postgres,
7
+ MySQL); the journal DDL is plain standard SQL.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import time
15
+
16
+ JOURNAL_DDL = (
17
+ "CREATE TABLE IF NOT EXISTS pyweb_migrations ("
18
+ "version VARCHAR(255) PRIMARY KEY, applied_at VARCHAR(64) NOT NULL)"
19
+ )
20
+
21
+ _FILE_RE = re.compile(r"^(\d+)_([A-Za-z0-9_]+)\.(up|down)\.sql$")
22
+
23
+
24
+ def _discover(outdir):
25
+ """Return ``[(version, name)]`` sorted, validating up/down pairs."""
26
+ pairs: dict[tuple[str, str], set[str]] = {}
27
+ if os.path.isdir(outdir):
28
+ for fn in sorted(os.listdir(outdir)):
29
+ m = _FILE_RE.match(fn)
30
+ if m:
31
+ pairs.setdefault((m.group(1), m.group(2)), set()).add(m.group(3))
32
+ ordered = sorted(pairs)
33
+ for key, sides in pairs.items():
34
+ if sides != {"up", "down"}:
35
+ raise ValueError(f"migration {key[0]}_{key[1]} is missing "
36
+ f"{'down' if 'up' in sides else 'up'} file")
37
+ return ordered
38
+
39
+
40
+ def _read(outdir, version, name, direction):
41
+ path = os.path.join(outdir, f"{version}_{name}.{direction}.sql")
42
+ with open(path) as fh:
43
+ return [s.strip() for s in _split(fh.read()) if s.strip()]
44
+
45
+
46
+ def _split(script):
47
+ lines = [ln for ln in script.splitlines() if not ln.strip().startswith("--")]
48
+ return [s.strip() for s in "\n".join(lines).split(";")]
49
+
50
+
51
+ def _applied(db) -> set[str]:
52
+ db.execute(JOURNAL_DDL)
53
+ res = db.execute("SELECT version FROM pyweb_migrations")
54
+ rows = res.fetchall() if hasattr(res, "fetchall") else res
55
+ out = set()
56
+ for row in rows or []:
57
+ if isinstance(row, (list, tuple)):
58
+ out.add(row[0])
59
+ elif isinstance(row, dict):
60
+ out.add(row.get("version"))
61
+ else:
62
+ out.add(row)
63
+ return out
64
+
65
+
66
+ def status(db_or_url, outdir="migrations"):
67
+ """Return ``[(label, applied_bool)]`` for every discovered migration."""
68
+ from pyweb.db import connect
69
+ db = connect(db_or_url) if isinstance(db_or_url, str) else db_or_url
70
+ done = _applied(db)
71
+ return [(f"{v}_{n}", f"{v}_{n}" in done) for v, n in _discover(outdir)]
72
+
73
+
74
+ def migrate(db_or_url, outdir="migrations"):
75
+ """Apply pending ``*.up.sql`` in version order. Returns applied labels."""
76
+ from pyweb.db import connect
77
+ db = connect(db_or_url) if isinstance(db_or_url, str) else db_or_url
78
+ done = _applied(db)
79
+ applied = []
80
+ for version, name in _discover(outdir):
81
+ label = f"{version}_{name}"
82
+ if label in done:
83
+ continue
84
+ statements = _read(outdir, version, name, "up")
85
+ ph = "?" if getattr(db, "paramstyle", "qmark") == "qmark" else "%s"
86
+ with db.transaction():
87
+ for stmt in statements:
88
+ db.execute(stmt)
89
+ db.execute(
90
+ "INSERT INTO pyweb_migrations (version, applied_at) "
91
+ f"VALUES ({ph}, {ph})",
92
+ (label, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
93
+ applied.append(label)
94
+ return applied
95
+
96
+
97
+ def rollback(db_or_url, outdir="migrations", *, steps=1, to=None):
98
+ """Apply ``*.down.sql`` for the latest applied migration(s).
99
+
100
+ ``steps=N`` rolls back the N most recent applied migrations (default
101
+ 1). ``to=<label>`` rolls back everything applied *after* ``label``,
102
+ keeping ``label`` itself; unknown labels raise ``ValueError`` without
103
+ touching the database.
104
+ """
105
+ from pyweb.db import connect
106
+ db = connect(db_or_url) if isinstance(db_or_url, str) else db_or_url
107
+ done = _applied(db)
108
+ ordered = [f"{v}_{n}" for v, n in _discover(outdir) if f"{v}_{n}" in done]
109
+ if to is not None:
110
+ if to not in ordered:
111
+ raise ValueError(
112
+ f"rollback --to {to!r}: not an applied migration; "
113
+ f"applied: {', '.join(ordered) or 'none'}")
114
+ targets = ordered[ordered.index(to) + 1:]
115
+ else:
116
+ targets = ordered[max(len(ordered) - max(steps, 0), 0):]
117
+ rolled = []
118
+ for label in reversed(targets):
119
+ version, name = label.split("_", 1)
120
+ with db.transaction():
121
+ for stmt in _read(outdir, version, name, "down"):
122
+ db.execute(stmt)
123
+ ph = "?" if getattr(db, "paramstyle", "qmark") == "qmark" else "%s"
124
+ db.execute(f"DELETE FROM pyweb_migrations WHERE version = {ph}",
125
+ (label,))
126
+ rolled.append(label)
127
+ return rolled
128
+
129
+
130
+ def new_migration(outdir, name):
131
+ """Scaffold the next ``NNN_name.{up,down}.sql`` pair. Returns up path."""
132
+ safe = re.sub(r"[^A-Za-z0-9_]+", "_", name).strip("_") or "migration"
133
+ os.makedirs(outdir, exist_ok=True)
134
+ existing = [m.group(1) for m in
135
+ (_FILE_RE.match(f) for f in os.listdir(outdir)) if m]
136
+ nxt = f"{(max(map(int, existing)) + 1) if existing else 1:03d}"
137
+ up = os.path.join(outdir, f"{nxt}_{safe}.up.sql")
138
+ down = os.path.join(outdir, f"{nxt}_{safe}.down.sql")
139
+ with open(up, "w") as fh:
140
+ fh.write(f"-- Migration {nxt}_{safe} (up).\n")
141
+ with open(down, "w") as fh:
142
+ fh.write(f"-- Migration {nxt}_{safe} (down).\n")
143
+ return up
pyweb/decorators.py ADDED
@@ -0,0 +1,49 @@
1
+ """Placement and component markers. All are metadata-only at runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def _mark(fn_or_cls, **flags):
7
+ if fn_or_cls is None:
8
+ def deco(target):
9
+ return _mark(target, **flags)
10
+ return deco
11
+ for key, value in flags.items():
12
+ setattr(fn_or_cls, f"__pyweb_{key}__", value)
13
+ return fn_or_cls
14
+
15
+
16
+ def component(fn=None):
17
+ return _mark(fn, component=True) if fn else lambda t: _mark(t, component=True)
18
+
19
+
20
+ def server(fn=None):
21
+ return _mark(fn, location="server") if fn else lambda t: _mark(t, location="server")
22
+
23
+
24
+
25
+ def edge(fn=None):
26
+ return _mark(fn, location="edge") if fn else lambda t: _mark(t, location="edge")
27
+
28
+
29
+ def worker(fn=None):
30
+ return _mark(fn, location="worker") if fn else lambda t: _mark(t, location="worker")
31
+
32
+
33
+
34
+ def csrf_exempt(fn=None):
35
+ """Skip the CSRF check for this RPC (safe methods / token endpoints)."""
36
+ return _mark(fn, csrf_exempt=True) if fn else lambda t: _mark(t, csrf_exempt=True)
37
+
38
+
39
+ def auth_required(fn=None):
40
+ """Require an authenticated session to call this RPC."""
41
+ return _mark(fn, auth="required") if fn else lambda t: _mark(t, auth="required")
42
+
43
+
44
+ def rate_limited(fn=None, *, max_calls=60, window=60.0):
45
+ """Per-RPC rate-limit hint; enforced when a Server has a limiter."""
46
+ def apply(target):
47
+ _mark(target, rate_limit=(max_calls, window))
48
+ return target
49
+ return apply(fn) if fn else apply
pyweb/deploy.py ADDED
@@ -0,0 +1,102 @@
1
+ """Deployment: Dockerfile, compose, k8s manifests, env/secret handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
8
+
9
+ def dockerfile(python="3.12-slim", port=8000, app_file="app.pyweb"):
10
+ """Dockerfile for an app directory (``app.pyweb`` + optional requirements.txt)."""
11
+ from pyweb import __version__
12
+ pin = f"=={__version__}" if __version__[:1].isdigit() and "+" not in __version__ else ""
13
+ return (
14
+ f"FROM python:{python}\n"
15
+ "WORKDIR /app\n"
16
+ f"RUN pip install --no-cache-dir pyweb-stack{pin}\n"
17
+ "COPY requirements.tx[t] ./\n"
18
+ "RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi\n"
19
+ "COPY . .\n"
20
+ f"RUN pyweb build {app_file} --out dist --production\n"
21
+ "ENV PYWEB_ENV=production\n"
22
+ f"EXPOSE {port}\n"
23
+ 'HEALTHCHECK --interval=30s --timeout=5s CMD python -c '
24
+ '"import urllib.request,sys;sys.exit(0 if urllib.request.urlopen('
25
+ f"'http://127.0.0.1:{port}/healthz').status==200 else 1)\"\n"
26
+ f'CMD ["pyweb", "serve", "dist", "--host", "0.0.0.0", "--port", "{port}"]\n'
27
+ )
28
+
29
+
30
+ def compose(service="pyweb", port=8000, db_url=""):
31
+ db = f'\n - DATABASE_URL={db_url}' if db_url else ""
32
+ return (
33
+ "services:\n"
34
+ f" {service}:\n"
35
+ " build: .\n"
36
+ " ports:\n"
37
+ f' - "{port}:{port}"\n'
38
+ " environment:\n"
39
+ f" - PORT={port}{db}\n"
40
+ " restart: unless-stopped\n"
41
+ )
42
+
43
+
44
+ def k8s_manifest(app="pyweb", image="pyweb:latest", port=8000, replicas=2):
45
+ return (
46
+ "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n"
47
+ f" name: {app}\n"
48
+ "spec:\n"
49
+ f" replicas: {replicas}\n"
50
+ " selector:\n"
51
+ " matchLabels:\n"
52
+ f" app: {app}\n"
53
+ " template:\n"
54
+ " metadata:\n"
55
+ f" labels:\n app: {app}\n"
56
+ " spec:\n"
57
+ " containers:\n"
58
+ f" - name: {app}\n"
59
+ f" image: {image}\n"
60
+ " ports:\n"
61
+ f" - containerPort: {port}\n"
62
+ " livenessProbe:\n"
63
+ " httpGet:\n"
64
+ " path: /healthz\n"
65
+ f" port: {port}\n"
66
+ " periodSeconds: 30\n"
67
+ " readinessProbe:\n"
68
+ " httpGet:\n"
69
+ " path: /healthz\n"
70
+ f" port: {port}\n"
71
+ " periodSeconds: 5\n"
72
+ " env:\n"
73
+ " - name: PORT\n"
74
+ f' value: "{port}"\n'
75
+ "---\n"
76
+ "apiVersion: v1\nkind: Service\nmetadata:\n"
77
+ f" name: {app}\n"
78
+ "spec:\n"
79
+ " selector:\n"
80
+ f" app: {app}\n"
81
+ " ports:\n"
82
+ f" - port: {port}\n"
83
+ )
84
+
85
+
86
+ _ENV_RE = re.compile(r"\$\{(\w+)(?::-([^}]*))?\}")
87
+
88
+
89
+ def expand_env(text, env=None):
90
+ env = env if env is not None else os.environ
91
+
92
+ def repl(m):
93
+ return env.get(m.group(1), m.group(2) if m.group(2) is not None else "")
94
+ return _ENV_RE.sub(repl, text)
95
+
96
+
97
+ def required_env(names, env=None):
98
+ env = env if env is not None else os.environ
99
+ missing = [n for n in names if not env.get(n)]
100
+ if missing:
101
+ raise RuntimeError(f"missing required env: {', '.join(missing)}")
102
+ return {n: env[n] for n in names}
pyweb/forms.py ADDED
@@ -0,0 +1,108 @@
1
+ """Forms: single-schema derivation from Model annotations, validation,
2
+ plus rule-based request validation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import html as _html
7
+ import re
8
+
9
+ MAX_FIELD_LENGTH = 500
10
+
11
+ _EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
12
+
13
+
14
+ def _check_rule(value, rule):
15
+ name, _, arg = rule.partition(":")
16
+ if name == "required":
17
+ if value is None or (isinstance(value, str) and not value.strip()):
18
+ return "this field is required"
19
+ elif name == "email":
20
+ if value is not None and not _EMAIL_RE.match(str(value)):
21
+ return "enter a valid email address"
22
+ elif name == "max_length":
23
+ if value is not None and len(str(value)) > int(arg):
24
+ return f"must be at most {arg} characters"
25
+ elif name == "min_length":
26
+ if value is not None and len(str(value)) < int(arg):
27
+ return f"must be at least {arg} characters"
28
+ return None
29
+
30
+
31
+ def validate_request(data, rules):
32
+ """Validate ``data`` against ``{field: [rules]}``; return error dict."""
33
+ errors = {}
34
+ for field, field_rules in rules.items():
35
+ value = data.get(field)
36
+ if isinstance(value, str) and len(value) > MAX_FIELD_LENGTH * 10:
37
+ errors[field] = ["value too long"]
38
+ continue
39
+ for rule in field_rules:
40
+ err = _check_rule(value, rule)
41
+ if err:
42
+ errors.setdefault(field, []).append(err)
43
+ return errors
44
+
45
+
46
+ def _kind(ann):
47
+ name = getattr(ann, "__name__", str(ann))
48
+ if name == "Email":
49
+ return "email"
50
+ return {"str": "text", "int": "number", "float": "number",
51
+ "bool": "checkbox"}.get(name, "text")
52
+
53
+
54
+ def fields_for(model):
55
+ fields = []
56
+ for fname, ann in getattr(model, "_fields", {}).items():
57
+ name = getattr(ann, "__name__", str(ann))
58
+ fields.append({"name": fname, "kind": _kind(ann), "type": name,
59
+ "required": True})
60
+ return fields
61
+
62
+
63
+ def render_form(model, *, action="", method="post", submit="Submit",
64
+ csrf_token=None):
65
+ parts = [f'<form action="{_html.escape(action)}" method="{method}">']
66
+ if csrf_token:
67
+ parts.append(f'<input type="hidden" name="csrf_token" '
68
+ f'value="{_html.escape(csrf_token)}" />')
69
+ for f in fields_for(model):
70
+ if f["kind"] == "checkbox":
71
+ parts.append(f'<label><input type="checkbox" name="{f["name"]}" /> {f["name"]}</label>')
72
+ else:
73
+ parts.append(f'<label>{f["name"]}<input type="{f["kind"]}" name="{f["name"]}" required /></label>')
74
+ parts.append(f"<button>{_html.escape(submit)}</button></form>")
75
+ return "".join(parts)
76
+
77
+
78
+ def validate(model, data):
79
+ """Validate a dict against model annotations. Returns (clean, errors)."""
80
+ clean, errors = {}, {}
81
+ import datetime
82
+ from decimal import Decimal
83
+ for fname, ann in getattr(model, "_fields", {}).items():
84
+ name = getattr(ann, "__name__", str(ann))
85
+ raw = (data or {}).get(fname)
86
+ if raw in (None, ""):
87
+ errors[fname] = "required"
88
+ continue
89
+ try:
90
+ if name == "Email":
91
+ if "@" not in str(raw):
92
+ raise ValueError("must be a valid email")
93
+ clean[fname] = str(raw)
94
+ elif name == "int":
95
+ clean[fname] = int(raw)
96
+ elif name == "float":
97
+ clean[fname] = float(raw)
98
+ elif name == "Decimal":
99
+ clean[fname] = Decimal(str(raw))
100
+ elif name == "bool":
101
+ clean[fname] = str(raw).lower() in ("1", "true", "yes", "on")
102
+ elif name == "datetime":
103
+ clean[fname] = raw if isinstance(raw, datetime.datetime) else datetime.datetime.fromisoformat(str(raw))
104
+ else:
105
+ clean[fname] = str(raw)
106
+ except (ValueError, TypeError, ArithmeticError) as exc:
107
+ errors[fname] = str(exc)
108
+ return clean, errors
pyweb/hosting.py ADDED
@@ -0,0 +1,139 @@
1
+ """Hosting glue shared by ``pyweb dev`` and the ASGI adapter.
2
+
3
+ A :class:`Site` answers one HTTP request: static assets, ``/healthz``,
4
+ pages and RPC (via :class:`pyweb.runtime.server.Server`). It is
5
+ transport-agnostic: callers pass method/path/headers/body and get back
6
+ ``(status, [(header, value)], body_bytes)``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import mimetypes
13
+ import os
14
+ import time
15
+
16
+ from .runtime.server import Request, Server
17
+
18
+ RUNTIME_PATH = os.path.join(os.path.dirname(__file__), "runtime", "browser", "runtime.js")
19
+
20
+ SECURITY_HEADERS = [
21
+ ("X-Content-Type-Options", "nosniff"),
22
+ ("Referrer-Policy", "same-origin"),
23
+ ("X-Frame-Options", "SAMEORIGIN"),
24
+ ]
25
+
26
+
27
+ def _ctype(path):
28
+ if path.endswith(".js") or path.endswith(".mjs"):
29
+ return "text/javascript; charset=utf-8"
30
+ if path.endswith(".map") or path.endswith(".json"):
31
+ return "application/json"
32
+ if path.endswith(".css"):
33
+ return "text/css; charset=utf-8"
34
+ return mimetypes.guess_type(path)[0] or "application/octet-stream"
35
+
36
+
37
+ class Site:
38
+ """Serve an app from source (``app.pyweb``) or from a built ``dist/``."""
39
+
40
+ def __init__(self, target, *, debug=False, max_body=1_048_576, **server_kwargs):
41
+ from .app_loader import LoadedApp
42
+ from .serve import DEFAULT_CSP, load_dist
43
+ self.debug = debug
44
+ self.max_body = max_body
45
+ self.started = time.time()
46
+ self.csp = os.environ.get("PYWEB_CSP", DEFAULT_CSP)
47
+ self.server_kwargs = dict(server_kwargs)
48
+ # Same production defaults as `pyweb serve`.
49
+ if "rate_limit" not in self.server_kwargs and not debug:
50
+ from .rpc import RateLimiter
51
+ self.server_kwargs["rate_limit"] = RateLimiter(max_calls=120, window=60.0)
52
+ elif self.server_kwargs.get("rate_limit") is False:
53
+ self.server_kwargs["rate_limit"] = None
54
+ self.server_kwargs.setdefault("auth_secret", os.environ.get("PYWEB_AUTH_SECRET"))
55
+ self.server_kwargs.setdefault("csrf_secret", os.environ.get("PYWEB_CSRF_SECRET"))
56
+ self.server_kwargs.setdefault(
57
+ "secure_cookies", os.environ.get("PYWEB_COOKIE_SECURE", "").lower() in ("1", "true"))
58
+ if os.path.isdir(target):
59
+ manifest, static_dir, _ = load_dist(target)
60
+ self.static_dirs = [static_dir]
61
+ self.memory_js = {}
62
+ asset_urls = {name: f"/static/{p['js']}" for name, p in manifest.get("pages", {}).items()
63
+ if p.get("js")}
64
+ self.app = LoadedApp(os.path.join(target, manifest.get("app") or "app.pyweb"),
65
+ asset_urls=asset_urls)
66
+ self.source_mode = False
67
+ else:
68
+ self.app_path = target
69
+ self.static_dirs = [os.path.join(os.path.dirname(os.path.abspath(target)), "static")]
70
+ self.source_mode = True
71
+ self.app = LoadedApp(target)
72
+ self._index_memory_js()
73
+ self.server = Server(app=self.app, debug=debug, max_body=max_body, **server_kwargs)
74
+
75
+ def _index_memory_js(self):
76
+ self.memory_js = {f"{n}.js": p["js"] for n, p in self.app.compiled["pages"].items() if p["js"]}
77
+
78
+ def reload(self):
79
+ """Re-load the app from source (dev hot reload)."""
80
+ from .app_loader import LoadedApp
81
+ app = LoadedApp(self.app_path)
82
+ self.app = app
83
+ self._index_memory_js()
84
+ self.server = Server(app=app, debug=self.debug, max_body=self.max_body, **self.server_kwargs)
85
+
86
+ # ------------------------------------------------------------ static
87
+ def static(self, rel):
88
+ rel = rel.split("?")[0]
89
+ if self.source_mode:
90
+ if rel == "runtime.js":
91
+ with open(RUNTIME_PATH, "rb") as fh:
92
+ return 200, [("Content-Type", _ctype(rel)), ("Cache-Control", "no-cache")], fh.read()
93
+ if rel in self.memory_js:
94
+ return 200, [("Content-Type", _ctype(rel)), ("Cache-Control", "no-cache")], \
95
+ self.memory_js[rel].encode()
96
+ for base in self.static_dirs:
97
+ base = os.path.realpath(base)
98
+ target = os.path.realpath(os.path.join(base, rel))
99
+ if target != base and not target.startswith(base + os.sep):
100
+ return 403, [("Content-Type", "text/plain")], b"forbidden"
101
+ if os.path.isfile(target):
102
+ with open(target, "rb") as fh:
103
+ body = fh.read()
104
+ cache = "no-cache" if self.source_mode else "public, max-age=31536000, immutable"
105
+ return 200, [("Content-Type", _ctype(target)), ("Cache-Control", cache)], body
106
+ return 404, [("Content-Type", "text/plain")], b"not found"
107
+
108
+ # ----------------------------------------------------------- request
109
+ def respond(self, method, path, headers, body=b""):
110
+ bare = path.split("?")[0]
111
+ if bare in ("/healthz", "/health", "/readyz"):
112
+ from . import __version__
113
+ payload = {"ok": True, "version": __version__, "uptime_s": round(time.time() - self.started, 3)}
114
+ out = (200, [("Content-Type", "application/json")], json.dumps(payload).encode())
115
+ elif bare.startswith("/static/") and method in ("GET", "HEAD"):
116
+ out = self.static(bare[len("/static/"):])
117
+ elif len(body or b"") > self.max_body:
118
+ out = (413, [("Content-Type", "application/json")],
119
+ json.dumps({"error": {"code": "http_413", "message": "request body too large"}}).encode())
120
+ else:
121
+ resp = self.server.handle(Request(method, path, headers, body))
122
+ hdrs = []
123
+ for k, v in resp.headers.items():
124
+ for item in (v if isinstance(v, list) else [v]):
125
+ hdrs.append((k, str(item)))
126
+ raw = resp.body.encode() if isinstance(resp.body, str) else (resp.body or b"")
127
+ out = (resp.status, hdrs, raw)
128
+ status, hdrs, raw = out
129
+ names = {k.lower() for k, _ in hdrs}
130
+ for k, v in SECURITY_HEADERS:
131
+ if k.lower() not in names:
132
+ hdrs.append((k, v))
133
+ ctype = next((v for k, v in hdrs if k.lower() == "content-type"), "")
134
+ if ctype.startswith("text/html") and not self.debug and "content-security-policy" not in names:
135
+ hdrs.append(("Content-Security-Policy", self.csp))
136
+ if method == "HEAD":
137
+ hdrs.append(("Content-Length", str(len(raw))))
138
+ raw = b""
139
+ return status, hdrs, raw