diffract-core 0.2.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 (193) hide show
  1. diffract/__init__.py +52 -0
  2. diffract/configs/fast_speed_without_disk.ini +48 -0
  3. diffract/configs/hybrid.ini +74 -0
  4. diffract/configs/sqlite.ini +62 -0
  5. diffract/containers.py +405 -0
  6. diffract/core/__init__.py +23 -0
  7. diffract/core/cache/__init__.py +24 -0
  8. diffract/core/cache/containers.py +93 -0
  9. diffract/core/cache/interface.py +117 -0
  10. diffract/core/cache/redis_manager.py +464 -0
  11. diffract/core/cache/simple_manager.py +478 -0
  12. diffract/core/compute/__init__.py +46 -0
  13. diffract/core/compute/config.py +48 -0
  14. diffract/core/compute/containers.py +81 -0
  15. diffract/core/compute/decorator.py +125 -0
  16. diffract/core/compute/exceptions.py +31 -0
  17. diffract/core/compute/execution/__init__.py +14 -0
  18. diffract/core/compute/execution/_types.py +70 -0
  19. diffract/core/compute/execution/aggregation.py +74 -0
  20. diffract/core/compute/execution/aggregation_runner.py +362 -0
  21. diffract/core/compute/execution/enums.py +38 -0
  22. diffract/core/compute/execution/executor.py +125 -0
  23. diffract/core/compute/execution/parameter_runner.py +125 -0
  24. diffract/core/compute/execution/restrictions.py +61 -0
  25. diffract/core/compute/execution/strategy.py +118 -0
  26. diffract/core/compute/execution/utils.py +112 -0
  27. diffract/core/compute/extensions/__init__.py +0 -0
  28. diffract/core/compute/extensions/power_law.py +1051 -0
  29. diffract/core/compute/extensions/rmt.py +150 -0
  30. diffract/core/compute/extensions/utils.py +36 -0
  31. diffract/core/compute/field_signature.py +183 -0
  32. diffract/core/compute/kernels/__init__.py +48 -0
  33. diffract/core/compute/kernels/alignment.py +106 -0
  34. diffract/core/compute/kernels/heavy_tailed.py +394 -0
  35. diffract/core/compute/kernels/marchenko_pastur.py +99 -0
  36. diffract/core/compute/kernels/mat_decomposition.py +101 -0
  37. diffract/core/compute/kernels/mat_properties.py +53 -0
  38. diffract/core/compute/kernels/model_quality.py +27 -0
  39. diffract/core/compute/kernels/norms.py +88 -0
  40. diffract/core/compute/kernels/ranks.py +35 -0
  41. diffract/core/compute/kernels/tracy_widom.py +36 -0
  42. diffract/core/compute/metadata.py +67 -0
  43. diffract/core/compute/registry.py +485 -0
  44. diffract/core/constants.py +147 -0
  45. diffract/core/data/__init__.py +32 -0
  46. diffract/core/data/interface.py +304 -0
  47. diffract/core/data/metadata/__init__.py +15 -0
  48. diffract/core/data/metadata/containers.py +60 -0
  49. diffract/core/data/metadata/interface.py +208 -0
  50. diffract/core/data/metadata/sqlite_index.py +604 -0
  51. diffract/core/data/nn/__init__.py +43 -0
  52. diffract/core/data/nn/aggregates/__init__.py +35 -0
  53. diffract/core/data/nn/aggregates/metadata.py +96 -0
  54. diffract/core/data/nn/aggregates/proxy.py +57 -0
  55. diffract/core/data/nn/aggregates/repository.py +87 -0
  56. diffract/core/data/nn/aggregates/view.py +307 -0
  57. diffract/core/data/nn/containers.py +86 -0
  58. diffract/core/data/nn/extractors/__init__.py +49 -0
  59. diffract/core/data/nn/extractors/base.py +300 -0
  60. diffract/core/data/nn/extractors/factory.py +234 -0
  61. diffract/core/data/nn/extractors/flax.py +112 -0
  62. diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
  63. diffract/core/data/nn/extractors/handlers/base.py +110 -0
  64. diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
  65. diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
  66. diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
  67. diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
  68. diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
  69. diffract/core/data/nn/extractors/interface.py +56 -0
  70. diffract/core/data/nn/extractors/numpy.py +94 -0
  71. diffract/core/data/nn/extractors/onnx.py +108 -0
  72. diffract/core/data/nn/extractors/tensorflow.py +99 -0
  73. diffract/core/data/nn/extractors/torch.py +204 -0
  74. diffract/core/data/nn/params/__init__.py +27 -0
  75. diffract/core/data/nn/params/interface.py +402 -0
  76. diffract/core/data/nn/params/metadata.py +110 -0
  77. diffract/core/data/nn/params/proxy.py +93 -0
  78. diffract/core/data/nn/params/repository.py +45 -0
  79. diffract/core/data/nn/params/schema.py +82 -0
  80. diffract/core/data/nn/params/view.py +393 -0
  81. diffract/core/data/proxy.py +246 -0
  82. diffract/core/data/repository.py +227 -0
  83. diffract/core/data/utils.py +33 -0
  84. diffract/core/data/view.py +389 -0
  85. diffract/core/export/__init__.py +27 -0
  86. diffract/core/export/containers.py +47 -0
  87. diffract/core/export/exporters.py +191 -0
  88. diffract/core/export/formatters/__init__.py +23 -0
  89. diffract/core/export/formatters/base.py +68 -0
  90. diffract/core/export/formatters/dict_formatter.py +38 -0
  91. diffract/core/export/formatters/json_formatter.py +58 -0
  92. diffract/core/export/formatters/list_formatter.py +41 -0
  93. diffract/core/export/formatters/pandas_formatter.py +86 -0
  94. diffract/core/export/formatters/polars_formatter.py +86 -0
  95. diffract/core/export/formatters/registry.py +84 -0
  96. diffract/core/export/interface.py +105 -0
  97. diffract/core/parallel/__init__.py +21 -0
  98. diffract/core/parallel/containers.py +57 -0
  99. diffract/core/parallel/execution.py +91 -0
  100. diffract/core/parallel/runtime.py +91 -0
  101. diffract/core/storage/__init__.py +35 -0
  102. diffract/core/storage/base_manager.py +330 -0
  103. diffract/core/storage/containers.py +122 -0
  104. diffract/core/storage/hdf5_manager.py +856 -0
  105. diffract/core/storage/hybrid_manager.py +218 -0
  106. diffract/core/storage/interface.py +222 -0
  107. diffract/core/storage/metadata.py +89 -0
  108. diffract/core/storage/ram_manager.py +159 -0
  109. diffract/core/storage/sqlite_manager.py +1062 -0
  110. diffract/core/storage/zarr_manager.py +992 -0
  111. diffract/core/utils/__init__.py +45 -0
  112. diffract/core/utils/build.py +46 -0
  113. diffract/core/utils/exceptions.py +11 -0
  114. diffract/core/utils/hashing.py +90 -0
  115. diffract/core/utils/imports.py +292 -0
  116. diffract/core/utils/math.py +15 -0
  117. diffract/py.typed +0 -0
  118. diffract/session/__init__.py +24 -0
  119. diffract/session/errors.py +17 -0
  120. diffract/session/field_cache.py +216 -0
  121. diffract/session/namespaces/__init__.py +15 -0
  122. diffract/session/namespaces/compute/__init__.py +219 -0
  123. diffract/session/namespaces/models/__init__.py +282 -0
  124. diffract/session/namespaces/models/parameters/__init__.py +133 -0
  125. diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
  126. diffract/session/namespaces/results/__init__.py +378 -0
  127. diffract/session/namespaces/results/eraser.py +129 -0
  128. diffract/session/namespaces/results/injester.py +249 -0
  129. diffract/session/namespaces/utils/__init__.py +141 -0
  130. diffract/session/namespaces/utils/merger.py +295 -0
  131. diffract/session/namespaces/viz/__init__.py +91 -0
  132. diffract/session/namespaces/viz/_utils.py +14 -0
  133. diffract/session/namespaces/viz/box.py +207 -0
  134. diffract/session/namespaces/viz/grid.py +160 -0
  135. diffract/session/namespaces/viz/heatmap.py +164 -0
  136. diffract/session/namespaces/viz/scatter.py +202 -0
  137. diffract/session/namespaces/viz/sparkline.py +270 -0
  138. diffract/session/namespaces/viz/violin.py +212 -0
  139. diffract/session/session.py +260 -0
  140. diffract/session/utils.py +81 -0
  141. diffract/viz/__init__.py +42 -0
  142. diffract/viz/data/__init__.py +34 -0
  143. diffract/viz/data/detection.py +57 -0
  144. diffract/viz/data/extraction.py +117 -0
  145. diffract/viz/data/filtering.py +57 -0
  146. diffract/viz/data/ordering.py +129 -0
  147. diffract/viz/data/provider.py +99 -0
  148. diffract/viz/data/types.py +98 -0
  149. diffract/viz/plots/__init__.py +37 -0
  150. diffract/viz/plots/base/__init__.py +20 -0
  151. diffract/viz/plots/base/axis.py +219 -0
  152. diffract/viz/plots/base/coloraxis.py +133 -0
  153. diffract/viz/plots/base/configurator.py +18 -0
  154. diffract/viz/plots/base/jitter.py +368 -0
  155. diffract/viz/plots/base/line.py +197 -0
  156. diffract/viz/plots/base/marker.py +278 -0
  157. diffract/viz/plots/base/overlay.py +14 -0
  158. diffract/viz/plots/base/plot.py +110 -0
  159. diffract/viz/plots/base/update.py +50 -0
  160. diffract/viz/plots/boxplot.py +283 -0
  161. diffract/viz/plots/cluster.py +374 -0
  162. diffract/viz/plots/heatmap.py +168 -0
  163. diffract/viz/plots/scatter.py +233 -0
  164. diffract/viz/plots/sparkline.py +405 -0
  165. diffract/viz/plots/subplots/__init__.py +32 -0
  166. diffract/viz/plots/subplots/coloraxis.py +339 -0
  167. diffract/viz/plots/subplots/factory.py +713 -0
  168. diffract/viz/plots/subplots/grid.py +214 -0
  169. diffract/viz/plots/subplots/layout.py +370 -0
  170. diffract/viz/plots/subplots/spec.py +39 -0
  171. diffract/viz/plots/violin.py +298 -0
  172. diffract/viz/renderer.py +513 -0
  173. diffract/viz/styling/__init__.py +78 -0
  174. diffract/viz/styling/palettes/__init__.py +20 -0
  175. diffract/viz/styling/palettes/bundle.py +16 -0
  176. diffract/viz/styling/palettes/color.py +83 -0
  177. diffract/viz/styling/palettes/dashes.py +27 -0
  178. diffract/viz/styling/palettes/symbols.py +42 -0
  179. diffract/viz/styling/resolvers/__init__.py +11 -0
  180. diffract/viz/styling/resolvers/categorical.py +68 -0
  181. diffract/viz/styling/resolvers/color.py +77 -0
  182. diffract/viz/styling/resolvers/numeric.py +108 -0
  183. diffract/viz/styling/sources.py +27 -0
  184. diffract/viz/styling/theme/__init__.py +29 -0
  185. diffract/viz/styling/theme/applier.py +114 -0
  186. diffract/viz/styling/theme/components.py +66 -0
  187. diffract/viz/styling/theme/presets.py +58 -0
  188. diffract/viz/styling/theme/theme.py +36 -0
  189. diffract_core-0.2.1.dist-info/METADATA +530 -0
  190. diffract_core-0.2.1.dist-info/RECORD +193 -0
  191. diffract_core-0.2.1.dist-info/WHEEL +4 -0
  192. diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
  193. diffract_core-0.2.1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,856 @@
