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/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 = "&gt;" if i + 1 == lineno else "&nbsp;"
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
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ main()
@@ -0,0 +1,5 @@
1
+ """PyWeb compiler pipeline: parse → analyze → place → RPC → codegen."""
2
+
3
+ from .pipeline import compile_source
4
+
5
+ __all__ = ["compile_source"]