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.
- kalfa/__init__.py +19 -0
- kalfa/api.py +284 -0
- kalfa/board/__init__.py +1 -0
- kalfa/board/answers.py +419 -0
- kalfa/board/server.py +536 -0
- kalfa/board/static/api.js +176 -0
- kalfa/board/static/app.js +122 -0
- kalfa/board/static/board.css +228 -0
- kalfa/board/static/charts.css +46 -0
- kalfa/board/static/components/badge.js +14 -0
- kalfa/board/static/components/chart.js +204 -0
- kalfa/board/static/components/diagram.js +857 -0
- kalfa/board/static/components/error.js +17 -0
- kalfa/board/static/components/loading.js +7 -0
- kalfa/board/static/components/progress.js +25 -0
- kalfa/board/static/components/settings.js +418 -0
- kalfa/board/static/components/spark.js +43 -0
- kalfa/board/static/components/table.js +156 -0
- kalfa/board/static/diagram.css +76 -0
- kalfa/board/static/format.js +138 -0
- kalfa/board/static/index.html +34 -0
- kalfa/board/static/plot.js +594 -0
- kalfa/board/static/predictions.css +20 -0
- kalfa/board/static/store.js +215 -0
- kalfa/board/static/views/compare.js +185 -0
- kalfa/board/static/views/home.js +90 -0
- kalfa/board/static/views/record/config.js +81 -0
- kalfa/board/static/views/record/curves.js +137 -0
- kalfa/board/static/views/record/data.js +378 -0
- kalfa/board/static/views/record/files.js +128 -0
- kalfa/board/static/views/record/log.js +164 -0
- kalfa/board/static/views/record/model.js +286 -0
- kalfa/board/static/views/record/overview.js +216 -0
- kalfa/board/static/views/record/plots.js +103 -0
- kalfa/board/static/views/record/predictions.js +834 -0
- kalfa/board/static/views/record.js +179 -0
- kalfa/board/static/views/runs.js +124 -0
- kalfa/board/static/views/sweep.js +282 -0
- kalfa/board/watch.py +87 -0
- kalfa/cli.py +371 -0
- kalfa/commands/__init__.py +0 -0
- kalfa/commands/common.py +8 -0
- kalfa/commands/export.py +117 -0
- kalfa/commands/generate.py +31 -0
- kalfa/commands/guide/__init__.py +101 -0
- kalfa/commands/guide/about.py +93 -0
- kalfa/commands/guide/calls.py +102 -0
- kalfa/commands/guide/sections.py +186 -0
- kalfa/commands/guide/seen.py +307 -0
- kalfa/commands/guide/show.py +106 -0
- kalfa/commands/guide/training.py +333 -0
- kalfa/commands/guide/words.py +97 -0
- kalfa/commands/legos.py +508 -0
- kalfa/commands/plots.py +27 -0
- kalfa/commands/report/__init__.py +64 -0
- kalfa/commands/report/page.py +112 -0
- kalfa/commands/report/predictions.py +216 -0
- kalfa/commands/report/sections.py +356 -0
- kalfa/commands/stop.py +60 -0
- kalfa/commands/sweep/__init__.py +202 -0
- kalfa/commands/sweep/checks.py +98 -0
- kalfa/commands/sweep/plan.py +183 -0
- kalfa/commands/sweep/points.py +123 -0
- kalfa/commands/sweep/runner.py +100 -0
- kalfa/commands/sweep/space.py +67 -0
- kalfa/commands/sweep/strategies.py +228 -0
- kalfa/core/__init__.py +0 -0
- kalfa/core/aliases.py +229 -0
- kalfa/core/build.py +727 -0
- kalfa/core/check.py +550 -0
- kalfa/core/coerce.py +36 -0
- kalfa/core/context.py +149 -0
- kalfa/core/device.py +63 -0
- kalfa/core/errors.py +45 -0
- kalfa/core/graph.py +304 -0
- kalfa/core/history.py +70 -0
- kalfa/core/loop.py +710 -0
- kalfa/core/make.py +121 -0
- kalfa/core/plan.py +156 -0
- kalfa/core/predict.py +290 -0
- kalfa/core/protocols.py +122 -0
- kalfa/core/record.py +347 -0
- kalfa/core/refs.py +88 -0
- kalfa/core/rng.py +100 -0
- kalfa/core/rules.py +513 -0
- kalfa/core/run.py +568 -0
- kalfa/core/spec.py +413 -0
- kalfa/core/state.py +100 -0
- kalfa/core/wrap.py +671 -0
- kalfa/records/__init__.py +24 -0
- kalfa/records/compare.py +86 -0
- kalfa/records/reader.py +561 -0
- kalfa/records/stats/__init__.py +36 -0
- kalfa/records/stats/choices.py +282 -0
- kalfa/records/stats/classes.py +236 -0
- kalfa/records/stats/measures.py +144 -0
- kalfa/records/stats/pairs.py +225 -0
- kalfa/records/stats/tables.py +153 -0
- kalfa/records/sweep.py +244 -0
- kalfa/std/__init__.py +17 -0
- kalfa/std/actions.py +35 -0
- kalfa/std/common.py +56 -0
- kalfa/std/data/__init__.py +1 -0
- kalfa/std/data/base.py +45 -0
- kalfa/std/data/columns.py +190 -0
- kalfa/std/data/flow.py +317 -0
- kalfa/std/data/images.py +443 -0
- kalfa/std/data/options.py +147 -0
- kalfa/std/data/pre.py +84 -0
- kalfa/std/data/splits.py +140 -0
- kalfa/std/data/stream.py +294 -0
- kalfa/std/data/table.py +642 -0
- kalfa/std/data/text.py +436 -0
- kalfa/std/data/transforms.py +327 -0
- kalfa/std/data/weights.py +113 -0
- kalfa/std/data/window.py +135 -0
- kalfa/std/figure.py +37 -0
- kalfa/std/init.py +72 -0
- kalfa/std/loggers.py +105 -0
- kalfa/std/losses.py +161 -0
- kalfa/std/metrics.py +463 -0
- kalfa/std/models.py +122 -0
- kalfa/std/notes.py +1553 -0
- kalfa/std/objectives.py +360 -0
- kalfa/std/plots/__init__.py +75 -0
- kalfa/std/plots/classification.py +207 -0
- kalfa/std/plots/common.py +44 -0
- kalfa/std/plots/curves.py +56 -0
- kalfa/std/plots/images.py +188 -0
- kalfa/std/plots/pairs.py +66 -0
- kalfa/std/plots/predictions.py +154 -0
- kalfa/std/samplers.py +261 -0
- kalfa/std/samples.py +72 -0
- kalfa/std/schedules.py +74 -0
- kalfa/std/steps.py +133 -0
- kalfa/std/triggers.py +135 -0
- kalfa/summary.py +296 -0
- kalfa/tui/__init__.py +4 -0
- kalfa/tui/app.py +107 -0
- kalfa/tui/config.py +61 -0
- kalfa/tui/guide.py +100 -0
- kalfa/tui/jobs.py +97 -0
- kalfa/tui/legos.py +62 -0
- kalfa/tui/records.py +439 -0
- kalfa-3.0.0.dev0.dist-info/METADATA +261 -0
- kalfa-3.0.0.dev0.dist-info/RECORD +148 -0
- kalfa-3.0.0.dev0.dist-info/WHEEL +4 -0
- 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
|
+
});
|