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,751 @@
1
+ """What the server knows about a library, and how it learns of changes.
2
+
3
+ One :class:`LibraryState` per app. It keeps every run's index row in memory
4
+ (the row set is small: contracts 2 says all rows, no paging), the encoded
5
+ ``/api/runs`` body for the current ``seq``, and a short log of which runs
6
+ changed at which ``seq`` for ``/api/changes``. A single ``watchfiles`` thread
7
+ is the only writer of that state besides :meth:`LibraryState.touch`, which the
8
+ annotation route calls so a client sees its own write at once.
9
+ """
10
+
11
+ import collections
12
+ import dataclasses
13
+ import gzip
14
+ import json
15
+ import logging
16
+ import os
17
+ import pathlib
18
+ import threading
19
+ import time
20
+ from collections.abc import Callable, Iterable
21
+ from typing import Any
22
+
23
+ from simscope import annotations, derived, highlights, index, library
24
+ from simscope.io import errors, manifest
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _LOG_LIMIT = 512
29
+ """Change-log entries kept; a client further behind gets a full resync."""
30
+ _WATCH_DEBOUNCE_MS = 150
31
+ _WATCH_STEP_MS = 30
32
+ _WATCH_TIMEOUT_MS = 1000
33
+ _SAFETY_RESCAN_EVERY = 5
34
+ """Idle watcher timeouts between full rescans (a missed-event backstop)."""
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class Extras:
39
+ """Row fields that the SQLite index does not hold.
40
+
41
+ Attributes:
42
+ simulator: ``source.simulator`` of the manifest, or ``None``.
43
+ importer: ``source.importer`` of the manifest, or ``None``.
44
+ streams: Stream names, in manifest order.
45
+ n_highlights: Highlights found, or ``None`` until computed.
46
+ """
47
+
48
+ simulator: str | None = None
49
+ importer: str | None = None
50
+ streams: tuple[str, ...] = ()
51
+ n_highlights: int | None = None
52
+
53
+
54
+ def _read_extras(run_dir: pathlib.Path) -> Extras:
55
+ """Reads ``source`` and the stream names from a run's manifest."""
56
+ for name in (manifest.MANIFEST_NAME, manifest.PARTIAL_NAME):
57
+ try:
58
+ obj = json.loads((run_dir / name).read_bytes())
59
+ except (OSError, ValueError):
60
+ continue
61
+ if not isinstance(obj, dict):
62
+ continue
63
+ source = obj.get("source")
64
+ source = source if isinstance(source, dict) else {}
65
+ streams = obj.get("streams")
66
+ simulator, importer = source.get("simulator"), source.get("importer")
67
+ return Extras(
68
+ simulator if isinstance(simulator, str) else None,
69
+ importer if isinstance(importer, str) else None,
70
+ tuple(streams) if isinstance(streams, dict) else (),
71
+ )
72
+ return Extras()
73
+
74
+
75
+ class GroupError(Exception):
76
+ """A group operation the library cannot do.
77
+
78
+ Attributes:
79
+ status: The HTTP status that describes it: 400 for a bad name or
80
+ index, 404 for a group that does not exist, 409 for a name that
81
+ is taken or a ``groups.json`` that cannot be read.
82
+ """
83
+
84
+ def __init__(self, status: int, message: str) -> None:
85
+ """Creates the error.
86
+
87
+ Args:
88
+ status: HTTP status code.
89
+ message: What went wrong.
90
+ """
91
+ super().__init__(message)
92
+ self.status = status
93
+
94
+
95
+ def _group_name(op: dict[str, Any], key: str) -> str:
96
+ """Reads and validates a group name from an op.
97
+
98
+ Raises:
99
+ GroupError: With status 400 if it is missing or invalid.
100
+ """
101
+ try:
102
+ return annotations.check_group_name(op.get(key))
103
+ except ValueError as exc:
104
+ raise GroupError(400, f"{key!r}: {exc}") from exc
105
+
106
+
107
+ def _encode(obj: Any) -> bytes:
108
+ """Serializes compact JSON."""
109
+ return json.dumps(obj, separators=(",", ":"), allow_nan=False).encode()
110
+
111
+
112
+ class LibraryState:
113
+ """The in-memory view of one library directory.
114
+
115
+ Attributes:
116
+ root: The library folder as given.
117
+ root_real: Its real path (symlinks resolved), for confinement.
118
+ name: Folder name, shown as the library name.
119
+ author: Author of annotations made through the API.
120
+ token: The per-process token that writes must present.
121
+ writable: Whether the folder accepts writes.
122
+ lib: The :class:`simscope.library.Library` handle.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ root: pathlib.Path,
128
+ *,
129
+ author: str | None,
130
+ token: str,
131
+ ) -> None:
132
+ """Scans the library once.
133
+
134
+ Args:
135
+ root: The library folder (must exist).
136
+ author: Author of new annotations; the OS user if ``None``.
137
+ token: The write token.
138
+ """
139
+ self.root = pathlib.Path(root)
140
+ self.root_real = os.path.realpath(self.root)
141
+ self.name = pathlib.Path(self.root_real).name or str(self.root)
142
+ self.author = author
143
+ self.token = token
144
+ self.writable = os.access(self.root, os.W_OK)
145
+ self.lib = library.Library(self.root)
146
+ self.index = index.Index(self.root)
147
+ self._lock = threading.RLock()
148
+ self._infos: dict[str, library.RunInfo] = {}
149
+ self._extras: dict[str, Extras] = {}
150
+ self._rows: dict[str, bytes] = {}
151
+ self._body: tuple[int, bytes] | None = None
152
+ self._gzipped: tuple[int, bytes] | None = None
153
+ self._log: list[tuple[int, frozenset[str], frozenset[str]]] = []
154
+ self._run_locks: dict[str, threading.Lock] = {}
155
+ self._groups_lock = threading.Lock()
156
+ # Wall-clock milliseconds: a restarted server starts above any seq
157
+ # an old client remembers, so its next poll resyncs.
158
+ self.seq = time.time_ns() // 1_000_000
159
+ self._floor = self.seq
160
+ """Changes after this seq are all in the log."""
161
+ self._stop = threading.Event()
162
+ self._threads: list[threading.Thread] = []
163
+ self._scan(None)
164
+ self.runs_body() # build the first reply now, not on the first request
165
+
166
+ # -- scanning --
167
+
168
+ def _highlight_count(self, info: library.RunInfo) -> int | None:
169
+ """Counts cached highlights of a run, or ``None`` if not cached."""
170
+ path = derived.cache_root(self.root) / info.id / derived.HIGHLIGHTS
171
+ try:
172
+ doc = json.loads(path.read_bytes())
173
+ if doc["detector"] != highlights.DETECTOR_VERSION:
174
+ return None # computed by an older detector
175
+ return len(doc["highlights"])
176
+ except (OSError, ValueError, KeyError, TypeError):
177
+ return None
178
+
179
+ def _load_extras(self, info: library.RunInfo) -> Extras:
180
+ """Builds the extra row fields of one run."""
181
+ extras = _read_extras(self.lib.runs_dir / info.name)
182
+ if info.status == "complete" and info.id:
183
+ extras = dataclasses.replace(
184
+ extras, n_highlights=self._highlight_count(info)
185
+ )
186
+ return extras
187
+
188
+ def _scan(self, names: Iterable[str] | None) -> tuple[set[str], set[str]]:
189
+ """Refreshes the index and diffs it against what is in memory.
190
+
191
+ Args:
192
+ names: Runs whose files were touched, or ``None`` to check all.
193
+
194
+ Returns:
195
+ ``(changed, removed)`` run names. A run that is still recording
196
+ counts as changed whenever it was touched, because its blocks
197
+ grew even when its manifest did not.
198
+ """
199
+ with self._lock:
200
+ self.index.refresh()
201
+ changed: set[str] = set()
202
+ removed: set[str] = set()
203
+ if names is None:
204
+ fresh = {i.name: i for i in self.index.query(limit=None)}
205
+ removed = set(self._infos) - set(fresh)
206
+ changed = {
207
+ n
208
+ for n, i in fresh.items()
209
+ if self._infos.get(n) != i or i.status == "recording"
210
+ }
211
+ touched = fresh
212
+ else:
213
+ touched = {}
214
+ for name in names:
215
+ info = self.index.get(name)
216
+ if info is None:
217
+ if name in self._infos:
218
+ removed.add(name)
219
+ continue
220
+ touched[name] = info
221
+ if self._infos.get(name) != info or (
222
+ info.status == "recording"
223
+ ):
224
+ changed.add(name)
225
+ for name in removed:
226
+ self._infos.pop(name, None)
227
+ self._extras.pop(name, None)
228
+ self._rows.pop(name, None)
229
+ for name in changed:
230
+ info = touched[name]
231
+ self._infos[name] = info
232
+ self._extras[name] = self._load_extras(info)
233
+ self._rows.pop(name, None)
234
+ if changed or removed:
235
+ self._bump(changed, removed)
236
+ return changed, removed
237
+
238
+ def _bump(self, changed: set[str], removed: set[str]) -> None:
239
+ """Advances ``seq`` and logs what changed (lock held)."""
240
+ self.seq += 1
241
+ self._log.append((self.seq, frozenset(changed), frozenset(removed)))
242
+ if len(self._log) > _LOG_LIMIT:
243
+ self._floor = self._log[-_LOG_LIMIT - 1][0]
244
+ del self._log[:-_LOG_LIMIT]
245
+ self._body = self._gzipped = None
246
+
247
+ def rescan(self, names: Iterable[str] | None = None) -> bool:
248
+ """Re-reads the library now and returns whether anything changed.
249
+
250
+ Args:
251
+ names: Runs to re-check, or ``None`` for all of them.
252
+
253
+ Returns:
254
+ True if ``seq`` advanced.
255
+ """
256
+ changed, removed = self._scan(names)
257
+ return bool(changed or removed)
258
+
259
+ def touch(self, name: str) -> None:
260
+ """Re-reads one run after the server itself changed its files."""
261
+ self._scan([name])
262
+
263
+ def set_highlights(self, name: str, count: int) -> None:
264
+ """Records a finished highlight computation and announces it."""
265
+ with self._lock:
266
+ old = self._extras.get(name)
267
+ if old is None or old.n_highlights == count:
268
+ return
269
+ self._extras[name] = dataclasses.replace(old, n_highlights=count)
270
+ self._rows.pop(name, None)
271
+ self._bump({name}, set())
272
+
273
+ # -- views --
274
+
275
+ def run_lock(self, name: str) -> threading.Lock:
276
+ """Returns the lock that serializes writes to one run's sidecar."""
277
+ with self._lock:
278
+ return self._run_locks.setdefault(name, threading.Lock())
279
+
280
+ def edit_annotations(
281
+ self,
282
+ name: str,
283
+ edit: Callable[[annotations.Annotations], None],
284
+ *,
285
+ announce: bool = True,
286
+ ) -> dict[str, Any]:
287
+ """Loads, changes and saves one run's sidecar under its lock.
288
+
289
+ Args:
290
+ name: Run name.
291
+ edit: Changes the loaded annotations in place.
292
+ announce: Re-read the run and advance ``seq`` at once. Turn it
293
+ off to write many runs, then call :meth:`rescan` once.
294
+
295
+ Returns:
296
+ The saved ``annotations.json`` object.
297
+
298
+ Raises:
299
+ FileNotFoundError: If the run is gone.
300
+ errors.FormatError: If its manifest or sidecar is invalid.
301
+ """
302
+ with self.run_lock(name):
303
+ m = manifest.read_manifest(self.lib.run_dir(name))
304
+ ann = annotations.Annotations.load(
305
+ self.lib.run_dir(name), m.id, m.dt, m.n_frames
306
+ )
307
+ edit(ann)
308
+ ann.save()
309
+ result = ann.to_json()
310
+ if announce:
311
+ self.touch(name)
312
+ return result
313
+
314
+ def rename_run(self, old: str, new: str) -> None:
315
+ """Renames a run and announces it at once.
316
+
317
+ The old name goes into ``removed`` and the new one into ``changed``
318
+ of ``/api/changes``, without waiting for the file watcher.
319
+
320
+ Args:
321
+ old: The run's current name.
322
+ new: Its new name.
323
+
324
+ Raises:
325
+ ValueError: If a name is invalid or the run is recording.
326
+ FileNotFoundError: If there is no run ``old``.
327
+ FileExistsError: If a run ``new`` exists.
328
+ OSError: If the folder cannot be renamed.
329
+ errors.FormatError: If the manifest is invalid.
330
+ """
331
+ with self.run_lock(old): # no sidecar write while the folder moves
332
+ self.lib.rename(old, new)
333
+ self._scan([old, new])
334
+
335
+ # -- groups --
336
+
337
+ def _load_groups(
338
+ self,
339
+ ) -> tuple[list[annotations.Group], dict[str, Any]]:
340
+ """Reads ``groups.json``, failing as a 409 if it is invalid."""
341
+ try:
342
+ return annotations.load_groups(self.root)
343
+ except errors.FormatError as exc:
344
+ raise GroupError(409, str(exc)) from exc
345
+
346
+ def _members(self, name: str) -> list[str]:
347
+ """Returns the runs that belong to a group (ignoring case)."""
348
+ key = name.casefold()
349
+ with self._lock:
350
+ return sorted(
351
+ n
352
+ for n, i in self._infos.items()
353
+ if i.group is not None and i.group.casefold() == key
354
+ )
355
+
356
+ def _group_order(
357
+ self, listed: list[annotations.Group]
358
+ ) -> list[tuple[str, str | None]]:
359
+ """Lists every group in library order as ``(name, created)``.
360
+
361
+ The listed groups come first; groups that runs use but the file
362
+ lacks follow, alphabetically.
363
+ """
364
+ known = {g.name.casefold() for g in listed}
365
+ with self._lock:
366
+ used = sorted(
367
+ {i.group for i in self._infos.values() if i.group},
368
+ key=lambda g: (g.casefold(), g),
369
+ )
370
+ order: list[tuple[str, str | None]] = [
371
+ (g.name, g.created) for g in listed
372
+ ]
373
+ for name in used:
374
+ if name.casefold() not in known:
375
+ known.add(name.casefold())
376
+ order.append((name, None))
377
+ return order
378
+
379
+ def groups_view(self) -> dict[str, Any]:
380
+ """Builds the body of ``GET /api/groups``.
381
+
382
+ Returns:
383
+ ``{"groups": [{"name", "count"}], "ungrouped": int}`` in library
384
+ order. An unreadable ``groups.json`` counts as no listed groups.
385
+ """
386
+ try:
387
+ listed, _ = annotations.load_groups(self.root)
388
+ except errors.FormatError:
389
+ logger.exception("ignoring invalid groups.json")
390
+ listed = []
391
+ order = self._group_order(listed)
392
+ counts: collections.Counter[str] = collections.Counter()
393
+ ungrouped = 0
394
+ with self._lock:
395
+ for info in self._infos.values():
396
+ if info.group:
397
+ counts[info.group.casefold()] += 1
398
+ else:
399
+ ungrouped += 1
400
+ return {
401
+ "groups": [
402
+ {"name": n, "count": counts[n.casefold()]} for n, _ in order
403
+ ],
404
+ "ungrouped": ungrouped,
405
+ }
406
+
407
+ def _save_order(
408
+ self,
409
+ order: list[tuple[str, str | None]],
410
+ extra: dict[str, Any],
411
+ listed: list[annotations.Group],
412
+ ) -> None:
413
+ """Writes ``groups.json`` with the groups in ``order``."""
414
+ old = {g.name.casefold(): g for g in listed}
415
+ now = annotations.utc_now()
416
+ out = []
417
+ for name, created in order:
418
+ group = old.get(name.casefold())
419
+ if group is None:
420
+ group = annotations.Group(name=name, created=created or now)
421
+ else:
422
+ group.name = name
423
+ out.append(group)
424
+ annotations.save_groups(self.root, out, extra)
425
+
426
+ def ensure_group(self, name: str) -> str:
427
+ """Makes sure a group exists and returns its canonical spelling.
428
+
429
+ Args:
430
+ name: The group's name, in any case.
431
+
432
+ Returns:
433
+ The listed spelling if the group exists (listed, or used by a
434
+ run), else the name as given, which is now listed last.
435
+
436
+ Raises:
437
+ GroupError: For an invalid name or an unreadable ``groups.json``.
438
+ """
439
+ try:
440
+ name = annotations.check_group_name(name)
441
+ except ValueError as exc:
442
+ raise GroupError(400, str(exc)) from exc
443
+ with self._groups_lock:
444
+ listed, extra = self._load_groups()
445
+ order = self._group_order(listed)
446
+ for known, _ in order:
447
+ if known.casefold() == name.casefold():
448
+ if known.casefold() not in {
449
+ g.name.casefold() for g in listed
450
+ }:
451
+ self._save_order(order, extra, listed)
452
+ self.bump()
453
+ return known
454
+ self._save_order([*order, (name, None)], extra, listed)
455
+ self.bump()
456
+ return name
457
+
458
+ def change_groups(self, op: dict[str, Any]) -> dict[str, Any]:
459
+ """Applies one op of ``POST /api/groups``.
460
+
461
+ Args:
462
+ op: ``{"op": "create", "name"}``, ``{"op": "rename", "name",
463
+ "to"}``, ``{"op": "delete", "name"}`` or ``{"op": "move",
464
+ "name", "index"}``.
465
+
466
+ Returns:
467
+ The new body of ``GET /api/groups``.
468
+
469
+ Raises:
470
+ GroupError: If the op is malformed, names no group, or collides
471
+ with an existing name.
472
+ """
473
+ kind = op.get("op")
474
+ if kind not in ("create", "rename", "delete", "move"):
475
+ raise GroupError(400, f"unsupported op {kind!r}")
476
+ with self._groups_lock:
477
+ listed, extra = self._load_groups()
478
+ order = self._group_order(listed)
479
+ names = {n.casefold(): n for n, _ in order}
480
+ if kind == "create":
481
+ new = _group_name(op, "name")
482
+ if new.casefold() in {g.name.casefold() for g in listed}:
483
+ raise GroupError(409, f"group {new!r} exists")
484
+ # A group that runs use but the file lacks is only listed now.
485
+ new = names.get(new.casefold(), new)
486
+ order = [(g.name, g.created) for g in listed] + [(new, None)]
487
+ self._save_order(order, extra, listed)
488
+ self.bump()
489
+ else:
490
+ self._change_existing(kind, op, order, names, extra, listed)
491
+ return self.groups_view()
492
+
493
+ def _change_existing(
494
+ self,
495
+ kind: str,
496
+ op: dict[str, Any],
497
+ order: list[tuple[str, str | None]],
498
+ names: dict[str, str],
499
+ extra: dict[str, Any],
500
+ listed: list[annotations.Group],
501
+ ) -> None:
502
+ """Renames, deletes or moves a group (the groups lock is held)."""
503
+ name = _group_name(op, "name")
504
+ if name.casefold() not in names:
505
+ raise GroupError(404, f"no group {name!r}")
506
+ name = names[name.casefold()]
507
+ position = [n for n, _ in order].index(name)
508
+ members = self._members(name)
509
+ if kind == "move":
510
+ index_ = op.get("index")
511
+ if not isinstance(index_, int) or isinstance(index_, bool):
512
+ raise GroupError(400, "'index' must be an integer")
513
+ entry = order.pop(position)
514
+ order.insert(max(0, min(index_, len(order))), entry)
515
+ self._save_order(order, extra, listed)
516
+ self.bump()
517
+ return
518
+ if kind == "rename":
519
+ to = _group_name(op, "to")
520
+ if to.casefold() != name.casefold() and to.casefold() in names:
521
+ raise GroupError(409, f"group {to!r} exists")
522
+ order[position] = (to, order[position][1])
523
+ self._save_order(order, extra, listed)
524
+ self._rewrite_members(members, to)
525
+ else: # delete
526
+ del order[position]
527
+ self._save_order(order, extra, listed)
528
+ self._rewrite_members(members, None)
529
+ self.bump()
530
+
531
+ def _rewrite_members(self, members: list[str], group: str | None) -> None:
532
+ """Writes ``marks.group`` into the sidecars of ``members``.
533
+
534
+ Each file is replaced atomically. The rows are re-read once at the
535
+ end, so clients see one change.
536
+ """
537
+ done = []
538
+ for name in members:
539
+ try:
540
+ self.edit_annotations(
541
+ name, lambda ann: ann.set_group(group), announce=False
542
+ )
543
+ except (FileNotFoundError, errors.FormatError):
544
+ logger.exception("cannot move %s to group %r", name, group)
545
+ continue
546
+ done.append(name)
547
+ self.rescan(done)
548
+
549
+ def bump(self) -> None:
550
+ """Advances ``seq`` for a change that touches no run row.
551
+
552
+ The group list is one: clients that see ``seq`` move re-read it.
553
+ """
554
+ with self._lock:
555
+ self._bump(set(), set())
556
+
557
+ def snapshot(self) -> list[tuple[str, library.RunInfo]]:
558
+ """Returns every run as ``(name, row)``, newest first."""
559
+ with self._lock:
560
+ items = sorted(self._infos.items(), key=lambda kv: kv[0])
561
+ items.sort(key=lambda kv: kv[1].created, reverse=True)
562
+ return items
563
+
564
+ def info(self, name: str) -> library.RunInfo | None:
565
+ """Returns one run's index row, or ``None``."""
566
+ with self._lock:
567
+ return self._infos.get(name)
568
+
569
+ def n_runs(self) -> int:
570
+ """Returns the number of runs."""
571
+ with self._lock:
572
+ return len(self._infos)
573
+
574
+ def _row_bytes(self, info: library.RunInfo) -> bytes:
575
+ """Returns the encoded ``RunRow`` of a run, cached until it changes."""
576
+ row = self._rows.get(info.name)
577
+ if row is None:
578
+ ex = self._extras.get(info.name) or Extras()
579
+ row = _encode(
580
+ {
581
+ "name": info.name,
582
+ "id": info.id,
583
+ "created": info.created,
584
+ "status": info.status,
585
+ "dt": info.dt,
586
+ "n_frames": info.n_frames,
587
+ "n_envs": info.n_envs,
588
+ "n_bodies": info.n_bodies,
589
+ "favorite": info.favorite,
590
+ "group": info.group,
591
+ "rating": info.rating,
592
+ "tags": list(info.tags),
593
+ "n_notes": info.n_notes,
594
+ "n_highlights": ex.n_highlights,
595
+ "simulator": ex.simulator,
596
+ "importer": ex.importer,
597
+ "streams": list(ex.streams),
598
+ }
599
+ )
600
+ self._rows[info.name] = row
601
+ return row
602
+
603
+ def runs_body(self, *, gzipped: bool = False) -> tuple[int, bytes]:
604
+ """Returns ``(seq, body)`` of ``/api/runs`` for the current state.
605
+
606
+ The body is built once per ``seq`` from per-row bytes that are kept
607
+ until their run changes, so a live run costs one row to re-encode.
608
+
609
+ Args:
610
+ gzipped: Return the gzip-compressed body.
611
+
612
+ Returns:
613
+ The sequence number the body belongs to, and the bytes.
614
+ """
615
+ with self._lock:
616
+ if self._body is None:
617
+ infos = sorted(self._infos.values(), key=lambda i: i.name)
618
+ infos.sort(key=lambda i: i.created, reverse=True)
619
+ rows = b",".join(self._row_bytes(i) for i in infos)
620
+ self._body = (
621
+ self.seq,
622
+ b'{"seq":%d,"runs":[%s]}' % (self.seq, rows),
623
+ )
624
+ if not gzipped:
625
+ return self._body
626
+ if self._gzipped is None:
627
+ self._gzipped = (
628
+ self._body[0],
629
+ gzip.compress(self._body[1], compresslevel=3, mtime=0),
630
+ )
631
+ return self._gzipped
632
+
633
+ def changes(self, since: int) -> dict[str, Any]:
634
+ """Answers ``/api/changes?since=``.
635
+
636
+ Args:
637
+ since: The ``seq`` the client last saw.
638
+
639
+ Returns:
640
+ ``{"seq", "changed", "removed", "live"}``. When the server no
641
+ longer remembers ``since`` (it restarted, or the client fell
642
+ far behind) every run is reported as changed so the client
643
+ reloads its rows.
644
+ """
645
+ with self._lock:
646
+ live = {
647
+ n: i.n_frames
648
+ for n, i in self._infos.items()
649
+ if i.status == "recording"
650
+ }
651
+ if since < self._floor or since > self.seq:
652
+ return {
653
+ "seq": self.seq,
654
+ "changed": sorted(self._infos),
655
+ "removed": [],
656
+ "live": live,
657
+ }
658
+ changed: set[str] = set()
659
+ removed: set[str] = set()
660
+ for seq, ch, rm in self._log:
661
+ if seq > since:
662
+ changed |= ch
663
+ removed |= rm
664
+ changed -= removed
665
+ return {
666
+ "seq": self.seq,
667
+ "changed": sorted(changed),
668
+ "removed": sorted(removed),
669
+ "live": live,
670
+ }
671
+
672
+ # -- background threads --
673
+
674
+ def start(self, *, watch: bool = True) -> None:
675
+ """Starts the file watcher.
676
+
677
+ Args:
678
+ watch: Set false to leave the state static (tests call
679
+ :meth:`rescan` themselves).
680
+ """
681
+ if watch and not self._threads:
682
+ thread = threading.Thread(
683
+ target=self._watch, name="simscope-watch", daemon=True
684
+ )
685
+ thread.start()
686
+ self._threads.append(thread)
687
+
688
+ def stop(self) -> None:
689
+ """Stops background threads and closes the index."""
690
+ self._stop.set()
691
+ for thread in self._threads:
692
+ thread.join(timeout=5)
693
+ self._threads.clear()
694
+ self.lib.close()
695
+ self.index.close()
696
+
697
+ def _names_of(self, paths: Iterable[str]) -> set[str]:
698
+ """Maps changed absolute paths to the names of the runs they touch."""
699
+ prefix = os.path.join(self.root_real, "runs") + os.sep
700
+ names = set()
701
+ for path in paths:
702
+ if path.startswith(prefix):
703
+ name = path[len(prefix) :].split(os.sep, 1)[0]
704
+ if name:
705
+ names.add(name)
706
+ return names
707
+
708
+ def _watch(self) -> None:
709
+ """Turns file events into ``seq`` bumps until stopped."""
710
+ import watchfiles
711
+
712
+ marker = os.sep + ".simscope" + os.sep
713
+ idle = 0
714
+
715
+ def relevant(_change: watchfiles.Change, path: str) -> bool:
716
+ return marker not in path
717
+
718
+ try:
719
+ for batch in watchfiles.watch(
720
+ self.root_real,
721
+ watch_filter=relevant,
722
+ debounce=_WATCH_DEBOUNCE_MS,
723
+ step=_WATCH_STEP_MS,
724
+ rust_timeout=_WATCH_TIMEOUT_MS,
725
+ yield_on_timeout=True,
726
+ stop_event=self._stop,
727
+ raise_interrupt=False,
728
+ ):
729
+ names: set[str] | None = self._names_of(p for _, p in batch)
730
+ if not batch:
731
+ idle += 1
732
+ if idle % _SAFETY_RESCAN_EVERY:
733
+ continue
734
+ names = None # backstop against missed events
735
+ elif not names:
736
+ continue # changes outside runs/ (assets, scenes)
737
+ try:
738
+ self._scan(names)
739
+ except Exception:
740
+ logger.exception("library rescan failed")
741
+ except Exception:
742
+ logger.exception("file watcher stopped; polling instead")
743
+ self._poll()
744
+
745
+ def _poll(self) -> None:
746
+ """Fallback when the OS watcher is unavailable: rescan every 2 s."""
747
+ while not self._stop.wait(2.0):
748
+ try:
749
+ self._scan(None)
750
+ except Exception:
751
+ logger.exception("library rescan failed")