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
simscope/index.py ADDED
@@ -0,0 +1,579 @@
1
+ """SQLite index cache over ``rollout.json`` and ``annotations.json``.
2
+
3
+ The index lives at ``<library>/.simscope/index.sqlite``. It is a disposable
4
+ cache (decision D8): the sidecars stay the source of truth, and a missing,
5
+ corrupt or out-of-date-schema file is dropped and rebuilt on the next scan.
6
+
7
+ ``Index.refresh`` does one ``os.scandir`` of ``runs/`` and one ``stat`` per
8
+ source file, and re-reads only runs whose files changed.
9
+ """
10
+
11
+ import contextlib
12
+ import dataclasses
13
+ import json
14
+ import logging
15
+ import os
16
+ import pathlib
17
+ import sqlite3
18
+ import threading
19
+ from collections.abc import Iterable, Sequence
20
+ from typing import Any
21
+
22
+ from simscope.io import manifest
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ SCHEMA_VERSION = 2
27
+ Signature = tuple[int, int, int, int, int]
28
+ """``(m_mtime, m_size, m_partial, a_mtime, a_size)`` of a run."""
29
+ INVALID = "invalid"
30
+ """Status shown for a run whose ``rollout.json`` could not be read."""
31
+
32
+ _SCHEMA = """
33
+ CREATE TABLE runs (
34
+ name TEXT PRIMARY KEY,
35
+ id TEXT NOT NULL,
36
+ created TEXT NOT NULL,
37
+ status TEXT NOT NULL,
38
+ dt REAL NOT NULL,
39
+ n_frames INTEGER NOT NULL,
40
+ n_envs INTEGER NOT NULL,
41
+ n_bodies INTEGER NOT NULL,
42
+ scene_hash TEXT NOT NULL,
43
+ favorite INTEGER NOT NULL,
44
+ grp TEXT,
45
+ n_events INTEGER NOT NULL,
46
+ n_notes INTEGER NOT NULL,
47
+ rating REAL,
48
+ tags TEXT NOT NULL,
49
+ hay TEXT NOT NULL,
50
+ m_mtime INTEGER NOT NULL,
51
+ m_size INTEGER NOT NULL,
52
+ m_partial INTEGER NOT NULL,
53
+ a_mtime INTEGER NOT NULL,
54
+ a_size INTEGER NOT NULL
55
+ ) WITHOUT ROWID;
56
+ CREATE TABLE run_tags (
57
+ tag TEXT NOT NULL,
58
+ name TEXT NOT NULL,
59
+ PRIMARY KEY (tag, name)
60
+ ) WITHOUT ROWID;
61
+ CREATE INDEX runs_created ON runs (created);
62
+ """
63
+
64
+ _COLUMNS = [
65
+ *("name", "id", "created", "status", "dt", "n_frames", "n_envs"),
66
+ *("n_bodies", "scene_hash", "favorite", "grp"),
67
+ *("n_events", "n_notes", "rating", "tags", "hay", "m_mtime", "m_size"),
68
+ *("m_partial", "a_mtime", "a_size"),
69
+ ]
70
+ _UPSERT = (
71
+ f"INSERT OR REPLACE INTO runs ({', '.join(_COLUMNS)}) "
72
+ f"VALUES ({', '.join('?' * len(_COLUMNS))})"
73
+ )
74
+ _INFO_COLUMNS = _COLUMNS[:15]
75
+ _SORTS = {
76
+ "created": "created {d}, name",
77
+ "name": "name {d}",
78
+ "n_frames": "n_frames {d}, name",
79
+ "rating": "rating IS NULL, rating {d}, name",
80
+ }
81
+
82
+
83
+ @dataclasses.dataclass(frozen=True)
84
+ class RunInfo:
85
+ """Summary of one rollout, as browsing needs it.
86
+
87
+ Attributes:
88
+ name: Run name (the directory name).
89
+ id: The run's ULID.
90
+ created: UTC creation time.
91
+ status: ``"recording"``, ``"complete"`` or ``"invalid"`` (the
92
+ manifest could not be read).
93
+ dt: Seconds per frame.
94
+ n_frames: Frames per stream.
95
+ n_envs: Envs per stream.
96
+ n_bodies: Bodies per pose.
97
+ scene_hash: SHA-256 of the scene descriptor.
98
+ favorite: Whether the run is starred.
99
+ group: Name of the group the run belongs to, or ``None``.
100
+ n_events: Number of events.
101
+ n_notes: Number of notes.
102
+ rating: Mean ``overall`` rating, or ``None``.
103
+ tags: Record-time tags, sorted and unique. (Curation tags, a
104
+ deprecated field of the sidecar, are not indexed.)
105
+ """
106
+
107
+ name: str
108
+ id: str
109
+ created: str
110
+ status: str
111
+ dt: float
112
+ n_frames: int
113
+ n_envs: int
114
+ n_bodies: int
115
+ scene_hash: str
116
+ favorite: bool
117
+ group: str | None
118
+ n_events: int
119
+ n_notes: int
120
+ rating: float | None
121
+ tags: tuple[str, ...]
122
+
123
+
124
+ @dataclasses.dataclass(frozen=True)
125
+ class RefreshStats:
126
+ """What a refresh did.
127
+
128
+ Attributes:
129
+ added: Runs seen for the first time.
130
+ updated: Runs whose files changed.
131
+ removed: Runs that vanished.
132
+ unchanged: Runs skipped because their stat signature matched.
133
+ """
134
+
135
+ added: int = 0
136
+ updated: int = 0
137
+ removed: int = 0
138
+ unchanged: int = 0
139
+
140
+
141
+ def _stat(path: str) -> tuple[int, int]:
142
+ """Returns ``(mtime_ns, size)``, or ``(0, -1)`` if the file is missing."""
143
+ try:
144
+ st = os.stat(path)
145
+ except OSError:
146
+ return 0, -1
147
+ return st.st_mtime_ns, st.st_size
148
+
149
+
150
+ def _read_json(path: str) -> Any:
151
+ """Reads a JSON file, returning ``None`` if it is missing or invalid."""
152
+ try:
153
+ with open(path, "rb") as f:
154
+ return json.loads(f.read())
155
+ except (OSError, ValueError):
156
+ return None
157
+
158
+
159
+ def _summarize_annotations(obj: Any) -> tuple[Any, ...]:
160
+ """Extracts marks, counts and rating from a parsed annotations file.
161
+
162
+ Returns:
163
+ ``(favorite, group, n_events, notes, rating)``.
164
+ """
165
+ if not isinstance(obj, dict):
166
+ return False, None, 0, [], None
167
+ marks = obj.get("marks")
168
+ marks = marks if isinstance(marks, dict) else {}
169
+ notes = [
170
+ n["text"]
171
+ for n in obj.get("notes") or ()
172
+ if isinstance(n, dict) and isinstance(n.get("text"), str)
173
+ ]
174
+ overall = [
175
+ r["value"]
176
+ for r in obj.get("ratings") or ()
177
+ if isinstance(r, dict)
178
+ and r.get("criterion", "overall") == "overall"
179
+ and isinstance(r.get("value"), int | float)
180
+ ]
181
+ events = obj.get("events")
182
+ group = marks.get("group")
183
+ return (
184
+ bool(marks.get("favorite", False)),
185
+ group if isinstance(group, str) and group else None,
186
+ len(events) if isinstance(events, list) else 0,
187
+ notes,
188
+ sum(overall) / len(overall) if overall else None,
189
+ )
190
+
191
+
192
+ def _row(
193
+ name: str,
194
+ m_path: str,
195
+ signature: Signature,
196
+ a_path: str,
197
+ ) -> tuple[tuple[Any, ...], list[str]]:
198
+ """Builds the index row for one run from its files.
199
+
200
+ Args:
201
+ name: Run directory name.
202
+ m_path: Path of ``rollout.json`` or ``rollout.json.partial``.
203
+ signature: ``(m_mtime, m_size, m_partial, a_mtime, a_size)``.
204
+ a_path: Path of ``annotations.json``.
205
+
206
+ Returns:
207
+ ``(row, tags)`` where ``row`` follows ``_COLUMNS``.
208
+ """
209
+ obj = _read_json(m_path)
210
+ try:
211
+ if not isinstance(obj, dict):
212
+ raise ValueError("not a JSON object")
213
+ fmt = obj["format"]
214
+ if not fmt.startswith("simscope-rollout/1"):
215
+ raise ValueError(f"unknown format {fmt!r}")
216
+ head: tuple[Any, ...] = (
217
+ str(obj["id"]),
218
+ str(obj["created"]),
219
+ str(obj["status"]),
220
+ float(obj["dt"]),
221
+ int(obj["n_frames"]),
222
+ int(obj["n_envs"]),
223
+ int(obj["n_bodies"]),
224
+ str(obj["scene"]["sha256"]),
225
+ )
226
+ record_tags = [str(t) for t in obj.get("tags") or ()]
227
+ except (KeyError, TypeError, ValueError, AttributeError) as exc:
228
+ logger.warning("%s: unreadable manifest: %s", m_path, exc)
229
+ head = ("", "", INVALID, 0.0, 0, 0, 0, "")
230
+ record_tags = []
231
+ fav, group, n_events, notes, rating = _summarize_annotations(
232
+ _read_json(a_path) if signature[4] >= 0 else None
233
+ )
234
+ tags = sorted(set(record_tags))
235
+ hay = "\n".join([name, *tags, *([group] if group else []), *notes]).lower()
236
+ row = (
237
+ name,
238
+ *head,
239
+ int(fav),
240
+ group,
241
+ n_events,
242
+ len(notes),
243
+ rating,
244
+ json.dumps(tags),
245
+ hay,
246
+ *signature,
247
+ )
248
+ return row, tags
249
+
250
+
251
+ def _like(term: str) -> str:
252
+ r"""Escapes a search term for ``LIKE ... ESCAPE '\'``."""
253
+ escaped = term.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
254
+ return f"%{escaped.lower()}%"
255
+
256
+
257
+ class Index:
258
+ """The scan cache of one library.
259
+
260
+ Thread-safe: a lock serializes access to the SQLite connection. If the
261
+ cache cannot be created on disk (for example a read-only library) it
262
+ falls back to an in-memory database.
263
+
264
+ Attributes:
265
+ root: The library root.
266
+ path: The index file.
267
+ """
268
+
269
+ def __init__(self, root: os.PathLike[str] | str) -> None:
270
+ """Creates a handle. The database opens lazily.
271
+
272
+ Args:
273
+ root: The library root.
274
+ """
275
+ self.root = pathlib.Path(root)
276
+ self.path = self.root / ".simscope" / "index.sqlite"
277
+ self._conn: sqlite3.Connection | None = None
278
+ self._lock = threading.RLock()
279
+
280
+ # -- connection management --
281
+
282
+ def _connect(self) -> sqlite3.Connection:
283
+ """Returns the open connection, creating or rebuilding as needed."""
284
+ if self._conn is not None:
285
+ return self._conn
286
+ try:
287
+ self.path.parent.mkdir(parents=True, exist_ok=True)
288
+ conn = self._open(str(self.path))
289
+ except sqlite3.DatabaseError:
290
+ logger.warning("dropping corrupt index %s", self.path)
291
+ self._drop_files()
292
+ conn = self._open(str(self.path))
293
+ except OSError as exc:
294
+ logger.warning("index unavailable (%s); using memory", exc)
295
+ conn = self._open(":memory:")
296
+ self._conn = conn
297
+ return conn
298
+
299
+ def _open(self, target: str) -> sqlite3.Connection:
300
+ """Opens a database and makes sure the schema is current."""
301
+ conn = sqlite3.connect(
302
+ target, check_same_thread=False, isolation_level=None, timeout=30
303
+ )
304
+ try:
305
+ if target != ":memory:":
306
+ conn.execute("PRAGMA journal_mode=WAL")
307
+ conn.execute("PRAGMA synchronous=NORMAL")
308
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
309
+ if version != SCHEMA_VERSION:
310
+ if version != 0:
311
+ logger.info("index schema %d is stale; rebuilding", version)
312
+ self._reset_schema(conn)
313
+ else:
314
+ conn.execute("SELECT COUNT(*) FROM runs").fetchone()
315
+ except sqlite3.DatabaseError:
316
+ conn.close()
317
+ raise
318
+ return conn
319
+
320
+ @staticmethod
321
+ def _reset_schema(conn: sqlite3.Connection) -> None:
322
+ """Drops every table and creates the current schema."""
323
+ conn.execute("BEGIN IMMEDIATE")
324
+ try:
325
+ conn.execute("DROP TABLE IF EXISTS runs")
326
+ conn.execute("DROP TABLE IF EXISTS run_tags")
327
+ for stmt in _SCHEMA.split(";"):
328
+ if stmt.strip():
329
+ conn.execute(stmt)
330
+ conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
331
+ conn.execute("COMMIT")
332
+ except BaseException:
333
+ conn.execute("ROLLBACK")
334
+ raise
335
+
336
+ def _absent(self) -> bool:
337
+ """True if nothing was ever indexed (so queries need not create it)."""
338
+ return self._conn is None and not self.path.exists()
339
+
340
+ def _drop_files(self) -> None:
341
+ """Deletes the database file and its WAL companions."""
342
+ for suffix in ("", "-wal", "-shm"):
343
+ pathlib.Path(str(self.path) + suffix).unlink(missing_ok=True)
344
+
345
+ def close(self) -> None:
346
+ """Closes the connection. The index reopens on the next use."""
347
+ with self._lock:
348
+ if self._conn is not None:
349
+ self._conn.close()
350
+ self._conn = None
351
+
352
+ def rebuild(self) -> RefreshStats:
353
+ """Drops every row and rescans all runs."""
354
+ with self._lock:
355
+ self._reset_schema(self._connect())
356
+ return self.refresh()
357
+
358
+ # -- refresh --
359
+
360
+ def refresh(self) -> RefreshStats:
361
+ """Brings the index up to date with ``runs/``.
362
+
363
+ One ``os.scandir`` plus a ``stat`` of each run's manifest and
364
+ annotations file. Only runs whose mtime or size changed are re-read.
365
+ Everything happens in one transaction.
366
+
367
+ Returns:
368
+ Counts of added, updated, removed and unchanged runs.
369
+ """
370
+ runs_dir = self.root / "runs"
371
+ if (
372
+ not runs_dir.is_dir()
373
+ and self._conn is None
374
+ and not self.path.exists()
375
+ ):
376
+ return RefreshStats()
377
+ with self._lock:
378
+ return self._refresh(runs_dir)
379
+
380
+ def _refresh(self, runs_dir: pathlib.Path) -> RefreshStats:
381
+ """Implements :meth:`refresh` with the lock held."""
382
+ conn = self._connect()
383
+ known = {
384
+ row[0]: row[1:]
385
+ for row in conn.execute(
386
+ "SELECT name, m_mtime, m_size, m_partial, a_mtime, a_size "
387
+ "FROM runs"
388
+ )
389
+ }
390
+ seen: set[str] = set()
391
+ changed: list[tuple[str, str, Signature, str]] = []
392
+ added = unchanged = 0
393
+ base = str(runs_dir) + os.sep
394
+ with contextlib.suppress(FileNotFoundError), os.scandir(runs_dir) as it:
395
+ for entry in it:
396
+ name = entry.name
397
+ if not entry.is_dir() or not _is_run_name(name):
398
+ continue
399
+ d = base + name + os.sep
400
+ m_path = d + manifest.MANIFEST_NAME
401
+ m_mtime, m_size = _stat(m_path)
402
+ partial = 0
403
+ if m_size < 0:
404
+ m_path = d + manifest.PARTIAL_NAME
405
+ m_mtime, m_size = _stat(m_path)
406
+ if m_size < 0:
407
+ continue # not a run (yet)
408
+ partial = 1
409
+ a_path = d + "annotations.json"
410
+ a_mtime, a_size = _stat(a_path)
411
+ sig = (m_mtime, m_size, partial, a_mtime, a_size)
412
+ seen.add(name)
413
+ old = known.get(name)
414
+ if old == sig:
415
+ unchanged += 1
416
+ continue
417
+ added += old is None
418
+ changed.append((name, m_path, sig, a_path))
419
+ removed = [n for n in known if n not in seen]
420
+ if not changed and not removed:
421
+ return RefreshStats(unchanged=unchanged)
422
+ rows, tag_rows = [], []
423
+ for name, m_path, sig, a_path in changed:
424
+ row, tags = _row(name, m_path, sig, a_path)
425
+ rows.append(row)
426
+ tag_rows += [(t, name) for t in tags]
427
+ conn.execute("BEGIN IMMEDIATE")
428
+ try:
429
+ if removed:
430
+ _delete_names(conn, removed)
431
+ if changed:
432
+ _delete_names(conn, [c[0] for c in changed], runs=False)
433
+ conn.executemany(_UPSERT, rows)
434
+ conn.executemany("INSERT INTO run_tags VALUES (?, ?)", tag_rows)
435
+ conn.execute("COMMIT")
436
+ except BaseException:
437
+ conn.execute("ROLLBACK")
438
+ raise
439
+ return RefreshStats(
440
+ added, len(changed) - added, len(removed), unchanged
441
+ )
442
+
443
+ # -- queries --
444
+
445
+ def query(
446
+ self,
447
+ text: str | None = None,
448
+ tags: Iterable[str] = (),
449
+ favorite: bool | None = None,
450
+ status: str | None = None,
451
+ sort: str = "created",
452
+ descending: bool = True,
453
+ limit: int | None = 50,
454
+ offset: int = 0,
455
+ ) -> list[RunInfo]:
456
+ """Returns matching runs.
457
+
458
+ Args:
459
+ text: Whitespace-separated terms; a run must contain every term
460
+ (case-insensitive) in its name, tags, group or note text.
461
+ tags: Record-time tags a run must have (all of them).
462
+ favorite: If set, only runs with this favorite mark.
463
+ status: If set, only runs whose manifest status
464
+ (``"recording"``, ``"complete"``) equals it.
465
+ sort: ``"created"``, ``"name"``, ``"n_frames"`` or ``"rating"``
466
+ (runs without a rating sort last).
467
+ descending: Sort direction.
468
+ limit: Maximum rows, or ``None`` for all.
469
+ offset: Rows to skip.
470
+
471
+ Returns:
472
+ The page of runs.
473
+
474
+ Raises:
475
+ ValueError: If ``sort`` is unknown.
476
+ """
477
+ if sort not in _SORTS:
478
+ raise ValueError(f"sort must be one of {sorted(_SORTS)}")
479
+ where, params = _filters(text, tags, favorite, status)
480
+ order = _SORTS[sort].format(d="DESC" if descending else "ASC")
481
+ sql = (
482
+ f"SELECT {', '.join(_INFO_COLUMNS)} FROM runs{where} "
483
+ f"ORDER BY {order} LIMIT ? OFFSET ?"
484
+ )
485
+ params += [-1 if limit is None else int(limit), max(0, int(offset))]
486
+ with self._lock:
487
+ if self._absent():
488
+ return []
489
+ rows = self._connect().execute(sql, params).fetchall()
490
+ return [_info(r) for r in rows]
491
+
492
+ def count(
493
+ self,
494
+ text: str | None = None,
495
+ tags: Iterable[str] = (),
496
+ favorite: bool | None = None,
497
+ status: str | None = None,
498
+ ) -> int:
499
+ """Returns how many runs match the filters of :meth:`query`."""
500
+ where, params = _filters(text, tags, favorite, status)
501
+ with self._lock:
502
+ if self._absent():
503
+ return 0
504
+ sql = f"SELECT COUNT(*) FROM runs{where}"
505
+ return self._connect().execute(sql, params).fetchone()[0]
506
+
507
+ def get(self, name: str) -> RunInfo | None:
508
+ """Returns one run's summary, or ``None`` if it is not indexed."""
509
+ with self._lock:
510
+ if self._absent():
511
+ return None
512
+ row = (
513
+ self._connect()
514
+ .execute(
515
+ f"SELECT {', '.join(_INFO_COLUMNS)} FROM runs "
516
+ "WHERE name = ?",
517
+ (name,),
518
+ )
519
+ .fetchone()
520
+ )
521
+ return None if row is None else _info(row)
522
+
523
+
524
+ def _is_run_name(name: str) -> bool:
525
+ """Tells whether a directory name is a valid run name."""
526
+ try:
527
+ manifest.validate_run_name(name)
528
+ except ValueError:
529
+ return False
530
+ return True
531
+
532
+
533
+ def _delete_names(
534
+ conn: sqlite3.Connection, names: Sequence[str], *, runs: bool = True
535
+ ) -> None:
536
+ """Deletes runs (and their tags) by name, in batches."""
537
+ for i in range(0, len(names), 500):
538
+ batch = list(names[i : i + 500])
539
+ marks = ",".join("?" * len(batch))
540
+ conn.execute(f"DELETE FROM run_tags WHERE name IN ({marks})", batch)
541
+ if runs:
542
+ conn.execute(f"DELETE FROM runs WHERE name IN ({marks})", batch)
543
+
544
+
545
+ def _filters(
546
+ text: str | None,
547
+ tags: Iterable[str],
548
+ favorite: bool | None,
549
+ status: str | None,
550
+ ) -> tuple[str, list[Any]]:
551
+ """Builds a parameterized WHERE clause."""
552
+ clauses: list[str] = []
553
+ params: list[Any] = []
554
+ for term in (text or "").split():
555
+ clauses.append("hay LIKE ? ESCAPE '\\'")
556
+ params.append(_like(term))
557
+ tag_list = sorted(set(tags))
558
+ if tag_list:
559
+ marks = ",".join("?" * len(tag_list))
560
+ clauses.append(
561
+ "name IN (SELECT name FROM run_tags WHERE tag IN "
562
+ f"({marks}) GROUP BY name HAVING COUNT(*) = ?)"
563
+ )
564
+ params += [*tag_list, len(tag_list)]
565
+ if favorite is not None:
566
+ clauses.append("favorite = ?")
567
+ params.append(int(favorite))
568
+ if status is not None:
569
+ clauses.append("status = ?")
570
+ params.append(status)
571
+ return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
572
+
573
+
574
+ def _info(row: Sequence[Any]) -> RunInfo:
575
+ """Converts a database row (``_INFO_COLUMNS`` order) to a RunInfo."""
576
+ r = list(row)
577
+ r[9] = bool(r[9])
578
+ r[14] = tuple(json.loads(r[14]))
579
+ return RunInfo(*r)
@@ -0,0 +1,45 @@
1
+ """On-disk format shared by the recorder and the player.
2
+
3
+ Modules:
4
+ codecs: Block payload codecs (f32s, q16d) and mesh blobs (raw, q16).
5
+ blockfile: ``BlockWriter``, ``BlockReader`` and ``recover``.
6
+ cas: ``Ref`` and the content-addressed ``ContentStore``.
7
+ scene: Canonical scene descriptors (``put_scene``, ``load_scene``).
8
+ manifest: ``RolloutManifest``, ULIDs and run-name validation.
9
+ pack: ``write_pack`` and ``PackReader``.
10
+ """
11
+
12
+ from simscope.io import errors
13
+ from simscope.io.blockfile import BlockReader, BlockWriter, recover
14
+ from simscope.io.cas import ContentStore, Ref
15
+ from simscope.io.manifest import (
16
+ RolloutManifest,
17
+ StreamInfo,
18
+ new_ulid,
19
+ read_manifest,
20
+ validate_run_name,
21
+ write_manifest,
22
+ )
23
+ from simscope.io.pack import PackReader, write_pack
24
+ from simscope.io.scene import load_scene, put_scene
25
+
26
+ FormatError = errors.FormatError
27
+
28
+ __all__ = [
29
+ "BlockReader",
30
+ "BlockWriter",
31
+ "ContentStore",
32
+ "FormatError",
33
+ "PackReader",
34
+ "Ref",
35
+ "RolloutManifest",
36
+ "StreamInfo",
37
+ "load_scene",
38
+ "new_ulid",
39
+ "put_scene",
40
+ "read_manifest",
41
+ "recover",
42
+ "validate_run_name",
43
+ "write_manifest",
44
+ "write_pack",
45
+ ]