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/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)
|
simscope/io/__init__.py
ADDED
|
@@ -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
|
+
]
|