simscope 0.1.1__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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""HTTP handlers (contracts 2).
|
|
2
|
+
|
|
3
|
+
Handlers are plain functions so Starlette runs them on its thread pool: they
|
|
4
|
+
do file I/O and never need to await anything, except the annotation route,
|
|
5
|
+
which reads a request body.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import re
|
|
14
|
+
import tempfile
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from starlette.concurrency import run_in_threadpool
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import (
|
|
20
|
+
FileResponse,
|
|
21
|
+
JSONResponse,
|
|
22
|
+
Response,
|
|
23
|
+
StreamingResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from simscope import annotations, export
|
|
27
|
+
from simscope.io import blockfile, errors, manifest
|
|
28
|
+
from simscope.server import blocks, jobs, security, static
|
|
29
|
+
from simscope.server import state as state_mod
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_NO_CACHE = "no-cache"
|
|
34
|
+
_IMMUTABLE = "public, max-age=31536000, immutable"
|
|
35
|
+
_MAX_BODY = 64 << 10
|
|
36
|
+
_JSON_TYPE = "application/json"
|
|
37
|
+
_STREAM_CHUNK = 1 << 20
|
|
38
|
+
_NOT_APPLICABLE = "no such derived data for this run"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Services:
|
|
42
|
+
"""Everything a handler needs, stored on ``app.state``."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
state: state_mod.LibraryState,
|
|
47
|
+
job_runner: jobs.Jobs,
|
|
48
|
+
block_store: blocks.BlockStore,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Bundles the services.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
state: The library state.
|
|
54
|
+
job_runner: The derived-data jobs.
|
|
55
|
+
block_store: The open block files.
|
|
56
|
+
"""
|
|
57
|
+
self.state = state
|
|
58
|
+
self.jobs = job_runner
|
|
59
|
+
self.blocks = block_store
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _svc(request: Request) -> Services:
|
|
63
|
+
"""Returns the services of the app handling ``request``."""
|
|
64
|
+
return request.app.state.services
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def error(status: int, message: str, **headers: str) -> JSONResponse:
|
|
68
|
+
"""Builds the ``{"error": ...}`` reply of contracts 2."""
|
|
69
|
+
return JSONResponse({"error": message}, status_code=status, headers=headers)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# -- conditional responses --
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _etag_matches(request: Request, etag: str) -> bool:
|
|
76
|
+
"""Tells whether ``If-None-Match`` already holds this ETag."""
|
|
77
|
+
header = request.headers.get("if-none-match")
|
|
78
|
+
if not header:
|
|
79
|
+
return False
|
|
80
|
+
if header.strip() == "*":
|
|
81
|
+
return True
|
|
82
|
+
weak = etag.removeprefix("W/")
|
|
83
|
+
return any(
|
|
84
|
+
tag.strip().removeprefix("W/") == weak for tag in header.split(",")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _not_modified(etag: str, cache: str) -> Response:
|
|
89
|
+
return Response(
|
|
90
|
+
status_code=304, headers={"ETag": etag, "Cache-Control": cache}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _file_etag(st: os.stat_result) -> str:
|
|
95
|
+
return f'"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def file_response(
|
|
99
|
+
request: Request,
|
|
100
|
+
path: pathlib.Path,
|
|
101
|
+
*,
|
|
102
|
+
immutable: bool = False,
|
|
103
|
+
media_type: str | None = None,
|
|
104
|
+
) -> Response:
|
|
105
|
+
"""Serves a file with ETag, conditional GET, and Range support.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
request: The request (for ``If-None-Match`` and ``Range``).
|
|
109
|
+
path: The file.
|
|
110
|
+
immutable: Send the year-long immutable cache header (CAS files).
|
|
111
|
+
media_type: Override of the guessed content type.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
A 304, a 404 if the file is gone, or the file. A block file that a
|
|
115
|
+
recorder is still writing is sent up to its size at this moment, so
|
|
116
|
+
a growing file never contradicts its ``Content-Length``.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
st = path.stat()
|
|
120
|
+
except OSError:
|
|
121
|
+
return error(404, "not found")
|
|
122
|
+
cache = _IMMUTABLE if immutable else _NO_CACHE
|
|
123
|
+
etag = _file_etag(st)
|
|
124
|
+
if _etag_matches(request, etag):
|
|
125
|
+
return _not_modified(etag, cache)
|
|
126
|
+
headers = {"ETag": etag, "Cache-Control": cache}
|
|
127
|
+
if path.suffix == ".blk" and _unfinished(path):
|
|
128
|
+
return _partial_blk(path, st.st_size, headers)
|
|
129
|
+
return FileResponse(
|
|
130
|
+
path, headers=headers, stat_result=st, media_type=media_type
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _unfinished(path: pathlib.Path) -> bool:
|
|
135
|
+
"""Tells whether a block file still lacks its directory."""
|
|
136
|
+
try:
|
|
137
|
+
with open(path, "rb") as f:
|
|
138
|
+
head = f.read(blockfile.HEADER_SIZE)
|
|
139
|
+
return blockfile.Header.parse(head).dir_offset == 0
|
|
140
|
+
except (OSError, errors.FormatError):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _partial_blk(
|
|
145
|
+
path: pathlib.Path, size: int, headers: dict[str, str]
|
|
146
|
+
) -> Response:
|
|
147
|
+
"""Streams the first ``size`` bytes of a file that may be growing."""
|
|
148
|
+
|
|
149
|
+
def chunks():
|
|
150
|
+
left = size
|
|
151
|
+
with open(path, "rb") as f:
|
|
152
|
+
while left > 0:
|
|
153
|
+
data = f.read(min(_STREAM_CHUNK, left))
|
|
154
|
+
if not data:
|
|
155
|
+
break
|
|
156
|
+
left -= len(data)
|
|
157
|
+
yield data
|
|
158
|
+
|
|
159
|
+
return StreamingResponse(
|
|
160
|
+
chunks(),
|
|
161
|
+
media_type="application/octet-stream",
|
|
162
|
+
headers={**headers, "Content-Length": str(size)},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# -- library, runs, changes --
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def library_info(request: Request) -> Response:
|
|
170
|
+
"""``GET /api/library``."""
|
|
171
|
+
st = _svc(request).state
|
|
172
|
+
return JSONResponse(
|
|
173
|
+
{
|
|
174
|
+
"name": st.name,
|
|
175
|
+
"root": st.root_real,
|
|
176
|
+
"n_runs": st.n_runs(),
|
|
177
|
+
"seq": st.seq,
|
|
178
|
+
"writable": st.writable,
|
|
179
|
+
},
|
|
180
|
+
headers={"Cache-Control": _NO_CACHE},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def runs(request: Request) -> Response:
|
|
185
|
+
"""``GET /api/runs``: every row, ETag on ``seq``."""
|
|
186
|
+
st = _svc(request).state
|
|
187
|
+
gzipped = "gzip" in request.headers.get("accept-encoding", "")
|
|
188
|
+
seq, body = st.runs_body(gzipped=gzipped)
|
|
189
|
+
etag = f'W/"runs-{seq}"'
|
|
190
|
+
if _etag_matches(request, etag):
|
|
191
|
+
return _not_modified(etag, _NO_CACHE)
|
|
192
|
+
headers = {
|
|
193
|
+
"ETag": etag,
|
|
194
|
+
"Cache-Control": _NO_CACHE,
|
|
195
|
+
"Vary": "Accept-Encoding",
|
|
196
|
+
}
|
|
197
|
+
if gzipped:
|
|
198
|
+
headers["Content-Encoding"] = "gzip"
|
|
199
|
+
return Response(body, media_type=_JSON_TYPE, headers=headers)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def changes(request: Request) -> Response:
|
|
203
|
+
"""``GET /api/changes?since=``."""
|
|
204
|
+
try:
|
|
205
|
+
since = int(request.query_params.get("since", "0"))
|
|
206
|
+
except ValueError:
|
|
207
|
+
return error(400, "since must be an integer")
|
|
208
|
+
return JSONResponse(
|
|
209
|
+
_svc(request).state.changes(since),
|
|
210
|
+
headers={"Cache-Control": "no-store"},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# -- files and derived data --
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _resolve(
|
|
218
|
+
st: state_mod.LibraryState, target: security.Target
|
|
219
|
+
) -> pathlib.Path | None:
|
|
220
|
+
"""Maps a parsed path to a file inside the library, or ``None``."""
|
|
221
|
+
root = st.root
|
|
222
|
+
if target.kind == "cas":
|
|
223
|
+
candidate = root / target.path
|
|
224
|
+
elif target.file == manifest.MANIFEST_NAME:
|
|
225
|
+
run_dir = root / "runs" / target.run
|
|
226
|
+
candidate = run_dir / manifest.MANIFEST_NAME
|
|
227
|
+
if not candidate.is_file():
|
|
228
|
+
candidate = run_dir / manifest.PARTIAL_NAME
|
|
229
|
+
else:
|
|
230
|
+
candidate = root / "runs" / target.run / target.file
|
|
231
|
+
if not candidate.is_file():
|
|
232
|
+
return None
|
|
233
|
+
return security.confine(st.root_real, candidate)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _derived(
|
|
237
|
+
request: Request, target: security.Target
|
|
238
|
+
) -> tuple[pathlib.Path | None, Response | None]:
|
|
239
|
+
"""Resolves a derived path to a ready file, or to the reply to send."""
|
|
240
|
+
status, value = _svc(request).jobs.request(target.run, target.file)
|
|
241
|
+
match status:
|
|
242
|
+
case jobs.Status.READY:
|
|
243
|
+
assert isinstance(value, pathlib.Path)
|
|
244
|
+
return value, None
|
|
245
|
+
case jobs.Status.PENDING:
|
|
246
|
+
return None, JSONResponse(
|
|
247
|
+
{"status": "pending"},
|
|
248
|
+
status_code=202,
|
|
249
|
+
headers={"Retry-After": "1", "Cache-Control": "no-store"},
|
|
250
|
+
)
|
|
251
|
+
case jobs.Status.FAILED:
|
|
252
|
+
return None, error(500, f"could not derive: {value}")
|
|
253
|
+
case jobs.Status.MISSING:
|
|
254
|
+
return None, error(404, "no such run")
|
|
255
|
+
case _:
|
|
256
|
+
return None, error(404, _NOT_APPLICABLE)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def files(request: Request) -> Response:
|
|
260
|
+
"""``GET /files/<path>``."""
|
|
261
|
+
target = security.parse_path(request.path_params["path"])
|
|
262
|
+
if target is None:
|
|
263
|
+
return error(404, "not found")
|
|
264
|
+
st = _svc(request).state
|
|
265
|
+
if target.kind == "derived":
|
|
266
|
+
path, reply = _derived(request, target)
|
|
267
|
+
if reply is not None:
|
|
268
|
+
return reply
|
|
269
|
+
else:
|
|
270
|
+
path = _resolve(st, target)
|
|
271
|
+
if path is None:
|
|
272
|
+
return error(404, "not found")
|
|
273
|
+
return file_response(request, path, immutable=target.immutable)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _block_file(
|
|
277
|
+
request: Request,
|
|
278
|
+
) -> tuple[pathlib.Path | None, Response | None]:
|
|
279
|
+
"""Resolves the ``path=`` query of the block routes to a ``.blk`` file."""
|
|
280
|
+
target = security.parse_path(request.query_params.get("path", ""))
|
|
281
|
+
if target is None or not target.path.endswith(".blk"):
|
|
282
|
+
return None, error(400, "path must name a .blk file of the library")
|
|
283
|
+
if target.kind == "derived":
|
|
284
|
+
return _derived(request, target)
|
|
285
|
+
path = _resolve(_svc(request).state, target)
|
|
286
|
+
if path is None:
|
|
287
|
+
return None, error(404, "not found")
|
|
288
|
+
return path, None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def blk(request: Request) -> Response:
|
|
292
|
+
"""``GET /api/blk?path=``: header and directory of a block file."""
|
|
293
|
+
path, reply = _block_file(request)
|
|
294
|
+
if path is None:
|
|
295
|
+
assert reply is not None
|
|
296
|
+
return reply
|
|
297
|
+
try:
|
|
298
|
+
reader, etag = _svc(request).blocks.reader(path)
|
|
299
|
+
except FileNotFoundError:
|
|
300
|
+
return error(404, "not found")
|
|
301
|
+
except errors.FormatError as exc:
|
|
302
|
+
return error(422, str(exc))
|
|
303
|
+
if _etag_matches(request, etag):
|
|
304
|
+
return _not_modified(etag, _NO_CACHE)
|
|
305
|
+
return Response(
|
|
306
|
+
blocks.index_bytes(reader),
|
|
307
|
+
media_type="application/octet-stream",
|
|
308
|
+
headers={
|
|
309
|
+
"ETag": etag,
|
|
310
|
+
"Cache-Control": _NO_CACHE,
|
|
311
|
+
"X-Simscope-Live": "0" if reader.finished else "1",
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _int(value: str | None, name: str) -> int:
|
|
317
|
+
"""Parses a non-negative integer query value."""
|
|
318
|
+
try:
|
|
319
|
+
n = int(value or "")
|
|
320
|
+
except ValueError:
|
|
321
|
+
raise ValueError(f"{name} must be an integer") from None
|
|
322
|
+
if n < 0:
|
|
323
|
+
raise ValueError(f"{name} must not be negative")
|
|
324
|
+
return n
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def blocks_route(request: Request) -> Response:
|
|
328
|
+
"""``GET /api/blocks?path=&w=&envs=``: raw blocks of one window."""
|
|
329
|
+
path, reply = _block_file(request)
|
|
330
|
+
if path is None:
|
|
331
|
+
assert reply is not None
|
|
332
|
+
return reply
|
|
333
|
+
q = request.query_params
|
|
334
|
+
try:
|
|
335
|
+
window = _int(q.get("w"), "w")
|
|
336
|
+
envs = [_int(e, "envs") for e in q.get("envs", "").split(",") if e]
|
|
337
|
+
except ValueError as exc:
|
|
338
|
+
return error(400, str(exc))
|
|
339
|
+
if not envs:
|
|
340
|
+
return error(400, "envs must list at least one env")
|
|
341
|
+
if len(envs) > blocks.MAX_ENVS:
|
|
342
|
+
return error(400, f"at most {blocks.MAX_ENVS} envs per request")
|
|
343
|
+
try:
|
|
344
|
+
reader, _ = _svc(request).blocks.reader(path)
|
|
345
|
+
parts = blocks.read_blocks(reader, path, window, envs)
|
|
346
|
+
except FileNotFoundError:
|
|
347
|
+
return error(404, "not found")
|
|
348
|
+
except errors.FormatError as exc:
|
|
349
|
+
return error(422, str(exc))
|
|
350
|
+
except IndexError:
|
|
351
|
+
return error(400, "env out of range")
|
|
352
|
+
if parts is None:
|
|
353
|
+
return error(404, "window not written yet")
|
|
354
|
+
# A window never changes once it exists, whatever the file does next.
|
|
355
|
+
return Response(
|
|
356
|
+
b"".join(parts),
|
|
357
|
+
media_type="application/octet-stream",
|
|
358
|
+
headers={
|
|
359
|
+
"X-Simscope-Block-Lengths": ",".join(str(len(p)) for p in parts),
|
|
360
|
+
"Cache-Control": "private, max-age=3600, immutable"
|
|
361
|
+
if reader.finished
|
|
362
|
+
else _NO_CACHE,
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# -- writes --
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class BadOpError(ValueError):
|
|
371
|
+
"""An annotation op the client got wrong (400)."""
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _field(op: dict[str, Any], key: str, types: tuple[type, ...]) -> Any:
|
|
375
|
+
"""Returns ``op[key]`` if it has one of ``types`` (bool is not int)."""
|
|
376
|
+
value = op.get(key)
|
|
377
|
+
if not isinstance(value, types) or (
|
|
378
|
+
isinstance(value, bool) and bool not in types
|
|
379
|
+
):
|
|
380
|
+
names = "/".join(t.__name__ for t in types)
|
|
381
|
+
raise BadOpError(f"{key!r} must be {names}")
|
|
382
|
+
return value
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _opt_str(op: dict[str, Any], key: str) -> str | None:
|
|
386
|
+
if op.get(key) is None:
|
|
387
|
+
return None
|
|
388
|
+
return _field(op, key, (str,))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _event_changes(
|
|
392
|
+
ann: annotations.Annotations, op: dict[str, Any]
|
|
393
|
+
) -> dict[str, Any]:
|
|
394
|
+
"""Reads the fields an ``event_update`` op changes.
|
|
395
|
+
|
|
396
|
+
Moving an instant (``t0 == t1``) without naming ``t1`` keeps it an
|
|
397
|
+
instant, which is what a label is.
|
|
398
|
+
|
|
399
|
+
Raises:
|
|
400
|
+
BadOpError: If a field has the wrong type.
|
|
401
|
+
KeyError: If the op names no event.
|
|
402
|
+
"""
|
|
403
|
+
event_id = _field(op, "id", (str,))
|
|
404
|
+
event = next((e for e in ann.events if e.id == event_id), None)
|
|
405
|
+
if event is None:
|
|
406
|
+
raise KeyError(event_id)
|
|
407
|
+
changes: dict[str, Any] = {}
|
|
408
|
+
if "label" in op:
|
|
409
|
+
changes["label"] = _field(op, "label", (str,))
|
|
410
|
+
if "t0" in op:
|
|
411
|
+
changes["t0"] = _field(op, "t0", (int, float))
|
|
412
|
+
if event.t0 == event.t1 and "t1" not in op:
|
|
413
|
+
changes["t1"] = changes["t0"]
|
|
414
|
+
if "t1" in op:
|
|
415
|
+
t1 = op["t1"]
|
|
416
|
+
changes["t1"] = (
|
|
417
|
+
changes.get("t0", event.t0)
|
|
418
|
+
if t1 is None
|
|
419
|
+
else _field(op, "t1", (int, float))
|
|
420
|
+
)
|
|
421
|
+
return changes
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def apply_op(
|
|
425
|
+
ann: annotations.Annotations, op: dict[str, Any], author: str | None
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Applies one annotation op of contracts 2.1 and 8.1 in memory.
|
|
428
|
+
|
|
429
|
+
``tag_add``, ``tag_remove``, ``status`` and ``flag`` were removed in
|
|
430
|
+
viewer v3.1 (decision D23) and are rejected like any unknown op.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
ann: The run's annotations.
|
|
434
|
+
op: The decoded request body.
|
|
435
|
+
author: Author of records the op creates.
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
BadOpError: If the op is unsupported or malformed.
|
|
439
|
+
KeyError: If ``remove`` or ``event_update`` names no record.
|
|
440
|
+
"""
|
|
441
|
+
kind = op.get("op")
|
|
442
|
+
if kind == "favorite":
|
|
443
|
+
ann.set_favorite(_field(op, "value", (bool,)))
|
|
444
|
+
elif kind == "rate":
|
|
445
|
+
value = _field(op, "value", (int, float))
|
|
446
|
+
if not 1 <= value <= 5:
|
|
447
|
+
raise BadOpError("'value' must be 1 to 5")
|
|
448
|
+
ann.rate(value, scale="stars5", author=author)
|
|
449
|
+
elif kind == "group":
|
|
450
|
+
try:
|
|
451
|
+
ann.set_group(_opt_str(op, "value"))
|
|
452
|
+
except ValueError as exc:
|
|
453
|
+
raise BadOpError(str(exc)) from exc
|
|
454
|
+
elif kind == "note_add":
|
|
455
|
+
text = _field(op, "text", (str,))
|
|
456
|
+
if not text.strip():
|
|
457
|
+
raise BadOpError("'text' must not be empty")
|
|
458
|
+
ann.add_note(text, author=author)
|
|
459
|
+
elif kind == "event_add":
|
|
460
|
+
t1 = op.get("t1")
|
|
461
|
+
env = op.get("env")
|
|
462
|
+
if t1 is not None:
|
|
463
|
+
t1 = _field(op, "t1", (int, float))
|
|
464
|
+
if env is not None:
|
|
465
|
+
env = _field(op, "env", (int,))
|
|
466
|
+
ann.add_event(
|
|
467
|
+
_field(op, "type", (str,)),
|
|
468
|
+
t0=_field(op, "t0", (int, float)),
|
|
469
|
+
t1=t1,
|
|
470
|
+
label=_field(op, "label", (str,)) if "label" in op else "",
|
|
471
|
+
env=env,
|
|
472
|
+
author=author,
|
|
473
|
+
)
|
|
474
|
+
elif kind == "event_update":
|
|
475
|
+
changes = _event_changes(ann, op)
|
|
476
|
+
ann.update_event(op["id"], **changes)
|
|
477
|
+
elif kind == "remove":
|
|
478
|
+
if not ann.remove(_field(op, "id", (str,))):
|
|
479
|
+
raise KeyError(op["id"])
|
|
480
|
+
else:
|
|
481
|
+
raise BadOpError(f"unsupported op {kind!r}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _write_annotations(
|
|
485
|
+
st: state_mod.LibraryState, name: str, op: dict[str, Any]
|
|
486
|
+
) -> dict[str, Any]:
|
|
487
|
+
"""Applies one op to a run's sidecar under its lock and saves it."""
|
|
488
|
+
if op.get("op") == "group" and isinstance(op.get("value"), str):
|
|
489
|
+
# A run may only join a group that exists: make it, and use the
|
|
490
|
+
# spelling the library already has.
|
|
491
|
+
try:
|
|
492
|
+
op = {**op, "value": st.ensure_group(op["value"])}
|
|
493
|
+
except state_mod.GroupError as exc:
|
|
494
|
+
raise BadOpError(str(exc)) from exc
|
|
495
|
+
return st.edit_annotations(name, lambda ann: apply_op(ann, op, st.author))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def _write_request(
|
|
499
|
+
request: Request,
|
|
500
|
+
) -> tuple[dict[str, Any] | None, Response | None]:
|
|
501
|
+
"""Checks a write and decodes its body.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
``(body, None)`` for a good request, else ``(None, reply)``.
|
|
505
|
+
"""
|
|
506
|
+
st = _svc(request).state
|
|
507
|
+
why = security.check_write(request.headers, st.token)
|
|
508
|
+
if why is not None:
|
|
509
|
+
return None, error(403, why)
|
|
510
|
+
if not st.writable:
|
|
511
|
+
return None, error(403, "the library is read-only")
|
|
512
|
+
body = await request.body()
|
|
513
|
+
if len(body) > _MAX_BODY:
|
|
514
|
+
return None, error(413, "request body too large")
|
|
515
|
+
try:
|
|
516
|
+
obj = json.loads(body)
|
|
517
|
+
except ValueError:
|
|
518
|
+
return None, error(400, "body must be JSON")
|
|
519
|
+
if not isinstance(obj, dict):
|
|
520
|
+
return None, error(400, "body must be a JSON object")
|
|
521
|
+
return obj, None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
async def annotate(request: Request) -> Response:
|
|
525
|
+
"""``POST /api/runs/<name>/annotations``."""
|
|
526
|
+
st = _svc(request).state
|
|
527
|
+
name = request.path_params["name"]
|
|
528
|
+
obj, reply = await _write_request(request)
|
|
529
|
+
if reply is not None:
|
|
530
|
+
return reply
|
|
531
|
+
assert obj is not None
|
|
532
|
+
try:
|
|
533
|
+
manifest.validate_run_name(name)
|
|
534
|
+
except ValueError:
|
|
535
|
+
return error(404, "no such run")
|
|
536
|
+
if st.info(name) is None:
|
|
537
|
+
return error(404, "no such run")
|
|
538
|
+
try:
|
|
539
|
+
result = await run_in_threadpool(_write_annotations, st, name, obj)
|
|
540
|
+
except BadOpError as exc:
|
|
541
|
+
return error(400, str(exc))
|
|
542
|
+
except KeyError as exc:
|
|
543
|
+
return error(404, f"no record {exc.args[0]!r}")
|
|
544
|
+
except (errors.FormatError, FileNotFoundError) as exc:
|
|
545
|
+
return error(409, str(exc))
|
|
546
|
+
except ValueError as exc: # the annotations module's own validation
|
|
547
|
+
return error(400, str(exc))
|
|
548
|
+
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
async def rename_run(request: Request) -> Response:
|
|
552
|
+
"""``POST /api/runs/<name>/rename`` with ``{"to": "<new name>"}``."""
|
|
553
|
+
st = _svc(request).state
|
|
554
|
+
name = request.path_params["name"]
|
|
555
|
+
obj, reply = await _write_request(request)
|
|
556
|
+
if reply is not None:
|
|
557
|
+
return reply
|
|
558
|
+
assert obj is not None
|
|
559
|
+
new = obj.get("to")
|
|
560
|
+
if not isinstance(new, str):
|
|
561
|
+
return error(400, "to must be a string")
|
|
562
|
+
try:
|
|
563
|
+
manifest.validate_run_name(new)
|
|
564
|
+
except ValueError as exc:
|
|
565
|
+
return error(400, str(exc))
|
|
566
|
+
try:
|
|
567
|
+
manifest.validate_run_name(name)
|
|
568
|
+
except ValueError:
|
|
569
|
+
return error(404, "no such run")
|
|
570
|
+
info = st.info(name)
|
|
571
|
+
if info is None:
|
|
572
|
+
return error(404, "no such run")
|
|
573
|
+
if info.status == "recording":
|
|
574
|
+
return error(409, "the run is still recording")
|
|
575
|
+
if st.info(new) is not None:
|
|
576
|
+
return error(409, f"a run named {new!r} already exists")
|
|
577
|
+
try:
|
|
578
|
+
await run_in_threadpool(st.rename_run, name, new)
|
|
579
|
+
except FileNotFoundError:
|
|
580
|
+
return error(404, "no such run")
|
|
581
|
+
except FileExistsError:
|
|
582
|
+
return error(409, f"a run named {new!r} already exists")
|
|
583
|
+
except (ValueError, errors.FormatError, OSError) as exc:
|
|
584
|
+
return error(409, str(exc))
|
|
585
|
+
return JSONResponse({"name": new}, headers={"Cache-Control": "no-store"})
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def groups(request: Request) -> Response:
|
|
589
|
+
"""``GET /api/groups``."""
|
|
590
|
+
return JSONResponse(
|
|
591
|
+
_svc(request).state.groups_view(),
|
|
592
|
+
headers={"Cache-Control": "no-store"},
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def change_groups(request: Request) -> Response:
|
|
597
|
+
"""``POST /api/groups``."""
|
|
598
|
+
st = _svc(request).state
|
|
599
|
+
obj, reply = await _write_request(request)
|
|
600
|
+
if reply is not None:
|
|
601
|
+
return reply
|
|
602
|
+
assert obj is not None
|
|
603
|
+
try:
|
|
604
|
+
result = await run_in_threadpool(st.change_groups, obj)
|
|
605
|
+
except state_mod.GroupError as exc:
|
|
606
|
+
return error(exc.status, str(exc))
|
|
607
|
+
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# -- export --
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
_RUN_LIST = re.compile(r"^[A-Za-z0-9._,-]+$")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def export_route(request: Request) -> Response:
|
|
617
|
+
"""``GET /api/export?runs=&layout=&arrange=&ui=&envs=``: a download."""
|
|
618
|
+
st = _svc(request).state
|
|
619
|
+
q = request.query_params
|
|
620
|
+
raw = q.get("runs", "")
|
|
621
|
+
if not raw or not _RUN_LIST.match(raw):
|
|
622
|
+
return error(400, "runs must list run names")
|
|
623
|
+
names = raw.split(",")
|
|
624
|
+
try:
|
|
625
|
+
for name in names:
|
|
626
|
+
manifest.validate_run_name(name)
|
|
627
|
+
except ValueError as exc:
|
|
628
|
+
return error(400, str(exc))
|
|
629
|
+
if len(set(names)) != len(names):
|
|
630
|
+
return error(400, "duplicate run names")
|
|
631
|
+
if any(st.info(n) is None for n in names):
|
|
632
|
+
return error(404, "no such run")
|
|
633
|
+
layout = q.get("layout") or ("single" if len(names) == 1 else "grid")
|
|
634
|
+
if layout not in ("single", "grid", "compare"):
|
|
635
|
+
return error(400, "layout must be single, grid or compare")
|
|
636
|
+
arrange = q.get("arrange") or None
|
|
637
|
+
if arrange is not None and arrange not in export.ARRANGEMENTS:
|
|
638
|
+
return error(400, "arrange must be side, stack or grid")
|
|
639
|
+
if layout != "compare":
|
|
640
|
+
arrange = None # only a compare page has an arrangement
|
|
641
|
+
ui = q.get("ui", "lean")
|
|
642
|
+
if ui not in ("lean", "full"):
|
|
643
|
+
return error(400, "ui must be lean or full")
|
|
644
|
+
envs = None
|
|
645
|
+
if q.get("envs"):
|
|
646
|
+
try:
|
|
647
|
+
envs = [_int(e, "envs") for e in q["envs"].split(",") if e]
|
|
648
|
+
except ValueError as exc:
|
|
649
|
+
return error(400, str(exc))
|
|
650
|
+
kwargs: dict[str, Any] = {}
|
|
651
|
+
supported = inspect.signature(export.export_html).parameters
|
|
652
|
+
for key, value, default in (("ui", ui, "lean"), ("envs", envs, None)):
|
|
653
|
+
if key in supported:
|
|
654
|
+
kwargs[key] = value
|
|
655
|
+
elif value != default:
|
|
656
|
+
return error(501, f"export option {key!r} is not available yet")
|
|
657
|
+
with tempfile.TemporaryDirectory(prefix="simscope-export-") as tmp:
|
|
658
|
+
out = pathlib.Path(tmp) / "export.html"
|
|
659
|
+
try:
|
|
660
|
+
export.export_html(
|
|
661
|
+
st.root,
|
|
662
|
+
names,
|
|
663
|
+
out,
|
|
664
|
+
layout=layout,
|
|
665
|
+
arrange=arrange,
|
|
666
|
+
**kwargs,
|
|
667
|
+
)
|
|
668
|
+
except ValueError as exc: # still recording, bad layout for the runs
|
|
669
|
+
return error(400, str(exc))
|
|
670
|
+
except FileNotFoundError as exc:
|
|
671
|
+
return error(404, str(exc))
|
|
672
|
+
data = out.read_bytes()
|
|
673
|
+
filename = f"{names[0]}.html" if len(names) == 1 else "simscope-export.html"
|
|
674
|
+
return Response(
|
|
675
|
+
data,
|
|
676
|
+
media_type="text/html; charset=utf-8",
|
|
677
|
+
headers={
|
|
678
|
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
679
|
+
"Cache-Control": "no-store",
|
|
680
|
+
},
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# -- page and assets --
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def index(request: Request) -> Response:
|
|
688
|
+
"""``GET /``: the app page with its boot block."""
|
|
689
|
+
st = _svc(request).state
|
|
690
|
+
page = static.index_page(
|
|
691
|
+
token=st.token, library=st.name, writable=st.writable
|
|
692
|
+
)
|
|
693
|
+
return Response(
|
|
694
|
+
page,
|
|
695
|
+
media_type="text/html; charset=utf-8",
|
|
696
|
+
headers={"Cache-Control": "no-store"},
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def asset(request: Request) -> Response:
|
|
701
|
+
"""``GET /assets/<name>``: a built bundle from ``simscope/_assets``."""
|
|
702
|
+
name = request.path_params["name"]
|
|
703
|
+
media = static.ASSET_TYPES.get(name)
|
|
704
|
+
path = static.ASSETS_DIR / name
|
|
705
|
+
if media is None or not path.is_file():
|
|
706
|
+
return error(404, "not found")
|
|
707
|
+
return file_response(request, path, media_type=media)
|