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/cache.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Caching: memory + Redis backends, TTL, tags, SWR, @cache decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import pickle
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Cache:
|
|
13
|
+
def get(self, key, default=None):
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
def set(self, key, value, ttl=None, tags=()):
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def delete(self, key):
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def clear(self):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def invalidate_tag(self, tag):
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryCache(Cache):
|
|
30
|
+
def __init__(self, time_fn=None):
|
|
31
|
+
self._store: dict = {}
|
|
32
|
+
self._tags: dict[str, set] = {}
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._now = time_fn or time.monotonic
|
|
35
|
+
|
|
36
|
+
def get(self, key, default=None):
|
|
37
|
+
"""Return ``(value, hit)`` tuple, preserving the prototype API."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
item = self._store.get(key)
|
|
40
|
+
if item is None:
|
|
41
|
+
return None, False
|
|
42
|
+
value, expires, _tags = item
|
|
43
|
+
if expires is not None and expires <= self._now():
|
|
44
|
+
del self._store[key]
|
|
45
|
+
return None, False
|
|
46
|
+
return value, True
|
|
47
|
+
|
|
48
|
+
def get_value(self, key, default=None):
|
|
49
|
+
value, hit = self.get(key)
|
|
50
|
+
return value if hit else default
|
|
51
|
+
|
|
52
|
+
def set(self, key, value, ttl=None, tags=(), **kw):
|
|
53
|
+
# ``ttl`` is seconds; ``minutes=`` is the human-friendly alias.
|
|
54
|
+
if "minutes" in kw and kw["minutes"] is not None:
|
|
55
|
+
ttl = kw["minutes"] * 60
|
|
56
|
+
expires = None if ttl is None else self._now() + ttl
|
|
57
|
+
with self._lock:
|
|
58
|
+
old = self._store.get(key)
|
|
59
|
+
if isinstance(old, tuple) and len(old) == 3:
|
|
60
|
+
for t in old[2]:
|
|
61
|
+
self._tags.get(t, set()).discard(key)
|
|
62
|
+
tagset = set(tags)
|
|
63
|
+
self._store[key] = (value, expires, tagset)
|
|
64
|
+
for t in tagset:
|
|
65
|
+
self._tags.setdefault(t, set()).add(key)
|
|
66
|
+
|
|
67
|
+
def delete(self, key):
|
|
68
|
+
with self._lock:
|
|
69
|
+
item = self._store.pop(key, None)
|
|
70
|
+
if item is None:
|
|
71
|
+
return False
|
|
72
|
+
if isinstance(item, tuple) and len(item) == 3:
|
|
73
|
+
for t in item[2]:
|
|
74
|
+
self._tags.get(t, set()).discard(key)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def invalidate_tag(self, tag):
|
|
78
|
+
with self._lock:
|
|
79
|
+
keys = self._tags.pop(tag, set())
|
|
80
|
+
for key in keys:
|
|
81
|
+
self._store.pop(key, None)
|
|
82
|
+
return len(keys)
|
|
83
|
+
|
|
84
|
+
def clear(self):
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._store.clear()
|
|
87
|
+
self._tags.clear()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _dumps(value) -> bytes:
|
|
91
|
+
try:
|
|
92
|
+
return b"j:" + json.dumps(value).encode("utf-8")
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
return b"p:" + pickle.dumps(value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _loads(raw: bytes):
|
|
98
|
+
if raw.startswith(b"j:"):
|
|
99
|
+
return json.loads(raw[2:].decode("utf-8"))
|
|
100
|
+
if raw.startswith(b"p:"):
|
|
101
|
+
return pickle.loads(raw[2:])
|
|
102
|
+
return raw
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RedisCache(Cache):
|
|
106
|
+
def __init__(self, url="redis://localhost:6379/0", client=None, prefix="pyweb:"):
|
|
107
|
+
if client is not None:
|
|
108
|
+
self._r = client
|
|
109
|
+
else:
|
|
110
|
+
try:
|
|
111
|
+
import redis
|
|
112
|
+
except ImportError as e:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"RedisCache requires the 'redis' package, which is not "
|
|
115
|
+
"installed. Install it with: pip install redis"
|
|
116
|
+
) from e
|
|
117
|
+
self._r = redis.Redis.from_url(url)
|
|
118
|
+
self._prefix = prefix
|
|
119
|
+
|
|
120
|
+
def _k(self, key):
|
|
121
|
+
return f"{self._prefix}k:{key}"
|
|
122
|
+
|
|
123
|
+
def _t(self, tag):
|
|
124
|
+
return f"{self._prefix}tag:{tag}"
|
|
125
|
+
|
|
126
|
+
def set(self, key, value, ttl=None, tags=()):
|
|
127
|
+
self._r.set(self._k(key), _dumps(value), ex=ttl)
|
|
128
|
+
for t in tags:
|
|
129
|
+
self._r.sadd(self._t(t), self._k(key))
|
|
130
|
+
|
|
131
|
+
def get(self, key, default=None):
|
|
132
|
+
raw = self._r.get(self._k(key))
|
|
133
|
+
if raw is None:
|
|
134
|
+
return default
|
|
135
|
+
return _loads(bytes(raw))
|
|
136
|
+
|
|
137
|
+
def delete(self, key):
|
|
138
|
+
return bool(self._r.delete(self._k(key)))
|
|
139
|
+
|
|
140
|
+
def clear(self):
|
|
141
|
+
self._r.flushdb()
|
|
142
|
+
|
|
143
|
+
def invalidate_tag(self, tag):
|
|
144
|
+
members = [m if isinstance(m, bytes) else str(m).encode("utf-8")
|
|
145
|
+
for m in self._r.smembers(self._t(tag))]
|
|
146
|
+
count = 0
|
|
147
|
+
for m in members:
|
|
148
|
+
count += self._r.delete(m.decode("utf-8", "ignore"))
|
|
149
|
+
self._r.delete(self._t(tag))
|
|
150
|
+
return count
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_cache(backend="memory", **kwargs):
|
|
154
|
+
if backend == "memory":
|
|
155
|
+
return MemoryCache(**{k: v for k, v in kwargs.items() if k == "time_fn"})
|
|
156
|
+
if backend == "redis":
|
|
157
|
+
return RedisCache(**kwargs)
|
|
158
|
+
raise ValueError(f"unknown cache backend: {backend!r}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _key(fn, args, kwargs):
|
|
162
|
+
return f"{fn.__module__}.{fn.__qualname__}:{args!r}:{sorted(kwargs.items())!r}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cache(_fn=None, *, minutes=5, tags=(), backend=None):
|
|
166
|
+
store = backend or MemoryCache()
|
|
167
|
+
|
|
168
|
+
def deco(fn):
|
|
169
|
+
@functools.wraps(fn)
|
|
170
|
+
def wrapper(*args, **kwargs):
|
|
171
|
+
key = _key(fn, args, kwargs)
|
|
172
|
+
value, hit = store.get(key)
|
|
173
|
+
if hit:
|
|
174
|
+
return value
|
|
175
|
+
value = fn(*args, **kwargs)
|
|
176
|
+
store.set(key, value, ttl=minutes * 60, tags=tags if isinstance(tags, (list, tuple)) else [tags])
|
|
177
|
+
return value
|
|
178
|
+
wrapper.cache_store = store
|
|
179
|
+
wrapper.cache_invalidate = lambda: store.clear()
|
|
180
|
+
return wrapper
|
|
181
|
+
return deco(_fn) if _fn else deco
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def swr(store, key, loader, *, ttl=60, stale=300):
|
|
185
|
+
"""Stale-while-revalidate: serve stale copy, refresh in background."""
|
|
186
|
+
value, hit = store.get("__swr__" + key)
|
|
187
|
+
now = store._now()
|
|
188
|
+
if hit:
|
|
189
|
+
data, ts = value
|
|
190
|
+
if now - ts < ttl:
|
|
191
|
+
return data, "fresh"
|
|
192
|
+
if now - ts < stale:
|
|
193
|
+
try:
|
|
194
|
+
fresh = loader()
|
|
195
|
+
store.set("__swr__" + key, (fresh, now), ttl=stale + ttl)
|
|
196
|
+
return fresh, "revalidated"
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
return data, "stale"
|
|
199
|
+
data = loader()
|
|
200
|
+
store.set("__swr__" + key, (data, now), ttl=stale + ttl)
|
|
201
|
+
return data, "miss"
|
pyweb/cli/__init__.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""`pyweb` CLI: new / dev / build / test / check / inspect / deploy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import http.server
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load(path):
|
|
12
|
+
with open(path) as fh:
|
|
13
|
+
return fh.read()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_inspect(args):
|
|
17
|
+
from pyweb.compiler import compile_source
|
|
18
|
+
src = _load(args.file)
|
|
19
|
+
out = compile_source(src, filename=args.file)
|
|
20
|
+
print(out["ir_text"])
|
|
21
|
+
print("---")
|
|
22
|
+
for name, page in out["pages"].items():
|
|
23
|
+
print(f"page {name} route={page['route']} signals={page['signals']} computeds={list(page['computeds'])}")
|
|
24
|
+
placement = page.get("placement") or {}
|
|
25
|
+
for symbol, decision in placement.items():
|
|
26
|
+
loc, reason = decision if isinstance(decision, tuple) else (decision, "")
|
|
27
|
+
print(f" {loc:14s} {symbol}" + (f" # {reason}" if reason else ""))
|
|
28
|
+
for spec in out["rpc"]:
|
|
29
|
+
print(f"rpc {spec['name']}({', '.join(a['name']+': '+a['type'] for a in spec['args'])}) -> {spec['returns']} [{spec['location']}] line {spec['line']}")
|
|
30
|
+
if getattr(args, "security", False):
|
|
31
|
+
from pyweb.security import check_source
|
|
32
|
+
for finding in check_source(src, args.file):
|
|
33
|
+
print(f"{finding['kind']} {args.file}:{finding['line']}: {finding['message']}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_check(args):
|
|
37
|
+
from pyweb.compiler import compile_source
|
|
38
|
+
from pyweb.security import check_source
|
|
39
|
+
src = _load(args.file)
|
|
40
|
+
findings = check_source(src, args.file)
|
|
41
|
+
try:
|
|
42
|
+
out = compile_source(src, filename=args.file)
|
|
43
|
+
n_sig = sum(len(p["signals"]) for p in out["pages"].values())
|
|
44
|
+
print(f"ok: {len(out['pages'])} page(s), {n_sig} signal(s), {len(out['rpc'])} rpc(s)")
|
|
45
|
+
except Exception as exc: # noqa: BLE001
|
|
46
|
+
findings.append({"kind": "compile-error", "line": 0, "message": str(exc)})
|
|
47
|
+
for f in findings:
|
|
48
|
+
print(f"{f['kind']} {args.file}:{f['line']}: {f['message']}")
|
|
49
|
+
if any(f["kind"] in ("secret-leak", "compile-error") for f in findings):
|
|
50
|
+
raise SystemExit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_build(args):
|
|
54
|
+
from pyweb.compiler import compile_source
|
|
55
|
+
from pyweb.build import build as _build
|
|
56
|
+
src = _load(args.file)
|
|
57
|
+
out = compile_source(src, filename=args.file)
|
|
58
|
+
production = getattr(args, "production", False)
|
|
59
|
+
manifest = _build(out, args.out, source=src, production=production,
|
|
60
|
+
app_dir=os.path.dirname(os.path.abspath(args.file)))
|
|
61
|
+
mode = " (production)" if production else ""
|
|
62
|
+
print(f"built {len(manifest['pages'])} page(s) + {len(out['rpc'])} rpc(s) -> {args.out}/{mode}")
|
|
63
|
+
from pathlib import Path as _Path
|
|
64
|
+
from pyweb.observability import Timer as _Timer
|
|
65
|
+
with _Timer() as _t:
|
|
66
|
+
_total = sum(p.stat().st_size for p in _Path(args.out).rglob("*") if p.is_file())
|
|
67
|
+
_breaches = []
|
|
68
|
+
for _spec in getattr(args, "budget", []) or []:
|
|
69
|
+
_name, _, _limit = _spec.partition("=")
|
|
70
|
+
_limit_b = _parse_budget(_limit) if _limit else _parse_budget(_name)
|
|
71
|
+
_target = _Path(args.out) / _name if _limit else None
|
|
72
|
+
_size = _target.stat().st_size if _target and _target.exists() else _total
|
|
73
|
+
_label = _name if _limit else "total"
|
|
74
|
+
if _size > _limit_b:
|
|
75
|
+
_breaches.append(f"budget breach: {_label} is {_size}B > {_limit_b}B")
|
|
76
|
+
for _b in _breaches:
|
|
77
|
+
print(f"build: {_b}", file=sys.stderr)
|
|
78
|
+
if _breaches:
|
|
79
|
+
raise SystemExit(2)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
DEV_RELOAD_JS = """<script>(()=>{let v=null;async function t(){try{const r=await fetch('/__pyweb/dev/version');const n=await r.text();if(v!==null&&n!==v)location.reload();v=n}catch{}setTimeout(t,600)}t()})()</script>"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _error_overlay(exc, path):
|
|
86
|
+
"""HTML page shown in the browser while the app has a compile error."""
|
|
87
|
+
import html as _h
|
|
88
|
+
lineno = getattr(exc, "lineno", None)
|
|
89
|
+
snippet = ""
|
|
90
|
+
try:
|
|
91
|
+
lines = _load(path).splitlines()
|
|
92
|
+
if lineno:
|
|
93
|
+
lo, hi = max(0, lineno - 4), min(len(lines), lineno + 3)
|
|
94
|
+
rows = []
|
|
95
|
+
for i in range(lo, hi):
|
|
96
|
+
mark = ">" if i + 1 == lineno else " "
|
|
97
|
+
rows.append(f"{mark} {i + 1:4d} | {_h.escape(lines[i])}")
|
|
98
|
+
snippet = "<pre class=code>" + "\n".join(rows) + "</pre>"
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
return ("<!doctype html><meta charset=utf-8><title>PyWeb error</title>"
|
|
102
|
+
"<style>body{font:15px/1.5 system-ui;margin:0;background:#1b1b1f;color:#eee}"
|
|
103
|
+
"main{max-width:880px;margin:8vh auto;padding:0 24px}h1{color:#ff6b6b;font-size:20px}"
|
|
104
|
+
"pre{background:#0f0f12;padding:16px;border-radius:8px;overflow:auto;font-size:13px}"
|
|
105
|
+
".code{border-left:3px solid #ff6b6b}</style><main>"
|
|
106
|
+
f"<h1>{_h.escape(type(exc).__name__)}</h1><pre>{_h.escape(str(exc))}</pre>{snippet}"
|
|
107
|
+
"<p>Fix the file and save: this page reloads automatically.</p></main>" + DEV_RELOAD_JS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _DevState:
|
|
111
|
+
def __init__(self, path):
|
|
112
|
+
self.path = path
|
|
113
|
+
self.site = None
|
|
114
|
+
self.error = None
|
|
115
|
+
self.version = 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _dev_load(state, Site):
|
|
119
|
+
try:
|
|
120
|
+
if state.site is None:
|
|
121
|
+
state.site = Site(state.path, debug=True)
|
|
122
|
+
else:
|
|
123
|
+
state.site.reload()
|
|
124
|
+
state.error = None
|
|
125
|
+
except Exception as exc: # noqa: BLE001 - shown in the browser overlay
|
|
126
|
+
state.error = exc
|
|
127
|
+
state.version += 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_dev(args):
|
|
131
|
+
"""Development server: compile on save, live reload, error overlay."""
|
|
132
|
+
from pyweb.hosting import Site
|
|
133
|
+
from pyweb.serve import ThreadedServer
|
|
134
|
+
|
|
135
|
+
state = _DevState(args.file)
|
|
136
|
+
_dev_load(state, Site)
|
|
137
|
+
if state.error is not None:
|
|
138
|
+
print(f"error: {state.error}", file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
141
|
+
def _send(self, status, headers, body):
|
|
142
|
+
self.send_response(status)
|
|
143
|
+
for k, v in headers:
|
|
144
|
+
if k.lower() != "content-length":
|
|
145
|
+
self.send_header(k, v)
|
|
146
|
+
self.send_header("Content-Length", str(len(body)))
|
|
147
|
+
self.end_headers()
|
|
148
|
+
if self.command != "HEAD":
|
|
149
|
+
self.wfile.write(body)
|
|
150
|
+
|
|
151
|
+
def _handle(self, method, body=b""):
|
|
152
|
+
if self.path == "/__pyweb/dev/version":
|
|
153
|
+
return self._send(200, [("Content-Type", "text/plain"), ("Cache-Control", "no-store")],
|
|
154
|
+
str(state.version).encode())
|
|
155
|
+
if state.error is not None or state.site is None:
|
|
156
|
+
html = _error_overlay(state.error, state.path).encode()
|
|
157
|
+
return self._send(500, [("Content-Type", "text/html; charset=utf-8")], html)
|
|
158
|
+
status, headers, raw = state.site.respond(method, self.path, dict(self.headers), body)
|
|
159
|
+
ctype = next((v for k, v in headers if k.lower() == "content-type"), "")
|
|
160
|
+
if ctype.startswith("text/html") and b"</body>" in raw:
|
|
161
|
+
raw = raw.replace(b"</body>", DEV_RELOAD_JS.encode() + b"</body>", 1)
|
|
162
|
+
return self._send(status, headers, raw)
|
|
163
|
+
|
|
164
|
+
def do_GET(self): # noqa: N802
|
|
165
|
+
self._handle("GET")
|
|
166
|
+
|
|
167
|
+
def do_HEAD(self): # noqa: N802
|
|
168
|
+
self._handle("HEAD")
|
|
169
|
+
|
|
170
|
+
def do_POST(self): # noqa: N802
|
|
171
|
+
try:
|
|
172
|
+
n = int(self.headers.get("Content-Length", 0) or 0)
|
|
173
|
+
except ValueError:
|
|
174
|
+
n = 0
|
|
175
|
+
self._handle("POST", self.rfile.read(n) if n else b"")
|
|
176
|
+
|
|
177
|
+
def log_message(self, *a):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
host = getattr(args, "host", "127.0.0.1") or "127.0.0.1"
|
|
181
|
+
with ThreadedServer((host, args.port), H) as httpd:
|
|
182
|
+
print(f"PyWeb dev server: http://{host}:{httpd.server_address[1]}/ ({args.file})", flush=True)
|
|
183
|
+
if not getattr(args, "no_reload", False):
|
|
184
|
+
_watch_and_reload(state, Site)
|
|
185
|
+
httpd.serve_forever()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _watch_and_reload(state, Site):
|
|
189
|
+
"""Poll the app file and sibling ``.py``/``static`` files; reload on change."""
|
|
190
|
+
import threading
|
|
191
|
+
import time
|
|
192
|
+
|
|
193
|
+
app_dir = os.path.dirname(os.path.abspath(state.path))
|
|
194
|
+
|
|
195
|
+
def snapshot():
|
|
196
|
+
stamps = {}
|
|
197
|
+
for root, dirs, files in os.walk(app_dir):
|
|
198
|
+
dirs[:] = [d for d in dirs if not d.startswith((".", "__pycache__", "node_modules", "dist"))]
|
|
199
|
+
for fn in files:
|
|
200
|
+
if fn.endswith((".pyweb", ".py", ".css", ".js")):
|
|
201
|
+
p = os.path.join(root, fn)
|
|
202
|
+
try:
|
|
203
|
+
stamps[p] = os.path.getmtime(p)
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
return stamps
|
|
207
|
+
|
|
208
|
+
def purge_local_modules():
|
|
209
|
+
for name, mod in list(sys.modules.items()):
|
|
210
|
+
f = getattr(mod, "__file__", None) or ""
|
|
211
|
+
if f and os.path.abspath(f).startswith(app_dir + os.sep) and f.endswith(".py"):
|
|
212
|
+
del sys.modules[name]
|
|
213
|
+
|
|
214
|
+
def poll():
|
|
215
|
+
last = snapshot()
|
|
216
|
+
while True:
|
|
217
|
+
time.sleep(0.4)
|
|
218
|
+
now = snapshot()
|
|
219
|
+
if now != last:
|
|
220
|
+
last = now
|
|
221
|
+
purge_local_modules()
|
|
222
|
+
_dev_load(state, Site)
|
|
223
|
+
if state.error is not None:
|
|
224
|
+
print(f"error: {state.error}", flush=True)
|
|
225
|
+
else:
|
|
226
|
+
print(f"reloaded {state.path}", flush=True)
|
|
227
|
+
|
|
228
|
+
threading.Thread(target=poll, daemon=True, name="pyweb-reload").start()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_serve(args):
|
|
232
|
+
from pyweb import serve as _serve
|
|
233
|
+
from pyweb import observability as _obs
|
|
234
|
+
httpd = _serve.serve(args.dir, host=args.host, port=args.port,
|
|
235
|
+
app_factory=args.app, logger=_obs.Logger("serve"))
|
|
236
|
+
addr = httpd.server_address
|
|
237
|
+
print(f"serving {args.dir} on http://{addr[0]}:{addr[1]} "
|
|
238
|
+
f"(health: /healthz)")
|
|
239
|
+
_serve.install_shutdown_handlers(httpd, logger=_obs.Logger("serve"))
|
|
240
|
+
try:
|
|
241
|
+
httpd.serve_forever()
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
pass
|
|
244
|
+
finally:
|
|
245
|
+
httpd.server_close()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_test(args):
|
|
249
|
+
import subprocess
|
|
250
|
+
import sys as _sys
|
|
251
|
+
cmd = [_sys.executable, "-m", "pytest", "tests/", "-q"]
|
|
252
|
+
if getattr(args, "path", None):
|
|
253
|
+
cmd = [_sys.executable, "-m", "pytest", args.path, "-q"]
|
|
254
|
+
raise SystemExit(subprocess.call(cmd))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_fmt(args):
|
|
258
|
+
"""Format: ruff if available, else a stdlib py_compile sanity pass."""
|
|
259
|
+
import subprocess
|
|
260
|
+
import sys as _sys
|
|
261
|
+
targets = [args.path] if getattr(args, "path", None) else ["pyweb", "tests"]
|
|
262
|
+
try:
|
|
263
|
+
raise SystemExit(subprocess.call(
|
|
264
|
+
[_sys.executable, "-m", "ruff", "format", *targets]))
|
|
265
|
+
except FileNotFoundError:
|
|
266
|
+
pass
|
|
267
|
+
import py_compile
|
|
268
|
+
count, failed = 0, []
|
|
269
|
+
for target in targets:
|
|
270
|
+
for root, _dirs, files in os.walk(target):
|
|
271
|
+
if "__pycache__" in root:
|
|
272
|
+
continue
|
|
273
|
+
for fn in files:
|
|
274
|
+
if fn.endswith(".py"):
|
|
275
|
+
p = os.path.join(root, fn)
|
|
276
|
+
try:
|
|
277
|
+
py_compile.compile(p, doraise=True)
|
|
278
|
+
count += 1
|
|
279
|
+
except py_compile.PyCompileError:
|
|
280
|
+
failed.append(p)
|
|
281
|
+
print(f"fmt: {count} file(s) compile-clean"
|
|
282
|
+
+ (f", FAILED: {failed}" if failed else " (ruff not installed; "
|
|
283
|
+
"install ruff for real formatting)"))
|
|
284
|
+
raise SystemExit(1 if failed else 0)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_lint(args):
|
|
288
|
+
import subprocess
|
|
289
|
+
import sys as _sys
|
|
290
|
+
targets = [args.path] if getattr(args, "path", None) else ["pyweb", "tests"]
|
|
291
|
+
try:
|
|
292
|
+
raise SystemExit(subprocess.call(
|
|
293
|
+
[_sys.executable, "-m", "ruff", "check", *targets]))
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
pass
|
|
296
|
+
import ast as _ast
|
|
297
|
+
issues = []
|
|
298
|
+
for target in targets:
|
|
299
|
+
for root, _dirs, files in os.walk(target):
|
|
300
|
+
if "__pycache__" in root:
|
|
301
|
+
continue
|
|
302
|
+
for fn in files:
|
|
303
|
+
if fn.endswith(".py"):
|
|
304
|
+
p = os.path.join(root, fn)
|
|
305
|
+
with open(p) as fh:
|
|
306
|
+
src = fh.read()
|
|
307
|
+
try:
|
|
308
|
+
_ast.parse(src)
|
|
309
|
+
except SyntaxError as exc:
|
|
310
|
+
issues.append(f"{p}:{exc.lineno}: syntax {exc.msg}")
|
|
311
|
+
for i, line in enumerate(src.splitlines(), 1):
|
|
312
|
+
if len(line) > 120:
|
|
313
|
+
issues.append(f"{p}:{i}: line too long ({len(line)})")
|
|
314
|
+
for issue in issues[:50]:
|
|
315
|
+
print(issue)
|
|
316
|
+
print(f"lint: {len(issues)} issue(s) (ruff not installed; "
|
|
317
|
+
"install ruff for full lint)")
|
|
318
|
+
raise SystemExit(1 if issues else 0)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_db(args):
|
|
322
|
+
from pyweb.db import migrate as _migrate
|
|
323
|
+
action = args.db_action
|
|
324
|
+
if action == "migrate":
|
|
325
|
+
applied = _migrate.migrate(args.database or os.environ.get("DATABASE_URL", ":memory:"),
|
|
326
|
+
args.migrations)
|
|
327
|
+
print(f"applied {len(applied)} migration(s): "
|
|
328
|
+
+ (", ".join(applied) if applied else "already up to date"))
|
|
329
|
+
elif action == "new":
|
|
330
|
+
path = _migrate.new_migration(args.migrations, args.name)
|
|
331
|
+
print(f"created {path}")
|
|
332
|
+
elif action == "status":
|
|
333
|
+
rows = _migrate.status(args.database or os.environ.get("DATABASE_URL", ":memory:"),
|
|
334
|
+
args.migrations)
|
|
335
|
+
for name, applied in rows:
|
|
336
|
+
print(f"[{'x' if applied else ' '}] {name}")
|
|
337
|
+
elif action == "rollback":
|
|
338
|
+
try:
|
|
339
|
+
rolled = _migrate.rollback(
|
|
340
|
+
args.database or os.environ.get("DATABASE_URL", ":memory:"),
|
|
341
|
+
args.migrations, steps=args.steps, to=args.to)
|
|
342
|
+
except ValueError as exc:
|
|
343
|
+
raise SystemExit(f"error: {exc}")
|
|
344
|
+
print("rolled back: " + (", ".join(rolled) if rolled else "nothing to roll back"))
|
|
345
|
+
else:
|
|
346
|
+
raise SystemExit(f"unknown db action {action!r}")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def cmd_deploy(args):
|
|
350
|
+
from pyweb import deploy as D
|
|
351
|
+
if not getattr(args, "db_url", None) and not os.environ.get("DATABASE_URL"):
|
|
352
|
+
print("note: no DATABASE_URL set (--db-url or env); "
|
|
353
|
+
"deploying with embedded sqlite", file=sys.stderr)
|
|
354
|
+
target = (args.target or "docker").lower()
|
|
355
|
+
outdir = args.out
|
|
356
|
+
os.makedirs(outdir, exist_ok=True)
|
|
357
|
+
files = {}
|
|
358
|
+
if target in ("docker", "compose"):
|
|
359
|
+
files["Dockerfile"] = D.dockerfile(port=args.port)
|
|
360
|
+
if target == "compose" or args.compose:
|
|
361
|
+
files["compose.yaml"] = D.compose(port=args.port, db_url=args.db_url or "")
|
|
362
|
+
elif target == "k8s":
|
|
363
|
+
files["k8s.yaml"] = D.k8s_manifest(app=args.app, image=args.image, port=args.port)
|
|
364
|
+
else:
|
|
365
|
+
raise SystemExit(f"unknown deploy target {args.target!r} (docker|compose|k8s)")
|
|
366
|
+
for name, body in files.items():
|
|
367
|
+
with open(os.path.join(outdir, name), "w") as fh:
|
|
368
|
+
fh.write(body)
|
|
369
|
+
print(f"deploy {target} -> {outdir}/ ({', '.join(files)})")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def cmd_npm(args):
|
|
373
|
+
from pyweb.npm import npm_main
|
|
374
|
+
raise SystemExit(npm_main([args.dts] + (["-o", args.out] if args.out else [])))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _parse_budget(spec):
|
|
378
|
+
spec = spec.strip().lower()
|
|
379
|
+
for suffix, mult in (("kb", 1024), ("k", 1024), ("mb", 1024 * 1024), ("m", 1024 * 1024), ("b", 1)):
|
|
380
|
+
if spec.endswith(suffix):
|
|
381
|
+
return int(float(spec[:-len(suffix)]) * mult)
|
|
382
|
+
return int(float(spec))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
NEW_APP = """from pyweb import App
|
|
386
|
+
|
|
387
|
+
app = App(title="{title}")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@app.page("/")
|
|
391
|
+
def Home():
|
|
392
|
+
count = 0
|
|
393
|
+
|
|
394
|
+
def increment():
|
|
395
|
+
count += 1
|
|
396
|
+
|
|
397
|
+
<main>
|
|
398
|
+
<h1>Counter</h1>
|
|
399
|
+
<button onclick={{increment}}>
|
|
400
|
+
Count: {{count}}
|
|
401
|
+
</button>
|
|
402
|
+
</main>
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def cmd_new(args):
|
|
407
|
+
if os.path.exists(os.path.join(args.name, "app.pyweb")):
|
|
408
|
+
raise SystemExit(f"{args.name}/app.pyweb already exists")
|
|
409
|
+
os.makedirs(os.path.join(args.name, "static"), exist_ok=True)
|
|
410
|
+
title = os.path.basename(os.path.abspath(args.name)).replace("-", " ").replace("_", " ").title()
|
|
411
|
+
with open(os.path.join(args.name, "app.pyweb"), "w", encoding="utf-8") as fh:
|
|
412
|
+
fh.write(NEW_APP.format(title=title))
|
|
413
|
+
with open(os.path.join(args.name, ".gitignore"), "w", encoding="utf-8") as fh:
|
|
414
|
+
fh.write("dist/\n__pycache__/\n*.db\n")
|
|
415
|
+
print(f"created {args.name}/app.pyweb\nnext: cd {args.name} && pyweb dev app.pyweb")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def main(argv=None):
|
|
419
|
+
ap = argparse.ArgumentParser(prog="pyweb")
|
|
420
|
+
ap.add_argument("--version", action="store_true",
|
|
421
|
+
help="print the PyWeb version and exit")
|
|
422
|
+
sub = ap.add_subparsers(dest="cmd", required=False)
|
|
423
|
+
p = sub.add_parser("inspect"); p.add_argument("file"); p.add_argument("--security", action="store_true", help="include security findings"); p.set_defaults(fn=cmd_inspect)
|
|
424
|
+
p = sub.add_parser("build"); p.add_argument("file"); p.add_argument("--out", default="dist"); p.add_argument("--budget", action="append", default=[]); p.add_argument("--production", action="store_true", help="hashed assets, minified JS, split bundles, extracted CSS"); p.set_defaults(fn=cmd_build)
|
|
425
|
+
p = sub.add_parser("dev"); p.add_argument("file"); p.add_argument("--port", type=int, default=8000); p.add_argument("--host", default="127.0.0.1"); p.add_argument("--no-reload", action="store_true", help="disable hot-reload watcher"); p.set_defaults(fn=cmd_dev)
|
|
426
|
+
p = sub.add_parser("serve"); p.add_argument("dir", default="dist", nargs="?"); p.add_argument("--host", default="0.0.0.0"); p.add_argument("--port", type=int, default=8000); p.add_argument("--app", default=None, help="live RPC factory module:attr"); p.set_defaults(fn=cmd_serve)
|
|
427
|
+
p = sub.add_parser("db"); p.add_argument("db_action", choices=["migrate", "new", "status", "rollback"]); p.add_argument("--database", default=None); p.add_argument("--migrations", default="migrations"); p.add_argument("--name", default="migration"); p.add_argument("--steps", type=int, default=1, help="rollback: how many applied migrations to revert"); p.add_argument("--to", default=None, help="rollback: revert everything applied after this label"); p.set_defaults(fn=cmd_db)
|
|
428
|
+
p = sub.add_parser("new"); p.add_argument("name"); p.set_defaults(fn=cmd_new)
|
|
429
|
+
p = sub.add_parser("check"); p.add_argument("file"); p.set_defaults(fn=cmd_check)
|
|
430
|
+
p = sub.add_parser("npm"); p.add_argument("dts"); p.add_argument("-o", "--out", default=None); p.set_defaults(fn=cmd_npm)
|
|
431
|
+
p = sub.add_parser("deploy")
|
|
432
|
+
p.add_argument("--target", default="docker")
|
|
433
|
+
p.add_argument("--out", default="deploy")
|
|
434
|
+
p.add_argument("--port", type=int, default=8000)
|
|
435
|
+
p.add_argument("--compose", action="store_true")
|
|
436
|
+
p.add_argument("--db-url", default="")
|
|
437
|
+
p.add_argument("--app", default="pyweb")
|
|
438
|
+
p.add_argument("--image", default="pyweb:latest")
|
|
439
|
+
p.set_defaults(fn=cmd_deploy)
|
|
440
|
+
p = sub.add_parser("test"); p.add_argument("path", nargs="?", default=None); p.set_defaults(fn=cmd_test)
|
|
441
|
+
p = sub.add_parser("fmt"); p.add_argument("path", nargs="?", default=None); p.set_defaults(fn=cmd_fmt)
|
|
442
|
+
p = sub.add_parser("lint"); p.add_argument("path", nargs="?", default=None); p.set_defaults(fn=cmd_lint)
|
|
443
|
+
args = ap.parse_args(argv)
|
|
444
|
+
if args.version:
|
|
445
|
+
from pyweb import __version__
|
|
446
|
+
print(__version__)
|
|
447
|
+
return
|
|
448
|
+
if not args.cmd:
|
|
449
|
+
ap.print_help()
|
|
450
|
+
raise SystemExit(2)
|
|
451
|
+
from pyweb.compiler.errors import CompileError
|
|
452
|
+
try:
|
|
453
|
+
args.fn(args)
|
|
454
|
+
except (CompileError, SyntaxError) as exc:
|
|
455
|
+
if isinstance(exc, SyntaxError) and not isinstance(exc, CompileError):
|
|
456
|
+
where = f"{exc.filename or getattr(args, 'file', '')}:{exc.lineno}" if exc.lineno else ""
|
|
457
|
+
msg = getattr(exc, "pyweb_msg", None) or exc.msg
|
|
458
|
+
print(f"error: {where}: {msg}" if where else f"error: {msg}", file=sys.stderr)
|
|
459
|
+
else:
|
|
460
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
461
|
+
raise SystemExit(1)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
if __name__ == "__main__":
|
|
465
|
+
main()
|
pyweb/cli/__main__.py
ADDED