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
simscope/server/state.py
ADDED
|
@@ -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")
|