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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The simscope viewer server: Starlette over a library directory.
|
|
2
|
+
|
|
3
|
+
``create_app(root, author=...)`` builds the ASGI app and ``serve`` runs it
|
|
4
|
+
with uvicorn. The routes read the library folder on disk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from simscope.server.app import create_app, serve
|
|
8
|
+
|
|
9
|
+
__all__ = ["create_app", "serve"]
|
simscope/server/app.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The Starlette app and the ``serve`` entry point (contracts 2)."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import pathlib
|
|
6
|
+
import secrets
|
|
7
|
+
from collections.abc import AsyncIterator, Sequence
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
from starlette.applications import Starlette
|
|
11
|
+
from starlette.middleware import Middleware
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
from starlette.routing import Route
|
|
15
|
+
|
|
16
|
+
from simscope.server import blocks, jobs, routes, security
|
|
17
|
+
from simscope.server import state as state_mod
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_app(
|
|
23
|
+
root: pathlib.Path | str,
|
|
24
|
+
*,
|
|
25
|
+
author: str | None = None,
|
|
26
|
+
token: str | None = None,
|
|
27
|
+
allowed_hosts: Sequence[str] | None = None,
|
|
28
|
+
watch: bool = True,
|
|
29
|
+
warm: bool = True,
|
|
30
|
+
) -> Starlette:
|
|
31
|
+
"""Builds the app that serves one library to the browser.
|
|
32
|
+
|
|
33
|
+
The library is scanned before this returns. The file watcher and the
|
|
34
|
+
highlight warmer start with the app's lifespan (``uvicorn`` runs it; a
|
|
35
|
+
test client runs it inside ``with``).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
root: The library folder.
|
|
39
|
+
author: Author of annotations made through the API; the OS user name
|
|
40
|
+
if ``None``.
|
|
41
|
+
token: The token that writes must send in ``X-Simscope-Token``; a
|
|
42
|
+
random one if ``None``. The page's boot block carries it.
|
|
43
|
+
allowed_hosts: ``Host`` header values to accept (DNS-rebinding
|
|
44
|
+
guard); loopback names by default.
|
|
45
|
+
watch: Watch the folder for changes. Turn off to drive
|
|
46
|
+
``app.state.services.state.rescan()`` by hand.
|
|
47
|
+
warm: Compute highlights of every run in the background.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The ASGI app. Its services are on ``app.state.services``.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
FileNotFoundError: If ``root`` is not a directory.
|
|
54
|
+
"""
|
|
55
|
+
path = pathlib.Path(root)
|
|
56
|
+
if not path.is_dir():
|
|
57
|
+
raise FileNotFoundError(f"no library folder at {path}")
|
|
58
|
+
state = state_mod.LibraryState(
|
|
59
|
+
path, author=author, token=token or secrets.token_urlsafe(24)
|
|
60
|
+
)
|
|
61
|
+
services = routes.Services(state, jobs.Jobs(state), blocks.BlockStore())
|
|
62
|
+
|
|
63
|
+
@contextlib.asynccontextmanager
|
|
64
|
+
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
|
|
65
|
+
state.start(watch=watch)
|
|
66
|
+
if warm:
|
|
67
|
+
services.jobs.start_warmer()
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
finally:
|
|
71
|
+
services.jobs.stop()
|
|
72
|
+
services.blocks.close()
|
|
73
|
+
state.stop()
|
|
74
|
+
|
|
75
|
+
async def not_found(_request: Request, _exc: Exception) -> Response:
|
|
76
|
+
return routes.error(404, "not found")
|
|
77
|
+
|
|
78
|
+
async def not_allowed(_request: Request, _exc: Exception) -> Response:
|
|
79
|
+
return routes.error(405, "method not allowed")
|
|
80
|
+
|
|
81
|
+
app = Starlette(
|
|
82
|
+
routes=[
|
|
83
|
+
Route("/", routes.index),
|
|
84
|
+
Route("/assets/{name}", routes.asset),
|
|
85
|
+
Route("/api/library", routes.library_info),
|
|
86
|
+
Route("/api/runs", routes.runs),
|
|
87
|
+
Route("/api/changes", routes.changes),
|
|
88
|
+
Route("/api/blk", routes.blk),
|
|
89
|
+
Route("/api/blocks", routes.blocks_route),
|
|
90
|
+
Route("/api/export", routes.export_route),
|
|
91
|
+
Route("/api/groups", routes.groups, methods=["GET"]),
|
|
92
|
+
Route("/api/groups", routes.change_groups, methods=["POST"]),
|
|
93
|
+
Route(
|
|
94
|
+
"/api/runs/{name}/annotations",
|
|
95
|
+
routes.annotate,
|
|
96
|
+
methods=["POST"],
|
|
97
|
+
),
|
|
98
|
+
Route(
|
|
99
|
+
"/api/runs/{name}/rename",
|
|
100
|
+
routes.rename_run,
|
|
101
|
+
methods=["POST"],
|
|
102
|
+
),
|
|
103
|
+
Route("/files/{path:path}", routes.files),
|
|
104
|
+
],
|
|
105
|
+
middleware=[
|
|
106
|
+
Middleware(
|
|
107
|
+
security.HostGuard,
|
|
108
|
+
allowed=allowed_hosts or security.LOOPBACK_HOSTS,
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
exception_handlers={404: not_found, 405: not_allowed},
|
|
112
|
+
lifespan=lifespan,
|
|
113
|
+
)
|
|
114
|
+
app.state.services = services
|
|
115
|
+
return app
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def serve(
|
|
119
|
+
root: pathlib.Path | str,
|
|
120
|
+
host: str = "127.0.0.1",
|
|
121
|
+
port: int = 8080,
|
|
122
|
+
author: str | None = None,
|
|
123
|
+
*,
|
|
124
|
+
token: str | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Serves a library until interrupted.
|
|
127
|
+
|
|
128
|
+
Binds ``host`` (loopback by default). A loopback bind answers only
|
|
129
|
+
loopback ``Host`` headers, which stops DNS rebinding; a wildcard bind
|
|
130
|
+
answers any.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
root: The library folder.
|
|
134
|
+
host: Address to listen on.
|
|
135
|
+
port: Port to listen on (0 picks a free one).
|
|
136
|
+
author: Author of annotations made through the API.
|
|
137
|
+
token: The write token; random if ``None``.
|
|
138
|
+
"""
|
|
139
|
+
app = create_app(
|
|
140
|
+
root,
|
|
141
|
+
author=author,
|
|
142
|
+
token=token,
|
|
143
|
+
allowed_hosts=security.allowed_hosts_for(host),
|
|
144
|
+
)
|
|
145
|
+
config = uvicorn.Config(
|
|
146
|
+
app, host=host, port=port, log_level="warning", access_log=False
|
|
147
|
+
)
|
|
148
|
+
server = uvicorn.Server(config)
|
|
149
|
+
server.run()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Raw block access for ``/api/blk`` and ``/api/blocks`` (contracts 2).
|
|
2
|
+
|
|
3
|
+
The server never decodes a block. A :class:`simscope.io.blockfile.BlockReader`
|
|
4
|
+
supplies the header and directory (and follows a file that is still being
|
|
5
|
+
written, scanning only the bytes added since the last request); the block
|
|
6
|
+
bytes themselves are sliced out of the file with ``pread``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from simscope.io import blockfile, cas, errors
|
|
18
|
+
|
|
19
|
+
MAX_ENVS = 256
|
|
20
|
+
"""Most envs one ``/api/blocks`` request may ask for."""
|
|
21
|
+
_MAX_OPEN = 64
|
|
22
|
+
_BLOCK_HEAD = 32
|
|
23
|
+
|
|
24
|
+
Signature = tuple[int, int, int]
|
|
25
|
+
"""``(inode, size, mtime_ns)`` of a block file."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def signature(path: pathlib.Path) -> Signature:
|
|
29
|
+
"""Returns ``(inode, size, mtime_ns)``, raising if the file is missing."""
|
|
30
|
+
st = os.stat(path)
|
|
31
|
+
return st.st_ino, st.st_size, st.st_mtime_ns
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Entry:
|
|
35
|
+
"""An open reader and the file signature it last saw."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, reader: blockfile.BlockReader, sig: Signature) -> None:
|
|
38
|
+
self.reader = reader
|
|
39
|
+
self.sig = sig
|
|
40
|
+
self.lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BlockStore:
|
|
44
|
+
"""Keeps block files open so a window request costs one ``stat``."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
"""Creates an empty store."""
|
|
48
|
+
self._entries: collections.OrderedDict[str, _Entry] = (
|
|
49
|
+
collections.OrderedDict()
|
|
50
|
+
)
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
def reader(self, path: pathlib.Path) -> tuple[blockfile.BlockReader, str]:
|
|
54
|
+
"""Returns an up-to-date reader of a block file.
|
|
55
|
+
|
|
56
|
+
A finished file is opened once and reopened if the file is replaced.
|
|
57
|
+
An unfinished one is refreshed in place, which costs time
|
|
58
|
+
proportional to what the recorder appended.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path: The block file.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
``(reader, etag)``. The etag changes whenever the readable
|
|
65
|
+
content does.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
FileNotFoundError: If the file is missing or has no header yet.
|
|
69
|
+
errors.FormatError: If the file is corrupt.
|
|
70
|
+
"""
|
|
71
|
+
key = str(path)
|
|
72
|
+
sig = signature(path)
|
|
73
|
+
if sig[1] < blockfile.HEADER_SIZE:
|
|
74
|
+
# The recorder has created the file but not written its header.
|
|
75
|
+
raise FileNotFoundError(path)
|
|
76
|
+
with self._lock:
|
|
77
|
+
entry = self._entries.get(key)
|
|
78
|
+
if entry is not None:
|
|
79
|
+
self._entries.move_to_end(key)
|
|
80
|
+
if entry is None or (
|
|
81
|
+
entry.sig != sig
|
|
82
|
+
and (entry.reader.finished or sig[0] != entry.sig[0])
|
|
83
|
+
):
|
|
84
|
+
entry = self._open(key, path, sig)
|
|
85
|
+
elif entry.sig != sig:
|
|
86
|
+
with entry.lock:
|
|
87
|
+
try:
|
|
88
|
+
entry.reader.refresh()
|
|
89
|
+
except errors.FormatError:
|
|
90
|
+
entry = self._open(key, path, sig)
|
|
91
|
+
else:
|
|
92
|
+
entry.sig = sig
|
|
93
|
+
reader = entry.reader
|
|
94
|
+
etag = f'W/"{sig[0]:x}-{sig[1]:x}-{sig[2]:x}-{reader.n_blocks}"'
|
|
95
|
+
return reader, etag
|
|
96
|
+
|
|
97
|
+
def _open(self, key: str, path: pathlib.Path, sig: Signature) -> _Entry:
|
|
98
|
+
"""Opens (or reopens) a file and stores the entry."""
|
|
99
|
+
reader = blockfile.BlockReader(
|
|
100
|
+
path, cache_blocks=0, verify=False, partial=True
|
|
101
|
+
)
|
|
102
|
+
entry = _Entry(reader, sig)
|
|
103
|
+
with self._lock:
|
|
104
|
+
old = self._entries.pop(key, None)
|
|
105
|
+
self._entries[key] = entry
|
|
106
|
+
while len(self._entries) > _MAX_OPEN:
|
|
107
|
+
self._entries.popitem(last=False)
|
|
108
|
+
if old is not None and old.reader is not reader:
|
|
109
|
+
old.reader.close()
|
|
110
|
+
return entry
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
"""Closes every open file."""
|
|
114
|
+
with self._lock:
|
|
115
|
+
entries, self._entries = (
|
|
116
|
+
list(self._entries.values()),
|
|
117
|
+
(collections.OrderedDict()),
|
|
118
|
+
)
|
|
119
|
+
for entry in entries:
|
|
120
|
+
entry.reader.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def index_bytes(reader: blockfile.BlockReader) -> bytes:
|
|
124
|
+
"""Builds the body of ``/api/blk``: the header, then the directory.
|
|
125
|
+
|
|
126
|
+
The reply is shaped like a finished block file that has had its blocks
|
|
127
|
+
cut out: a valid 64-byte header (``dir_offset`` is 64, so the directory
|
|
128
|
+
follows at once) and ``n_blocks`` 32-byte directory entries whose offsets
|
|
129
|
+
still point into the real file. For a file that is still being written
|
|
130
|
+
the counts cover complete windows only. ``fmt.parseBlk`` reads it as is.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
reader: An up-to-date reader.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The bytes.
|
|
137
|
+
"""
|
|
138
|
+
d = reader.directory
|
|
139
|
+
n_blocks = min(reader.n_blocks, len(d))
|
|
140
|
+
head = blockfile.Header(
|
|
141
|
+
reader.item_shape,
|
|
142
|
+
reader.n_envs,
|
|
143
|
+
reader.n_frames,
|
|
144
|
+
reader.block_frames,
|
|
145
|
+
n_blocks,
|
|
146
|
+
blockfile.HEADER_SIZE,
|
|
147
|
+
32 * n_blocks,
|
|
148
|
+
)
|
|
149
|
+
return head.pack() + d[:n_blocks].tobytes()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def read_blocks(
|
|
153
|
+
reader: blockfile.BlockReader,
|
|
154
|
+
path: pathlib.Path,
|
|
155
|
+
window: int,
|
|
156
|
+
envs: Sequence[int],
|
|
157
|
+
) -> list[bytes] | None:
|
|
158
|
+
"""Slices the raw ``SSBB`` blocks of one window out of the file.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
reader: An up-to-date reader of ``path``.
|
|
162
|
+
path: The block file.
|
|
163
|
+
window: Window index (frames ``window * block_frames`` onward).
|
|
164
|
+
envs: Env indices, in the order wanted.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
One ``bytes`` per env (the 32-byte block header plus payload), or
|
|
168
|
+
``None`` if the window is not complete yet.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
IndexError: If an env is out of range.
|
|
172
|
+
"""
|
|
173
|
+
n_envs = reader.n_envs
|
|
174
|
+
d = reader.directory
|
|
175
|
+
n_blocks = min(reader.n_blocks, len(d))
|
|
176
|
+
if window < 0 or (window + 1) * n_envs > n_blocks:
|
|
177
|
+
return None
|
|
178
|
+
ids = np.asarray(envs, np.int64)
|
|
179
|
+
if ids.size and (ids.min() < 0 or ids.max() >= n_envs):
|
|
180
|
+
raise IndexError("env out of range")
|
|
181
|
+
rows = d[window * n_envs + ids]
|
|
182
|
+
offsets = rows["offset"].tolist()
|
|
183
|
+
lengths = (rows["clen"].astype(np.int64) + _BLOCK_HEAD).tolist()
|
|
184
|
+
fd = cas.open_read(path)
|
|
185
|
+
try:
|
|
186
|
+
return [
|
|
187
|
+
cas.read_at(fd, n, off)
|
|
188
|
+
for off, n in zip(offsets, lengths, strict=True)
|
|
189
|
+
]
|
|
190
|
+
finally:
|
|
191
|
+
os.close(fd)
|
simscope/server/jobs.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Background computation of derived files (contracts 2 and 5).
|
|
2
|
+
|
|
3
|
+
A request for ``derived/<run>/...`` never blocks: it is answered at once from
|
|
4
|
+
the cache, or a job is queued on a small pool and the request gets "pending"
|
|
5
|
+
until the file exists. Jobs for one file are never queued twice, and a job
|
|
6
|
+
that failed is remembered (for that manifest) so a broken run is reported
|
|
7
|
+
instead of retried on every poll.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import enum
|
|
12
|
+
import logging
|
|
13
|
+
import pathlib
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
from simscope import derived
|
|
17
|
+
from simscope.io import errors
|
|
18
|
+
from simscope.server import state as state_mod
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
WORKERS = 2
|
|
23
|
+
"""Derived passes that may run at once."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Status(enum.Enum):
|
|
27
|
+
"""Outcome of asking for a derived file."""
|
|
28
|
+
|
|
29
|
+
READY = "ready"
|
|
30
|
+
PENDING = "pending"
|
|
31
|
+
MISSING = "missing" # no such run
|
|
32
|
+
NOT_APPLICABLE = "not_applicable"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Jobs:
|
|
37
|
+
"""Runs derived-data passes on a thread pool."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, state: state_mod.LibraryState, workers: int = WORKERS):
|
|
40
|
+
"""Creates the pool.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
state: The library state, told when highlights are counted.
|
|
44
|
+
workers: Pool size (at most 2 is sensible: a pass is already
|
|
45
|
+
multi-threaded inside).
|
|
46
|
+
"""
|
|
47
|
+
self._state = state
|
|
48
|
+
self._pool = concurrent.futures.ThreadPoolExecutor(
|
|
49
|
+
min(workers, WORKERS), thread_name_prefix="simscope-derived"
|
|
50
|
+
)
|
|
51
|
+
self._pending: dict[
|
|
52
|
+
tuple[str, str, str], concurrent.futures.Future
|
|
53
|
+
] = {}
|
|
54
|
+
self._failed: dict[tuple[str, str, str], str] = {}
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._stop = threading.Event()
|
|
57
|
+
self._warmer: threading.Thread | None = None
|
|
58
|
+
|
|
59
|
+
def request(
|
|
60
|
+
self, run: str, what: str
|
|
61
|
+
) -> tuple[Status, pathlib.Path | str | None]:
|
|
62
|
+
"""Returns a derived file if it is ready, else queues its job.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
run: Run name.
|
|
66
|
+
what: Path relative to ``derived/<run>/``.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
``(status, value)``: the file path for ``READY``, an error
|
|
70
|
+
message for ``FAILED``, otherwise ``None``.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
rollout = self._state.lib.open(run)
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
return Status.MISSING, None
|
|
76
|
+
except errors.FormatError as exc:
|
|
77
|
+
return Status.FAILED, str(exc)
|
|
78
|
+
try:
|
|
79
|
+
if not derived.applicable(rollout, what):
|
|
80
|
+
return Status.NOT_APPLICABLE, None
|
|
81
|
+
cache = derived.cache_dir(self._state.root, rollout)
|
|
82
|
+
path = derived.fresh(rollout, cache, what)
|
|
83
|
+
if path is not None:
|
|
84
|
+
return Status.READY, path
|
|
85
|
+
key = (rollout.manifest.id, what, derived.manifest_digest(rollout))
|
|
86
|
+
finally:
|
|
87
|
+
rollout.close()
|
|
88
|
+
with self._lock:
|
|
89
|
+
if key in self._failed:
|
|
90
|
+
return Status.FAILED, self._failed[key]
|
|
91
|
+
if key not in self._pending:
|
|
92
|
+
future = self._pool.submit(self._run, run, what, key)
|
|
93
|
+
self._pending[key] = future
|
|
94
|
+
return Status.PENDING, None
|
|
95
|
+
|
|
96
|
+
def _run(self, run: str, what: str, key: tuple[str, str, str]) -> None:
|
|
97
|
+
"""Computes one derived file (worker thread)."""
|
|
98
|
+
try:
|
|
99
|
+
self.compute(run, what)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
logger.info("run %s is gone (renamed or deleted)", run)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
logger.exception("deriving %s for %s failed", what, run)
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._failed[key] = f"{type(exc).__name__}: {exc}"
|
|
106
|
+
finally:
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._pending.pop(key, None)
|
|
109
|
+
|
|
110
|
+
def compute(self, run: str, what: str) -> pathlib.Path | None:
|
|
111
|
+
"""Computes a derived file now, in the calling thread.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
run: Run name.
|
|
115
|
+
what: Path relative to ``derived/<run>/``.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The file path, or ``None`` if not applicable.
|
|
119
|
+
"""
|
|
120
|
+
rollout = self._state.lib.open(run)
|
|
121
|
+
try:
|
|
122
|
+
cache = derived.cache_dir(self._state.root, rollout)
|
|
123
|
+
path = derived.ensure(rollout, cache, what)
|
|
124
|
+
if what == derived.HIGHLIGHTS and path is not None:
|
|
125
|
+
count = derived.highlight_count(cache)
|
|
126
|
+
if count is not None:
|
|
127
|
+
self._state.set_highlights(run, count)
|
|
128
|
+
return path
|
|
129
|
+
finally:
|
|
130
|
+
rollout.close()
|
|
131
|
+
|
|
132
|
+
def start_warmer(self) -> None:
|
|
133
|
+
"""Computes highlights of every complete run in the background.
|
|
134
|
+
|
|
135
|
+
Newest first, one run at a time, so the library's highlight counts
|
|
136
|
+
fill in without competing with what the user is looking at.
|
|
137
|
+
"""
|
|
138
|
+
if self._warmer is None:
|
|
139
|
+
self._warmer = threading.Thread(
|
|
140
|
+
target=self._warm, name="simscope-warm", daemon=True
|
|
141
|
+
)
|
|
142
|
+
self._warmer.start()
|
|
143
|
+
|
|
144
|
+
def _warm(self) -> None:
|
|
145
|
+
"""Worker for :meth:`start_warmer`."""
|
|
146
|
+
names = [
|
|
147
|
+
name
|
|
148
|
+
for name, info in self._state.snapshot()
|
|
149
|
+
if info.status == "complete"
|
|
150
|
+
]
|
|
151
|
+
for name in names:
|
|
152
|
+
if self._stop.is_set():
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
self.compute(name, derived.HIGHLIGHTS)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
continue # renamed or deleted since the list was made
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.exception("highlights for %s failed", name)
|
|
160
|
+
|
|
161
|
+
def stop(self) -> None:
|
|
162
|
+
"""Stops the warmer and waits for running passes."""
|
|
163
|
+
self._stop.set()
|
|
164
|
+
if self._warmer is not None:
|
|
165
|
+
self._warmer.join(timeout=2)
|
|
166
|
+
self._pool.shutdown(wait=False, cancel_futures=True)
|