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