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/io/cas.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Content-addressed storage for assets and scenes."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from simscope.io import errors
|
|
15
|
+
|
|
16
|
+
_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
17
|
+
|
|
18
|
+
Kind = Literal["asset", "scene"]
|
|
19
|
+
_KIND_DIRS: dict[str, tuple[str, str]] = {
|
|
20
|
+
"asset": ("assets", ""),
|
|
21
|
+
"scene": ("scenes", ".json"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class Ref:
|
|
27
|
+
"""A reference to a content-addressed file.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
sha256: 64-character lowercase hex SHA-256 of the file bytes.
|
|
31
|
+
size: File size in bytes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
sha256: str
|
|
35
|
+
size: int
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
"""Validates the hash and size.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If ``sha256`` is not 64 lowercase hex characters or
|
|
42
|
+
``size`` is negative.
|
|
43
|
+
"""
|
|
44
|
+
if not _HEX_RE.match(self.sha256):
|
|
45
|
+
raise ValueError(f"invalid sha256 {self.sha256!r}")
|
|
46
|
+
if self.size < 0:
|
|
47
|
+
raise ValueError(f"invalid size {self.size}")
|
|
48
|
+
|
|
49
|
+
def to_json(self) -> dict[str, Any]:
|
|
50
|
+
"""Returns the spec's ``{"sha256", "size"}`` object."""
|
|
51
|
+
return {"sha256": self.sha256, "size": self.size}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_json(cls, obj: dict[str, Any]) -> "Ref":
|
|
55
|
+
"""Parses a reference object, ignoring unknown fields.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
obj: A mapping with ``sha256`` and ``size``.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The reference.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
errors.FormatError: If a field is missing or malformed.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
return cls(str(obj["sha256"]), int(obj["size"]))
|
|
68
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
69
|
+
raise errors.FormatError(f"bad content reference: {obj!r}") from exc
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def of(cls, data: bytes | bytearray | memoryview) -> "Ref":
|
|
73
|
+
"""Computes the reference of some bytes.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
data: The file contents.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The reference.
|
|
80
|
+
"""
|
|
81
|
+
return cls(hashlib.sha256(data).hexdigest(), memoryview(data).nbytes)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_temp(directory: pathlib.Path) -> tuple[int, str]:
|
|
85
|
+
"""Creates a temporary file next to its final destination.
|
|
86
|
+
|
|
87
|
+
Unlike ``tempfile.mkstemp`` the mode honors the umask (0666 & ~umask), so
|
|
88
|
+
published files are readable like any other file.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
directory: Directory of the destination file.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
``(fd, path)`` of the new file, opened for writing.
|
|
95
|
+
"""
|
|
96
|
+
while True:
|
|
97
|
+
name = str(directory / f".tmp-{uuid.uuid4().hex}")
|
|
98
|
+
try:
|
|
99
|
+
return os.open(
|
|
100
|
+
name, os.O_CREAT | os.O_EXCL | os.O_WRONLY | _BINARY, 0o666
|
|
101
|
+
), name
|
|
102
|
+
except FileExistsError:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_BINARY = getattr(os, "O_BINARY", 0)
|
|
107
|
+
"""Windows opens descriptors in text mode unless asked; 0 elsewhere."""
|
|
108
|
+
|
|
109
|
+
_RETRY_SECONDS = 2.0
|
|
110
|
+
"""How long a replace or delete keeps retrying while a reader holds the file."""
|
|
111
|
+
|
|
112
|
+
_read_lock = threading.Lock()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def open_read(path: os.PathLike[str] | str) -> int:
|
|
116
|
+
"""Opens a file for reading as a raw descriptor, in binary mode.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
path: The file.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A file descriptor. Close it with ``os.close``.
|
|
123
|
+
"""
|
|
124
|
+
return os.open(path, os.O_RDONLY | _BINARY)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_at(fd: int, size: int, offset: int) -> bytes:
|
|
128
|
+
"""Reads ``size`` bytes at ``offset`` without moving a shared position.
|
|
129
|
+
|
|
130
|
+
Uses ``os.pread`` where it exists. Windows has none, so there the seek
|
|
131
|
+
and the read happen under one lock.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
fd: A descriptor from :func:`open_read`.
|
|
135
|
+
size: Bytes to read.
|
|
136
|
+
offset: Where to start.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The bytes read (fewer than ``size`` at the end of the file).
|
|
140
|
+
"""
|
|
141
|
+
if hasattr(os, "pread"):
|
|
142
|
+
return os.pread(fd, size, offset)
|
|
143
|
+
with _read_lock: # pragma: no cover - Windows only
|
|
144
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
145
|
+
return os.read(fd, size)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _retry_while_open(action: Callable[[], object]) -> None:
|
|
149
|
+
"""Runs ``action``, retrying briefly on Windows' sharing violations.
|
|
150
|
+
|
|
151
|
+
Windows refuses to replace or delete a file that another handle has open,
|
|
152
|
+
for example a viewer reading a manifest that the recorder rewrites. The
|
|
153
|
+
other side only holds it for a moment, so a short retry is enough.
|
|
154
|
+
"""
|
|
155
|
+
deadline = time.monotonic() + _RETRY_SECONDS
|
|
156
|
+
delay = 0.002
|
|
157
|
+
while True:
|
|
158
|
+
try:
|
|
159
|
+
action()
|
|
160
|
+
return
|
|
161
|
+
except PermissionError:
|
|
162
|
+
if os.name != "nt" or time.monotonic() >= deadline:
|
|
163
|
+
raise
|
|
164
|
+
time.sleep(delay)
|
|
165
|
+
delay = min(delay * 2, 0.05)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def replace_file(
|
|
169
|
+
src: os.PathLike[str] | str, dst: os.PathLike[str] | str
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Moves ``src`` over ``dst`` atomically, waiting out Windows readers.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
src: The new file.
|
|
175
|
+
dst: The file to replace.
|
|
176
|
+
"""
|
|
177
|
+
_retry_while_open(lambda: os.replace(src, dst))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def remove_file(path: os.PathLike[str] | str) -> None:
|
|
181
|
+
"""Deletes a file if it exists, waiting out Windows readers.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
path: The file.
|
|
185
|
+
"""
|
|
186
|
+
_retry_while_open(lambda: pathlib.Path(path).unlink(missing_ok=True))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def atomic_write(path: os.PathLike[str] | str, data: bytes) -> None:
|
|
190
|
+
"""Writes a file atomically: temp file in the same directory, then rename.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
path: Destination. Parent directories are created.
|
|
194
|
+
data: File contents.
|
|
195
|
+
"""
|
|
196
|
+
target = pathlib.Path(path)
|
|
197
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
fd, tmp_name = create_temp(target.parent)
|
|
199
|
+
try:
|
|
200
|
+
with os.fdopen(fd, "wb") as f:
|
|
201
|
+
f.write(data)
|
|
202
|
+
replace_file(tmp_name, target)
|
|
203
|
+
except BaseException:
|
|
204
|
+
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ContentStore:
|
|
209
|
+
"""A directory of immutable files named by their SHA-256.
|
|
210
|
+
|
|
211
|
+
Assets live at ``assets/<h0h1>/<hex>`` and scene descriptors at
|
|
212
|
+
``scenes/<h0h1>/<hex>.json`` under the library root.
|
|
213
|
+
|
|
214
|
+
Attributes:
|
|
215
|
+
root: The library root directory.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, root: os.PathLike[str] | str) -> None:
|
|
219
|
+
"""Creates a store handle. Nothing is created on disk until a put.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
root: The library root.
|
|
223
|
+
"""
|
|
224
|
+
self.root = pathlib.Path(root)
|
|
225
|
+
|
|
226
|
+
def path(self, ref: Ref, kind: Kind = "asset") -> pathlib.Path:
|
|
227
|
+
"""Returns the file path for a reference.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
ref: The content reference.
|
|
231
|
+
kind: ``"asset"`` (meshes, textures) or ``"scene"``.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The path, which may not exist.
|
|
235
|
+
"""
|
|
236
|
+
subdir, suffix = _KIND_DIRS[kind]
|
|
237
|
+
return self.root / subdir / ref.sha256[:2] / (ref.sha256 + suffix)
|
|
238
|
+
|
|
239
|
+
def put(
|
|
240
|
+
self, data: bytes | bytearray | memoryview, kind: Kind = "asset"
|
|
241
|
+
) -> Ref:
|
|
242
|
+
"""Stores bytes, skipping the write if the target already exists.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
data: The file contents.
|
|
246
|
+
kind: ``"asset"`` or ``"scene"``.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The reference to the stored file.
|
|
250
|
+
"""
|
|
251
|
+
ref = Ref.of(data)
|
|
252
|
+
target = self.path(ref, kind)
|
|
253
|
+
if not target.exists():
|
|
254
|
+
atomic_write(target, bytes(data))
|
|
255
|
+
return ref
|
|
256
|
+
|
|
257
|
+
def has(self, ref: Ref, kind: Kind = "asset") -> bool:
|
|
258
|
+
"""Tells whether a file is present.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
ref: The content reference.
|
|
262
|
+
kind: ``"asset"`` or ``"scene"``.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
True if the file exists.
|
|
266
|
+
"""
|
|
267
|
+
return self.path(ref, kind).is_file()
|
|
268
|
+
|
|
269
|
+
def get(
|
|
270
|
+
self, ref: Ref, kind: Kind = "asset", *, verify: bool = False
|
|
271
|
+
) -> bytes:
|
|
272
|
+
"""Reads a file and checks its size.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
ref: The content reference.
|
|
276
|
+
kind: ``"asset"`` or ``"scene"``.
|
|
277
|
+
verify: Also check the SHA-256.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The file contents.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
FileNotFoundError: If the file is missing.
|
|
284
|
+
errors.FormatError: If the size or hash does not match.
|
|
285
|
+
"""
|
|
286
|
+
data = self.path(ref, kind).read_bytes()
|
|
287
|
+
if len(data) != ref.size:
|
|
288
|
+
raise errors.FormatError(
|
|
289
|
+
f"size mismatch for {ref.sha256}: file has {len(data)} "
|
|
290
|
+
f"bytes, reference says {ref.size}"
|
|
291
|
+
)
|
|
292
|
+
if verify and hashlib.sha256(data).hexdigest() != ref.sha256:
|
|
293
|
+
raise errors.FormatError(f"hash mismatch for {ref.sha256}")
|
|
294
|
+
return data
|