1
+ """HDF5-backed storage manager optimized for high-throughput reads.
2
+
3
+ Design notes:
4
+ - Writes are serialized via a single write handle under a lock.
5
+ - Reads use per-thread read-only file handles (thread-local) to avoid
6
+ Python-level locks.
7
+ - Object existence is tracked by a dataset-based index (resizable 1D string dataset).
8
+ - Data is organized by tables for logical separation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import logging
16
+ import pickle
17
+ import threading
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any, Self
20
+
21
+ import numpy as np
22
+
23
+ import diffract.core.utils.imports as import_utils
24
+ from diffract.core.constants import (
25
+ HDF5_INDEX_DATASET,
26
+ HDF5_INDEX_GROUP,
27
+ HDF5_INDEX_TOMBSTONE,
28
+ STORAGE_ATTR_META,
29
+ STORAGE_ATTR_TYPE,
30
+ )
31
+ from diffract.core.utils.exceptions import format_exception_message
32
+
33
+ from .base_manager import BaseStorageManager
34
+ from .interface import DEFAULT_TABLE, UID
35
+ from .metadata import infer_value_metadata
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Generator
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ if not import_utils.is_available("h5py"):
44
+ logger.debug("h5py not available, disabling HDF5 storage manager")
45
+
46
+ class HDF5StorageManager(BaseStorageManager):
47
+ """Stub implementation when h5py is not available."""
48
+
49
+ def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
50
+ """Raise ImportError because the optional dependency is missing."""
51
+ msg = "h5py package not available"
52
+ raise ImportError(msg)
53
+
54
+ else:
55
+ h5py = import_utils.require("h5py")
56
+
57
+ class HDF5StorageManager(BaseStorageManager):
58
+ """Persistent storage manager using an HDF5 file.
59
+
60
+ Supports storing arbitrary Python values (pickled fallback) and NumPy arrays.
61
+ Intended for workloads with frequent multi-threaded reads and serialized writes.
62
+
63
+ Example:
64
+ >>> storage = HDF5StorageManager("data.h5")
65
+ >>> with storage:
66
+ ... storage.set_field("param_001", "weights", np.random.randn(100, 100))
67
+ ... weights = storage.get_field("param_001", "weights")
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ path: str,
73
+ *,
74
+ root: str = "root",
75
+ compression: str | None = None,
76
+ compression_opts: int | None = None,
77
+ shuffle: bool = False,
78
+ fletcher: bool = False,
79
+ swmr: bool = True,
80
+ verify_index: bool = False,
81
+ keep_file_open: bool = True,
82
+ readonly: bool = False,
83
+ refresh_on_read: bool = False,
84
+ chunks: bool | tuple[int, ...] | None = None,
85
+ index_cache: bool = True,
86
+ index_group: str = HDF5_INDEX_GROUP,
87
+ index_all_objs: str = HDF5_INDEX_DATASET,
88
+ index_tombstone: str = HDF5_INDEX_TOMBSTONE,
89
+ index_encoding: str = "utf-8",
90
+ index_chunk_len: int = 4096,
91
+ sort_list_objs: bool = True,
92
+ libver: str = "latest",
93
+ **kwargs: Any,
94
+ ) -> None:
95
+ """Create a storage manager.
96
+
97
+ Args:
98
+ path: Path to the HDF5 file.
99
+ root: Top-level group name used for data storage.
100
+ compression: Compression algorithm (e.g., "gzip", "lzf").
101
+ compression_opts: Compression options (e.g., compression level).
102
+ shuffle: Enable shuffle filter for compression.
103
+ fletcher: Enable Fletcher32 checksum.
104
+ swmr: Enable SWMR mode (Single Writer Multiple Reader) when supported.
105
+ keep_file_open: Keep file handles open between operations.
106
+ readonly: If True, reject any write operation.
107
+ refresh_on_read: If True and SWMR is enabled, call `file.refresh()`
108
+ on reads.
109
+ chunks: Chunking policy for ndarray datasets; use contiguous layout
110
+ when possible.
111
+ verify_index: If True, verify index consistency in `list_objs()`
112
+ and rebuild if needed.
113
+ index_cache: Enable in-memory index cache.
114
+ index_group: Group name for object index.
115
+ index_all_objs: Dataset name for "all objects" index.
116
+ index_tombstone: Dataset name for tombstone markers.
117
+ index_encoding: Encoding for index string datasets.
118
+ index_chunk_len: Chunk size for index datasets.
119
+ sort_list_objs: Sort object IDs in list_objs() output.
120
+ libver: HDF5 library format bounds (e.g. "latest").
121
+ **kwargs: Additional keyword arguments for BaseStorageManager.
122
+ """
123
+ super().__init__(**kwargs)
124
+
125
+ self._path = path
126
+ self._root = root
127
+ self._compression = compression
128
+ self._compression_opts = compression_opts
129
+ self._shuffle = shuffle
130
+ self._fletcher = fletcher
131
+ self._swmr = swmr
132
+ self._verify_index = verify_index
133
+ self._keep_file_open = keep_file_open
134
+ self._readonly = readonly
135
+ self._refresh_on_read = refresh_on_read
136
+ self._chunks = chunks
137
+ self._index_cache_enabled = index_cache
138
+ self._index_group = index_group
139
+ self._index_all_objs = index_all_objs
140
+ self._index_tombstone = index_tombstone
141
+ self._index_encoding = index_encoding
142
+ if index_chunk_len <= 0:
143
+ msg = "index_chunk_len must be > 0"
144
+ raise ValueError(msg)
145
+ self._index_chunk_len = index_chunk_len
146
+ self._sort_list_objs = sort_list_objs
147
+ self._libver = libver
148
+
149
+ # Per-table index caches (only created when index_cache=True)
150
+ self._index_caches: dict[str, set[str]] = {}
151
+ self._index_cache_enabled = index_cache
152
+
153
+ # Writes serialized; reads use per-thread read-only file handles.
154
+ self._write_lock = threading.Lock()
155
+ self._write_handle: h5py.File | None = None
156
+ self._read_local = threading.local()
157
+
158
+ dirpath = Path(self._path).parent
159
+ if not dirpath.exists():
160
+ dirpath.mkdir(parents=True)
161
+
162
+ # Ensure schema exists unless in readonly mode.
163
+ if not self._readonly:
164
+ with h5py.File(self._path, "a", libver=self._libver) as f:
165
+ f.require_group(self._root)
166
+ if self._swmr:
167
+ with contextlib.suppress(Exception):
168
+ if not f.swmr_mode:
169
+ f.swmr_mode = True
170
+
171
+ # Track read handles across threads so we can close them before opening a
172
+ # write handle. HDF5 disallows opening the same file read-only and
173
+ # read-write concurrently within a single process.
174
+ self._read_handles_lock = threading.Lock()
175
+ self._read_handles_by_thread: dict[int, h5py.File] = {}
176
+
177
+ def _register_read_handle(self, handle: h5py.File) -> None:
178
+ tid = threading.get_ident()
179
+ with self._read_handles_lock:
180
+ self._read_handles_by_thread[tid] = handle
181
+
182
+ def _unregister_read_handle(self, handle: h5py.File | None) -> None:
183
+ if handle is None:
184
+ return
185
+ tid = threading.get_ident()
186
+ with self._read_handles_lock:
187
+ current = self._read_handles_by_thread.get(tid)
188
+ if current is handle:
189
+ self._read_handles_by_thread.pop(tid, None)
190
+
191
+ def _close_all_read_handles(self) -> int:
192
+ """Close all known thread-local read handles (best-effort)."""
193
+ with self._read_handles_lock:
194
+ items = list(self._read_handles_by_thread.items())
195
+ self._read_handles_by_thread.clear()
196
+
197
+ closed = 0
198
+ for _, handle in items:
199
+ try:
200
+ if handle is not None and handle.id.valid:
201
+ handle.close()
202
+ closed += 1
203
+ except Exception as exc: # noqa: BLE001
204
+ logger.warning(
205
+ "Failed to close read handle %s: %s",
206
+ handle,
207
+ format_exception_message(exc),
208
+ )
209
+ continue
210
+ return closed
211
+
212
+ def _close_current_thread_read_handle(self) -> None:
213
+ """Close the current thread's read handle if valid."""
214
+ read_handle = getattr(self._read_local, "handle", None)
215
+ if read_handle is not None and read_handle.id.valid:
216
+ with contextlib.suppress(Exception):
217
+ read_handle.close()
218
+ self._read_local.handle = None
219
+ self._unregister_read_handle(read_handle)
220
+
221
+ def _need_reopen_write(self, reopen: bool) -> bool:
222
+ """Check if write handle needs to be reopened."""
223
+ if self._write_handle is None:
224
+ return True
225
+ if not self._write_handle.id.valid:
226
+ return True
227
+ if reopen:
228
+ return True
229
+ return self._write_handle.mode != "r+"
230
+
231
+ def _open_write_handle(self) -> h5py.File:
232
+ """Open or reopen write handle, closing existing handles first."""
233
+ if self._write_handle is not None:
234
+ with contextlib.suppress(Exception):
235
+ self._write_handle.close()
236
+
237
+ self._close_current_thread_read_handle()
238
+ self._close_all_read_handles()
239
+
240
+ self._write_handle = h5py.File(self._path, "a", libver=self._libver)
241
+ if self._swmr and not self._write_handle.swmr_mode:
242
+ with contextlib.suppress(Exception):
243
+ self._write_handle.swmr_mode = True
244
+ return self._write_handle
245
+
246
+ def _need_reopen_read(self, handle: h5py.File | None, reopen: bool) -> bool:
247
+ """Check if read handle needs to be reopened."""
248
+ if handle is None or not handle.id.valid:
249
+ return True
250
+ if reopen:
251
+ return True
252
+ mode = None
253
+ with contextlib.suppress(Exception):
254
+ mode = str(handle.mode)
255
+ return mode is not None and not mode.startswith("r")
256
+
257
+ def _open_read_handle(self, handle: h5py.File | None) -> h5py.File:
258
+ """Open or reopen read handle."""
259
+ if handle is not None:
260
+ with contextlib.suppress(Exception):
261
+ handle.close()
262
+ self._unregister_read_handle(handle)
263
+ handle = h5py.File(self._path, "r", libver=self._libver, swmr=self._swmr)
264
+ self._read_local.handle = handle
265
+ self._register_read_handle(handle)
266
+ return handle
267
+
268
+ def _get_or_open_file(self, *, write: bool, reopen: bool) -> h5py.File:
269
+ """Return an HDF5 file handle (write-shared or read thread-local)."""
270
+ if write and self._readonly:
271
+ msg = "HDF5StorageManager is readonly; write operation is not allowed"
272
+ raise OSError(msg)
273
+
274
+ if write:
275
+ if self._need_reopen_write(reopen):
276
+ return self._open_write_handle()
277
+ return self._write_handle # type: ignore[return-value]
278
+
279
+ # Read-only: use thread-local handle to avoid python-level locks on read.
280
+ handle = getattr(self._read_local, "handle", None)
281
+ if self._need_reopen_read(handle, reopen):
282
+ return self._open_read_handle(handle)
283
+ return handle # type: ignore[return-value]
284
+
285
+ @contextlib.contextmanager
286
+ def _open(self, *, write: bool = False) -> Generator[h5py.File, None, None]:
287
+ """Context manager that yields an opened HDF5 file handle."""
288
+ if write:
289
+ with self._write_lock:
290
+ file = self._get_or_open_file(
291
+ write=True, reopen=(not self._keep_file_open)
292
+ )
293
+ try:
294
+ yield file
295
+ file.flush()
296
+ finally:
297
+ if not self._keep_file_open:
298
+ with contextlib.suppress(Exception):
299
+ file.close()
300
+ self._write_handle = None
301
+ return
302
+
303
+ reopen = not self._keep_file_open
304
+ file = self._get_or_open_file(write=False, reopen=reopen)
305
+ if self._swmr and self._refresh_on_read:
306
+ with contextlib.suppress(Exception):
307
+ file.refresh()
308
+ try:
309
+ yield file
310
+ finally:
311
+ if not self._keep_file_open:
312
+ with contextlib.suppress(Exception):
313
+ file.close()
314
+ if getattr(self._read_local, "handle", None) is file:
315
+ self._read_local.handle = None
316
+
317
+ def _table_path(self, table: str) -> str:
318
+ """Build path to a table group."""
319
+ return f"{self._root}/{table}"
320
+
321
+ def _h5path(self, table: str, field: str, uid: UID) -> str:
322
+ """Build an absolute HDF5 path for a stored field dataset."""
323
+ return f"{self._root}/{table}/{field}/{uid}"
324
+
325
+ def _index_group_path(self, table: str) -> str:
326
+ """Path to the index group under table."""
327
+ return f"{self._root}/{table}/{self._index_group}"
328
+
329
+ def _index_all_objs_path(self, table: str) -> str:
330
+ """Path to the dataset-based all-objs index for a table."""
331
+ return f"{self._root}/{table}/{self._index_group}/{self._index_all_objs}"
332
+
333
+ def _decode_index_value(self, raw_value: bytes | str) -> str:
334
+ """Decode a raw index value to string."""
335
+ if isinstance(raw_value, bytes):
336
+ return raw_value.decode(self._index_encoding)
337
+ return str(raw_value)
338
+
339
+ def _ensure_table(self, f: h5py.File, table: str) -> h5py.Group:
340
+ """Ensure table group and its index exist."""
341
+ table_group = f.require_group(self._table_path(table))
342
+ idx_root = table_group.require_group(self._index_group)
343
+ self._ensure_index(idx_root)
344
+ return table_group
345
+
346
+ def _ensure_index(self, idx_root: h5py.Group) -> None:
347
+ """Ensure the all-objs index exists."""
348
+ if self._index_all_objs not in idx_root:
349
+ dt = h5py.string_dtype(encoding=self._index_encoding)
350
+ idx_root.create_dataset(
351
+ self._index_all_objs,
352
+ shape=(0,),
353
+ maxshape=(None,),
354
+ dtype=dt,
355
+ chunks=(self._index_chunk_len,),
356
+ )
357
+ return
358
+
359
+ obj = idx_root[self._index_all_objs]
360
+ if isinstance(obj, h5py.Dataset):
361
+ return
362
+
363
+ msg = "Unsupported index format for HDF5 all-objs index."
364
+ raise TypeError(msg)
365
+
366
+ def _index_dataset(self, f: h5py.File, table: str) -> h5py.Dataset | None:
367
+ """Return the dataset-based all-objs index dataset for a table."""
368
+ idx_path = self._index_all_objs_path(table)
369
+ if idx_path not in f:
370
+ return None
371
+ obj = f[idx_path]
372
+ return obj if isinstance(obj, h5py.Dataset) else None
373
+
374
+ def _get_index_cache(self, table: str) -> set[str] | None:
375
+ """Get or create index cache for a table."""
376
+ if not self._index_cache_enabled:
377
+ return None
378
+ if table not in self._index_caches:
379
+ self._index_caches[table] = set()
380
+ return self._index_caches[table]
381
+
382
+ def _load_index_cache(self, f: h5py.File, table: str) -> None:
383
+ """Load the all-objs index into memory for a table."""
384
+ cache = self._get_index_cache(table)
385
+ if cache is None:
386
+ return
387
+ if cache:
388
+ return
389
+ index_dataset = self._index_dataset(f, table)
390
+ if index_dataset is not None:
391
+ vals = [self._decode_index_value(raw) for raw in index_dataset[:]]
392
+ cache.update(x for x in vals if x and x != self._index_tombstone)
393
+
394
+ def _index_add_obj(self, f: h5py.File, table: str, uid: UID) -> None:
395
+ """Ensure `uid` is present in the index dataset."""
396
+ index_dataset = self._index_dataset(f, table)
397
+ if index_dataset is None:
398
+ msg = "Missing dataset-based all-objs index."
399
+ raise KeyError(msg)
400
+
401
+ cache = self._get_index_cache(table)
402
+ if cache is not None:
403
+ self._load_index_cache(f, table)
404
+ if uid in cache:
405
+ return
406
+ cache.add(uid)
407
+ else:
408
+ # Use set for O(1) lookup instead of O(n) any() iteration
409
+ existing = {self._decode_index_value(raw) for raw in index_dataset[:]}
410
+ if uid in existing:
411
+ return
412
+
413
+ current_len = index_dataset.shape[0]
414
+ index_dataset.resize((current_len + 1,))
415
+ index_dataset[current_len] = uid
416
+
417
+ def _index_remove_obj(self, f: h5py.File, table: str, uid: UID) -> None:
418
+ """Mark `uid` as removed in the index dataset (tombstone).
419
+
420
+ If the index dataset doesn't exist, this is a no-op (object was never
421
+ stored in this backend, common in hybrid storage scenarios).
422
+ """
423
+ index_dataset = self._index_dataset(f, table)
424
+ if index_dataset is None:
425
+ return
426
+
427
+ vals = index_dataset[:]
428
+ for index, raw_value in enumerate(vals):
429
+ if self._decode_index_value(raw_value) == uid:
430
+ index_dataset[index] = self._index_tombstone
431
+ break
432
+ cache = self._get_index_cache(table)
433
+ if cache is not None:
434
+ cache.discard(uid)
435
+
436
+ def _scan_actual_objs(self, f: h5py.File, table: str) -> set[str]:
437
+ """Scan and return UIDs that exist in any field for a table."""
438
+ objs: set[str] = set()
439
+ table_path = self._table_path(table)
440
+ if table_path not in f:
441
+ return objs
442
+
443
+ table_group = f[table_path]
444
+ for field in table_group:
445
+ if field == self._index_group:
446
+ continue
447
+ group = f[f"{table_path}/{field}"]
448
+ for uid in group:
449
+ objs.add(uid)
450
+
451
+ return objs
452
+
453
+ def _rebuild_index(self, f: h5py.File, table: str) -> None:
454
+ """Rebuild the dataset-based all-objs index from actual file contents."""
455
+ actual = self._scan_actual_objs(f, table)
456
+ idx_root = f[self._index_group_path(table)]
457
+
458
+ self._ensure_index(idx_root)
459
+ index_dataset = self._index_dataset(f, table)
460
+ if index_dataset is None:
461
+ return
462
+ uids = sorted(actual)
463
+
464
+ index_dataset.resize((len(uids),))
465
+ if uids:
466
+ index_dataset[:] = uids
467
+ cache = self._get_index_cache(table)
468
+ if cache is not None:
469
+ cache.clear()
470
+ cache.update(uids)
471
+
472
+ def _has_field(
473
+ self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
474
+ ) -> bool:
475
+ """Return True if the given object has the specified field."""
476
+ with self._open(write=False) as f:
477
+ return self._h5path(table, field_name, obj_uid) in f
478
+
479
+ def _get_field(
480
+ self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
481
+ ) -> Any:
482
+ """Load a stored field value."""
483
+ try:
484
+ with self._open(write=False) as f:
485
+ path = self._h5path(table, field_name, obj_uid)
486
+
487
+ if path not in f:
488
+ msg = f"Field '{field_name}' of '{obj_uid}' not found"
489
+ raise KeyError(msg) # noqa: TRY301
490
+
491
+ dataset = f[path]
492
+ kind = dataset.attrs.get(STORAGE_ATTR_TYPE, "ndarray")
493
+ if kind == "ndarray":
494
+ data = dataset[()]
495
+ if not data.shape:
496
+ data = data.item()
497
+ return data
498
+
499
+ if kind == "bytes":
500
+ raw = dataset[()]
501
+ if isinstance(raw, np.ndarray):
502
+ data_bytes = raw.tobytes()
503
+ return pickle.loads(data_bytes) # noqa: S301
504
+ if isinstance(raw, (bytes, bytearray)):
505
+ return raw
506
+ return bytes(raw)
507
+
508
+ msg = f"Unknown stored type: {kind}"
509
+ raise TypeError(msg) # noqa: TRY301
510
+
511
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
512
+ msg = f"Data corruption in '{field_name}/{obj_uid}': {exc}"
513
+ raise ValueError(msg) from exc
514
+
515
+ except Exception:
516
+ logger.exception(
517
+ "Failed to read field '%s' for '%s'", field_name, obj_uid
518
+ )
519
+ raise
520
+
521
+ def _get_field_metadata(
522
+ self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
523
+ ) -> dict[str, Any] | None:
524
+ """Return stored metadata (dtype/shape/kind) if present."""
525
+ with self._open(write=False) as f:
526
+ path = self._h5path(table, field_name, obj_uid)
527
+ if path not in f:
528
+ return None
529
+
530
+ dataset = f[path]
531
+ raw_meta = dataset.attrs.get(STORAGE_ATTR_META)
532
+ if raw_meta is None:
533
+ return None
534
+ try:
535
+ if isinstance(raw_meta, bytes):
536
+ raw_meta = raw_meta.decode("utf-8")
537
+ return json.loads(raw_meta)
538
+ except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
539
+ return None
540
+
541
+ def _list_fields(
542
+ self, obj_uid: UID = None, *, table: str = DEFAULT_TABLE
543
+ ) -> list[str]:
544
+ """List stored field names."""
545
+ with self._open(write=False) as f:
546
+ table_path = self._table_path(table)
547
+ if table_path not in f:
548
+ return []
549
+ table_group = f[table_path]
550
+
551
+ out: list[str] = []
552
+
553
+ if obj_uid is None:
554
+ return [fld for fld in table_group if fld != self._index_group]
555
+ for field in table_group:
556
+ if field == self._index_group:
557
+ continue
558
+
559
+ if f"{table_path}/{field}/{obj_uid}" in f:
560
+ out.append(field)
561
+
562
+ return out
563
+
564
+ def _parse_index_dataset(self, index_dataset: h5py.Dataset) -> set[str]:
565
+ """Parse index dataset into a set of valid UIDs."""
566
+ vals = [self._decode_index_value(raw) for raw in index_dataset[:]]
567
+ return {x for x in vals if x and x != self._index_tombstone}
568
+
569
+ def _format_uid_list(self, uids: set[str]) -> list[str]:
570
+ """Format UID set as list, optionally sorted."""
571
+ return sorted(uids) if self._sort_list_objs else list(uids)
572
+
573
+ def _list_objs(self, *, table: str = DEFAULT_TABLE) -> list[str]:
574
+ """List known object UIDs."""
575
+ with self._open(write=False) as f:
576
+ table_path = self._table_path(table)
577
+ if table_path not in f:
578
+ return []
579
+
580
+ index_dataset = self._index_dataset(f, table)
581
+ if index_dataset is None and self._readonly:
582
+ actual = self._scan_actual_objs(f, table)
583
+ return self._format_uid_list(actual)
584
+
585
+ if index_dataset is not None:
586
+ indexed = self._parse_index_dataset(index_dataset)
587
+ if not self._verify_index:
588
+ return self._format_uid_list(indexed)
589
+
590
+ actual = self._scan_actual_objs(f, table)
591
+ if indexed == actual:
592
+ return self._format_uid_list(indexed)
593
+
594
+ logger.warning(
595
+ "HDF5 index out of sync; rebuilding or returning actual."
596
+ )
597
+ if self._readonly:
598
+ return self._format_uid_list(actual)
599
+
600
+ # Rebuild index
601
+ with self._open(write=True, reopen=True) as f:
602
+ self._ensure_table(f, table)
603
+ self._rebuild_index(f, table)
604
+
605
+ with self._open(write=False, reopen=True) as f:
606
+ actual = self._scan_actual_objs(f, table)
607
+ return self._format_uid_list(actual)
608
+
609
+ def _list_objs_has_field(
610
+ self, field_name: str, *, table: str = DEFAULT_TABLE
611
+ ) -> list[UID]:
612
+ """List object UIDs that have the given field."""
613
+ with self._open(write=False) as f:
614
+ field_path = f"{self._table_path(table)}/{field_name}"
615
+ if field_path not in f:
616
+ return []
617
+ return list(f[field_path].keys())
618
+
619
+ def _get_chunk_config(self) -> bool | tuple[int, ...] | None:
620
+ """Determine chunk configuration for dataset creation."""
621
+ if self._chunks is None and self._compression is None:
622
+ return None
623
+ return self._chunks if self._chunks is not None else True
624
+
625
+ def _write_ndarray_dataset(
626
+ self, group: h5py.Group, obj_uid: UID, value: np.ndarray, meta_json: str
627
+ ) -> None:
628
+ """Write a numpy array as an HDF5 dataset."""
629
+ chunks = self._get_chunk_config()
630
+ dataset = group.create_dataset(
631
+ obj_uid,
632
+ data=value,
633
+ compression=self._compression,
634
+ compression_opts=self._compression_opts,
635
+ shuffle=self._shuffle,
636
+ fletcher32=self._fletcher,
637
+ chunks=chunks,
638
+ )
639
+ dataset.attrs[STORAGE_ATTR_TYPE] = "ndarray"
640
+ dataset.attrs[STORAGE_ATTR_META] = meta_json
641
+
642
+ def _write_generic_dataset(
643
+ self, group: h5py.Group, obj_uid: UID, value: Any, meta_json: str
644
+ ) -> None:
645
+ """Write a non-ndarray value as an HDF5 dataset."""
646
+ try:
647
+ dataset = group.create_dataset(obj_uid, data=np.array(value))
648
+ dataset.attrs[STORAGE_ATTR_TYPE] = "ndarray"
649
+ except (TypeError, ValueError):
650
+ dataset = group.create_dataset(
651
+ obj_uid,
652
+ data=np.frombuffer(
653
+ pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL),
654
+ dtype=np.uint8,
655
+ ),
656
+ chunks=True,
657
+ )
658
+ dataset.attrs[STORAGE_ATTR_TYPE] = "bytes"
659
+ dataset.attrs[STORAGE_ATTR_META] = meta_json
660
+
661
+ def _flush_set_field_batch(self) -> None:
662
+ """Flush queued `set_field` operations."""
663
+ done: list[tuple[str, UID, str]] = []
664
+
665
+ try:
666
+ with self._open(write=True) as f:
667
+ for key, value in self._set_field_batch.items():
668
+ tbl, obj_uid, field_name = key
669
+ self._ensure_table(f, tbl)
670
+ table_path = self._table_path(tbl)
671
+ group = f.require_group(f"{table_path}/{field_name}")
672
+ dest = self._h5path(tbl, field_name, obj_uid)
673
+ meta = infer_value_metadata(value).to_jsonable()
674
+ meta_json = json.dumps(meta, ensure_ascii=False)
675
+
676
+ if dest in f:
677
+ del f[dest]
678
+
679
+ if isinstance(value, np.ndarray):
680
+ self._write_ndarray_dataset(
681
+ group, obj_uid, value, meta_json
682
+ )
683
+ else:
684
+ self._write_generic_dataset(
685
+ group, obj_uid, value, meta_json
686
+ )
687
+
688
+ self._index_add_obj(f, tbl, obj_uid)
689
+ done.append((tbl, obj_uid, field_name))
690
+
691
+ except Exception:
692
+ logger.exception("Failed to flush set_field batch")
693
+ raise
694
+
695
+ finally:
696
+ for key in done:
697
+ del self._set_field_batch[key]
698
+ size = self._set_field_sizes.pop(key, 0)
699
+ if size:
700
+ self._pending_set_bytes = max(0, self._pending_set_bytes - size)
701
+ if not self._set_field_sizes:
702
+ self._pending_set_bytes = 0
703
+
704
+ def _obj_has_any_field(
705
+ self, f: h5py.File, table_path: str, obj_uid: UID
706
+ ) -> bool:
707
+ """Check if object has any field in the table."""
708
+ if table_path not in f:
709
+ return False
710
+ table_group = f[table_path]
711
+ for field in table_group:
712
+ if field == self._index_group:
713
+ continue
714
+ if f"{table_path}/{field}/{obj_uid}" in f:
715
+ return True
716
+ return False
717
+
718
+ def _flush_erase_field_batch(self) -> None:
719
+ """Flush queued `erase_field` operations."""
720
+ done: list[tuple[str, UID, str]] = []
721
+
722
+ try:
723
+ with self._open(write=True) as f:
724
+ for tbl, obj_uid, field_name in self._erase_field_batch:
725
+ path = self._h5path(tbl, field_name, obj_uid)
726
+ if path in f:
727
+ del f[path]
728
+
729
+ table_path = self._table_path(tbl)
730
+ if not self._obj_has_any_field(f, table_path, obj_uid):
731
+ self._index_remove_obj(f, tbl, obj_uid)
732
+
733
+ done.append((tbl, obj_uid, field_name))
734
+
735
+ except Exception:
736
+ logger.exception("Failed to flush erase_field batch")
737
+ raise
738
+
739
+ finally:
740
+ for key in done:
741
+ self._erase_field_batch.discard(key)
742
+
743
+ def _flush_erase_obj_batch(self) -> None:
744
+ """Flush queued `erase_obj` operations."""
745
+ done: list[tuple[str, UID]] = []
746
+
747
+ try:
748
+ with self._open(write=True) as f:
749
+ for tbl, obj_uid in self._erase_obj_batch:
750
+ table_path = self._table_path(tbl)
751
+ if table_path not in f:
752
+ done.append((tbl, obj_uid))
753
+ continue
754
+
755
+ table_group = f[table_path]
756
+ for field in tuple(table_group.keys()):
757
+ if field == self._index_group:
758
+ continue
759
+
760
+ path = self._h5path(tbl, field, obj_uid)
761
+ if path in f:
762
+ del f[path]
763
+
764
+ self._index_remove_obj(f, tbl, obj_uid)
765
+ done.append((tbl, obj_uid))
766
+
767
+ except Exception:
768
+ logger.exception("Failed to flush erase_obj batch")
769
+ raise
770
+
771
+ finally:
772
+ for key in done:
773
+ self._erase_obj_batch.discard(key)
774
+
775
+ def _sync_index_after_field_erase(
776
+ self, f: h5py.File, uids_by_table: dict[str, set[UID]]
777
+ ) -> None:
778
+ """Remove UIDs from index if they have no remaining fields."""
779
+ for tbl, uids in uids_by_table.items():
780
+ if not uids:
781
+ continue
782
+ actual = self._scan_actual_objs(f, tbl)
783
+ for uid in uids:
784
+ if uid not in actual:
785
+ self._index_remove_obj(f, tbl, uid)
786
+
787
+ def _flush_erase_field_for_all_batch(self) -> None:
788
+ """Flush queued `erase_field_for_all` operations."""
789
+ done: list[tuple[str, str]] = []
790
+ uids_by_table: dict[str, set[UID]] = {}
791
+
792
+ try:
793
+ with self._open(write=True) as f:
794
+ for tbl, field_name in self._erase_field_for_all_batch:
795
+ field_path = f"{self._table_path(tbl)}/{field_name}"
796
+
797
+ if field_path not in f:
798
+ continue
799
+
800
+ if tbl not in uids_by_table:
801
+ uids_by_table[tbl] = set()
802
+ uids_by_table[tbl].update(f[field_path].keys())
803
+
804
+ del f[field_path]
805
+ done.append((tbl, field_name))
806
+
807
+ self._sync_index_after_field_erase(f, uids_by_table)
808
+
809
+ except Exception:
810
+ logger.exception("Failed to flush erase_field_for_all batch")
811
+ raise
812
+
813
+ finally:
814
+ for key in done:
815
+ self._erase_field_for_all_batch.discard(key)
816
+
817
+ def _clear(self, *, table: str | None = None) -> None:
818
+ """Remove data and recreate structure."""
819
+ with self._open(write=True) as f:
820
+ if table is None:
821
+ # Clear all data
822
+ if self._root in f:
823
+ del f[self._root]
824
+ f.create_group(self._root)
825
+ self._index_caches.clear()
826
+ else:
827
+ # Clear specific table
828
+ table_path = self._table_path(table)
829
+ if table_path in f:
830
+ del f[table_path]
831
+ self._ensure_table(f, table)
832
+ if table in self._index_caches:
833
+ self._index_caches[table].clear()
834
+
835
+ def close(self) -> None:
836
+ """Close open file handles owned by this manager (best-effort)."""
837
+ handle = getattr(getattr(self, "_read_local", None), "handle", None)
838
+ if handle is not None and handle.id.valid:
839
+ with contextlib.suppress(Exception):
840
+ handle.close()
841
+ self._read_local.handle = None
842
+ self._unregister_read_handle(handle)
843
+
844
+ self._close_all_read_handles()
845
+
846
+ write_handle = getattr(self, "_write_handle", None)
847
+ if write_handle is not None and write_handle.id.valid:
848
+ with contextlib.suppress(Exception):
849
+ write_handle.close()
850
+ self._write_handle = None
851
+ logger.debug("HDF5 file descriptor(s) closed")
852
+
853
+ def __del__(self) -> None:
854
+ """Best-effort cleanup for GC/abnormal shutdown scenarios."""
855
+ with contextlib.suppress(Exception):
856
+ self.close()