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/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