リポジトリ / emssr / independent_common_compiler
r2
| r2 emssr | 1 | # -*- coding: utf-8 -*- |
| r2 emssr | 2 | """Common scope notebook の web server。標準機能だけで書く。 |
| r2 emssr | 3 | |
| r2 emssr | 4 | python server.py 8787 番で起動し、ブラウザを開く |
| r2 emssr | 5 | python server.py --port 9000 --no-browser |
| r2 emssr | 6 | |
| r2 emssr | 7 | 役目は三つ。 |
| r2 emssr | 8 | |
| r2 emssr | 9 | 1. web/ の静的 file、notebooks/、assets/ の画像と動画を配る |
| r2 emssr | 10 | 2. 実行時のファイル(vcpu.py、toolchain.py、commonc の image…)を /runtime/ で配る。 |
| r2 emssr | 11 | ブラウザ側の Pyodide がこれを取り込んで lane64 を動かす |
| r2 emssr | 12 | 3. ウィンドウの同期。/events が Server-Sent Events、POST /state が書き込みになる |
| r2 emssr | 13 | |
| r2 emssr | 14 | ウィンドウを分けるのは表示量のためになる。lane の作業領域は交わらない(20 式 1.2)ので、 |
| r2 emssr | 15 | ウィンドウごとに自分の kernel を持たせても state が混ざらない。同期するのは平文と初期値だけで、 |
| r2 emssr | 16 | 計算はそれぞれのウィンドウが自分で回す。 |
| r2 emssr | 17 | """ |
| r2 emssr | 18 | |
| r2 emssr | 19 | import json |
| r2 emssr | 20 | import os |
| r2 emssr | 21 | import sys |
| r2 emssr | 22 | import threading |
| r2 emssr | 23 | import time |
| r2 emssr | 24 | import webbrowser |
| r2 emssr | 25 | from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer |
| r2 emssr | 26 | |
| r2 emssr | 27 | HERE = os.path.dirname(os.path.abspath(__file__)) |
| r2 emssr | 28 | WEB = os.path.join(HERE, "web") |
| r2 emssr | 29 | NOTEBOOKS = os.path.join(HERE, "notebooks") |
| r2 emssr | 30 | VENDOR = os.path.join(HERE, "vendor") |
| r2 emssr | 31 | ASSETS = os.path.join(HERE, "assets") # notebook が貼る画像と動画 |
| r2 emssr | 32 | |
| r2 emssr | 33 | |
| r2 emssr | 34 | # ===== 実行時のファイル ===== |
| r2 emssr | 35 | # |
| r2 emssr | 36 | # 場所が二通りある。repository の中では Python_vCPU と builds を直に指し、 |
| r2 emssr | 37 | # 配布版では runtime/ に写しが揃っている。どちらでも同じ名前で配る。 |
| r2 emssr | 38 | |
| r2 emssr | 39 | def runtime_map(): |
| r2 emssr | 40 | local = os.path.join(HERE, "runtime") |
| r2 emssr | 41 | names = ("vcpu.py", "vcpu_vec.py", "lex.py", "toolchain.py", |
| r2 emssr | 42 | "commonc_lane64.bin", "joinrank.bin", "anf.bin", "joinrank.dic") |
| r2 emssr | 43 | out = {} |
| r2 emssr | 44 | for n in names: |
| r2 emssr | 45 | p = os.path.join(local, n) |
| r2 emssr | 46 | if os.path.exists(p): |
| r2 emssr | 47 | out[n] = p |
| r2 emssr | 48 | if len(out) == len(names): |
| r2 emssr | 49 | return out |
| r2 emssr | 50 | |
| r2 emssr | 51 | root = os.path.abspath(os.path.join(HERE, os.pardir, os.pardir, os.pardir)) |
| r2 emssr | 52 | vcpu_dir = os.path.join(root, "continuous_republic", "common_systems", "Python_vCPU") |
| r2 emssr | 53 | jr = os.path.join(root, "continuous_republic", "common_apps", "joinrank") |
| r2 emssr | 54 | fall = { |
| r2 emssr | 55 | "vcpu.py": os.path.join(vcpu_dir, "vcpu.py"), |
| r2 emssr | 56 | "vcpu_vec.py": os.path.join(vcpu_dir, "vcpu_vec.py"), |
| r2 emssr | 57 | "lex.py": os.path.join(vcpu_dir, "lex.py"), |
| r2 emssr | 58 | "toolchain.py": os.path.join(vcpu_dir, "toolchain.py"), |
| r2 emssr | 59 | "commonc_lane64.bin": os.path.join(root, "builds", "commonc", "commonc_lane64.bin"), |
| r2 emssr | 60 | "joinrank.bin": os.path.join(jr, "build", "joinrank.bin"), |
| r2 emssr | 61 | "anf.bin": os.path.join(jr, "build", "anf.bin"), |
| r2 emssr | 62 | "joinrank.dic": os.path.join(jr, "joinrank", "joinrank.dic"), |
| r2 emssr | 63 | } |
| r2 emssr | 64 | for n, p in fall.items(): |
| r2 emssr | 65 | if n not in out and os.path.exists(p): |
| r2 emssr | 66 | out[n] = p |
| r2 emssr | 67 | for n in ("generators.py", "scope.py", "session.py", "joint.py"): |
| r2 emssr | 68 | out[n] = os.path.join(HERE, "kernel", n) |
| r2 emssr | 69 | return out |
| r2 emssr | 70 | |
| r2 emssr | 71 | |
| r2 emssr | 72 | def kernel_map(): |
| r2 emssr | 73 | return {n: os.path.join(HERE, "kernel", n) |
| r2 emssr | 74 | for n in ("generators.py", "scope.py", "session.py", "joint.py")} |
| r2 emssr | 75 | |
| r2 emssr | 76 | |
| r2 emssr | 77 | # ===== ウィンドウの同期 ===== |
| r2 emssr | 78 | |
| r2 emssr | 79 | class Hub(object): |
| r2 emssr | 80 | """いま編集している平文と種を持ち、繋がっているウィンドウへ配る""" |
| r2 emssr | 81 | |
| r2 emssr | 82 | def __init__(self): |
| r2 emssr | 83 | self.state = {"rev": 0} |
| r2 emssr | 84 | self.clients = [] |
| r2 emssr | 85 | self.lock = threading.Lock() |
| r2 emssr | 86 | |
| r2 emssr | 87 | def snapshot(self): |
| r2 emssr | 88 | with self.lock: |
| r2 emssr | 89 | return dict(self.state) |
| r2 emssr | 90 | |
| r2 emssr | 91 | def publish(self, patch): |
| r2 emssr | 92 | with self.lock: |
| r2 emssr | 93 | self.state.update(patch) |
| r2 emssr | 94 | self.state["rev"] = self.state.get("rev", 0) + 1 |
| r2 emssr | 95 | body = ("data: %s\n\n" % json.dumps(self.state, ensure_ascii=False)).encode("utf-8") |
| r2 emssr | 96 | live = [] |
| r2 emssr | 97 | for w in self.clients: |
| r2 emssr | 98 | try: |
| r2 emssr | 99 | w.write(body) |
| r2 emssr | 100 | w.flush() |
| r2 emssr | 101 | live.append(w) |
| r2 emssr | 102 | except Exception: |
| r2 emssr | 103 | pass |
| r2 emssr | 104 | self.clients = live |
| r2 emssr | 105 | return self.state["rev"] |
| r2 emssr | 106 | |
| r2 emssr | 107 | def attach(self, w): |
| r2 emssr | 108 | with self.lock: |
| r2 emssr | 109 | self.clients.append(w) |
| r2 emssr | 110 | return ("data: %s\n\n" % json.dumps(self.state, ensure_ascii=False)).encode("utf-8") |
| r2 emssr | 111 | |
| r2 emssr | 112 | |
| r2 emssr | 113 | HUB = Hub() |
| r2 emssr | 114 | |
| r2 emssr | 115 | |
| r2 emssr | 116 | # 拡張子から媒体の型を引く。module の import は JavaScript の型でないと browser が断る |
| r2 emssr | 117 | _MEDIA = { |
| r2 emssr | 118 | ".js": "text/javascript", ".mjs": "text/javascript", ".json": "application/json", |
| r2 emssr | 119 | ".wasm": "application/wasm", ".css": "text/css", ".html": "text/html; charset=utf-8", |
| r2 emssr | 120 | ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", |
| r2 emssr | 121 | ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", |
| r2 emssr | 122 | ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", |
| r2 emssr | 123 | ".mp4": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", |
| r2 emssr | 124 | ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", |
| r2 emssr | 125 | ".pdf": "application/pdf", ".txt": "text/plain; charset=utf-8", |
| r2 emssr | 126 | } |
| r2 emssr | 127 | |
| r2 emssr | 128 | |
| r2 emssr | 129 | def _media(path): |
| r2 emssr | 130 | return _MEDIA.get(os.path.splitext(path)[1].lower(), "application/octet-stream") |
| r2 emssr | 131 | |
| r2 emssr | 132 | |
| r2 emssr | 133 | class Handler(SimpleHTTPRequestHandler): |
| r2 emssr | 134 | |
| r2 emssr | 135 | def __init__(self, *a, **k): |
| r2 emssr | 136 | SimpleHTTPRequestHandler.__init__(self, *a, directory=WEB, **k) |
| r2 emssr | 137 | |
| r2 emssr | 138 | def log_message(self, fmt, *args): |
| r2 emssr | 139 | pass |
| r2 emssr | 140 | |
| r2 emssr | 141 | def end_headers(self): |
| r2 emssr | 142 | # 直した web/ がそのまま反映されるように、静的 file もキャッシュさせない |
| r2 emssr | 143 | if "Cache-Control" not in self._headers_buffer_names(): |
| r2 emssr | 144 | self.send_header("Cache-Control", "no-store") |
| r2 emssr | 145 | SimpleHTTPRequestHandler.end_headers(self) |
| r2 emssr | 146 | |
| r2 emssr | 147 | def _headers_buffer_names(self): |
| r2 emssr | 148 | return b"".join(getattr(self, "_headers_buffer", []) or []).decode("latin-1") |
| r2 emssr | 149 | |
| r2 emssr | 150 | # ---- 送る ---- |
| r2 emssr | 151 | |
| r2 emssr | 152 | def _json(self, obj): |
| r2 emssr | 153 | body = json.dumps(obj, ensure_ascii=False).encode("utf-8") |
| r2 emssr | 154 | self.send_response(200) |
| r2 emssr | 155 | self.send_header("Content-Type", "application/json; charset=utf-8") |
| r2 emssr | 156 | self.send_header("Content-Length", str(len(body))) |
| r2 emssr | 157 | self.send_header("Cache-Control", "no-store") |
| r2 emssr | 158 | self.end_headers() |
| r2 emssr | 159 | self.wfile.write(body) |
| r2 emssr | 160 | |
| r2 emssr | 161 | def _file(self, path, ctype): |
| r2 emssr | 162 | try: |
| r2 emssr | 163 | data = open(path, "rb").read() |
| r2 emssr | 164 | except OSError: |
| r2 emssr | 165 | self.send_error(404, "無い: %s" % os.path.basename(path)) |
| r2 emssr | 166 | return |
| r2 emssr | 167 | self.send_response(200) |
| r2 emssr | 168 | self.send_header("Content-Type", ctype) |
| r2 emssr | 169 | self.send_header("Content-Length", str(len(data))) |
| r2 emssr | 170 | self.send_header("Cache-Control", "no-store") |
| r2 emssr | 171 | self.end_headers() |
| r2 emssr | 172 | self.wfile.write(data) |
| r2 emssr | 173 | |
| r2 emssr | 174 | def do_GET(self): |
| r2 emssr | 175 | p = self.path.split("?")[0] |
| r2 emssr | 176 | |
| r2 emssr | 177 | if p == "/api/runtime": |
| r2 emssr | 178 | files = runtime_map() |
| r2 emssr | 179 | files.update(kernel_map()) |
| r2 emssr | 180 | missing = [n for n, q in files.items() if not os.path.exists(q)] |
| r2 emssr | 181 | local = os.path.isfile(os.path.join(VENDOR, "pyodide", "pyodide.js")) |
| r2 emssr | 182 | web = os.path.isfile(os.path.join(VENDOR, "web", "marked.umd.js")) |
| r2 emssr | 183 | self._json({"files": sorted(files), "missing": missing, |
| r2 emssr | 184 | "pyodide": "/vendor/pyodide/" if local else None, |
| r2 emssr | 185 | "web": "/vendor/web/" if web else None}) |
| r2 emssr | 186 | return |
| r2 emssr | 187 | |
| r2 emssr | 188 | if p.startswith("/runtime/"): |
| r2 emssr | 189 | name = p[len("/runtime/"):] |
| r2 emssr | 190 | files = runtime_map() |
| r2 emssr | 191 | files.update(kernel_map()) |
| r2 emssr | 192 | if name not in files: |
| r2 emssr | 193 | self.send_error(404, "配信の表に無い: %s" % name) |
| r2 emssr | 194 | return |
| r2 emssr | 195 | self._file(files[name], "application/octet-stream") |
| r2 emssr | 196 | return |
| r2 emssr | 197 | |
| r2 emssr | 198 | if p == "/api/notebooks": |
| r2 emssr | 199 | if not os.path.isdir(NOTEBOOKS): |
| r2 emssr | 200 | self._json([]) |
| r2 emssr | 201 | return |
| r2 emssr | 202 | out = [] |
| r2 emssr | 203 | for n in sorted(os.listdir(NOTEBOOKS)): |
| r2 emssr | 204 | if n.endswith(".json"): |
| r2 emssr | 205 | d = json.load(open(os.path.join(NOTEBOOKS, n), encoding="utf-8")) |
| r2 emssr | 206 | out.append({"file": n, "title": d.get("title", n)}) |
| r2 emssr | 207 | self._json(out) |
| r2 emssr | 208 | return |
| r2 emssr | 209 | |
| r2 emssr | 210 | if p.startswith("/api/notebook/"): |
| r2 emssr | 211 | self._file(self._nb_path(p), "application/json; charset=utf-8") |
| r2 emssr | 212 | return |
| r2 emssr | 213 | |
| r2 emssr | 214 | if p == "/api/state": |
| r2 emssr | 215 | self._json(HUB.snapshot()) |
| r2 emssr | 216 | return |
| r2 emssr | 217 | |
| r2 emssr | 218 | if p == "/events": |
| r2 emssr | 219 | self.send_response(200) |
| r2 emssr | 220 | self.send_header("Content-Type", "text/event-stream; charset=utf-8") |
| r2 emssr | 221 | self.send_header("Cache-Control", "no-store") |
| r2 emssr | 222 | self.send_header("Connection", "keep-alive") |
| r2 emssr | 223 | self.end_headers() |
| r2 emssr | 224 | try: |
| r2 emssr | 225 | self.wfile.write(HUB.attach(self.wfile)) |
| r2 emssr | 226 | self.wfile.flush() |
| r2 emssr | 227 | except Exception: |
| r2 emssr | 228 | return |
| r2 emssr | 229 | # 接続は publish が書けなくなるまで開けたままにする |
| r2 emssr | 230 | while True: |
| r2 emssr | 231 | with HUB.lock: |
| r2 emssr | 232 | alive = self.wfile in HUB.clients |
| r2 emssr | 233 | if not alive: |
| r2 emssr | 234 | return |
| r2 emssr | 235 | time.sleep(0.5) |
| r2 emssr | 236 | |
| r2 emssr | 237 | if p == "/api/assets": |
| r2 emssr | 238 | os.path.isdir(ASSETS) or os.makedirs(ASSETS) |
| r2 emssr | 239 | self._json(sorted(os.listdir(ASSETS))) |
| r2 emssr | 240 | return |
| r2 emssr | 241 | |
| r2 emssr | 242 | if p.startswith("/assets/"): |
| r2 emssr | 243 | q = os.path.join(ASSETS, os.path.basename(p[len("/assets/"):])) |
| r2 emssr | 244 | self._file(q, _media(q)) |
| r2 emssr | 245 | return |
| r2 emssr | 246 | |
| r2 emssr | 247 | if p.startswith("/vendor/"): |
| r2 emssr | 248 | q = os.path.join(VENDOR, p[len("/vendor/"):].replace("/", os.sep)) |
| r2 emssr | 249 | if not os.path.abspath(q).startswith(VENDOR): |
| r2 emssr | 250 | self.send_error(403) |
| r2 emssr | 251 | return |
| r2 emssr | 252 | self._file(q, _media(q)) |
| r2 emssr | 253 | return |
| r2 emssr | 254 | |
| r2 emssr | 255 | SimpleHTTPRequestHandler.do_GET(self) |
| r2 emssr | 256 | |
| r2 emssr | 257 | # ---- notebook の保存と削除 ---- |
| r2 emssr | 258 | # |
| r2 emssr | 259 | # 書き込める場所は notebooks/ の直下だけにする。名前は basename を取り、 |
| r2 emssr | 260 | # 拡張子を .json に固定するので、上の階層へは出られない。 |
| r2 emssr | 261 | |
| r2 emssr | 262 | def _nb_path(self, p): |
| r2 emssr | 263 | name = os.path.basename(p.split("/api/notebook/")[-1]) |
| r2 emssr | 264 | if not name.endswith(".json"): |
| r2 emssr | 265 | name += ".json" |
| r2 emssr | 266 | return os.path.join(NOTEBOOKS, name) |
| r2 emssr | 267 | |
| r2 emssr | 268 | def _body(self): |
| r2 emssr | 269 | n = int(self.headers.get("Content-Length", 0)) |
| r2 emssr | 270 | return json.loads(self.rfile.read(n).decode("utf-8")) |
| r2 emssr | 271 | |
| r2 emssr | 272 | def do_POST(self): |
| r2 emssr | 273 | p = self.path.split("?")[0] |
| r2 emssr | 274 | if p == "/state": |
| r2 emssr | 275 | try: |
| r2 emssr | 276 | patch = self._body() |
| r2 emssr | 277 | except ValueError: |
| r2 emssr | 278 | self.send_error(400, "JSON ではありません") |
| r2 emssr | 279 | return |
| r2 emssr | 280 | self._json({"rev": HUB.publish(patch)}) |
| r2 emssr | 281 | return |
| r2 emssr | 282 | if p.startswith("/assets/"): |
| r2 emssr | 283 | name = os.path.basename(p[len("/assets/"):]) |
| r2 emssr | 284 | if not name: |
| r2 emssr | 285 | self.send_error(400, "名前がありません") |
| r2 emssr | 286 | return |
| r2 emssr | 287 | n = int(self.headers.get("Content-Length", 0)) |
| r2 emssr | 288 | if n > 64 * 1024 * 1024: |
| r2 emssr | 289 | self.send_error(413, "64 MB を越えています") |
| r2 emssr | 290 | return |
| r2 emssr | 291 | os.path.isdir(ASSETS) or os.makedirs(ASSETS) |
| r2 emssr | 292 | with open(os.path.join(ASSETS, name), "wb") as f: |
| r2 emssr | 293 | f.write(self.rfile.read(n)) |
| r2 emssr | 294 | self._json({"path": "assets/" + name}) |
| r2 emssr | 295 | return |
| r2 emssr | 296 | |
| r2 emssr | 297 | if p.startswith("/api/notebook/"): |
| r2 emssr | 298 | try: |
| r2 emssr | 299 | doc = self._body() |
| r2 emssr | 300 | except ValueError: |
| r2 emssr | 301 | self.send_error(400, "JSON ではありません") |
| r2 emssr | 302 | return |
| r2 emssr | 303 | os.path.isdir(NOTEBOOKS) or os.makedirs(NOTEBOOKS) |
| r2 emssr | 304 | path = self._nb_path(p) |
| r2 emssr | 305 | with open(path, "w", encoding="utf-8", newline="\n") as f: |
| r2 emssr | 306 | json.dump(doc, f, ensure_ascii=False, indent=2) |
| r2 emssr | 307 | f.write("\n") |
| r2 emssr | 308 | self._json({"file": os.path.basename(path)}) |
| r2 emssr | 309 | return |
| r2 emssr | 310 | self.send_error(404) |
| r2 emssr | 311 | |
| r2 emssr | 312 | def do_DELETE(self): |
| r2 emssr | 313 | p = self.path.split("?")[0] |
| r2 emssr | 314 | if not p.startswith("/api/notebook/"): |
| r2 emssr | 315 | self.send_error(404) |
| r2 emssr | 316 | return |
| r2 emssr | 317 | path = self._nb_path(p) |
| r2 emssr | 318 | if os.path.exists(path): |
| r2 emssr | 319 | os.remove(path) |
| r2 emssr | 320 | self._json({"file": os.path.basename(path)}) |
| r2 emssr | 321 | |
| r2 emssr | 322 | |
| r2 emssr | 323 | def main(argv): |
| r2 emssr | 324 | port, open_browser = 8787, True |
| r2 emssr | 325 | it = iter(argv) |
| r2 emssr | 326 | for a in it: |
| r2 emssr | 327 | if a == "--port": |
| r2 emssr | 328 | port = int(next(it)) |
| r2 emssr | 329 | elif a == "--no-browser": |
| r2 emssr | 330 | open_browser = False |
| r2 emssr | 331 | else: |
| r2 emssr | 332 | print(__doc__) |
| r2 emssr | 333 | return 2 |
| r2 emssr | 334 | files = runtime_map() |
| r2 emssr | 335 | files.update(kernel_map()) |
| r2 emssr | 336 | missing = [n for n, p in files.items() if not os.path.exists(p)] |
| r2 emssr | 337 | if missing: |
| r2 emssr | 338 | print("配れないファイル: %s" % ", ".join(missing)) |
| r2 emssr | 339 | print(" setup.py を実行するか、runtime/ へコピーを置く") |
| r2 emssr | 340 | url = "http://127.0.0.1:%d/" % port |
| r2 emssr | 341 | srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) |
| r2 emssr | 342 | srv.daemon_threads = True |
| r2 emssr | 343 | print("Common scope notebook %s" % url) |
| r2 emssr | 344 | print(" パネルを別ウィンドウで開く: %s?pane=dag ?pane=chamber ?pane=tsui ?pane=lane ?pane=joint" % url) |
| r2 emssr | 345 | if open_browser: |
| r2 emssr | 346 | threading.Timer(0.4, lambda: webbrowser.open(url)).start() |
| r2 emssr | 347 | try: |
| r2 emssr | 348 | srv.serve_forever() |
| r2 emssr | 349 | except KeyboardInterrupt: |
| r2 emssr | 350 | print("") |
| r2 emssr | 351 | return 0 |
| r2 emssr | 352 | |
| r2 emssr | 353 | |
| r2 emssr | 354 | if __name__ == "__main__": |
| r2 emssr | 355 | sys.exit(main(sys.argv[1:])) |