kalfa 3.0.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. kalfa/__init__.py +19 -0
  2. kalfa/api.py +284 -0
  3. kalfa/board/__init__.py +1 -0
  4. kalfa/board/answers.py +419 -0
  5. kalfa/board/server.py +536 -0
  6. kalfa/board/static/api.js +176 -0
  7. kalfa/board/static/app.js +122 -0
  8. kalfa/board/static/board.css +228 -0
  9. kalfa/board/static/charts.css +46 -0
  10. kalfa/board/static/components/badge.js +14 -0
  11. kalfa/board/static/components/chart.js +204 -0
  12. kalfa/board/static/components/diagram.js +857 -0
  13. kalfa/board/static/components/error.js +17 -0
  14. kalfa/board/static/components/loading.js +7 -0
  15. kalfa/board/static/components/progress.js +25 -0
  16. kalfa/board/static/components/settings.js +418 -0
  17. kalfa/board/static/components/spark.js +43 -0
  18. kalfa/board/static/components/table.js +156 -0
  19. kalfa/board/static/diagram.css +76 -0
  20. kalfa/board/static/format.js +138 -0
  21. kalfa/board/static/index.html +34 -0
  22. kalfa/board/static/plot.js +594 -0
  23. kalfa/board/static/predictions.css +20 -0
  24. kalfa/board/static/store.js +215 -0
  25. kalfa/board/static/views/compare.js +185 -0
  26. kalfa/board/static/views/home.js +90 -0
  27. kalfa/board/static/views/record/config.js +81 -0
  28. kalfa/board/static/views/record/curves.js +137 -0
  29. kalfa/board/static/views/record/data.js +378 -0
  30. kalfa/board/static/views/record/files.js +128 -0
  31. kalfa/board/static/views/record/log.js +164 -0
  32. kalfa/board/static/views/record/model.js +286 -0
  33. kalfa/board/static/views/record/overview.js +216 -0
  34. kalfa/board/static/views/record/plots.js +103 -0
  35. kalfa/board/static/views/record/predictions.js +834 -0
  36. kalfa/board/static/views/record.js +179 -0
  37. kalfa/board/static/views/runs.js +124 -0
  38. kalfa/board/static/views/sweep.js +282 -0
  39. kalfa/board/watch.py +87 -0
  40. kalfa/cli.py +371 -0
  41. kalfa/commands/__init__.py +0 -0
  42. kalfa/commands/common.py +8 -0
  43. kalfa/commands/export.py +117 -0
  44. kalfa/commands/generate.py +31 -0
  45. kalfa/commands/guide/__init__.py +101 -0
  46. kalfa/commands/guide/about.py +93 -0
  47. kalfa/commands/guide/calls.py +102 -0
  48. kalfa/commands/guide/sections.py +186 -0
  49. kalfa/commands/guide/seen.py +307 -0
  50. kalfa/commands/guide/show.py +106 -0
  51. kalfa/commands/guide/training.py +333 -0
  52. kalfa/commands/guide/words.py +97 -0
  53. kalfa/commands/legos.py +508 -0
  54. kalfa/commands/plots.py +27 -0
  55. kalfa/commands/report/__init__.py +64 -0
  56. kalfa/commands/report/page.py +112 -0
  57. kalfa/commands/report/predictions.py +216 -0
  58. kalfa/commands/report/sections.py +356 -0
  59. kalfa/commands/stop.py +60 -0
  60. kalfa/commands/sweep/__init__.py +202 -0
  61. kalfa/commands/sweep/checks.py +98 -0
  62. kalfa/commands/sweep/plan.py +183 -0
  63. kalfa/commands/sweep/points.py +123 -0
  64. kalfa/commands/sweep/runner.py +100 -0
  65. kalfa/commands/sweep/space.py +67 -0
  66. kalfa/commands/sweep/strategies.py +228 -0
  67. kalfa/core/__init__.py +0 -0
  68. kalfa/core/aliases.py +229 -0
  69. kalfa/core/build.py +727 -0
  70. kalfa/core/check.py +550 -0
  71. kalfa/core/coerce.py +36 -0
  72. kalfa/core/context.py +149 -0
  73. kalfa/core/device.py +63 -0
  74. kalfa/core/errors.py +45 -0
  75. kalfa/core/graph.py +304 -0
  76. kalfa/core/history.py +70 -0
  77. kalfa/core/loop.py +710 -0
  78. kalfa/core/make.py +121 -0
  79. kalfa/core/plan.py +156 -0
  80. kalfa/core/predict.py +290 -0
  81. kalfa/core/protocols.py +122 -0
  82. kalfa/core/record.py +347 -0
  83. kalfa/core/refs.py +88 -0
  84. kalfa/core/rng.py +100 -0
  85. kalfa/core/rules.py +513 -0
  86. kalfa/core/run.py +568 -0
  87. kalfa/core/spec.py +413 -0
  88. kalfa/core/state.py +100 -0
  89. kalfa/core/wrap.py +671 -0
  90. kalfa/records/__init__.py +24 -0
  91. kalfa/records/compare.py +86 -0
  92. kalfa/records/reader.py +561 -0
  93. kalfa/records/stats/__init__.py +36 -0
  94. kalfa/records/stats/choices.py +282 -0
  95. kalfa/records/stats/classes.py +236 -0
  96. kalfa/records/stats/measures.py +144 -0
  97. kalfa/records/stats/pairs.py +225 -0
  98. kalfa/records/stats/tables.py +153 -0
  99. kalfa/records/sweep.py +244 -0
  100. kalfa/std/__init__.py +17 -0
  101. kalfa/std/actions.py +35 -0
  102. kalfa/std/common.py +56 -0
  103. kalfa/std/data/__init__.py +1 -0
  104. kalfa/std/data/base.py +45 -0
  105. kalfa/std/data/columns.py +190 -0
  106. kalfa/std/data/flow.py +317 -0
  107. kalfa/std/data/images.py +443 -0
  108. kalfa/std/data/options.py +147 -0
  109. kalfa/std/data/pre.py +84 -0
  110. kalfa/std/data/splits.py +140 -0
  111. kalfa/std/data/stream.py +294 -0
  112. kalfa/std/data/table.py +642 -0
  113. kalfa/std/data/text.py +436 -0
  114. kalfa/std/data/transforms.py +327 -0
  115. kalfa/std/data/weights.py +113 -0
  116. kalfa/std/data/window.py +135 -0
  117. kalfa/std/figure.py +37 -0
  118. kalfa/std/init.py +72 -0
  119. kalfa/std/loggers.py +105 -0
  120. kalfa/std/losses.py +161 -0
  121. kalfa/std/metrics.py +463 -0
  122. kalfa/std/models.py +122 -0
  123. kalfa/std/notes.py +1553 -0
  124. kalfa/std/objectives.py +360 -0
  125. kalfa/std/plots/__init__.py +75 -0
  126. kalfa/std/plots/classification.py +207 -0
  127. kalfa/std/plots/common.py +44 -0
  128. kalfa/std/plots/curves.py +56 -0
  129. kalfa/std/plots/images.py +188 -0
  130. kalfa/std/plots/pairs.py +66 -0
  131. kalfa/std/plots/predictions.py +154 -0
  132. kalfa/std/samplers.py +261 -0
  133. kalfa/std/samples.py +72 -0
  134. kalfa/std/schedules.py +74 -0
  135. kalfa/std/steps.py +133 -0
  136. kalfa/std/triggers.py +135 -0
  137. kalfa/summary.py +296 -0
  138. kalfa/tui/__init__.py +4 -0
  139. kalfa/tui/app.py +107 -0
  140. kalfa/tui/config.py +61 -0
  141. kalfa/tui/guide.py +100 -0
  142. kalfa/tui/jobs.py +97 -0
  143. kalfa/tui/legos.py +62 -0
  144. kalfa/tui/records.py +439 -0
  145. kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
  146. kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
  147. kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
  148. kalfa-3.0.0.dev0.dist-info/entry_points.txt +2 -0
