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/pack.py ADDED
@@ -0,0 +1,563 @@
1
+ """Packs (``*.simscope``): one file holding a library subtree."""
2
+
3
+ import contextlib
4
+ import dataclasses
5
+ import json
6
+ import mmap
7
+ import os
8
+ import pathlib
9
+ import shutil
10
+ import struct
11
+ import tempfile
12
+ import zlib
13
+ from collections.abc import Mapping, Sequence
14
+ from types import TracebackType
15
+ from typing import BinaryIO
16
+
17
+ from simscope import core
18
+ from simscope.io import blockfile, cas, codecs, errors, manifest, scene
19
+
20
+ MAGIC = b"SSPK"
21
+ MAJOR = 1
22
+ MINOR = 0
23
+ MINOR_DERIVED = 1
24
+ """Minor version of a pack that carries ``derived/`` entries."""
25
+ DERIVED_PREFIX = "derived/"
26
+ HEADER = struct.Struct("<4sHHIIQII")
27
+ HEADER_SIZE = 32
28
+ ANNOTATIONS_NAME = "annotations.json"
29
+ POSTER_NAME = "poster.png"
30
+ assert HEADER.size == HEADER_SIZE
31
+
32
+ _COPY_CHUNK = 1 << 20
33
+ _MESH_CODEC_OFFSET = 20
34
+
35
+ Source = bytes | pathlib.Path
36
+
37
+
38
+ def asset_path(ref: cas.Ref) -> str:
39
+ """Pack path of an asset blob."""
40
+ return f"assets/{ref.sha256[:2]}/{ref.sha256}"
41
+
42
+
43
+ def scene_path(ref: cas.Ref) -> str:
44
+ """Pack path of a scene descriptor."""
45
+ return f"scenes/{ref.sha256[:2]}/{ref.sha256}.json"
46
+
47
+
48
+ @dataclasses.dataclass
49
+ class _Collector:
50
+ """Accumulates pack entries and caches per-library transcodes."""
51
+
52
+ store: cas.ContentStore
53
+ transcode: bool
54
+ entries: dict[str, Source] = dataclasses.field(default_factory=dict)
55
+ scene_map: dict[cas.Ref, cas.Ref] = dataclasses.field(default_factory=dict)
56
+ mesh_map: dict[cas.Ref, cas.Ref] = dataclasses.field(default_factory=dict)
57
+
58
+ def add_asset(self, ref: cas.Ref) -> None:
59
+ """Adds an asset blob unchanged."""
60
+ self.entries[asset_path(ref)] = self.store.get(ref)
61
+
62
+ def add_mesh(self, ref: cas.Ref) -> cas.Ref:
63
+ """Adds a mesh blob, transcoding raw meshes to q16 if requested.
64
+
65
+ Returns:
66
+ The reference of the blob that was stored in the pack.
67
+ """
68
+ if ref in self.mesh_map:
69
+ return self.mesh_map[ref]
70
+ blob = self.store.get(ref)
71
+ if self.transcode and blob[_MESH_CODEC_OFFSET] == codecs.MESH_RAW:
72
+ blob = codecs.encode_mesh(codecs.decode_mesh(blob), "q16")
73
+ new = cas.Ref.of(blob)
74
+ self.entries[asset_path(new)] = blob
75
+ self.mesh_map[ref] = new
76
+ return new
77
+
78
+ def add_scene(self, ref: cas.Ref) -> cas.Ref:
79
+ """Adds a scene descriptor with its meshes and textures.
80
+
81
+ Returns:
82
+ The reference of the descriptor stored in the pack (new when
83
+ meshes were transcoded).
84
+ """
85
+ if ref in self.scene_map:
86
+ return self.scene_map[ref]
87
+ data = self.store.get(ref, "scene")
88
+ doc = json.loads(data)
89
+ for tex in doc.get("textures", []):
90
+ self.add_asset(cas.Ref.from_json(tex))
91
+ changed = False
92
+ for mesh in doc.get("meshes", []):
93
+ old = cas.Ref.from_json(mesh)
94
+ new = self.add_mesh(old)
95
+ if new != old:
96
+ mesh.update(new.to_json())
97
+ changed = True
98
+ if changed:
99
+ data = scene.canonical_bytes(doc)
100
+ new_ref = cas.Ref.of(data)
101
+ self.entries[scene_path(new_ref)] = data
102
+ self.scene_map[ref] = new_ref
103
+ return new_ref
104
+
105
+
106
+ def transcode_stream(
107
+ src: os.PathLike[str] | str,
108
+ dst: os.PathLike[str] | str,
109
+ codec: str = "q16d",
110
+ *,
111
+ kind: core.StreamKind = "pose",
112
+ envs: Sequence[int] | None = None,
113
+ ) -> None:
114
+ """Re-encodes a block file window by window (bounded memory).
115
+
116
+ Args:
117
+ src: Source block file.
118
+ dst: Destination block file.
119
+ codec: Target codec.
120
+ kind: Stream kind, which selects quaternion sign handling.
121
+ envs: Keep only these envs, in this order (env ``i`` of the output
122
+ is env ``envs[i]`` of the source). ``None`` keeps all.
123
+ """
124
+ with blockfile.BlockReader(src, cache_blocks=0) as r:
125
+ n_out = r.n_envs if envs is None else len(envs)
126
+ with blockfile.BlockWriter(
127
+ dst,
128
+ item_shape=r.item_shape,
129
+ n_envs=n_out,
130
+ kind=kind,
131
+ codec=codec,
132
+ block_frames=r.block_frames,
133
+ ) as w:
134
+ for t0 in range(0, r.n_frames, r.block_frames):
135
+ w.append(r.read(t0, min(t0 + r.block_frames, r.n_frames), envs))
136
+
137
+
138
+ def check_envs(envs: Sequence[int], n_envs: int, what: str) -> list[int]:
139
+ """Validates an env subset against a run.
140
+
141
+ Args:
142
+ envs: Env indices to keep.
143
+ n_envs: Number of envs the run has.
144
+ what: Run name, for the error message.
145
+
146
+ Returns:
147
+ The indices as a list of ints.
148
+
149
+ Raises:
150
+ ValueError: If the list is empty, repeats an index, or names an env
151
+ the run does not have.
152
+ """
153
+ ids = [int(e) for e in envs]
154
+ if not ids:
155
+ raise ValueError("envs must not be empty")
156
+ if len(set(ids)) != len(ids):
157
+ raise ValueError("envs must not repeat an index")
158
+ bad = [e for e in ids if not 0 <= e < n_envs]
159
+ if bad:
160
+ raise ValueError(
161
+ f"run {what!r} has {n_envs} envs, but envs asks for {bad[:3]}"
162
+ )
163
+ return ids
164
+
165
+
166
+ def subset_annotations(data: bytes, envs: Sequence[int]) -> bytes:
167
+ """Restricts an ``annotations.json`` to an env subset.
168
+
169
+ Events and spatial annotations of envs that are kept follow them to
170
+ their new index; those of other envs are dropped. Records without an
171
+ env (``null``) stay. Unknown fields are preserved.
172
+
173
+ Args:
174
+ data: The sidecar bytes.
175
+ envs: Kept envs, in output order.
176
+
177
+ Returns:
178
+ The sidecar bytes, with only the kept envs.
179
+ """
180
+ doc = json.loads(data)
181
+ new = {env: i for i, env in enumerate(envs)}
182
+ for key in ("events", "spatial"):
183
+ kept = []
184
+ for rec in doc.get(key, []):
185
+ env = rec.get("env")
186
+ if env is None:
187
+ kept.append(rec)
188
+ elif env in new:
189
+ kept.append({**rec, "env": new[env]})
190
+ if key in doc:
191
+ doc[key] = kept
192
+ text = json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False)
193
+ return (text + "\n").encode("utf-8")
194
+
195
+
196
+ def _source_codec(path: pathlib.Path) -> str:
197
+ """Names the codec to keep when re-encoding a block file unchanged."""
198
+ return "q16d" if _only_q16d(path) else "f32s"
199
+
200
+
201
+ def _only_q16d(path: pathlib.Path) -> bool:
202
+ """Tells whether every block of a finished block file is q16d."""
203
+ with blockfile.BlockReader(path, cache_blocks=0) as r:
204
+ return bool((r.directory["codec"] == codecs.CODEC_Q16D).all())
205
+
206
+
207
+ def _write_entries(
208
+ out: BinaryIO, entries: dict[str, Source]
209
+ ) -> list[dict[str, str | int]]:
210
+ """Writes entry bytes (aligned to 8) and returns directory records."""
211
+ records: list[dict[str, str | int]] = []
212
+ for path in sorted(entries):
213
+ src = entries[path]
214
+ out.write(bytes(-out.tell() % 8))
215
+ offset = out.tell()
216
+ if isinstance(src, bytes):
217
+ out.write(src)
218
+ else:
219
+ with open(src, "rb") as f:
220
+ shutil.copyfileobj(f, out, _COPY_CHUNK)
221
+ records.append(
222
+ {"path": path, "offset": offset, "length": out.tell() - offset}
223
+ )
224
+ return records
225
+
226
+
227
+ def write_pack(
228
+ library_root: os.PathLike[str] | str,
229
+ run_names: list[str],
230
+ out_path: os.PathLike[str] | str,
231
+ *,
232
+ transcode: bool = True,
233
+ annotations: bool = True,
234
+ posters: bool = True,
235
+ envs: Sequence[int] | None = None,
236
+ derived: Mapping[str, Source] | None = None,
237
+ ) -> pathlib.Path:
238
+ """Writes runs and everything they reference into one pack file.
239
+
240
+ With ``transcode=True`` (the export default), pose streams are re-encoded
241
+ with ``q16d``, raw meshes are re-encoded as q16, and scenes are rewritten
242
+ with the new mesh references (so their hashes change). Other streams and
243
+ textures are copied unchanged. Each asset is stored once, however many
244
+ runs share it. The library is not modified.
245
+
246
+ Args:
247
+ library_root: The library directory.
248
+ run_names: Runs to include (each must be complete).
249
+ out_path: The pack file to write (replaced atomically).
250
+ transcode: Whether to shrink the pack as described above.
251
+ annotations: Whether to include each run's ``annotations.json``.
252
+ posters: Whether to include each run's ``poster.png``.
253
+ envs: Keep only these envs of every run, in this order. Streams are
254
+ re-encoded for them (the source codec, or q16d poses with
255
+ ``transcode``), ``env_origins`` and ``env_scenes`` are cut to
256
+ them, and the events of other envs leave ``annotations.json``.
257
+ derived: Extra entries by pack path, all under ``derived/``
258
+ (contracts 1). A pack that has any records minor version 1.
259
+
260
+ Returns:
261
+ The output path.
262
+
263
+ Raises:
264
+ ValueError: On duplicate run names, a run that is still recording,
265
+ an invalid ``envs`` for a run, or a ``derived`` path outside
266
+ ``derived/``.
267
+ FileNotFoundError: If a run or referenced file is missing.
268
+ """
269
+ root = pathlib.Path(library_root)
270
+ out_path = pathlib.Path(out_path)
271
+ if len(set(run_names)) != len(run_names):
272
+ raise ValueError("duplicate run names")
273
+ for path in derived or {}:
274
+ if not path.startswith(DERIVED_PREFIX) or ".." in path.split("/"):
275
+ raise ValueError(f"derived entries live under derived/: {path!r}")
276
+ col = _Collector(cas.ContentStore(root), transcode)
277
+ with tempfile.TemporaryDirectory(prefix="simscope-pack-") as tmp_name:
278
+ tmp = pathlib.Path(tmp_name)
279
+ for name in run_names:
280
+ _add_run(
281
+ col,
282
+ root,
283
+ manifest.validate_run_name(name),
284
+ tmp,
285
+ sidecars=_sidecars(annotations=annotations, posters=posters),
286
+ envs=envs,
287
+ )
288
+ col.entries.update(derived or {})
289
+ out_path.parent.mkdir(parents=True, exist_ok=True)
290
+ fd, tmp_out = cas.create_temp(out_path.parent)
291
+ try:
292
+ with os.fdopen(fd, "wb") as out:
293
+ out.write(bytes(HEADER_SIZE))
294
+ records = _write_entries(out, col.entries)
295
+ out.write(bytes(-out.tell() % 8))
296
+ dir_offset = out.tell()
297
+ directory = json.dumps(
298
+ {"entries": records},
299
+ separators=(",", ":"),
300
+ ensure_ascii=False,
301
+ ).encode("utf-8")
302
+ out.write(directory)
303
+ out.seek(0)
304
+ out.write(
305
+ HEADER.pack(
306
+ MAGIC,
307
+ MAJOR,
308
+ MINOR_DERIVED if derived else MINOR,
309
+ len(records),
310
+ 0,
311
+ dir_offset,
312
+ len(directory),
313
+ zlib.crc32(directory),
314
+ )
315
+ )
316
+ os.replace(tmp_out, out_path)
317
+ except BaseException:
318
+ pathlib.Path(tmp_out).unlink(missing_ok=True)
319
+ raise
320
+ return out_path
321
+
322
+
323
+ def _sidecars(*, annotations: bool, posters: bool) -> tuple[str, ...]:
324
+ """Names the optional per-run files a pack should carry."""
325
+ names = []
326
+ if annotations:
327
+ names.append(ANNOTATIONS_NAME)
328
+ if posters:
329
+ names.append(POSTER_NAME)
330
+ return tuple(names)
331
+
332
+
333
+ def _add_run(
334
+ col: _Collector,
335
+ root: pathlib.Path,
336
+ name: str,
337
+ tmp: pathlib.Path,
338
+ *,
339
+ sidecars: tuple[str, ...],
340
+ envs: Sequence[int] | None = None,
341
+ ) -> None:
342
+ """Collects the entries of one run, plus the named sidecar files."""
343
+ run_dir = root / "runs" / name
344
+ if not (run_dir / manifest.MANIFEST_NAME).exists():
345
+ if (run_dir / manifest.PARTIAL_NAME).exists():
346
+ raise ValueError(f"run {name!r} is still recording")
347
+ raise FileNotFoundError(run_dir / manifest.MANIFEST_NAME)
348
+ man = manifest.read_manifest(run_dir)
349
+ if man.name != name:
350
+ raise errors.FormatError(f"manifest name {man.name!r} != dir {name!r}")
351
+ if envs is not None:
352
+ ids = check_envs(envs, man.n_envs, name)
353
+ man.n_envs = len(ids)
354
+ if man.env_origins is not None:
355
+ man.env_origins = tuple(man.env_origins[i] for i in ids)
356
+ if man.env_scenes is not None:
357
+ man.env_scenes = tuple(man.env_scenes[i] for i in ids)
358
+ else:
359
+ ids = None
360
+ man.scene = col.add_scene(man.scene)
361
+ if man.env_scenes is not None:
362
+ man.env_scenes = tuple(col.add_scene(r) for r in man.env_scenes)
363
+ prefix = f"runs/{name}/"
364
+ col.entries[prefix + manifest.MANIFEST_NAME] = manifest.manifest_bytes(man)
365
+ for stream in man.streams.values():
366
+ src = run_dir / stream.file
367
+ pose = col.transcode and stream.kind == "pose"
368
+ if ids is not None or (pose and not _only_q16d(src)):
369
+ dst = tmp / f"{name}-{stream.file}"
370
+ codec = "q16d" if pose else _source_codec(src)
371
+ transcode_stream(src, dst, codec, kind=stream.kind, envs=ids)
372
+ src = dst
373
+ col.entries[prefix + stream.file] = src
374
+ for sidecar in sidecars:
375
+ path = run_dir / sidecar
376
+ if path.is_file():
377
+ data = path.read_bytes()
378
+ if ids is not None and sidecar == ANNOTATIONS_NAME:
379
+ data = subset_annotations(data, ids)
380
+ col.entries[prefix + sidecar] = data
381
+
382
+
383
+ class PackReader:
384
+ """Read access to a pack, from a file (memory-mapped) or from bytes.
385
+
386
+ ``read`` returns zero-copy memoryview slices of the pack. Close readers
387
+ opened with :meth:`stream` before closing the pack.
388
+ """
389
+
390
+ def __init__(
391
+ self, source: os.PathLike[str] | str | bytes | bytearray | memoryview
392
+ ) -> None:
393
+ """Opens a pack and reads its directory.
394
+
395
+ Args:
396
+ source: A path, or the pack bytes.
397
+
398
+ Raises:
399
+ errors.FormatError: On a bad magic, unknown major version, CRC
400
+ mismatch or invalid directory.
401
+ """
402
+ self._mm: mmap.mmap | None = None
403
+ self._view: memoryview | None = None
404
+ if isinstance(source, str | os.PathLike):
405
+ with open(source, "rb") as f:
406
+ if os.fstat(f.fileno()).st_size < HEADER_SIZE:
407
+ raise errors.FormatError(f"{source}: shorter than header")
408
+ self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
409
+ self._view = memoryview(self._mm)
410
+ else:
411
+ self._view = memoryview(source).cast("B")
412
+ try:
413
+ self._entries = self._parse(self._view)
414
+ except BaseException:
415
+ self.close()
416
+ raise
417
+
418
+ @staticmethod
419
+ def _parse(view: memoryview) -> dict[str, tuple[int, int]]:
420
+ """Validates the header and returns ``path -> (offset, length)``."""
421
+ if len(view) < HEADER_SIZE:
422
+ raise errors.FormatError("pack shorter than its header")
423
+ magic, major, _minor, n, _res, dir_off, dir_len, crc = (
424
+ HEADER.unpack_from(view, 0)
425
+ )
426
+ if magic != MAGIC:
427
+ raise errors.FormatError(f"bad pack magic {magic!r}")
428
+ if major != MAJOR:
429
+ raise errors.FormatError(f"unknown pack major version {major}")
430
+ if dir_off + dir_len > len(view):
431
+ raise errors.FormatError("pack directory is truncated")
432
+ with view[dir_off : dir_off + dir_len] as raw:
433
+ if zlib.crc32(raw) != crc:
434
+ raise errors.FormatError("pack directory CRC mismatch")
435
+ try:
436
+ records = json.loads(bytes(raw))["entries"]
437
+ except (ValueError, KeyError, TypeError) as exc:
438
+ raise errors.FormatError(f"bad pack directory: {exc}") from exc
439
+ entries: dict[str, tuple[int, int]] = {}
440
+ for rec in records:
441
+ path, off, length = rec["path"], rec["offset"], rec["length"]
442
+ if (
443
+ path.startswith("/")
444
+ or ".." in path.split("/")
445
+ or off + length > len(view)
446
+ ):
447
+ raise errors.FormatError(f"invalid pack entry {path!r}")
448
+ entries[path] = (off, length)
449
+ if len(entries) != n:
450
+ raise errors.FormatError("pack entry count mismatch")
451
+ return entries
452
+
453
+ def paths(self) -> list[str]:
454
+ """Returns all entry paths, sorted."""
455
+ return sorted(self._entries)
456
+
457
+ def __contains__(self, path: object) -> bool:
458
+ """Tells whether an entry exists."""
459
+ return path in self._entries
460
+
461
+ def read(self, path: str) -> memoryview:
462
+ """Returns an entry's bytes as a zero-copy view.
463
+
464
+ Args:
465
+ path: Entry path, for example ``runs/walk/rollout.json``.
466
+
467
+ Returns:
468
+ A read-only memoryview into the pack.
469
+
470
+ Raises:
471
+ KeyError: If the entry does not exist.
472
+ ValueError: If the reader is closed.
473
+ """
474
+ if self._view is None:
475
+ raise ValueError("pack reader is closed")
476
+ off, length = self._entries[path]
477
+ return self._view[off : off + length]
478
+
479
+ def runs(self) -> list[str]:
480
+ """Returns the names of the runs in the pack, sorted."""
481
+ return sorted(
482
+ p.split("/")[1]
483
+ for p in self._entries
484
+ if p.startswith("runs/")
485
+ and p.endswith("/" + manifest.MANIFEST_NAME)
486
+ )
487
+
488
+ def manifest(self, run: str) -> manifest.RolloutManifest:
489
+ """Parses a run's manifest.
490
+
491
+ Args:
492
+ run: Run name.
493
+
494
+ Returns:
495
+ The manifest.
496
+ """
497
+ with self.read(f"runs/{run}/{manifest.MANIFEST_NAME}") as raw:
498
+ return manifest.RolloutManifest.from_json(json.loads(bytes(raw)))
499
+
500
+ def _asset(self, ref: cas.Ref) -> bytes:
501
+ """Returns a blob's bytes, checking the size."""
502
+ with self.read(asset_path(ref)) as raw:
503
+ if len(raw) != ref.size:
504
+ raise errors.FormatError(f"size mismatch for {ref.sha256}")
505
+ return bytes(raw)
506
+
507
+ def scene(self, ref: cas.Ref) -> core.Scene:
508
+ """Loads a scene and its assets from the pack.
509
+
510
+ Args:
511
+ ref: Reference from a manifest (``scene`` or ``env_scenes``).
512
+
513
+ Returns:
514
+ The scene. Transcoded meshes have no normals.
515
+ """
516
+ with self.read(scene_path(ref)) as raw:
517
+ doc = json.loads(bytes(raw))
518
+ return scene.scene_from_json(doc, self._asset)
519
+
520
+ def stream(
521
+ self, run: str, name: str, *, cache_blocks: int = 64
522
+ ) -> blockfile.BlockReader:
523
+ """Opens a stream of a run for random access.
524
+
525
+ Args:
526
+ run: Run name.
527
+ name: Stream name from the run's manifest.
528
+ cache_blocks: Decoded blocks to cache.
529
+
530
+ Returns:
531
+ A reader over the block file inside the pack, with the stream
532
+ kind set so q16d poses are renormalized.
533
+ """
534
+ info = self.manifest(run).streams[name]
535
+ return blockfile.BlockReader(
536
+ self.read(f"runs/{run}/{info.file}"),
537
+ cache_blocks=cache_blocks,
538
+ kind=info.kind,
539
+ )
540
+
541
+ def close(self) -> None:
542
+ """Releases the memory map. Safe to call more than once."""
543
+ if self._view is not None:
544
+ self._view.release()
545
+ self._view = None
546
+ if self._mm is not None:
547
+ # Views still exported: the map closes when they are dropped.
548
+ with contextlib.suppress(BufferError):
549
+ self._mm.close()
550
+ self._mm = None
551
+
552
+ def __enter__(self) -> "PackReader":
553
+ """Returns the reader."""
554
+ return self
555
+
556
+ def __exit__(
557
+ self,
558
+ exc_type: type[BaseException] | None,
559
+ exc: BaseException | None,
560
+ tb: TracebackType | None,
561
+ ) -> None:
562
+ """Closes the reader."""
563
+ self.close()