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.
Files changed (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. 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)