kalfa/board/server.py ADDED
@@ -0,0 +1,536 @@
1
+ import ipaddress
2
+ import json
3
+ import logging
4
+ import math
5
+ import re
6
+ import socket
7
+ import threading
8
+ import uuid
9
+ import webbrowser
10
+ from functools import partial
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from socketserver import TCPServer
14
+ from urllib.parse import parse_qs, unquote, urlsplit
15
+
16
+ from kalfa.core.errors import KalfaError
17
+
18
+ from .answers import BadValue, Board, EscapeError, inside, jsonable
19
+ from .watch import Watcher
20
+
21
+
22
+ HOST = "127.0.0.1"
23
+ PORT = 8765
24
+ STATIC = Path(__file__).parent / "static"
25
+ KEEP_ALIVE = 15.0
26
+ SHORTEST = 0.05
27
+ BODY_LIMIT = 1_000_000
28
+ SUFFIXES = ("series", "updates", "events", "files", "file", "predictions", "image", "stop")
29
+ CHOICES = ("pair", "task", "score", "positive", "threshold")
30
+ FETCH_SITES = ("same-origin", "none")
31
+ PORT_TEXT = re.compile(r"(?::\d{1,5})?")
32
+ CONTENT_TYPES = {
33
+ ".html": "text/html; charset=utf-8",
34
+ ".css": "text/css; charset=utf-8",
35
+ ".js": "text/javascript; charset=utf-8",
36
+ ".mjs": "text/javascript; charset=utf-8",
37
+ ".json": "application/json",
38
+ ".map": "application/json",
39
+ ".txt": "text/plain; charset=utf-8",
40
+ ".svg": "image/svg+xml",
41
+ ".png": "image/png",
42
+ ".jpg": "image/jpeg",
43
+ ".jpeg": "image/jpeg",
44
+ ".gif": "image/gif",
45
+ ".webp": "image/webp",
46
+ ".ico": "image/x-icon",
47
+ }
48
+ LOGGER = logging.getLogger("kalfa.board")
49
+
50
+
51
+ class BadRequest(KalfaError):
52
+ pass
53
+
54
+
55
+ class NotFound(KalfaError):
56
+ pass
57
+
58
+
59
+ REFUSED = (BadRequest, EscapeError, BadValue)
60
+
61
+
62
+ def content_type_of(name):
63
+ return CONTENT_TYPES.get(Path(name).suffix.lower(), "application/octet-stream")
64
+
65
+
66
+ def parts_of(rest):
67
+ return [part for part in rest.split("/") if part]
68
+
69
+
70
+ def split_record(rest):
71
+ parts = parts_of(rest)
72
+ if len(parts) > 1 and parts[-1] in SUFFIXES:
73
+ return "/".join(parts[:-1]), parts[-1]
74
+ return "/".join(parts), None
75
+
76
+
77
+ def escapes(relative):
78
+ text = str(relative).replace("\\", "/")
79
+ return text.startswith("/") or ".." in text.split("/")
80
+
81
+
82
+ def checked(relative, what="the root"):
83
+ if escapes(relative):
84
+ raise BadRequest(f"{relative} escapes {what}")
85
+ return relative
86
+
87
+
88
+ def status_of(error):
89
+ return 400 if isinstance(error, REFUSED) else 404
90
+
91
+
92
+ def host_name(value):
93
+ text = value.strip().lower()
94
+ if text.startswith("["):
95
+ name, closed, port = text[1:].partition("]")
96
+ if not closed:
97
+ return None
98
+ else:
99
+ name, colon, digits = text.partition(":")
100
+ port = colon + digits
101
+ if not PORT_TEXT.fullmatch(port):
102
+ return None
103
+ return name.rstrip(".")
104
+
105
+
106
+ def host_allowed(value, names=()):
107
+ name = host_name(value)
108
+ if not name:
109
+ return False
110
+ if name == "localhost" or name in names:
111
+ return True
112
+ try:
113
+ ipaddress.ip_address(name)
114
+ except ValueError:
115
+ return False
116
+ return True
117
+
118
+
119
+ def names_of(host):
120
+ machine = socket.gethostname().strip().lower().rstrip(".")
121
+ configured = str(host or "").strip().strip("[]").lower().rstrip(".")
122
+ return frozenset(name for name in (configured, machine, machine.split(".")[0]) if name)
123
+
124
+
125
+ def text_of(query, key):
126
+ values = query.get(key)
127
+ return values[-1] if values else None
128
+
129
+
130
+ def listed(query, key):
131
+ text = text_of(query, key)
132
+ if text is None:
133
+ return None
134
+ items = [item.strip() for item in text.split(",") if item.strip()]
135
+ return items or None
136
+
137
+
138
+ def number(query, key, kind=int):
139
+ text = text_of(query, key)
140
+ if text is None or not text.strip():
141
+ return None
142
+ try:
143
+ value = kind(text)
144
+ except ValueError:
145
+ raise BadRequest(f"{key} must be {'an integer' if kind is int else 'a number'}, not {text!r}") from None
146
+ if kind is float and (not math.isfinite(value) or value <= 0):
147
+ raise BadRequest(f"{key} must be a positive number, not {text!r}")
148
+ return value
149
+
150
+
151
+ def given(**options):
152
+ return {key: value for key, value in options.items() if value is not None}
153
+
154
+
155
+ def choices_of(query):
156
+ return given(**{key: text_of(query, key) or None for key in CHOICES})
157
+
158
+
159
+ def histograms_of(query):
160
+ found = {}
161
+ for item in listed(query, "histograms") or []:
162
+ name, colon, count = (part.strip() for part in item.partition(":"))
163
+ if not colon or not name or not count:
164
+ raise BadRequest(f"histograms takes name:count pairs, like residual_hist:60, not {item!r}")
165
+ try:
166
+ found[name] = int(count)
167
+ except ValueError:
168
+ raise BadRequest(f"the bins of {name} must be an integer, not {count!r}") from None
169
+ return found or None
170
+
171
+
172
+ def run_report(board, jobs, lock, job, records, out):
173
+ try:
174
+ path = board.report(records, out=out)
175
+ except Exception as error:
176
+ if isinstance(error, KalfaError):
177
+ message = str(error)
178
+ else:
179
+ LOGGER.exception("the report job %s failed", job)
180
+ message = f"{type(error).__name__}: {error}"
181
+ result = {"status": "failed", "path": None, "error": message}
182
+ else:
183
+ result = {"status": "done", "path": str(path), "error": None}
184
+ with lock:
185
+ jobs[job] = result
186
+
187
+
188
+ class Handler(BaseHTTPRequestHandler):
189
+ board = None
190
+ watcher = None
191
+ jobs = None
192
+ live = None
193
+ lock = None
194
+ hosts = frozenset()
195
+
196
+ def do_GET(self):
197
+ self.dispatch("GET")
198
+
199
+ def do_POST(self):
200
+ self.dispatch("POST")
201
+
202
+ def do_PUT(self):
203
+ self.dispatch("PUT")
204
+
205
+ def do_PATCH(self):
206
+ self.dispatch("PATCH")
207
+
208
+ def do_DELETE(self):
209
+ self.dispatch("DELETE")
210
+
211
+ def do_HEAD(self):
212
+ self.dispatch("HEAD")
213
+
214
+ def do_OPTIONS(self):
215
+ self.dispatch("OPTIONS")
216
+
217
+ def log_message(self, format, *args):
218
+ LOGGER.debug(format, *args)
219
+
220
+ def dispatch(self, method):
221
+ self.answered = False
222
+ self.body = b""
223
+ url = urlsplit(self.path)
224
+ path = unquote(url.path)
225
+ query = parse_qs(url.query, keep_blank_values=True)
226
+ try:
227
+ self.body = self.read_body()
228
+ if not self.local():
229
+ self.send_json({"error": "requests for another host name are refused"}, 403, plain=True)
230
+ return
231
+ actions = self.routes(path)
232
+ if actions is None:
233
+ raise NotFound(f"no route {path}")
234
+ if method not in actions:
235
+ self.send_json({"error": f"{method} is not allowed on {path}"}, 405, plain=True,
236
+ headers={"Allow": ", ".join(actions)})
237
+ return
238
+ if (method != "GET" or "where" in query) and self.foreign():
239
+ self.send_json({"error": "requests from another origin are refused"}, 403, plain=True)
240
+ return
241
+ actions[method](query)
242
+ except ConnectionError:
243
+ self.close_connection = True
244
+ except KalfaError as error:
245
+ self.fail(status_of(error), str(error))
246
+ except Exception as error:
247
+ if self.answered and isinstance(error, OSError):
248
+ self.close_connection = True
249
+ return
250
+ LOGGER.exception("%s %s failed", method, self.path)
251
+ self.fail(500, f"{type(error).__name__}: {error}")
252
+
253
+ def routes(self, path):
254
+ if path in ("/", "/index.html"):
255
+ return {"GET": partial(self.get_static, "index.html")}
256
+ if path.startswith("/static/"):
257
+ return {"GET": partial(self.get_static, path[len("/static/"):])}
258
+ if path == "/api/records":
259
+ return {"GET": self.get_records}
260
+ if path == "/api/compare":
261
+ return {"GET": self.get_compare}
262
+ if path == "/api/live":
263
+ return {"GET": self.get_live}
264
+ if path == "/api/report":
265
+ return {"POST": self.post_report}
266
+ if path.startswith("/api/report/"):
267
+ return {"GET": partial(self.get_report, path[len("/api/report/"):])}
268
+ if path.startswith("/api/sweep/"):
269
+ relative = "/".join(parts_of(path[len("/api/sweep/"):]))
270
+ return {"GET": partial(self.get_sweep, self.located(relative))}
271
+ if path.startswith("/api/record/"):
272
+ relative, suffix = split_record(path[len("/api/record/"):])
273
+ return self.record_routes(self.located(relative), suffix)
274
+ return None
275
+
276
+ def located(self, relative):
277
+ if not relative:
278
+ raise NotFound("a record path is required")
279
+ return checked(relative)
280
+
281
+ def record_routes(self, relative, suffix):
282
+ if suffix == "stop":
283
+ return {"POST": partial(self.post_stop, relative)}
284
+ readers = {None: self.get_record, "series": self.get_series, "updates": self.get_updates,
285
+ "events": self.get_events, "files": self.get_files, "file": self.get_file,
286
+ "predictions": self.get_predictions, "image": self.get_image}
287
+ return {"GET": partial(readers[suffix], relative)}
288
+
289
+ def get_static(self, name, query):
290
+ base = STATIC.resolve()
291
+ try:
292
+ target = (base / name).resolve()
293
+ found = target.is_relative_to(base) and target.is_file()
294
+ except (OSError, ValueError):
295
+ found = False
296
+ if not found:
297
+ raise NotFound(f"no static file {name!r}")
298
+ self.send_body(200, target.read_bytes(), content_type_of(target.name), cache="no-cache")
299
+
300
+ def get_records(self, query):
301
+ self.send_json(self.board.records())
302
+
303
+ def get_record(self, relative, query):
304
+ self.send_json(self.board.record(relative))
305
+
306
+ def get_series(self, relative, query):
307
+ self.send_json(self.board.series(relative, **given(keys=listed(query, "keys"), limit=number(query, "limit"))))
308
+
309
+ def get_updates(self, relative, query):
310
+ self.send_json(self.board.updates(relative, **given(keys=listed(query, "keys"),
311
+ limit=number(query, "limit"))))
312
+
313
+ def get_events(self, relative, query):
314
+ self.send_json(self.board.events(relative, **given(kinds=listed(query, "kinds"),
315
+ limit=number(query, "limit"))))
316
+
317
+ def get_files(self, relative, query):
318
+ self.send_json(self.board.files(relative))
319
+
320
+ def get_file(self, relative, query):
321
+ name = text_of(query, "name")
322
+ if not name:
323
+ raise BadRequest("name is required")
324
+ checked(name, "the record")
325
+ body, kind = self.board.file(relative, name, **given(tail=number(query, "tail")))
326
+ self.send_body(200, body, kind)
327
+
328
+ def get_predictions(self, relative, query):
329
+ name = text_of(query, "name") or None
330
+ if name is not None:
331
+ checked(name, "the record")
332
+ options = given(where=text_of(query, "where") or None, sample=number(query, "sample"),
333
+ bins=number(query, "bins"), name=name, histograms=histograms_of(query), **choices_of(query))
334
+ self.send_json(self.board.predictions(relative, **options))
335
+
336
+ def get_image(self, relative, query):
337
+ name = text_of(query, "name") or None
338
+ if name is not None:
339
+ checked(name, "the record")
340
+ index = number(query, "index")
341
+ if index is None:
342
+ raise BadRequest("index is required")
343
+ body, kind = self.board.image(relative, index, **given(name=name))
344
+ self.send_body(200, body, kind, cache="private, max-age=60")
345
+
346
+ def get_compare(self, query):
347
+ relatives = listed(query, "records")
348
+ if not relatives:
349
+ raise BadRequest("records is required")
350
+ for relative in relatives:
351
+ checked(relative)
352
+ self.send_json(self.board.compare(relatives, **given(keys=listed(query, "keys"))))
353
+
354
+ def get_sweep(self, relative, query):
355
+ self.send_json(self.board.sweep(relative))
356
+
357
+ def post_stop(self, relative, query):
358
+ self.send_json(self.board.stop(relative))
359
+
360
+ def post_report(self, query):
361
+ body = self.parsed()
362
+ records = body.get("records")
363
+ valid = isinstance(records, list) and all(isinstance(item, str) and item for item in records)
364
+ if not valid or not records:
365
+ raise BadRequest("records must be a non empty list of record paths")
366
+ out = body.get("out")
367
+ if out is not None and not isinstance(out, str):
368
+ raise BadRequest("out must be a path or null")
369
+ if out:
370
+ inside(self.board.root, out)
371
+ for relative in records:
372
+ self.board.resolve(checked(relative))
373
+ job = uuid.uuid4().hex[:12]
374
+ with self.lock:
375
+ self.jobs[job] = {"status": "running", "path": None, "error": None}
376
+ worker = threading.Thread(target=run_report, args=(self.board, self.jobs, self.lock, job, records, out),
377
+ name=f"kalfa-report-{job}", daemon=True)
378
+ worker.start()
379
+ self.send_json({"job": job}, plain=True)
380
+
381
+ def get_report(self, job, query):
382
+ with self.lock:
383
+ found = self.jobs.get(job)
384
+ found = None if found is None else dict(found)
385
+ if found is None:
386
+ raise NotFound(f"no report job {job}")
387
+ self.send_json(found, plain=True)
388
+
389
+ def get_live(self, query):
390
+ every = number(query, "every", float)
391
+ watcher = self.watcher
392
+ own = every is not None and every != watcher.every
393
+ if own:
394
+ watcher = Watcher(self.board, max(every, SHORTEST))
395
+ with self.lock:
396
+ self.live.add(watcher)
397
+ try:
398
+ watcher.start()
399
+ self.stream(watcher)
400
+ finally:
401
+ self.close_connection = True
402
+ if own:
403
+ watcher.stop()
404
+ with self.lock:
405
+ self.live.discard(watcher)
406
+
407
+ def stream(self, watcher):
408
+ version = watcher.version
409
+ self.answered = True
410
+ self.send_response(200)
411
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
412
+ self.send_header("Cache-Control", "no-cache")
413
+ self.send_header("Connection", "keep-alive")
414
+ self.end_headers()
415
+ try:
416
+ self.emit(": connected\n\n")
417
+ while not watcher.stopping:
418
+ if watcher.wait(version, timeout=KEEP_ALIVE) is None:
419
+ if not watcher.stopping:
420
+ self.emit(": keep-alive\n\n")
421
+ continue
422
+ version, changed = watcher.since(version)
423
+ self.emit(f"event: change\ndata: {json.dumps({'version': version, 'changed': changed})}\n\n")
424
+ except OSError:
425
+ return
426
+
427
+ def emit(self, text):
428
+ self.wfile.write(text.encode("utf-8"))
429
+ self.wfile.flush()
430
+
431
+ def read_body(self):
432
+ try:
433
+ length = int(self.headers.get("Content-Length") or 0)
434
+ except ValueError:
435
+ raise BadRequest("Content-Length must be an integer") from None
436
+ if length < 0 or length > BODY_LIMIT:
437
+ raise BadRequest(f"the request body must hold at most {BODY_LIMIT} bytes")
438
+ return self.rfile.read(length) if length else b""
439
+
440
+ def parsed(self):
441
+ if not self.body.strip():
442
+ return {}
443
+ try:
444
+ body = json.loads(self.body)
445
+ except ValueError:
446
+ raise BadRequest("the request body is not JSON") from None
447
+ if not isinstance(body, dict):
448
+ raise BadRequest("the request body must be a JSON object")
449
+ return body
450
+
451
+ def local(self):
452
+ return all(host_allowed(value, self.hosts) for value in self.headers.get_all("Host") or [])
453
+
454
+ def foreign(self):
455
+ site = self.headers.get("Sec-Fetch-Site")
456
+ if site is not None and site.strip().lower() not in FETCH_SITES:
457
+ return True
458
+ origin = self.headers.get("Origin")
459
+ if origin is None:
460
+ return False
461
+ host = self.headers.get("Host") or ""
462
+ return not host or urlsplit(origin).netloc.lower() != host.strip().lower()
463
+
464
+ def send_json(self, value, status=200, plain=False, headers=None):
465
+ body = json.dumps(value if plain else jsonable(value), ensure_ascii=False, default=str).encode("utf-8")
466
+ self.send_body(status, body, "application/json", headers=headers)
467
+
468
+ def send_body(self, status, body, kind, cache="no-store", headers=None):
469
+ self.answered = True
470
+ self.send_response(status)
471
+ self.send_header("Content-Type", kind)
472
+ self.send_header("Content-Length", str(len(body)))
473
+ self.send_header("Cache-Control", cache)
474
+ for key, value in (headers or {}).items():
475
+ self.send_header(key, value)
476
+ self.end_headers()
477
+ if self.command != "HEAD":
478
+ self.wfile.write(body)
479
+
480
+ def fail(self, status, message):
481
+ if self.answered:
482
+ LOGGER.warning("%s failed after its answer began: %s", self.path, message)
483
+ self.close_connection = True
484
+ return
485
+ try:
486
+ self.send_json({"error": message}, status, plain=True)
487
+ except OSError:
488
+ self.close_connection = True
489
+
490
+
491
+ class BoardServer(ThreadingHTTPServer):
492
+ daemon_threads = True
493
+ allow_reuse_address = True
494
+
495
+ def server_bind(self):
496
+ TCPServer.server_bind(self)
497
+ self.server_name, self.server_port = self.server_address[:2]
498
+
499
+ def server_close(self):
500
+ super().server_close()
501
+ handler = self.RequestHandlerClass
502
+ handler.watcher.stop()
503
+ with handler.lock:
504
+ watchers = list(handler.live)
505
+ for watcher in watchers:
506
+ watcher.stop()
507
+
508
+
509
+ def make_server(root, host=HOST, port=PORT):
510
+ board = Board(root)
511
+ attributes = {"board": board, "watcher": Watcher(board), "jobs": {}, "live": set(), "lock": threading.Lock(),
512
+ "hosts": names_of(host)}
513
+ handler = type("BoardHandler", (Handler,), attributes)
514
+ try:
515
+ return BoardServer((host, int(port)), handler)
516
+ except OSError as error:
517
+ raise KalfaError(f"the board cannot listen on {host}:{port}: {error.strerror or error}") from error
518
+
519
+
520
+ def shown_host(host):
521
+ return "127.0.0.1" if host in ("", "0.0.0.0") else host
522
+
523
+
524
+ def serve(root, host=HOST, port=PORT, open_browser=False):
525
+ server = make_server(root, host, port)
526
+ url = f"http://{shown_host(host)}:{server.server_address[1]}/"
527
+ print(f"kalfa board on {url} over {server.RequestHandlerClass.board.root} (ctrl-c stops it)", flush=True)
528
+ if open_browser:
529
+ webbrowser.open(url)
530
+ try:
531
+ server.serve_forever()
532
+ except KeyboardInterrupt:
533
+ pass
534
+ finally:
535
+ server.shutdown()
536
+ server.server_close()
@@ -0,0 +1,176 @@
1
+ export function encodePath(path) {
2
+ return String(path || "").split("/").filter(Boolean).map(encodeURIComponent).join("/");
3
+ }
4
+
5
+ function component(value) {
6
+ return encodeURIComponent(value).replace(/%2C/gi, ",").replace(/%2F/gi, "/");
7
+ }
8
+
9
+ export function query(params) {
10
+ const parts = [];
11
+ for (const [key, value] of Object.entries(params || {})) {
12
+ if (value === null || value === undefined) continue;
13
+ const item = Array.isArray(value) ? value.join(",") : String(value);
14
+ if (item) parts.push(component(key) + "=" + component(item));
15
+ }
16
+ return parts.length ? "?" + parts.join("&") : "";
17
+ }
18
+
19
+ export function recordUrl(path, suffix = "", params = null) {
20
+ const tail = suffix ? "/" + suffix : "";
21
+ return "/api/record/" + encodePath(path) + tail + query(params);
22
+ }
23
+
24
+ export function fileUrl(path, name, tail = null) {
25
+ return recordUrl(path, "file", { name, tail });
26
+ }
27
+
28
+ export function histogramsText(histograms) {
29
+ if (!histograms || typeof histograms !== "object" || Array.isArray(histograms)) return histograms || null;
30
+ const parts = Object.entries(histograms)
31
+ .filter(([name, count]) => name && Number.isInteger(count) && count > 0)
32
+ .map(([name, count]) => name + ":" + count);
33
+ return parts.length ? parts.join(",") : null;
34
+ }
35
+
36
+ export function predictionsUrl(path, params = null) {
37
+ const found = { ...(params || {}) };
38
+ if (found.histograms !== undefined) found.histograms = histogramsText(found.histograms);
39
+ return recordUrl(path, "predictions", found);
40
+ }
41
+
42
+ export function imageUrl(path, index, name = null) {
43
+ return recordUrl(path, "image", { index, name });
44
+ }
45
+
46
+ export function sweepUrl(path) {
47
+ return "/api/sweep/" + encodePath(path);
48
+ }
49
+
50
+ async function reach(path, init) {
51
+ try {
52
+ return await fetch(path, { cache: "no-store", ...init });
53
+ } catch (error) {
54
+ throw new Error("cannot reach the board server (" + error.message + ")");
55
+ }
56
+ }
57
+
58
+ function failure(response, path, body) {
59
+ const message = body && typeof body === "object" && body.error
60
+ ? body.error
61
+ : response.status + " " + (response.statusText || "error") + " for " + path;
62
+ const error = new Error(message);
63
+ error.status = response.status;
64
+ return error;
65
+ }
66
+
67
+ async function readJson(response, path) {
68
+ const text = await response.text();
69
+ let body = null;
70
+ let parsed = false;
71
+ if (text) {
72
+ try {
73
+ body = JSON.parse(text);
74
+ parsed = true;
75
+ } catch (error) {
76
+ parsed = false;
77
+ }
78
+ }
79
+ if (!response.ok) throw failure(response, path, body);
80
+ if (text && !parsed) throw new Error("the answer of " + path + " is not JSON");
81
+ return body;
82
+ }
83
+
84
+ export async function getJson(path) {
85
+ const response = await reach(path, { headers: { Accept: "application/json" } });
86
+ return readJson(response, path);
87
+ }
88
+
89
+ export async function postJson(path, body = {}) {
90
+ const response = await reach(path, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
93
+ body: JSON.stringify(body === undefined ? {} : body),
94
+ });
95
+ return readJson(response, path);
96
+ }
97
+
98
+ export function getPredictions(path, params = null) {
99
+ return getJson(predictionsUrl(path, params));
100
+ }
101
+
102
+ export async function getText(path) {
103
+ const response = await reach(path, {});
104
+ if (!response.ok) {
105
+ let body = null;
106
+ try {
107
+ body = JSON.parse(await response.text());
108
+ } catch (error) {
109
+ body = null;
110
+ }
111
+ throw failure(response, path, body);
112
+ }
113
+ return response.text();
114
+ }
115
+
116
+ export function live(onChange, onState = null, every = 2) {
117
+ let source = null;
118
+ let timer = null;
119
+ let delay = 2000;
120
+ let stopped = false;
121
+ let dropped = false;
122
+ const state = (connected) => {
123
+ if (onState) onState(connected);
124
+ };
125
+ const connect = () => {
126
+ if (stopped) return;
127
+ if (typeof EventSource === "undefined") {
128
+ state(false);
129
+ return;
130
+ }
131
+ source = new EventSource("/api/live" + query({ every }));
132
+ source.addEventListener("open", () => {
133
+ delay = 2000;
134
+ state(true);
135
+ if (dropped) {
136
+ dropped = false;
137
+ onChange(null);
138
+ }
139
+ });
140
+ source.addEventListener("change", (event) => {
141
+ let changed = [];
142
+ try {
143
+ const data = JSON.parse(event.data);
144
+ changed = Array.isArray(data.changed) ? data.changed : [];
145
+ } catch (error) {
146
+ changed = [];
147
+ }
148
+ onChange(changed);
149
+ });
150
+ source.addEventListener("error", () => {
151
+ state(false);
152
+ if (source) source.close();
153
+ source = null;
154
+ if (stopped) return;
155
+ dropped = true;
156
+ timer = setTimeout(() => {
157
+ timer = null;
158
+ connect();
159
+ }, delay);
160
+ delay = Math.min(delay * 2, 30000);
161
+ });
162
+ };
163
+ connect();
164
+ return () => {
165
+ stopped = true;
166
+ if (timer) clearTimeout(timer);
167
+ if (source) source.close();
168
+ source = null;
169
+ state(false);
170
+ };
171
+ }
172
+
173
+ export const client = Object.freeze({
174
+ encodePath, query, recordUrl, fileUrl, histogramsText, predictionsUrl, imageUrl, sweepUrl, getJson, postJson,
175
+ getText, getPredictions, live,
176
+ });