runboard 0.2.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.
runboard/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ import sys
2
+
3
+ from .client import Run, sync
4
+
5
+ __all__ = ["init", "log", "finish", "Run", "sync", "run"]
6
+ __version__ = "0.2.0"
7
+
8
+ run = None
9
+ _orig_excepthook = sys.excepthook
10
+
11
+
12
+ def _excepthook(exc_type, exc, tb):
13
+ if run is not None and not issubclass(exc_type, KeyboardInterrupt):
14
+ run.finish("crashed")
15
+ elif run is not None:
16
+ run.finish("killed")
17
+ _orig_excepthook(exc_type, exc, tb)
18
+
19
+
20
+ def init(project="default", name=None, config=None, **kwargs):
21
+ global run
22
+ if run is not None:
23
+ run.finish()
24
+ run = Run(project=project, name=name, config=config, **kwargs)
25
+ sys.excepthook = _excepthook
26
+ return run
27
+
28
+
29
+ def log(data, step=None):
30
+ if run is None:
31
+ raise RuntimeError("call runboard.init() first")
32
+ run.log(data, step=step)
33
+
34
+
35
+ def finish(status="finished"):
36
+ global run
37
+ if run is not None:
38
+ run.finish(status)
39
+ run = None
runboard/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ import argparse
2
+ import getpass
3
+ import signal
4
+ import socket
5
+ import sys
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from datetime import datetime
10
+ from urllib.parse import quote
11
+
12
+ from . import __version__, config
13
+ from .client import sync
14
+ from .server import Server
15
+ from .storage import Storage
16
+
17
+
18
+ def _notify(url, text):
19
+ try:
20
+ urllib.request.urlopen(urllib.request.Request(url, data=text.encode(), method="POST"), timeout=10).close()
21
+ except OSError as e:
22
+ print(f"[runboard] notify failed: {e}", file=sys.stderr)
23
+
24
+
25
+ def _reachable_host():
26
+ name = socket.gethostname()
27
+ try:
28
+ socket.getaddrinfo(name, None)
29
+ return name
30
+ except OSError:
31
+ pass
32
+ try:
33
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
34
+ s.connect(("10.255.255.255", 1))
35
+ return s.getsockname()[0]
36
+ except OSError:
37
+ return "127.0.0.1"
38
+
39
+
40
+ def cmd_serve(args):
41
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
42
+ token = config.get_or_create_token()
43
+ try:
44
+ srv = Server(args.dir, token, host=args.host, port=args.port)
45
+ except OSError as e:
46
+ if args.port_explicit:
47
+ raise SystemExit(f"runboard: cannot bind port {args.port}: {e}")
48
+ srv = Server(args.dir, token, host=args.host, port=0)
49
+ print(f"[runboard] port {args.port} busy; using {srv.port}", file=sys.stderr)
50
+ srv.start()
51
+ advertise = args.advertise or f"http://{_reachable_host()}:{srv.port}"
52
+ config.write_server_info({"url": advertise, "token": token, "dir": str(srv.storage.root)})
53
+ print(f"runboard serving {srv.storage.root}")
54
+ print(f" local: http://127.0.0.1:{srv.port}/?token={token}")
55
+ print(f" cluster: {advertise}/?token={token}")
56
+ print(f" (training jobs pick up the server from {config.home() / 'server.json'})")
57
+ sys.stdout.flush()
58
+
59
+ tunnel = None
60
+
61
+ def open_tunnel():
62
+ from .tunnel import Tunnel
63
+
64
+ t = Tunnel(srv.port)
65
+ url = t.start()
66
+ public = f"{url}/?token={token}"
67
+ print(f" public: {public}", flush=True)
68
+ print(" (the public URL can take ~30s to start resolving)", flush=True)
69
+ info = config.read_server_info()
70
+ info["public_url"] = public
71
+ config.write_server_info(info)
72
+ if args.notify:
73
+ _notify(args.notify, f"runboard dashboard: {public}")
74
+ return t
75
+
76
+ try:
77
+ if args.tunnel:
78
+ tunnel = open_tunnel()
79
+ while True:
80
+ time.sleep(5)
81
+ if tunnel and tunnel.proc.poll() is not None:
82
+ print("[runboard] tunnel exited; restarting", file=sys.stderr, flush=True)
83
+ try:
84
+ tunnel = open_tunnel()
85
+ except RuntimeError as e:
86
+ print(f"[runboard] {e}; retrying in 30s", file=sys.stderr, flush=True)
87
+ time.sleep(30)
88
+ except KeyboardInterrupt:
89
+ pass
90
+ finally:
91
+ if tunnel:
92
+ tunnel.stop()
93
+ srv.stop()
94
+
95
+
96
+ def cmd_ls(args):
97
+ runs = Storage(args.dir).list_runs()
98
+ for r in runs:
99
+ upd = datetime.fromtimestamp(r["updated"]).strftime("%Y-%m-%d %H:%M") if r.get("updated") else "-"
100
+ print(f"{r['project']:20} {r['run_id']:28} {str(r.get('status', '')):10} {upd} {r.get('name', '')}")
101
+
102
+
103
+ def cmd_sync(args):
104
+ n = sync(server=args.server, token=args.token, paths=args.files or None)
105
+ print(f"uploaded {n} rows")
106
+
107
+
108
+ def cmd_url(args):
109
+ info = config.read_server_info()
110
+ token = info.get("token") or config.get_or_create_token()
111
+ print(info.get("public_url") or f"{info.get('url', 'http://127.0.0.1:8080')}/?token={token}")
112
+
113
+
114
+ def cmd_configure(args):
115
+ server = args.server.rstrip("/")
116
+ token = args.token or getpass.getpass("Runboard access token: ").strip()
117
+ if not token:
118
+ raise SystemExit("runboard: access token cannot be empty")
119
+ request = urllib.request.Request(
120
+ f"{server}/api/health",
121
+ headers={"Authorization": f"Bearer {token}", "User-Agent": "runboard/0.2"},
122
+ )
123
+ try:
124
+ with urllib.request.urlopen(request, timeout=10) as response:
125
+ if response.status != 200:
126
+ raise OSError(f"HTTP {response.status}")
127
+ except urllib.error.HTTPError as e:
128
+ if e.code == 401:
129
+ raise SystemExit("runboard: the server rejected that token") from e
130
+ raise SystemExit(f"runboard: could not validate the server (HTTP {e.code})") from e
131
+ except OSError as e:
132
+ raise SystemExit(f"runboard: could not reach {server}: {e}") from e
133
+ config.write_server_info({"url": server, "token": token})
134
+ print(f"configured {server}")
135
+ print(f"dashboard: {server}/?token={quote(token, safe='')}")
136
+
137
+
138
+ def main(argv=None):
139
+ p = argparse.ArgumentParser(prog="runboard", description="Live training dashboards for any cluster.")
140
+ sub = p.add_subparsers(dest="cmd", required=True)
141
+
142
+ s = sub.add_parser("serve", help="run the dashboard server")
143
+ s.add_argument("--dir", default="./runboard-runs", help="directory where runs are stored")
144
+ s.add_argument("--host", default="0.0.0.0")
145
+ s.add_argument("--port", type=int, default=None)
146
+ s.add_argument("--tunnel", action="store_true", help="expose publicly via a Cloudflare quick tunnel")
147
+ s.add_argument("--advertise", help="URL training jobs use to reach this server")
148
+ s.add_argument("--notify", help="POST the public URL to this webhook (e.g. https://ntfy.sh/<topic>)")
149
+ s.set_defaults(func=cmd_serve)
150
+
151
+ s = sub.add_parser("ls", help="list runs")
152
+ s.add_argument("--dir", default="./runboard-runs")
153
+ s.set_defaults(func=cmd_ls)
154
+
155
+ s = sub.add_parser("sync", help="upload spooled offline metrics")
156
+ s.add_argument("files", nargs="*")
157
+ s.add_argument("--server")
158
+ s.add_argument("--token")
159
+ s.set_defaults(func=cmd_sync)
160
+
161
+ s = sub.add_parser("url", help="print the dashboard URL")
162
+ s.set_defaults(func=cmd_url)
163
+
164
+ s = sub.add_parser("configure", help="save and verify a hosted Runboard server")
165
+ s.add_argument("server", help="Runboard URL, such as https://runboard.<account>.workers.dev")
166
+ s.add_argument("--token", help="access token; omitted values are read securely from the terminal")
167
+ s.set_defaults(func=cmd_configure)
168
+
169
+ p.add_argument("--version", action="version", version=f"runboard {__version__}")
170
+ args = p.parse_args(argv)
171
+ if getattr(args, "cmd", None) == "serve":
172
+ args.port_explicit = args.port is not None
173
+ args.port = args.port or 8080
174
+ args.func(args)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
runboard/client.py ADDED
@@ -0,0 +1,385 @@
1
+ import atexit
2
+ import json
3
+ import os
4
+ import secrets
5
+ import socket
6
+ import sys
7
+ import threading
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from urllib.parse import quote
13
+
14
+ from . import config as _config
15
+ from .storage import Storage, clean_row, valid_name
16
+
17
+
18
+ def _warn(msg):
19
+ print(f"[runboard] {msg}", file=sys.stderr, flush=True)
20
+
21
+
22
+ def _sanitize(name):
23
+ s = "".join(c if c.isalnum() or c in "._-" else "-" for c in str(name))[:128].strip(".")
24
+ return s or "default"
25
+
26
+
27
+ def post_json(server, token, project, run_id, payload, timeout=10):
28
+ url = f"{server.rstrip('/')}/api/runs/{quote(project)}/{quote(run_id)}"
29
+ req = urllib.request.Request(
30
+ url,
31
+ data=json.dumps(payload, default=str).encode(),
32
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "runboard/0.2"},
33
+ method="POST",
34
+ )
35
+ with urllib.request.urlopen(req, timeout=timeout) as r:
36
+ return json.loads(r.read() or b"{}")
37
+
38
+
39
+ class _HttpSink:
40
+ def __init__(self, server, token):
41
+ self.server = server
42
+ self.token = token
43
+
44
+ def send(self, project, run_id, meta, rows):
45
+ pending = [(meta, rows)]
46
+ while pending:
47
+ batch_meta, batch = pending.pop()
48
+ payload = {"rows": batch}
49
+ if batch_meta:
50
+ payload["meta"] = batch_meta
51
+ try:
52
+ post_json(self.server, self.token, project, run_id, payload)
53
+ except urllib.error.HTTPError as e:
54
+ if e.code != 413 or len(batch) < 2:
55
+ raise
56
+ middle = len(batch) // 2
57
+ pending.append(({}, batch[middle:]))
58
+ pending.append((batch_meta, batch[:middle]))
59
+
60
+
61
+ class _FileSink:
62
+ def __init__(self, root):
63
+ self.storage = Storage(root)
64
+
65
+ def send(self, project, run_id, meta, rows):
66
+ if meta:
67
+ self.storage.update_meta(project, run_id, meta)
68
+ if rows:
69
+ self.storage.append_rows(project, run_id, rows)
70
+
71
+
72
+ class _NullSink:
73
+ def send(self, project, run_id, meta, rows):
74
+ pass
75
+
76
+
77
+ def _is_nonzero_rank():
78
+ for var in ("RANK", "LOCAL_RANK"):
79
+ v = os.environ.get(var)
80
+ if v not in (None, "", "0"):
81
+ return True
82
+ return False
83
+
84
+
85
+ def spool_dir():
86
+ return _config.home() / "spool"
87
+
88
+
89
+ class Run:
90
+ def __init__(
91
+ self,
92
+ project="default",
93
+ name=None,
94
+ config=None,
95
+ run_id=None,
96
+ server=None,
97
+ token=None,
98
+ dir=None,
99
+ mode=None,
100
+ tags=None,
101
+ flush_interval=1.0,
102
+ max_buffer=1_000_000,
103
+ chunk_size=5000,
104
+ all_ranks=False,
105
+ ):
106
+ self.project = _sanitize(project)
107
+ self.run_id = run_id or time.strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(3)
108
+ if not valid_name(self.run_id):
109
+ raise ValueError(f"invalid run_id {self.run_id!r}")
110
+ self.name = name or self.run_id
111
+ self.config = dict(config or {})
112
+ self.flush_interval = flush_interval
113
+ self.max_buffer = max_buffer
114
+ self.chunk_size = chunk_size
115
+ self._send_lock = threading.Lock()
116
+ self._step = 0
117
+ self._seq = 0
118
+ self._sid = secrets.token_hex(8)
119
+ self._rows = []
120
+ self._overflow = []
121
+ self._pending_spools = []
122
+ self._meta = {}
123
+ self._lock = threading.Lock()
124
+ self._stop = threading.Event()
125
+ self._finished = False
126
+ self._warned = False
127
+ if _is_nonzero_rank() and not all_ranks:
128
+ self._sink, self.mode = _NullSink(), "disabled"
129
+ else:
130
+ self._sink, self.mode = self._resolve_sink(server, token, dir, mode)
131
+ self._set_meta(
132
+ name=self.name,
133
+ config=self.config,
134
+ tags=list(tags or []),
135
+ status="running",
136
+ created=time.time(),
137
+ host=socket.gethostname(),
138
+ argv=sys.argv,
139
+ slurm_job_id=os.environ.get("SLURM_JOB_ID"),
140
+ mode=self.mode,
141
+ )
142
+ self._thread = threading.Thread(target=self._loop, name="runboard-sender", daemon=True)
143
+ self._thread.start()
144
+ atexit.register(self.finish)
145
+
146
+ def _resolve_sink(self, server, token, dir, mode):
147
+ dir = dir or os.environ.get("RUNBOARD_DIR")
148
+ if mode == "file" or (mode is None and dir):
149
+ root = dir or "./runboard-runs"
150
+ return _FileSink(root), "file"
151
+ info = _config.read_server_info()
152
+ server = server or os.environ.get("RUNBOARD_SERVER") or info.get("url")
153
+ token = token or os.environ.get("RUNBOARD_TOKEN") or info.get("token")
154
+ if server and token:
155
+ try:
156
+ urllib.request.urlopen(
157
+ urllib.request.Request(
158
+ f"{server.rstrip('/')}/api/health",
159
+ headers={"Authorization": f"Bearer {token}", "User-Agent": "runboard/0.2"},
160
+ ),
161
+ timeout=3,
162
+ ).close()
163
+ except urllib.error.HTTPError as e:
164
+ if e.code == 401:
165
+ _warn(f"server {server} rejected the token; metrics will be buffered (check RUNBOARD_TOKEN)")
166
+ except OSError as e:
167
+ _warn(f"server {server} not reachable yet ({e}); metrics will be buffered and retried")
168
+ return _HttpSink(server, token), "http"
169
+ if mode == "http":
170
+ raise ValueError("http mode needs a server and token (args, RUNBOARD_SERVER/RUNBOARD_TOKEN, or `runboard serve`)")
171
+ _warn("no server found; writing to ./runboard-runs (run `runboard serve --dir ./runboard-runs` to view)")
172
+ return _FileSink("./runboard-runs"), "file"
173
+
174
+ def _set_meta(self, **kw):
175
+ with self._lock:
176
+ self._meta.update(kw)
177
+
178
+ def log(self, data, step=None):
179
+ if self._finished:
180
+ return
181
+ if step is None:
182
+ step = self._step
183
+ self._step = max(self._step, int(step) + 1)
184
+ row = clean_row(dict(data))
185
+ row["_step"] = int(step)
186
+ row["_time"] = time.time()
187
+ with self._lock:
188
+ row["_seq"] = self._seq
189
+ row["_sid"] = self._sid
190
+ self._seq += 1
191
+ self._rows.append(row)
192
+ if len(self._rows) > self.max_buffer:
193
+ overflow = self._rows[: len(self._rows) - self.max_buffer]
194
+ del self._rows[: len(overflow)]
195
+ self._overflow.extend(overflow)
196
+
197
+ def _take(self):
198
+ with self._lock:
199
+ rows, meta = self._overflow, self._meta
200
+ rows.extend(self._rows)
201
+ self._rows, self._meta = [], {}
202
+ self._overflow = []
203
+ return meta, rows
204
+
205
+ def _restore(self, meta, rows):
206
+ with self._lock:
207
+ self._overflow[:0] = rows
208
+ self._meta = {**meta, **self._meta}
209
+
210
+ def _is_empty(self):
211
+ with self._lock:
212
+ return not (self._pending_spools or self._overflow or self._rows or self._meta)
213
+
214
+ def _flush_once(self):
215
+ with self._send_lock:
216
+ with self._lock:
217
+ pending_spool = self._pending_spools[0] if self._pending_spools else None
218
+ if pending_spool is not None:
219
+ return self._flush_spool(pending_spool)
220
+ meta, rows = self._take()
221
+ if not meta and not rows:
222
+ return True
223
+ sent = 0
224
+ try:
225
+ while True:
226
+ chunk = rows[sent : sent + self.chunk_size]
227
+ try:
228
+ self._sink.send(self.project, self.run_id, meta, chunk)
229
+ except urllib.error.HTTPError as e:
230
+ if 400 <= e.code < 500 and e.code not in (401, 408, 429):
231
+ _warn(f"server rejected {len(chunk)} rows ({e.code}: {e.reason}); dropping them")
232
+ else:
233
+ raise
234
+ meta = {}
235
+ sent += len(chunk)
236
+ if sent >= len(rows):
237
+ break
238
+ if self._warned:
239
+ _warn("connection restored")
240
+ self._warned = False
241
+ return True
242
+ except Exception as e:
243
+ self._restore(meta, rows[sent:])
244
+ if not self._warned:
245
+ _warn(f"could not send metrics ({e}); buffering and retrying")
246
+ self._warned = True
247
+ return False
248
+
249
+ def _flush_spool(self, path):
250
+ try:
251
+ rec = json.loads(path.read_text())
252
+ rows = rec.get("rows", [])
253
+ meta = rec.get("meta") or {}
254
+ for i in range(0, max(len(rows), 1), self.chunk_size):
255
+ self._sink.send(self.project, self.run_id, meta, rows[i : i + self.chunk_size])
256
+ meta = {}
257
+ path.unlink()
258
+ with self._lock:
259
+ self._pending_spools.remove(path)
260
+ if self._warned:
261
+ _warn("connection restored")
262
+ self._warned = False
263
+ return True
264
+ except urllib.error.HTTPError as e:
265
+ if 400 <= e.code < 500 and e.code not in (401, 408, 429):
266
+ _warn(f"server rejected spooled metrics ({e.code}: {e.reason}); dropping {path}")
267
+ try:
268
+ path.unlink(missing_ok=True)
269
+ except OSError:
270
+ pass
271
+ with self._lock:
272
+ if path in self._pending_spools:
273
+ self._pending_spools.remove(path)
274
+ return True
275
+ if not self._warned:
276
+ _warn(f"could not send metrics ({e}); buffering and retrying")
277
+ self._warned = True
278
+ return False
279
+ except Exception as e:
280
+ if not self._warned:
281
+ _warn(f"could not send metrics ({e}); buffering and retrying")
282
+ self._warned = True
283
+ return False
284
+
285
+ def _spill_overflow(self):
286
+ with self._lock:
287
+ rows, self._overflow = self._overflow, []
288
+ if not rows:
289
+ return
290
+ try:
291
+ paths = [self._spool({}, rows[i : i + self.chunk_size * 10]) for i in range(0, len(rows), self.chunk_size * 10)]
292
+ except OSError as e:
293
+ with self._lock:
294
+ self._overflow[:0] = rows
295
+ if not self._warned:
296
+ _warn(f"could not spool metrics ({e}); retaining them in memory")
297
+ self._warned = True
298
+ return
299
+ with self._lock:
300
+ self._pending_spools.extend(paths)
301
+
302
+ def _loop(self):
303
+ delay = self.flush_interval
304
+ while not self._stop.wait(delay):
305
+ try:
306
+ self._spill_overflow()
307
+ ok = self._flush_once()
308
+ except Exception:
309
+ ok = False
310
+ delay = self.flush_interval if ok else min(delay * 2, 30.0)
311
+
312
+ def _spool(self, meta, rows):
313
+ d = spool_dir()
314
+ d.mkdir(parents=True, exist_ok=True)
315
+ first_seq = rows[0].get("_seq", 0) if rows else 0
316
+ p = d / f"{self.project[:48]}__{self.run_id[:48]}__{self._sid}__{first_seq:020d}.jsonl"
317
+ tmp = p.with_suffix(".jsonl.tmp")
318
+ tmp.write_text(json.dumps({"project": self.project, "run_id": self.run_id, "meta": meta, "rows": rows}, default=str) + "\n")
319
+ with open(tmp, "rb") as f:
320
+ os.fsync(f.fileno())
321
+ os.replace(tmp, p)
322
+ fd = os.open(d, os.O_RDONLY)
323
+ try:
324
+ os.fsync(fd)
325
+ finally:
326
+ os.close(fd)
327
+ return p
328
+
329
+ def finish(self, status="finished", timeout=10.0):
330
+ if self._finished:
331
+ return
332
+ self._finished = True
333
+ self._stop.set()
334
+ self._thread.join(timeout=5)
335
+ self._set_meta(status=status, finished=time.time())
336
+ deadline = time.time() + timeout
337
+ while True:
338
+ if self._flush_once() and self._is_empty():
339
+ return
340
+ if time.time() >= deadline:
341
+ break
342
+ time.sleep(1.0)
343
+ with self._send_lock:
344
+ meta, rows = self._take()
345
+ if rows or meta:
346
+ try:
347
+ p = self._spool(meta, rows)
348
+ _warn(f"server unreachable; {len(rows)} rows saved to {p}. Upload later with `runboard sync`.")
349
+ except OSError as e:
350
+ self._restore(meta, rows)
351
+ _warn(f"server unreachable and could not write spool file ({e}); metrics remain in memory")
352
+ with self._lock:
353
+ pending_spools = list(self._pending_spools)
354
+ if pending_spools:
355
+ _warn(f"server unreachable; {len(pending_spools)} metric batches saved to {spool_dir()}. Upload later with `runboard sync`.")
356
+
357
+ def __enter__(self):
358
+ return self
359
+
360
+ def __exit__(self, exc_type, exc, tb):
361
+ self.finish("crashed" if exc_type else "finished")
362
+
363
+
364
+ def sync(server=None, token=None, paths=None):
365
+ info = _config.read_server_info()
366
+ server = server or os.environ.get("RUNBOARD_SERVER") or info.get("url")
367
+ token = token or os.environ.get("RUNBOARD_TOKEN") or info.get("token")
368
+ if not (server and token):
369
+ raise ValueError("no server/token configured")
370
+ sink = _HttpSink(server, token)
371
+ files = [Path(p) for p in paths] if paths else sorted(spool_dir().glob("*.jsonl"))
372
+ total = 0
373
+ for p in files:
374
+ for line in p.read_text().splitlines():
375
+ if not line.strip():
376
+ continue
377
+ rec = json.loads(line)
378
+ rows = rec["rows"]
379
+ meta = rec.get("meta") or None
380
+ for i in range(0, max(len(rows), 1), 5000):
381
+ sink.send(rec["project"], rec["run_id"], meta, rows[i : i + 5000])
382
+ meta = None
383
+ total += len(rows)
384
+ p.unlink()
385
+ return total
runboard/config.py ADDED
@@ -0,0 +1,36 @@
1
+ import json
2
+ import os
3
+ import secrets
4
+ from pathlib import Path
5
+
6
+
7
+ def home():
8
+ return Path(os.environ.get("RUNBOARD_HOME", "~/.runboard")).expanduser()
9
+
10
+
11
+ def get_or_create_token():
12
+ p = home() / "token"
13
+ if p.exists():
14
+ t = p.read_text().strip()
15
+ if t:
16
+ return t
17
+ p.parent.mkdir(parents=True, exist_ok=True)
18
+ t = secrets.token_urlsafe(24)
19
+ p.write_text(t)
20
+ os.chmod(p, 0o600)
21
+ return t
22
+
23
+
24
+ def write_server_info(info):
25
+ p = home() / "server.json"
26
+ p.parent.mkdir(parents=True, exist_ok=True)
27
+ p.write_text(json.dumps(info, indent=2))
28
+ os.chmod(p, 0o600)
29
+
30
+
31
+ def read_server_info():
32
+ p = home() / "server.json"
33
+ try:
34
+ return json.loads(p.read_text())
35
+ except (OSError, ValueError):
36
+ return {}