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/bench.py ADDED
@@ -0,0 +1,101 @@
1
+ """Benchmark harness: measure what actually ships and how fast it renders.
2
+
3
+ ``python -m pyweb.bench`` compiles small reference apps and reports, per
4
+ app: source lines, compile time, shipped JavaScript (minified and gzip,
5
+ including the shared runtime only when the app ships any JS), HTML size
6
+ and real per-request server render time. No theater: numbers come from
7
+ the same code paths ``pyweb build`` and ``pyweb serve`` use.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import gzip
13
+ import json
14
+ import time
15
+
16
+ APPS = {
17
+ "counter": (
18
+ 'from pyweb import App\napp = App()\n@app.page("/")\n'
19
+ "def Home():\n count = 0\n"
20
+ " def increment():\n count += 1\n"
21
+ " <main>\n <button onclick={increment}>\n"
22
+ " Count: {count}\n </button>\n </main>\n"),
23
+ "todo": (
24
+ 'from pyweb import App\napp = App()\n@app.page("/")\n'
25
+ "def Home():\n text = ''\n items = []\n left = len(items)\n"
26
+ " def add():\n if text.strip():\n items.append(text.strip())\n text = ''\n"
27
+ " def remove(item):\n items.remove(item)\n"
28
+ " <main>\n <input bind={text} />\n"
29
+ " <button onclick={add}>Add</button>\n <p>{left} left</p>\n"
30
+ " <ul>\n for item in items:\n"
31
+ " <li>{item} <button onclick={remove(item)}>x</button></li>\n </ul>\n </main>\n"),
32
+ "blog": (
33
+ 'from pyweb import App\napp = App()\n@app.page("/posts/{slug}")\n'
34
+ "def Post(slug: str):\n title = slug.replace('-', ' ').title()\n"
35
+ " <article>\n <h1>{title}</h1>\n"
36
+ " <p>Static page: no JavaScript is shipped.</p>\n </article>\n"),
37
+ }
38
+
39
+
40
+ def _gz(data: str) -> int:
41
+ return len(gzip.compress(data.encode(), 9))
42
+
43
+
44
+ def runtime_sizes() -> dict:
45
+ """Minified and gzip size of the shared browser runtime."""
46
+ import os
47
+ from pyweb.build import minify_js
48
+ path = os.path.join(os.path.dirname(__file__), "runtime", "browser", "runtime.js")
49
+ with open(path, encoding="utf-8") as fh:
50
+ js = minify_js(fh.read())
51
+ return {"bytes": len(js.encode()), "gzip_bytes": _gz(js)}
52
+
53
+
54
+ def bench_app(name: str, source: str, *, repeat: int = 50) -> dict:
55
+ from pyweb.app_loader import LoadedApp
56
+ from pyweb.build import minify_js
57
+ from pyweb.compiler import compile_source
58
+ t0 = time.perf_counter()
59
+ out = compile_source(source, filename=f"{name}.pyweb")
60
+ compile_ms = (time.perf_counter() - t0) * 1000
61
+ pages = out["pages"]
62
+ page_js = [minify_js(p["js"]) for p in pages.values() if p.get("js")]
63
+ rt = runtime_sizes() if page_js else {"bytes": 0, "gzip_bytes": 0}
64
+ app = LoadedApp(source=source, filename=f"{name}.pyweb")
65
+ page_name, page = next(iter(pages.items()))
66
+ params = {p: "hello-world" for p in page["params"]}
67
+ html = app.render(page_name, params)
68
+ t0 = time.perf_counter()
69
+ for _ in range(repeat):
70
+ app.render(page_name, params)
71
+ ssr_ms = (time.perf_counter() - t0) * 1000 / repeat
72
+ page_bytes = sum(len(j.encode()) for j in page_js)
73
+ page_gz = sum(_gz(j) for j in page_js)
74
+ return {"app": name, "source_lines": len(source.splitlines()),
75
+ "compile_ms": round(compile_ms, 2),
76
+ "page_js_bytes": page_bytes, "page_js_gzip": page_gz,
77
+ "runtime_js_bytes": rt["bytes"], "runtime_js_gzip": rt["gzip_bytes"],
78
+ "js_bytes": page_bytes + rt["bytes"], "js_gzip": page_gz + rt["gzip_bytes"],
79
+ "html_bytes": len(html.encode()),
80
+ "signals": sum(len(p.get("signals", ())) for p in pages.values()),
81
+ "ssr_ms": round(ssr_ms, 3),
82
+ "rpc": len(out.get("rpc", []))}
83
+
84
+
85
+ def run(apps=None) -> dict:
86
+ apps = apps or APPS
87
+ return {name: bench_app(name, src) for name, src in apps.items()}
88
+
89
+
90
+ def main():
91
+ results = run()
92
+ print(json.dumps(results, indent=2))
93
+ rt = runtime_sizes()
94
+ print(f"\nshared runtime: {rt['bytes']} B minified, {rt['gzip_bytes']} B gzip "
95
+ "(downloaded once, cached across pages)")
96
+ for r in results.values():
97
+ print(f"{r['app']:>8}: page JS {r['page_js_gzip']} B gzip, server render {r['ssr_ms']} ms")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
pyweb/browser.py ADDED
@@ -0,0 +1,232 @@
1
+ """Typed browser APIs.
2
+
3
+ Every capability exposes the same Python surface on the server (safe
4
+ stub returning documented defaults) and in the browser (real binding).
5
+ The compiler maps each stub to its JS implementation via
6
+ :meth:`Api._js`; server execution never touches the DOM.
7
+
8
+ Sensor/action APIs (camera, location, clipboard, notifications, share,
9
+ filesystem, bluetooth) raise :class:`BrowserUnavailable` on the server
10
+ instead of silently returning fake data — fake sensor data would be
11
+ worse than an error. Query-style APIs (fetch, permissions, vibrate,
12
+ IDB) return documented safe defaults so SSR code paths keep working.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ class BrowserUnavailable(RuntimeError):
19
+ """Raised when a browser-only API is called outside the browser."""
20
+
21
+
22
+ class Api:
23
+ _js = ""
24
+ _server_default = None
25
+
26
+ def _call(self, method, *args):
27
+ raise BrowserUnavailable(
28
+ f"{type(self).__name__}.{method}() needs a browser; "
29
+ "guard with `if platform.web:` or move it into an event handler")
30
+
31
+
32
+ class _KeyValue(Api):
33
+ _js = "localStorage"
34
+ _server_default = None
35
+
36
+ def __init__(self):
37
+ self._d: dict = {}
38
+
39
+ def __getitem__(self, k):
40
+ return self._d.get(k)
41
+
42
+ def __setitem__(self, k, v):
43
+ self._d[k] = v
44
+
45
+ def __delitem__(self, k):
46
+ del self._d[k]
47
+
48
+ def get(self, k, default=None):
49
+ return self._d.get(k, default)
50
+
51
+
52
+ class _SessionStorage(_KeyValue):
53
+ _js = "sessionStorage"
54
+
55
+
56
+ storage = _KeyValue()
57
+ session = _SessionStorage()
58
+
59
+
60
+ class _Clipboard(Api):
61
+ _js = "navigator.clipboard"
62
+
63
+ async def write(self, text: str) -> bool:
64
+ self._call("write", text)
65
+ return False
66
+
67
+ async def read(self) -> str:
68
+ self._call("read")
69
+ return ""
70
+
71
+
72
+ clipboard = _Clipboard()
73
+
74
+
75
+ class _GeoPosition(dict):
76
+ pass
77
+
78
+
79
+ class _Location(Api):
80
+ _js = "navigator.geolocation"
81
+
82
+ async def current(self, *, timeout_ms=10000) -> dict:
83
+ self._call("current")
84
+ return {}
85
+
86
+ def watch(self, callback, *, timeout_ms=10000):
87
+ """Subscribe to position updates; returns an unsubscribe callable."""
88
+ self._call("watch", callback)
89
+ return lambda: None
90
+
91
+
92
+ location = _Location()
93
+
94
+
95
+ class _Notifications(Api):
96
+ _js = "Notification"
97
+
98
+ async def request_permission(self) -> str:
99
+ self._call("request_permission")
100
+ return "denied"
101
+
102
+ async def send(self, title: str, body="", *, tag="") -> bool:
103
+ self._call("send", title)
104
+ return False
105
+
106
+
107
+ notifications = _Notifications()
108
+
109
+
110
+ class _Camera(Api):
111
+ _js = "navigator.mediaDevices.getUserMedia"
112
+
113
+ async def photo(self, *, facing="user") -> bytes:
114
+ self._call("photo")
115
+ return b""
116
+
117
+
118
+ camera = _Camera()
119
+
120
+
121
+ class IDB(Api):
122
+ """Typed IndexedDB key-value store (offline persistence)."""
123
+
124
+ _js = "indexedDB"
125
+
126
+ def __init__(self, db_name="pyweb", store="kv"):
127
+ self._db_name = db_name
128
+ self._store = store
129
+ self._d: dict = {}
130
+
131
+ async def get(self, key, default=None):
132
+ try:
133
+ self._call("get", key)
134
+ except BrowserUnavailable:
135
+ return self._d.get(key, default)
136
+
137
+ async def set(self, key, value):
138
+ try:
139
+ self._call("set", key, value)
140
+ except BrowserUnavailable:
141
+ self._d[key] = value
142
+
143
+ async def delete(self, key):
144
+ try:
145
+ self._call("delete", key)
146
+ except BrowserUnavailable:
147
+ self._d.pop(key, None)
148
+
149
+
150
+ idb = IDB()
151
+
152
+
153
+ class _Fetch(Api):
154
+ _js = "fetch"
155
+
156
+ async def get(self, url: str, *, headers=None, timeout_s=30) -> dict:
157
+ return {"status": 0, "body": ""}
158
+
159
+ async def post(self, url: str, body=None, *, headers=None,
160
+ timeout_s=30) -> dict:
161
+ return {"status": 0, "body": ""}
162
+
163
+
164
+ fetch = _Fetch()
165
+
166
+
167
+ class _Permissions(Api):
168
+ _js = "navigator.permissions"
169
+
170
+ async def query(self, name: str) -> str:
171
+ """'granted' | 'denied' | 'prompt' — 'denied' on server."""
172
+ return "denied"
173
+
174
+
175
+ permissions = _Permissions()
176
+
177
+
178
+ class _Vibrate(Api):
179
+ _js = "navigator.vibrate"
180
+
181
+ def pulse(self, pattern=200) -> bool:
182
+ return False # no-op on server; real haptics in browser
183
+
184
+
185
+ vibrate = _Vibrate()
186
+
187
+
188
+ class _Share(Api):
189
+ _js = "navigator.share"
190
+
191
+ async def text(self, title: str, text: str, *, url="") -> bool:
192
+ self._call("text", title)
193
+ return False
194
+
195
+
196
+ share = _Share()
197
+
198
+
199
+ class _Bluetooth(Api):
200
+ _js = "navigator.bluetooth"
201
+
202
+ async def request_device(self, *, accept_all=False,
203
+ services=()) -> dict:
204
+ self._call("request_device")
205
+ return {}
206
+
207
+
208
+ bluetooth = _Bluetooth()
209
+
210
+
211
+ class _FileSystem(Api):
212
+ _js = "showOpenFilePicker"
213
+
214
+ async def pick_text(self, *, types=("text/plain",)) -> str:
215
+ self._call("pick_text")
216
+ return ""
217
+
218
+
219
+ filesystem = _FileSystem()
220
+
221
+
222
+ def bindings() -> dict:
223
+ """Name -> JS binding for every browser API (used by codegen)."""
224
+ import sys as _sys
225
+ mod = _sys.modules[__name__]
226
+ out = {}
227
+ for name in dir(mod):
228
+ obj = getattr(mod, name)
229
+ js = getattr(obj, "_js", "")
230
+ if isinstance(obj, Api) and js:
231
+ out[name] = js
232
+ return out
pyweb/build.py ADDED
@@ -0,0 +1,295 @@
1
+ """Build: a self-contained, deployable ``dist/`` directory.
2
+
3
+ ```text
4
+ dist/
5
+ app.pyweb the app source (server functions + page bodies)
6
+ manifest.json routes, assets, RPC table, byte sizes
7
+ Dockerfile `pyweb serve` image
8
+ static/
9
+ runtime.<hash>.js shared browser runtime (cached across pages)
10
+ <page>.<hash>.js per-page code (pages without interactivity ship none)
11
+ <page>.<hash>.js.map handler/signal → .pyweb line mapping
12
+ ... your app's own static/ folder, copied as-is
13
+ server/
14
+ <page>.html static prerender (literal state) for static hosting
15
+ ```
16
+
17
+ Content hashes give immutable caching: a deploy only invalidates what
18
+ changed. The built-in minifier is token-aware (strings, templates and
19
+ regex literals are never touched); plug in esbuild via ``minifier=``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+
29
+ __all__ = ["build", "minify_js", "content_hash", "write_dockerfile"]
30
+
31
+
32
+ def content_hash(text) -> str:
33
+ if isinstance(text, str):
34
+ text = text.encode()
35
+ return hashlib.sha256(text).hexdigest()[:12]
36
+
37
+
38
+ _REGEX_PREV = set("(,=:[!&|?{};+-*%<>~^") | {""}
39
+ _REGEX_KEYWORDS = {"return", "typeof", "case", "do", "else", "in", "of", "void", "throw", "new", "delete"}
40
+ _PUNCT = set("{}()[];,:=<>!&|?+-*/%^~.")
41
+
42
+
43
+ def minify_js(js: str, *, minifier=None) -> str:
44
+ """Minify JS safely: strip comments and collapse whitespace.
45
+
46
+ Token-aware: string literals, template literals and regex literals are
47
+ copied verbatim, so ``"Count: "`` or ``"https://x"`` are never altered.
48
+ Newlines are kept where removing one could change meaning (ASI).
49
+ An external ``minifier`` callable (e.g. esbuild) wins if given.
50
+ """
51
+ if minifier is not None:
52
+ return minifier(js)
53
+ out = []
54
+ i, n = 0, len(js)
55
+ last = "" # last significant token text
56
+ pending_space = False
57
+ pending_newline = False
58
+
59
+ def emit(tok):
60
+ nonlocal last, pending_space, pending_newline
61
+ if out:
62
+ prev = out[-1][-1]
63
+ if pending_newline and not (prev in "{(,;[" or tok[0] in "}),;]." or tok[0] in "=:?"):
64
+ out.append("\n")
65
+ elif pending_space and (prev.isalnum() or prev in "_$") and (tok[0].isalnum() or tok[0] in "_$"):
66
+ out.append(" ")
67
+ elif pending_space and prev in "+-" and tok[0] == prev:
68
+ out.append(" ")
69
+ out.append(tok)
70
+ last = tok
71
+ pending_space = pending_newline = False
72
+
73
+ while i < n:
74
+ c = js[i]
75
+ if c in " \t\r":
76
+ pending_space = True
77
+ i += 1
78
+ continue
79
+ if c == "\n":
80
+ pending_newline = True
81
+ i += 1
82
+ continue
83
+ if js.startswith("//", i):
84
+ j = js.find("\n", i)
85
+ i = n if j < 0 else j
86
+ continue
87
+ if js.startswith("/*", i):
88
+ j = js.find("*/", i + 2)
89
+ i = n if j < 0 else j + 2
90
+ pending_space = True
91
+ continue
92
+ if c in "\"'":
93
+ j = i + 1
94
+ while j < n and js[j] != c:
95
+ j += 2 if js[j] == "\\" else 1
96
+ emit(js[i:j + 1])
97
+ i = j + 1
98
+ continue
99
+ if c == "`":
100
+ j = i + 1
101
+ depth = 0
102
+ while j < n:
103
+ if js[j] == "\\":
104
+ j += 2
105
+ continue
106
+ if js.startswith("${", j):
107
+ depth += 1
108
+ j += 2
109
+ continue
110
+ if js[j] == "}" and depth:
111
+ depth -= 1
112
+ elif js[j] == "`" and not depth:
113
+ break
114
+ j += 1
115
+ emit(js[i:j + 1])
116
+ i = j + 1
117
+ continue
118
+ if c == "/" and (last[-1:] in _REGEX_PREV or last in _REGEX_KEYWORDS):
119
+ j = i + 1
120
+ in_class = False
121
+ while j < n:
122
+ ch = js[j]
123
+ if ch == "\\":
124
+ j += 2
125
+ continue
126
+ if ch == "[":
127
+ in_class = True
128
+ elif ch == "]":
129
+ in_class = False
130
+ elif ch == "/" and not in_class:
131
+ break
132
+ j += 1
133
+ j += 1
134
+ while j < n and (js[j].isalpha()):
135
+ j += 1
136
+ emit(js[i:j])
137
+ i = j
138
+ continue
139
+ if c.isalnum() or c in "_$":
140
+ j = i
141
+ while j < n and (js[j].isalnum() or js[j] in "_$"):
142
+ j += 1
143
+ emit(js[i:j])
144
+ i = j
145
+ continue
146
+ emit(c)
147
+ i += 1
148
+ return "".join(out).strip() + "\n"
149
+
150
+
151
+ def _gzip_size(text) -> int:
152
+ import gzip
153
+ return len(gzip.compress(text.encode() if isinstance(text, str) else text, 9))
154
+
155
+
156
+ def build(compiled: dict, out: str, *, minifier=None, extract_css=True,
157
+ source=None, app_dir=None, production=True) -> dict:
158
+ """Write a deployable ``dist/`` for ``compile_source`` output.
159
+
160
+ ``production=True`` minifies and content-hashes JS; otherwise files
161
+ keep plain names (``Home.js``) and pages reference ``?v=<hash>``.
162
+ ``source`` (the `.pyweb` text) is copied to ``dist/app.pyweb`` so
163
+ ``pyweb serve dist`` can run ``@server`` functions and render pages.
164
+ A ``static/`` folder next to the app (``app_dir``) is copied as-is.
165
+ """
166
+ import shutil
167
+ from pyweb.css import extract_styles
168
+ os.makedirs(f"{out}/static", exist_ok=True)
169
+ os.makedirs(f"{out}/server", exist_ok=True)
170
+
171
+ if app_dir and os.path.isdir(os.path.join(app_dir, "static")):
172
+ shutil.copytree(os.path.join(app_dir, "static"), f"{out}/static", dirs_exist_ok=True)
173
+
174
+ rt_path = os.path.join(os.path.dirname(__file__), "runtime", "browser", "runtime.js")
175
+ with open(rt_path, encoding="utf-8") as fh:
176
+ runtime_js = fh.read()
177
+ if production:
178
+ runtime_js = minify_js(runtime_js, minifier=minifier)
179
+ rt_name = f"runtime.{content_hash(runtime_js)}.js"
180
+ else:
181
+ rt_name = "runtime.js"
182
+ with open(f"{out}/static/{rt_name}", "w", encoding="utf-8") as fh:
183
+ fh.write(runtime_js)
184
+
185
+ manifest_pages = {}
186
+ for name, page in compiled["pages"].items():
187
+ js = page.get("js", "")
188
+ html = page.get("html", "")
189
+ js_name = ""
190
+ body = ""
191
+ if js:
192
+ body = js.replace('from "./runtime.js"', f'from "./{rt_name}"')
193
+ if production:
194
+ body = minify_js(body, minifier=minifier)
195
+ js_name = f"{name}.{content_hash(body)}.js"
196
+ url = f"/static/{js_name}"
197
+ else:
198
+ js_name = f"{name}.js"
199
+ url = f"/static/{js_name}?v={content_hash(body)[:10]}"
200
+ with open(f"{out}/static/{js_name}", "w", encoding="utf-8") as fh:
201
+ fh.write(body)
202
+ with open(f"{out}/static/{js_name}.map", "w", encoding="utf-8") as fh:
203
+ json.dump({"page": name, "mappings": page.get("sourcemap", [])}, fh)
204
+ html = re.sub(r'src="/static/' + re.escape(name) + r'\.js\?v=[0-9a-f]+"', f'src="{url}"', html)
205
+ css_text = ""
206
+ if extract_css:
207
+ html, css_text = extract_styles(html)
208
+ css_name = ""
209
+ if css_text.strip():
210
+ css_name = f"{name}.{content_hash(css_text)}.css"
211
+ with open(f"{out}/static/{css_name}", "w", encoding="utf-8") as fh:
212
+ fh.write(css_text + "\n")
213
+ html = html.replace("</head>", f'<link rel="stylesheet" href="/static/{css_name}"></head>')
214
+ with open(f"{out}/server/{name}.html", "w", encoding="utf-8") as fh:
215
+ fh.write(html)
216
+ manifest_pages[name] = {
217
+ "route": page.get("route"), "js": js_name, "css": css_name,
218
+ "runtime": rt_name if js else "", "bytes": len(body.encode()),
219
+ "gzip_bytes": _gzip_size(body) if body else 0,
220
+ "dynamic": bool(page.get("dynamic")),
221
+ "signals": page.get("signals", []),
222
+ "computeds": list(page.get("computeds", []))}
223
+
224
+ npm = detect_npm(compiled)
225
+ if npm:
226
+ importmap = {"imports": {
227
+ name: f"https://esm.sh/{name}@{ver}" for name, ver in npm.items()}}
228
+ for name in compiled["pages"]:
229
+ shell = f"{out}/server/{name}.html"
230
+ with open(shell, encoding="utf-8") as fh:
231
+ html = fh.read()
232
+ tag = ('<script type="importmap">' + json.dumps(importmap) + "</script></head>")
233
+ with open(shell, "w", encoding="utf-8") as fh:
234
+ fh.write(html.replace("</head>", tag))
235
+ if source is not None:
236
+ with open(f"{out}/app.pyweb", "w", encoding="utf-8") as fh:
237
+ fh.write(source)
238
+ try:
239
+ from pyweb import __version__ as pyweb_version
240
+ except ImportError: # pragma: no cover
241
+ pyweb_version = "unknown"
242
+ manifest = {"pyweb": pyweb_version, "pages": manifest_pages,
243
+ "rpc": compiled.get("rpc", []), "ir": compiled.get("ir_text", ""),
244
+ "runtime": rt_name, "runtime_bytes": len(runtime_js.encode()),
245
+ "runtime_gzip_bytes": _gzip_size(runtime_js),
246
+ "app": "app.pyweb" if source is not None else None, "npm": npm}
247
+ with open(f"{out}/manifest.json", "w", encoding="utf-8") as fh:
248
+ json.dump(manifest, fh, indent=2)
249
+ write_dockerfile(f"{out}/Dockerfile", version=pyweb_version)
250
+ return manifest
251
+
252
+
253
+ def detect_npm(compiled: dict) -> dict:
254
+ """Collect ``{package: version}`` from ``package(\"name[@ver]\")`` calls
255
+ and ``from npm[.x] import ...`` lines in page sources.
256
+
257
+ The mapping flows into the SSR importmap and ``manifest["npm"]`` so
258
+ deployments pin exactly what the browser loads (default: latest).
259
+ """
260
+ def split_spec(spec):
261
+ # "@scope/name@ver" keeps its scope; "name@ver" splits once.
262
+ if spec.startswith("@"):
263
+ head, _, tail = spec[1:].partition("@")
264
+ name = "@" + head
265
+ ver = tail
266
+ else:
267
+ name, _, ver = spec.partition("@")
268
+ return name or spec, ver or "latest"
269
+
270
+ found: dict[str, str] = {}
271
+ for page in compiled.get("pages", {}).values():
272
+ src = page.get("source") or ""
273
+ for m in re.finditer(r"""package\(\s*['"]([^'"]+)['"]""", src):
274
+ name, ver = split_spec(m.group(1))
275
+ found.setdefault(name, ver)
276
+ for m in re.finditer(r"""from\s+npm\.([\w-]+)\s+import""", src):
277
+ found.setdefault(m.group(1), "latest")
278
+ return found
279
+
280
+
281
+ def write_dockerfile(path: str, version: str = "") -> None:
282
+ """A minimal image that serves this dist with ``pyweb serve``."""
283
+ pin = f"=={version}" if version and version[0].isdigit() and "+" not in version else ""
284
+ with open(path, "w", encoding="utf-8") as fh:
285
+ fh.write(
286
+ "FROM python:3.12-slim\n"
287
+ "WORKDIR /app\n"
288
+ f"RUN pip install --no-cache-dir pyweb-stack{pin}\n"
289
+ "COPY . /app/\n"
290
+ "RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi\n"
291
+ "ENV PYWEB_ENV=production\n"
292
+ "EXPOSE 8000\n"
293
+ "HEALTHCHECK CMD python -c \"import urllib.request; "
294
+ "urllib.request.urlopen('http://127.0.0.1:8000/healthz')\"\n"
295
+ 'CMD ["pyweb", "serve", ".", "--host", "0.0.0.0", "--port", "8000"]\n')