openspuruntime 0.1.0__tar.gz

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.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: openspuruntime
3
+ Version: 0.1.0
4
+ Summary: Remote Alyx I100 SPU runtime: a Flask server tunneled with Cloudflare
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+ Provides-Extra: host
10
+ Requires-Dist: flask; extra == "host"
11
+ Requires-Dist: alyxspu; extra == "host"
12
+ Provides-Extra: waitress
13
+ Requires-Dist: waitress; extra == "waitress"
14
+
15
+ # OpenSPURuntime
16
+
17
+ Use any other computer's Alyx I100 SPU as your own. The host runs a plain Flask
18
+ server in front of the `alyxspu` driver and tunnels it with Cloudflare, so no
19
+ port forwarding or public IP is needed.
20
+
21
+ ## Installation :
22
+
23
+ ```
24
+ pip install "openspuruntime[host]" # on the machine with the SPU
25
+ pip install openspuruntime # on the client
26
+ ```
27
+
28
+ ## Streaming :
29
+ ```
30
+ openspuruntime stream --serial-port COM5 --allowed_ip all
31
+ ```
32
+
33
+ ## Connecting :
34
+ ```
35
+ openspuruntime connect https://random-words.trycloudflare.com/?token=...
36
+ ```
37
+
38
+ ## Usage :
39
+ #### For CLI :
40
+ ```
41
+ openspuruntime calc "sqrt(2)*3 + sin(pi/2)"
42
+ openspuruntime build --inputs 2 --hidden 4 --outputs 1
43
+ openspuruntime data -c "[[[0,0],[0]],[[0,1],[1]],[[1,0],[1]],[[1,1],[0]]]"
44
+ openspuruntime train --epochs 3000 --lr 0.5
45
+ openspuruntime run 1 0
46
+ ```
47
+
48
+ #### In python :
49
+ ```python
50
+ from openspuruntime import Runtime
51
+ rt = Runtime.connect("https://random-words.trycloudflare.com/?token=...")
52
+ print(rt.calc("2+2"))
53
+ ```
54
+
55
+ ## Disconnecting
56
+ ```
57
+ openspuruntime disconnect
58
+ ```
59
+
@@ -0,0 +1,45 @@
1
+ # OpenSPURuntime
2
+
3
+ Use any other computer's Alyx I100 SPU as your own. The host runs a plain Flask
4
+ server in front of the `alyxspu` driver and tunnels it with Cloudflare, so no
5
+ port forwarding or public IP is needed.
6
+
7
+ ## Installation :
8
+
9
+ ```
10
+ pip install "openspuruntime[host]" # on the machine with the SPU
11
+ pip install openspuruntime # on the client
12
+ ```
13
+
14
+ ## Streaming :
15
+ ```
16
+ openspuruntime stream --serial-port COM5 --allowed_ip all
17
+ ```
18
+
19
+ ## Connecting :
20
+ ```
21
+ openspuruntime connect https://random-words.trycloudflare.com/?token=...
22
+ ```
23
+
24
+ ## Usage :
25
+ #### For CLI :
26
+ ```
27
+ openspuruntime calc "sqrt(2)*3 + sin(pi/2)"
28
+ openspuruntime build --inputs 2 --hidden 4 --outputs 1
29
+ openspuruntime data -c "[[[0,0],[0]],[[0,1],[1]],[[1,0],[1]],[[1,1],[0]]]"
30
+ openspuruntime train --epochs 3000 --lr 0.5
31
+ openspuruntime run 1 0
32
+ ```
33
+
34
+ #### In python :
35
+ ```python
36
+ from openspuruntime import Runtime
37
+ rt = Runtime.connect("https://random-words.trycloudflare.com/?token=...")
38
+ print(rt.calc("2+2"))
39
+ ```
40
+
41
+ ## Disconnecting
42
+ ```
43
+ openspuruntime disconnect
44
+ ```
45
+
@@ -0,0 +1,13 @@
1
+ """OpenSPURuntime: remote Alyx I100 SPU runtime."""
2
+ from .client import Runtime, SPUError
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["Runtime", "SPUError", "serve", "create_app"]
6
+
7
+
8
+ def __getattr__(name):
9
+ # Lazy so a client-only install does not need Flask / alyxspu.
10
+ if name in ("serve", "create_app"):
11
+ from . import server
12
+ return getattr(server, name)
13
+ raise AttributeError(name)
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,182 @@
1
+ """openspuruntime command line."""
2
+ import argparse
3
+ import csv
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from .client import Runtime, SPUError
9
+
10
+ OUT_DIR = "openspu_outputs"
11
+
12
+
13
+ def _rt():
14
+ return Runtime.from_session()
15
+
16
+
17
+ def _load_samples(args):
18
+ if args.code:
19
+ text = args.code
20
+ elif args.file == "-":
21
+ text = sys.stdin.read()
22
+ elif args.file:
23
+ with open(args.file) as f:
24
+ text = f.read()
25
+ else:
26
+ raise SPUError("give a JSON file, '-' for stdin, or -c '<json>'")
27
+ return [(s[0], s[1]) for s in json.loads(text)]
28
+
29
+
30
+ def _train(rt, epochs, lr, quiet=False):
31
+ step = max(1, epochs // 20)
32
+ last = [0]
33
+
34
+ def show(e, loss):
35
+ last[0] = e
36
+ if not quiet and (e % step == 0 or e == epochs):
37
+ print("epoch %d loss %.6f" % (e, loss))
38
+
39
+ history = rt.train(epochs, lr, on_loss=show)
40
+ if history:
41
+ os.makedirs(OUT_DIR, exist_ok=True)
42
+ path = os.path.join(OUT_DIR, "loss.csv")
43
+ with open(path, "w", newline="") as f:
44
+ w = csv.writer(f)
45
+ w.writerow(["epoch", "loss"])
46
+ w.writerows(history)
47
+ try:
48
+ import matplotlib
49
+ matplotlib.use("Agg")
50
+ import matplotlib.pyplot as plt
51
+ plt.plot(*zip(*history))
52
+ plt.xlabel("epoch")
53
+ plt.ylabel("loss")
54
+ plt.savefig(os.path.join(OUT_DIR, "loss.png"))
55
+ plt.close()
56
+ except ImportError:
57
+ pass
58
+ print("saved loss curve to %s/" % OUT_DIR)
59
+ return history
60
+
61
+
62
+ def _shell(rt):
63
+ print("openspuruntime shell. Type an expression to calc it. Commands: info, ping, "
64
+ "build I H O, train EPOCHS LR, run X..., clear, quit")
65
+ while True:
66
+ try:
67
+ line = input("spu> ").strip()
68
+ except (EOFError, KeyboardInterrupt):
69
+ print()
70
+ return
71
+ if not line:
72
+ continue
73
+ cmd, *rest = line.split()
74
+ try:
75
+ if cmd in ("quit", "exit"):
76
+ return
77
+ elif cmd == "info":
78
+ print(rt.info())
79
+ elif cmd == "ping":
80
+ print(rt.ping())
81
+ elif cmd == "build":
82
+ rt.build(*map(int, rest)); print("ok")
83
+ elif cmd == "train":
84
+ _train(rt, int(rest[0]), float(rest[1]))
85
+ elif cmd == "run":
86
+ print(rt.run(map(float, rest)))
87
+ elif cmd == "clear":
88
+ rt.clear_data(); print("ok")
89
+ else:
90
+ print(rt.calc(line))
91
+ except (SPUError, ValueError, IndexError) as e:
92
+ print("error:", e)
93
+
94
+
95
+ def build_parser():
96
+ p = argparse.ArgumentParser(prog="openspuruntime",
97
+ description="Remote Alyx I100 SPU runtime")
98
+ sub = p.add_subparsers(dest="cmd", required=True)
99
+
100
+ s = sub.add_parser("stream", help="share the SPU plugged into this machine")
101
+ s.add_argument("--allowed_ip", "--allowed-ip", nargs="+", metavar="IP")
102
+ s.add_argument("--allow-any", action="store_true")
103
+ s.add_argument("--serial-port")
104
+ s.add_argument("--baud", type=int, default=115200)
105
+ s.add_argument("--no-tunnel", action="store_true")
106
+ s.add_argument("--install-cloudflared", action="store_true")
107
+ s.add_argument("--tunnel-token")
108
+ s.add_argument("--public-url")
109
+ s.add_argument("--port", type=int, default=9000)
110
+ s.add_argument("--host")
111
+ s.add_argument("--token")
112
+ s.add_argument("--allow-raw", action="store_true")
113
+ s.add_argument("--no-queue", action="store_true")
114
+ s.add_argument("--wsgi", choices=["werkzeug", "waitress"], default="werkzeug")
115
+ s.add_argument("--tls-cert")
116
+ s.add_argument("--tls-key")
117
+ s.add_argument("--public-host")
118
+
119
+ c = sub.add_parser("connect", help="connect to a host")
120
+ c.add_argument("url")
121
+ sub.add_parser("disconnect")
122
+ sub.add_parser("info")
123
+ sub.add_parser("ping")
124
+ sub.add_parser("shell")
125
+
126
+ k = sub.add_parser("calc"); k.add_argument("expr")
127
+ b = sub.add_parser("build")
128
+ for n in ("inputs", "hidden", "outputs"):
129
+ b.add_argument("--" + n, type=int, required=True)
130
+ d = sub.add_parser("data", help="upload a training set")
131
+ d.add_argument("file", nargs="?")
132
+ d.add_argument("-c", "--code")
133
+ sub.add_parser("clear")
134
+ t = sub.add_parser("train")
135
+ t.add_argument("--epochs", type=int, default=3000)
136
+ t.add_argument("--lr", type=float, default=0.5)
137
+ r = sub.add_parser("run"); r.add_argument("inputs", nargs="+", type=float)
138
+ return p
139
+
140
+
141
+ def main(argv=None):
142
+ a = build_parser().parse_args(argv)
143
+ try:
144
+ if a.cmd == "stream":
145
+ from .server import serve
146
+ allowed = ["all"] if a.allow_any else a.allowed_ip
147
+ serve(serial_port=a.serial_port, allowed_ip=allowed, token=a.token, host=a.host,
148
+ port=a.port, tunnel=not a.no_tunnel, tunnel_token=a.tunnel_token,
149
+ public_url=a.public_url, public_host=a.public_host,
150
+ install_cloudflared=a.install_cloudflared, allow_raw=a.allow_raw,
151
+ wait=not a.no_queue, baud=a.baud, wsgi=a.wsgi,
152
+ tls_cert=a.tls_cert, tls_key=a.tls_key)
153
+ elif a.cmd == "connect":
154
+ rt = Runtime.connect(a.url)
155
+ print("connected to", rt.base_url)
156
+ print("SPU:", rt.info())
157
+ elif a.cmd == "disconnect":
158
+ Runtime.disconnect(); print("disconnected")
159
+ elif a.cmd == "info":
160
+ print(_rt().info())
161
+ elif a.cmd == "ping":
162
+ print(_rt().ping())
163
+ elif a.cmd == "calc":
164
+ print(_rt().calc(a.expr))
165
+ elif a.cmd == "build":
166
+ _rt().build(a.inputs, a.hidden, a.outputs); print("ok")
167
+ elif a.cmd == "data":
168
+ s = _load_samples(a); _rt().set_data(s); print("uploaded %d samples" % len(s))
169
+ elif a.cmd == "clear":
170
+ _rt().clear_data(); print("ok")
171
+ elif a.cmd == "train":
172
+ _train(_rt(), a.epochs, a.lr)
173
+ elif a.cmd == "run":
174
+ print(_rt().run(a.inputs))
175
+ elif a.cmd == "shell":
176
+ _shell(_rt())
177
+ except SPUError as e:
178
+ sys.exit("error: %s" % e)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
@@ -0,0 +1,116 @@
1
+ """Client for a remote OpenSPURuntime host."""
2
+ import json
3
+ import os
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+ import requests
7
+
8
+ SESSION_FILE = os.path.join(os.path.expanduser("~"), ".openspuruntime", "session.json")
9
+
10
+
11
+ class SPUError(Exception):
12
+ """Raised for device, server or connection errors."""
13
+
14
+
15
+ def _split(url):
16
+ u = urlparse(url)
17
+ token = parse_qs(u.query).get("token", [""])[0]
18
+ if not u.scheme or not u.netloc:
19
+ raise SPUError("invalid URL: %s" % url)
20
+ return "%s://%s" % (u.scheme, u.netloc), token
21
+
22
+
23
+ class Runtime:
24
+ def __init__(self, base_url, token, timeout=60):
25
+ self.base_url, self.token, self.timeout = base_url.rstrip("/"), token, timeout
26
+
27
+ # --- connection -------------------------------------------------
28
+ @classmethod
29
+ def connect(cls, url, save=True):
30
+ base, token = _split(url)
31
+ rt = cls(base, token)
32
+ rt.info() # verifies URL, IP gate and token
33
+ if save:
34
+ os.makedirs(os.path.dirname(SESSION_FILE), exist_ok=True)
35
+ fd = os.open(SESSION_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
36
+ with os.fdopen(fd, "w") as f:
37
+ json.dump({"base_url": base, "token": token}, f)
38
+ return rt
39
+
40
+ @classmethod
41
+ def from_session(cls):
42
+ try:
43
+ with open(SESSION_FILE) as f:
44
+ s = json.load(f)
45
+ except (OSError, ValueError):
46
+ raise SPUError("not connected. Run: openspuruntime connect <url>")
47
+ return cls(s["base_url"], s["token"])
48
+
49
+ @staticmethod
50
+ def disconnect():
51
+ try:
52
+ os.remove(SESSION_FILE)
53
+ except OSError:
54
+ pass
55
+
56
+ # --- plumbing ---------------------------------------------------
57
+ def _req(self, method, path, payload=None, stream=False):
58
+ try:
59
+ r = requests.request(method, self.base_url + path, json=payload, stream=stream,
60
+ headers={"Authorization": "Bearer " + self.token},
61
+ timeout=None if stream else self.timeout)
62
+ except requests.RequestException as e:
63
+ raise SPUError("connection failed: %s" % e)
64
+ if r.status_code >= 400:
65
+ try:
66
+ msg = r.json().get("error", r.text)
67
+ except ValueError:
68
+ msg = r.text[:200]
69
+ raise SPUError("%s (HTTP %d)" % (msg, r.status_code))
70
+ return r
71
+
72
+ def _json(self, method, path, payload=None):
73
+ return self._req(method, path, payload).json()
74
+
75
+ # --- SPU API (mirrors alyxspu.AlyxSPU) --------------------------
76
+ def ping(self):
77
+ return self._json("GET", "/api/ping").get("pong")
78
+
79
+ def info(self):
80
+ return self._json("GET", "/api/info")["info"]
81
+
82
+ def calc(self, expr):
83
+ return self._json("POST", "/api/calc", {"expr": expr})["result"]
84
+
85
+ def build(self, inputs, hidden, outputs):
86
+ self._json("POST", "/api/build", {"inputs": inputs, "hidden": hidden, "outputs": outputs})
87
+
88
+ def set_data(self, samples):
89
+ self._json("POST", "/api/data", {"samples": [[list(x), list(y)] for x, y in samples]})
90
+
91
+ def add_sample(self, x, y):
92
+ self._json("POST", "/api/data/add", {"x": list(x), "y": list(y)})
93
+
94
+ def clear_data(self):
95
+ self._json("POST", "/api/data/clear")
96
+
97
+ def run(self, inputs):
98
+ return self._json("POST", "/api/run", {"inputs": list(inputs)})["outputs"]
99
+
100
+ def command(self, line):
101
+ return self._json("POST", "/api/command", {"line": line})["reply"]
102
+
103
+ def train(self, epochs, lr, on_loss=None):
104
+ r = self._req("POST", "/api/train", {"epochs": epochs, "lr": lr}, stream=True)
105
+ history = []
106
+ for line in r.iter_lines(decode_unicode=True):
107
+ if not line:
108
+ continue
109
+ msg = json.loads(line)
110
+ if "error" in msg:
111
+ raise SPUError(msg["error"])
112
+ if "epoch" in msg:
113
+ history.append((msg["epoch"], msg["loss"]))
114
+ if on_loss:
115
+ on_loss(msg["epoch"], msg["loss"])
116
+ return history
@@ -0,0 +1,232 @@
1
+ """Flask server that exposes an Alyx I100 SPU as a small JSON API."""
2
+ import hmac
3
+ import ipaddress
4
+ import json
5
+ import queue
6
+ import secrets
7
+ import threading
8
+
9
+ from flask import Flask, Response, jsonify, request, stream_with_context
10
+
11
+
12
+ def parse_allowed(values):
13
+ """Return a list of networks, or None meaning 'any IP'."""
14
+ nets = []
15
+ for v in values or []:
16
+ if str(v).lower() in ("all", "any", "*"):
17
+ return None
18
+ nets.append(ipaddress.ip_network(str(v), strict=False))
19
+ return nets
20
+
21
+
22
+ def _ip_ok(ip_str, nets):
23
+ if nets is None:
24
+ return True
25
+ try:
26
+ ip = ipaddress.ip_address(ip_str.strip())
27
+ except ValueError:
28
+ return False
29
+ if getattr(ip, "ipv4_mapped", None):
30
+ ip = ip.ipv4_mapped
31
+ return any(ip in n for n in nets)
32
+
33
+
34
+ def _err(msg, code):
35
+ return jsonify({"error": msg}), code
36
+
37
+
38
+ def create_app(spu, token, allowed_ips=None, tunnel=True, allow_raw=False, wait=True):
39
+ """Build the Flask app.
40
+
41
+ spu an open alyxspu.AlyxSPU
42
+ allowed_ips list of networks from parse_allowed(), or None for any IP
43
+ tunnel True: trust CF-Connecting-IP only (cloudflared hits us from loopback)
44
+ False: use the socket address, loopback always allowed
45
+ """
46
+ app = Flask("openspuruntime")
47
+ lock = threading.Lock()
48
+
49
+ @app.before_request
50
+ def gate():
51
+ if tunnel:
52
+ ip = request.headers.get("CF-Connecting-IP")
53
+ if not ip:
54
+ return _err("refused: request did not come through the tunnel", 403)
55
+ ok = _ip_ok(ip, allowed_ips)
56
+ else:
57
+ ip = request.remote_addr or ""
58
+ ok = _ip_ok(ip, allowed_ips) or _ip_ok(ip, [ipaddress.ip_network("127.0.0.0/8"),
59
+ ipaddress.ip_network("::1/128")])
60
+ if not ok:
61
+ return _err("access denied for IP %s. Ask the host to add it with "
62
+ "--allowed_ip %s" % (ip, ip), 403)
63
+ auth = request.headers.get("Authorization", "")
64
+ supplied = auth[7:] if auth.lower().startswith("bearer ") else request.args.get("token", "")
65
+ if not hmac.compare_digest(supplied.encode(), token.encode()):
66
+ return _err("invalid or missing token", 401)
67
+
68
+ @app.after_request
69
+ def no_reuse(resp):
70
+ resp.headers["Connection"] = "close" # one vetted connection is never reused
71
+ return resp
72
+
73
+ def body():
74
+ return request.get_json(silent=True) or {}
75
+
76
+ def device(fn):
77
+ if not lock.acquire(blocking=wait):
78
+ return _err("busy: the SPU is in use", 409)
79
+ try:
80
+ return jsonify(fn())
81
+ except (ValueError, KeyError, TypeError) as e:
82
+ return _err("bad request: %s" % e, 400)
83
+ except Exception as e: # AlyxError, serial errors, ...
84
+ return _err(str(e), 502)
85
+ finally:
86
+ lock.release()
87
+
88
+ def samples_from(raw):
89
+ out = []
90
+ for s in raw:
91
+ x, y = s
92
+ out.append(([float(v) for v in x], [float(v) for v in y]))
93
+ if not out:
94
+ raise ValueError("no samples")
95
+ return out
96
+
97
+ @app.get("/api/ping")
98
+ def ping():
99
+ return device(lambda: {"ok": True, "pong": spu.ping()})
100
+
101
+ @app.get("/api/info")
102
+ def info():
103
+ return device(lambda: {"info": spu.info()})
104
+
105
+ @app.post("/api/calc")
106
+ def calc():
107
+ expr = body()["expr"]
108
+ return device(lambda: {"result": spu.calc(str(expr))})
109
+
110
+ @app.post("/api/build")
111
+ def build():
112
+ b = body()
113
+ i, h, o = int(b["inputs"]), int(b["hidden"]), int(b["outputs"])
114
+ return device(lambda: (spu.build(i, h, o), {"ok": True})[1])
115
+
116
+ @app.post("/api/data")
117
+ def data():
118
+ s = samples_from(body()["samples"])
119
+ return device(lambda: (spu.set_data(s), {"ok": True, "count": len(s)})[1])
120
+
121
+ @app.post("/api/data/add")
122
+ def data_add():
123
+ b = body()
124
+ x, y = [float(v) for v in b["x"]], [float(v) for v in b["y"]]
125
+ return device(lambda: (spu.add_sample(x, y), {"ok": True})[1])
126
+
127
+ @app.post("/api/data/clear")
128
+ def data_clear():
129
+ return device(lambda: (spu.clear_data(), {"ok": True})[1])
130
+
131
+ @app.post("/api/run")
132
+ def run():
133
+ x = [float(v) for v in body()["inputs"]]
134
+ return device(lambda: {"outputs": spu.run(x)})
135
+
136
+ @app.post("/api/command")
137
+ def command():
138
+ if not allow_raw:
139
+ return _err("raw commands are disabled (start the host with --allow-raw)", 403)
140
+ line = str(body()["line"])
141
+ return device(lambda: {"reply": spu.command(line)})
142
+
143
+ @app.post("/api/train")
144
+ def train():
145
+ try:
146
+ b = body()
147
+ epochs, lr = int(b["epochs"]), float(b["lr"])
148
+ except (KeyError, ValueError, TypeError) as e:
149
+ return _err("bad request: %s" % e, 400)
150
+ if not lock.acquire(blocking=wait):
151
+ return _err("busy: the SPU is in use", 409)
152
+
153
+ q = queue.Queue()
154
+
155
+ def worker():
156
+ try:
157
+ spu.train(epochs=epochs, lr=lr,
158
+ on_loss=lambda e, l: q.put({"epoch": e, "loss": l}))
159
+ q.put({"done": True})
160
+ except Exception as e:
161
+ q.put({"error": str(e)})
162
+ finally:
163
+ lock.release() # released by the worker, even if the client disconnects
164
+ q.put(None)
165
+
166
+ threading.Thread(target=worker, daemon=True).start()
167
+
168
+ def gen():
169
+ while True:
170
+ item = q.get()
171
+ if item is None:
172
+ return
173
+ yield json.dumps(item) + "\n"
174
+
175
+ return Response(stream_with_context(gen()), mimetype="application/x-ndjson")
176
+
177
+ return app
178
+
179
+
180
+ def serve(serial_port=None, allowed_ip=None, token=None, host=None, port=9000,
181
+ tunnel=True, tunnel_token=None, public_url=None, public_host=None,
182
+ install_cloudflared=False, allow_raw=False, wait=True, baud=115200,
183
+ wsgi="werkzeug", tls_cert=None, tls_key=None, banner=True):
184
+ """Open the SPU, start the Flask server and (optionally) a Cloudflare tunnel."""
185
+ from alyxspu import AlyxSPU
186
+ from . import tunnel as tun
187
+
188
+ nets = parse_allowed(allowed_ip)
189
+ if tunnel and allowed_ip is None:
190
+ raise SystemExit("--allowed_ip is required in tunnel mode (use 'all' to allow any IP)")
191
+ token = token or secrets.token_urlsafe(24)
192
+ host = host or ("127.0.0.1" if tunnel else "0.0.0.0")
193
+
194
+ kwargs = {} if baud == 115200 else {"baud": baud}
195
+ try:
196
+ # AlyxSPU opens the port in its constructor
197
+ spu = AlyxSPU(serial_port, **kwargs) if serial_port else AlyxSPU(**kwargs)
198
+ except Exception as e:
199
+ raise SystemExit("could not open the SPU: %s\nPass the port with --serial-port (e.g. COM5)." % e)
200
+ app = create_app(spu, token, nets, tunnel=tunnel, allow_raw=allow_raw, wait=wait)
201
+
202
+ proc = None
203
+ try:
204
+ if tunnel:
205
+ proc, url = tun.start(port, tunnel_token, public_url, install_cloudflared)
206
+ else:
207
+ scheme = "https" if tls_cert else "http"
208
+ url = "%s://%s:%d" % (scheme, public_host or host, port)
209
+ if banner:
210
+ print("\nSPU connected. Run this on the client:\n")
211
+ print(" openspuruntime connect %s/?token=%s\n" % (url.rstrip("/"), token))
212
+ if allowed_ip and nets is not None:
213
+ print("Allowed IPs: %s" % ", ".join(str(n) for n in nets))
214
+ elif nets is None:
215
+ print("Allowing ANY IP - the token is your only protection.")
216
+ print("Press Ctrl+C to stop.\n")
217
+
218
+ if wsgi == "waitress":
219
+ from waitress import serve as wserve
220
+ wserve(app, host=host, port=port, threads=8)
221
+ else:
222
+ ssl_ctx = (tls_cert, tls_key) if tls_cert and tls_key else None
223
+ app.run(host=host, port=port, threaded=True, ssl_context=ssl_ctx)
224
+ except KeyboardInterrupt:
225
+ pass
226
+ finally:
227
+ if proc:
228
+ proc.terminate()
229
+ try:
230
+ spu.close()
231
+ except Exception:
232
+ pass
@@ -0,0 +1,92 @@
1
+ """Start a Cloudflare tunnel with cloudflared."""
2
+ import os
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import stat
7
+ import subprocess
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import urllib.request
14
+
15
+ BIN_DIR = os.path.join(os.path.expanduser("~"), ".openspuruntime", "bin")
16
+ BASE = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
17
+
18
+
19
+ def _asset():
20
+ sysname = platform.system().lower()
21
+ m = platform.machine().lower()
22
+ arch = "arm64" if m in ("arm64", "aarch64") else "arm" if m.startswith("arm") else \
23
+ "amd64" if m in ("x86_64", "amd64") else "386"
24
+ if sysname == "windows":
25
+ return "cloudflared-windows-%s.exe" % arch
26
+ if sysname == "darwin":
27
+ return "cloudflared-darwin-%s.tgz" % arch
28
+ return "cloudflared-linux-%s" % arch
29
+
30
+
31
+ def find():
32
+ p = shutil.which("cloudflared")
33
+ if p:
34
+ return p
35
+ local = os.path.join(BIN_DIR, "cloudflared.exe" if os.name == "nt" else "cloudflared")
36
+ return local if os.path.exists(local) else None
37
+
38
+
39
+ def install():
40
+ os.makedirs(BIN_DIR, exist_ok=True)
41
+ asset = _asset()
42
+ dest = os.path.join(BIN_DIR, "cloudflared.exe" if os.name == "nt" else "cloudflared")
43
+ print("Downloading cloudflared (%s)..." % asset)
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ path = os.path.join(tmp, asset)
46
+ urllib.request.urlretrieve(BASE + asset, path)
47
+ if asset.endswith(".tgz"):
48
+ with tarfile.open(path) as t:
49
+ t.extract("cloudflared", tmp)
50
+ path = os.path.join(tmp, "cloudflared")
51
+ shutil.copy(path, dest)
52
+ os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
53
+ return dest
54
+
55
+
56
+ def start(port, tunnel_token=None, public_url=None, auto_install=False):
57
+ """Return (process, public_url)."""
58
+ exe = find()
59
+ if not exe:
60
+ ok = auto_install
61
+ if not ok and sys.stdin.isatty():
62
+ ok = input("cloudflared not found. Download the official binary? [y/N] ").lower().startswith("y")
63
+ if not ok:
64
+ raise SystemExit("cloudflared is required. Install it or pass --install-cloudflared.")
65
+ exe = install()
66
+
67
+ if tunnel_token:
68
+ if not public_url:
69
+ raise SystemExit("--public-url is required with --tunnel-token")
70
+ proc = subprocess.Popen([exe, "tunnel", "--no-autoupdate", "run", "--token", tunnel_token],
71
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
72
+ return proc, public_url
73
+
74
+ proc = subprocess.Popen([exe, "tunnel", "--no-autoupdate", "--url", "http://127.0.0.1:%d" % port],
75
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
76
+ found = {}
77
+
78
+ def reader():
79
+ for line in proc.stderr:
80
+ m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line)
81
+ if m and "url" not in found:
82
+ found["url"] = m.group(0)
83
+
84
+ threading.Thread(target=reader, daemon=True).start()
85
+ for _ in range(300):
86
+ if "url" in found:
87
+ return proc, found["url"]
88
+ if proc.poll() is not None:
89
+ break
90
+ time.sleep(0.1)
91
+ proc.terminate()
92
+ raise SystemExit("could not start the Cloudflare quick tunnel")
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: openspuruntime
3
+ Version: 0.1.0
4
+ Summary: Remote Alyx I100 SPU runtime: a Flask server tunneled with Cloudflare
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+ Provides-Extra: host
10
+ Requires-Dist: flask; extra == "host"
11
+ Requires-Dist: alyxspu; extra == "host"
12
+ Provides-Extra: waitress
13
+ Requires-Dist: waitress; extra == "waitress"
14
+
15
+ # OpenSPURuntime
16
+
17
+ Use any other computer's Alyx I100 SPU as your own. The host runs a plain Flask
18
+ server in front of the `alyxspu` driver and tunnels it with Cloudflare, so no
19
+ port forwarding or public IP is needed.
20
+
21
+ ## Installation :
22
+
23
+ ```
24
+ pip install "openspuruntime[host]" # on the machine with the SPU
25
+ pip install openspuruntime # on the client
26
+ ```
27
+
28
+ ## Streaming :
29
+ ```
30
+ openspuruntime stream --serial-port COM5 --allowed_ip all
31
+ ```
32
+
33
+ ## Connecting :
34
+ ```
35
+ openspuruntime connect https://random-words.trycloudflare.com/?token=...
36
+ ```
37
+
38
+ ## Usage :
39
+ #### For CLI :
40
+ ```
41
+ openspuruntime calc "sqrt(2)*3 + sin(pi/2)"
42
+ openspuruntime build --inputs 2 --hidden 4 --outputs 1
43
+ openspuruntime data -c "[[[0,0],[0]],[[0,1],[1]],[[1,0],[1]],[[1,1],[0]]]"
44
+ openspuruntime train --epochs 3000 --lr 0.5
45
+ openspuruntime run 1 0
46
+ ```
47
+
48
+ #### In python :
49
+ ```python
50
+ from openspuruntime import Runtime
51
+ rt = Runtime.connect("https://random-words.trycloudflare.com/?token=...")
52
+ print(rt.calc("2+2"))
53
+ ```
54
+
55
+ ## Disconnecting
56
+ ```
57
+ openspuruntime disconnect
58
+ ```
59
+
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ openspuruntime/__init__.py
4
+ openspuruntime/__main__.py
5
+ openspuruntime/cli.py
6
+ openspuruntime/client.py
7
+ openspuruntime/server.py
8
+ openspuruntime/tunnel.py
9
+ openspuruntime.egg-info/PKG-INFO
10
+ openspuruntime.egg-info/SOURCES.txt
11
+ openspuruntime.egg-info/dependency_links.txt
12
+ openspuruntime.egg-info/entry_points.txt
13
+ openspuruntime.egg-info/requires.txt
14
+ openspuruntime.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openspuruntime = openspuruntime.cli:main
@@ -0,0 +1,8 @@
1
+ requests
2
+
3
+ [host]
4
+ flask
5
+ alyxspu
6
+
7
+ [waitress]
8
+ waitress
@@ -0,0 +1 @@
1
+ openspuruntime
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openspuruntime"
7
+ version = "0.1.0"
8
+ description = "Remote Alyx I100 SPU runtime: a Flask server tunneled with Cloudflare"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ dependencies = ["requests"]
13
+
14
+ [project.optional-dependencies]
15
+ host = ["flask", "alyxspu"]
16
+ waitress = ["waitress"]
17
+
18
+ [project.scripts]
19
+ openspuruntime = "openspuruntime.cli:main"
20
+
21
+ [tool.setuptools]
22
+ packages = ["openspuruntime"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+