simscope 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
@@ -0,0 +1,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)
@@ -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)