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