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,992 @@
1
+ """Zarr-backed storage manager (cloud-friendly via fsspec)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import logging
8
+ import pickle
9
+ import threading
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING, Any, NamedTuple
12
+ from urllib.parse import quote, unquote, urlparse
13
+
14
+ import numpy as np
15
+
16
+ import diffract.core.utils.imports as import_utils
17
+ from diffract.core.constants import (
18
+ STORAGE_ATTR_META,
19
+ STORAGE_ATTR_TYPE,
20
+ ZARR_INDEX_FIELDS,
21
+ ZARR_INDEX_GROUP,
22
+ ZARR_INDEX_OBJS,
23
+ )
24
+ from diffract.core.utils.exceptions import format_exception_message
25
+
26
+ from .base_manager import BaseStorageManager
27
+ from .interface import DEFAULT_TABLE, UID
28
+ from .metadata import infer_value_metadata
29
+
30
+ # Storage type markers for serialization
31
+ _TYPE_NDARRAY = "ndarray"
32
+ _TYPE_JSON = "json"
33
+ _TYPE_PICKLE = "pickle"
34
+
35
+ _READONLY_ERROR = "ZarrStorageManager is readonly; write operation is not allowed"
36
+
37
+
38
+ class _TableRemovalInfo(NamedTuple):
39
+ """Tracks removed objects and fields for a table during batch operations."""
40
+
41
+ removed_objs: set[str]
42
+ removed_fields: set[str]
43
+
44
+
45
+ if TYPE_CHECKING:
46
+ from typing import Self
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ if not import_utils.is_available("zarr"):
52
+ logger.debug("zarr not available, disabling Zarr storage manager")
53
+
54
+ class ZarrStorageManager(BaseStorageManager):
55
+ """Stub implementation when zarr is not available."""
56
+
57
+ def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
58
+ """Raise ImportError because the optional dependency is missing."""
59
+ msg = (
60
+ "zarr package not available; "
61
+ 'install with: pip install "diffract-core[zarr]"'
62
+ )
63
+ raise ImportError(msg)
64
+
65
+ else:
66
+ zarr = import_utils.require("zarr")
67
+
68
+ class ZarrStorageManager(BaseStorageManager):
69
+ """Persistent storage manager using Zarr v3 with fsspec-compatible stores.
70
+
71
+ Layout:
72
+ <root>/<table>/<field_name>/<obj_uid>
73
+
74
+ Each value is stored as a Zarr array with attributes:
75
+ - type: "ndarray" | "json" | "pickle"
76
+ - value_meta: JSON string from infer_value_metadata(...)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ store_url: str,
82
+ *,
83
+ storage_options: dict[str, Any] | None = None,
84
+ root: str = "root",
85
+ readonly: bool = False,
86
+ chunks: bool | tuple[int, ...] | None = None,
87
+ sort_list_objs: bool = True,
88
+ compressor: str | None = "lz4",
89
+ target_chunk_mb: float = 16.0,
90
+ lazy_index_sync: bool = True,
91
+ max_concurrency: int = 16,
92
+ **kwargs: Any,
93
+ ) -> None:
94
+ super().__init__(**kwargs)
95
+ self._store_url = store_url
96
+ self._storage_options = storage_options or {}
97
+ self._root = root
98
+ self._readonly = readonly
99
+ self._chunks = chunks
100
+ self._sort_list_objs = sort_list_objs
101
+ self._compressor = compressor
102
+ self._target_chunk_bytes = int(target_chunk_mb * 1024 * 1024)
103
+ self._lazy_index_sync = lazy_index_sync
104
+ self._max_concurrency = max_concurrency
105
+
106
+ self._write_lock = threading.Lock()
107
+ self._store: Any | None = None
108
+ self._root_group: Any | None = None
109
+
110
+ # Per-table index caches
111
+ self._index_cache_objs: dict[str, frozenset[str]] = {}
112
+ self._index_cache_fields: dict[str, frozenset[str]] = {}
113
+ self._index_dirty: dict[str, bool] = {}
114
+
115
+ def connect(self) -> None:
116
+ """Initialize store and root group (idempotent)."""
117
+ if self._root_group is not None:
118
+ return
119
+
120
+ with self._write_lock:
121
+ if self._root_group is not None:
122
+ return
123
+ store = self._create_store()
124
+ mode = "r" if self._readonly else "a"
125
+ try:
126
+ root_group = zarr.open_group(
127
+ store=store, path=self._root, mode=mode
128
+ )
129
+ except Exception as exc:
130
+ msg = (
131
+ "Failed to open Zarr store at "
132
+ f"'{self._store_url}': {format_exception_message(exc)}"
133
+ )
134
+ raise OSError(msg) from exc
135
+
136
+ self._store = store
137
+ self._root_group = root_group
138
+
139
+ def close(self) -> None:
140
+ """Close underlying store (best-effort). Syncs index if dirty."""
141
+ with self._write_lock:
142
+ self._sync_dirty_indexes()
143
+ store = self._store
144
+ self._store = None
145
+ self._root_group = None
146
+ self._index_cache_objs.clear()
147
+ self._index_cache_fields.clear()
148
+ self._index_dirty.clear()
149
+
150
+ if store is not None:
151
+ self._close_store(store)
152
+
153
+ def _sync_dirty_indexes(self) -> None:
154
+ """Sync all dirty indexes to persistent store."""
155
+ if self._readonly or self._root_group is None:
156
+ return
157
+ for table, is_dirty in self._index_dirty.items():
158
+ if not is_dirty:
159
+ continue
160
+ try:
161
+ self._sync_index(table)
162
+ except Exception as exc: # noqa: BLE001
163
+ logger.warning(
164
+ "Failed to sync index on close for table %s: %s",
165
+ table,
166
+ format_exception_message(exc),
167
+ exc_info=True,
168
+ )
169
+
170
+ def _close_store(self, store: Any) -> None:
171
+ """Close store handle if it has a close method."""
172
+ try:
173
+ close_fn = getattr(store, "close", None)
174
+ if callable(close_fn):
175
+ close_fn()
176
+ except Exception as exc: # noqa: BLE001
177
+ logger.debug(
178
+ "Failed to close Zarr store: %s",
179
+ format_exception_message(exc),
180
+ exc_info=True,
181
+ )
182
+
183
+ def _create_store(self) -> Any:
184
+ parsed = urlparse(self._store_url)
185
+ scheme = parsed.scheme
186
+ if scheme in ("", "file"):
187
+ from zarr.storage import LocalStore # type: ignore[import-untyped]
188
+
189
+ local_path = parsed.path if scheme == "file" else self._store_url
190
+ return LocalStore(local_path)
191
+
192
+ import fsspec
193
+ from zarr.storage import FsspecStore # type: ignore[import-untyped]
194
+
195
+ fs_kwargs = {
196
+ **self._storage_options,
197
+ "asynchronous": True,
198
+ "max_concurrency": self._max_concurrency,
199
+ }
200
+ fs = fsspec.filesystem(scheme, **fs_kwargs)
201
+ path = f"{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path
202
+ return FsspecStore(fs=fs, path=path)
203
+
204
+ def _get_root(self) -> Any:
205
+ if self._root_group is None:
206
+ self.connect()
207
+ assert self._root_group is not None
208
+ return self._root_group
209
+
210
+ def _get_table_group(self, table: str) -> Any:
211
+ """Get or create table group."""
212
+ root = self._get_root()
213
+ if not self._readonly:
214
+ table_group = root.require_group(table)
215
+ self._ensure_index(table_group, table)
216
+ return table_group
217
+ if table in root:
218
+ return root[table]
219
+ return None
220
+
221
+ def _encode_component(self, value: str) -> str:
222
+ return quote(value, safe="")
223
+
224
+ def _decode_component(self, value: str) -> str:
225
+ return unquote(value)
226
+
227
+ def _compute_optimal_chunks(
228
+ self, shape: tuple[int, ...], dtype: np.dtype
229
+ ) -> tuple[int, ...]:
230
+ """Compute chunks targeting _target_chunk_bytes for optimal cloud I/O."""
231
+ if not shape:
232
+ return ()
233
+
234
+ item_size = dtype.itemsize
235
+ total_elements = max(1, self._target_chunk_bytes // item_size)
236
+
237
+ ndim = len(shape)
238
+ if ndim == 2: # noqa: PLR2004
239
+ rows_per_chunk = max(1, total_elements // shape[1])
240
+ return (min(rows_per_chunk, shape[0]), shape[1])
241
+
242
+ if ndim == 1:
243
+ return (min(total_elements, shape[0]),)
244
+
245
+ chunk_side = max(1, int(total_elements ** (1.0 / ndim)))
246
+ return tuple(min(chunk_side, dim) for dim in shape)
247
+
248
+ _MIN_BYTES_FOR_COMPRESSION = 1024
249
+
250
+ def _get_compressors(self, nbytes: int) -> list[Any] | None:
251
+ """Get compressor list for array creation (None if no compression)."""
252
+ if not self._compressor or nbytes < self._MIN_BYTES_FOR_COMPRESSION:
253
+ return None
254
+ try:
255
+ from zarr.codecs import BloscCodec
256
+
257
+ return [BloscCodec(cname=self._compressor, clevel=5, shuffle="shuffle")]
258
+ except ImportError:
259
+ return None
260
+
261
+ def _ensure_index(self, table_group: Any, _table: str) -> None:
262
+ """Ensure index exists for a table."""
263
+ idx_group = table_group.require_group(ZARR_INDEX_GROUP)
264
+ if ZARR_INDEX_OBJS not in idx_group:
265
+ self._write_index_set(idx_group, ZARR_INDEX_OBJS, set())
266
+ if ZARR_INDEX_FIELDS not in idx_group:
267
+ self._write_index_set(idx_group, ZARR_INDEX_FIELDS, set())
268
+
269
+ def _read_index_set(self, table_group: Any, name: str) -> set[str] | None:
270
+ idx_group = table_group.get(ZARR_INDEX_GROUP)
271
+ if idx_group is None or name not in idx_group:
272
+ return None
273
+ dataset = idx_group[name]
274
+ try:
275
+ payload = bytes(dataset[:]).decode("utf-8")
276
+ values = json.loads(payload)
277
+ if not isinstance(values, list):
278
+ msg = "Index payload is not a list"
279
+ raise TypeError(msg) # noqa: TRY301
280
+ return {str(v) for v in values}
281
+ except Exception as exc: # noqa: BLE001
282
+ logger.warning(
283
+ "Failed to read Zarr index '%s': %s",
284
+ name,
285
+ format_exception_message(exc),
286
+ exc_info=True,
287
+ )
288
+ return None
289
+
290
+ def _write_index_set(self, idx_group: Any, name: str, values: set[str]) -> None:
291
+ payload = json.dumps(sorted(values), ensure_ascii=False).encode("utf-8")
292
+ data = np.frombuffer(payload, dtype=np.uint8)
293
+ idx_group.create_array(name, data=data, overwrite=True)
294
+
295
+ def _get_index_cache(self, table: str, name: str) -> frozenset[str] | None:
296
+ if name == ZARR_INDEX_OBJS:
297
+ return self._index_cache_objs.get(table)
298
+ return self._index_cache_fields.get(table)
299
+
300
+ def _set_index_cache(
301
+ self, table: str, name: str, values: frozenset[str] | None
302
+ ) -> None:
303
+ if name == ZARR_INDEX_OBJS:
304
+ if values is not None:
305
+ self._index_cache_objs[table] = values
306
+ elif table in self._index_cache_objs:
307
+ del self._index_cache_objs[table]
308
+ elif values is not None:
309
+ self._index_cache_fields[table] = values
310
+ elif table in self._index_cache_fields:
311
+ del self._index_cache_fields[table]
312
+
313
+ def _load_index_or_scan(
314
+ self,
315
+ table_group: Any,
316
+ table: str,
317
+ name: str,
318
+ scan_func: Callable[[Any], set[str]],
319
+ ) -> set[str]:
320
+ cached = self._get_index_cache(table, name)
321
+ if cached is not None:
322
+ return set(cached)
323
+
324
+ stored = self._read_index_set(table_group, name)
325
+ if stored is not None:
326
+ self._set_index_cache(table, name, frozenset(stored))
327
+ return stored
328
+
329
+ actual = scan_func(table_group)
330
+ if not self._readonly:
331
+ idx_group = table_group.require_group(ZARR_INDEX_GROUP)
332
+ self._write_index_set(idx_group, name, actual)
333
+ self._set_index_cache(table, name, frozenset(actual))
334
+ return actual
335
+
336
+ def _scan_actual_fields(self, table_group: Any) -> set[str]:
337
+ return {k for k in table_group if k != ZARR_INDEX_GROUP}
338
+
339
+ def _scan_actual_objs(self, table_group: Any) -> set[str]:
340
+ objs: set[str] = set()
341
+ for field in table_group:
342
+ if field == ZARR_INDEX_GROUP:
343
+ continue
344
+ objs.update(table_group[field].keys())
345
+ return objs
346
+
347
+ def _update_index_sets(
348
+ self,
349
+ table_group: Any,
350
+ table: str,
351
+ *,
352
+ add_objs: set[str] | None = None,
353
+ add_fields: set[str] | None = None,
354
+ remove_objs: set[str] | None = None,
355
+ remove_fields: set[str] | None = None,
356
+ ) -> None:
357
+ if self._readonly:
358
+ return
359
+
360
+ add_objs = add_objs or set()
361
+ add_fields = add_fields or set()
362
+ remove_objs = remove_objs or set()
363
+ remove_fields = remove_fields or set()
364
+
365
+ objs = self._load_index_or_scan(
366
+ table_group, table, ZARR_INDEX_OBJS, self._scan_actual_objs
367
+ )
368
+ fields = self._load_index_or_scan(
369
+ table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
370
+ )
371
+
372
+ if add_objs:
373
+ objs.update(add_objs)
374
+ if remove_objs:
375
+ objs.difference_update(remove_objs)
376
+ if add_fields:
377
+ fields.update(add_fields)
378
+ if remove_fields:
379
+ fields.difference_update(remove_fields)
380
+
381
+ self._set_index_cache(table, ZARR_INDEX_OBJS, frozenset(objs))
382
+ self._set_index_cache(table, ZARR_INDEX_FIELDS, frozenset(fields))
383
+
384
+ if self._lazy_index_sync:
385
+ self._index_dirty[table] = True
386
+ else:
387
+ self._write_index_to_store(table_group, objs, fields)
388
+ self._index_dirty[table] = False
389
+
390
+ def _write_index_to_store(
391
+ self, table_group: Any, objs: set[str], fields: set[str]
392
+ ) -> None:
393
+ """Write index sets to persistent store."""
394
+ idx_group = table_group.require_group(ZARR_INDEX_GROUP)
395
+ self._write_index_set(idx_group, ZARR_INDEX_OBJS, objs)
396
+ self._write_index_set(idx_group, ZARR_INDEX_FIELDS, fields)
397
+
398
+ def _sync_index(self, table: str) -> None:
399
+ """Sync cached index to persistent store if dirty."""
400
+ if not self._index_dirty.get(table) or self._readonly:
401
+ return
402
+ table_group = self._get_table_group(table)
403
+ if table_group is None:
404
+ return
405
+ objs = self._get_index_cache(table, ZARR_INDEX_OBJS) or frozenset()
406
+ fields = self._get_index_cache(table, ZARR_INDEX_FIELDS) or frozenset()
407
+ self._write_index_to_store(table_group, set(objs), set(fields))
408
+ self._index_dirty[table] = False
409
+
410
+ def _obj_exists_anywhere(
411
+ self,
412
+ table_group: Any,
413
+ table: str,
414
+ enc_uid: str,
415
+ skip_field: str | None,
416
+ *,
417
+ use_cache: bool = True,
418
+ ) -> bool:
419
+ """Check if object exists in any field."""
420
+ if use_cache:
421
+ cached_objs = self._get_index_cache(table, ZARR_INDEX_OBJS)
422
+ if cached_objs is not None:
423
+ return enc_uid in cached_objs
424
+
425
+ fields = self._load_index_or_scan(
426
+ table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
427
+ )
428
+ for field in fields:
429
+ if field == skip_field:
430
+ continue
431
+ if field not in table_group:
432
+ continue
433
+ if enc_uid in table_group[field]:
434
+ return True
435
+ return False
436
+
437
+ def _has_field(
438
+ self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
439
+ ) -> bool:
440
+ table_group = self._get_table_group(table)
441
+ if table_group is None:
442
+ return False
443
+ enc_field = self._encode_component(field_name)
444
+ enc_uid = self._encode_component(obj_uid)
445
+ try:
446
+ if enc_field not in table_group:
447
+ return False
448
+ return enc_uid in table_group[enc_field]
449
+ except Exception as exc:
450
+ msg = (
451
+ f"Failed to read Zarr field '{field_name}' for '{obj_uid}': "
452
+ f"{format_exception_message(exc)}"
453
+ )
454
+ raise OSError(msg) from exc
455
+
456
+ def _get_field(
457
+ self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
458
+ ) -> Any:
459
+ table_group = self._get_table_group(table)
460
+ if table_group is None:
461
+ msg = f"Field '{field_name}' of '{obj_uid}' not found"
462
+ raise KeyError(msg)
463
+ enc_field = self._encode_component(field_name)
464
+ enc_uid = self._encode_component(obj_uid)
465
+ try:
466
+ field_missing = (
467
+ enc_field not in table_group
468
+ or enc_uid not in table_group[enc_field]
469
+ )
470
+ if field_missing:
471
+ msg = f"Field '{field_name}' of '{obj_uid}' not found"
472
+ raise KeyError(msg) # noqa: TRY301
473
+
474
+ arr = table_group[enc_field][enc_uid]
475
+ kind = arr.attrs.get(STORAGE_ATTR_TYPE)
476
+
477
+ if kind == _TYPE_NDARRAY:
478
+ return arr[()]
479
+ if kind == _TYPE_JSON:
480
+ payload = bytes(arr[:]).decode("utf-8")
481
+ return json.loads(payload)
482
+ if kind == _TYPE_PICKLE:
483
+ payload = bytes(arr[:])
484
+ return pickle.loads(payload) # noqa: S301
485
+
486
+ msg = f"Unknown stored type: {kind}"
487
+ raise ValueError(msg) # noqa: TRY301
488
+ except KeyError:
489
+ raise
490
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
491
+ msg = (
492
+ f"Data corruption in '{field_name}/{obj_uid}': "
493
+ f"{format_exception_message(exc)}"
494
+ )
495
+ raise ValueError(msg) from exc
496
+ except Exception as exc:
497
+ msg = (
498
+ f"Failed to read Zarr field '{field_name}' for '{obj_uid}': "
499
+ f"{format_exception_message(exc)}"
500
+ )
501
+ raise OSError(msg) from exc
502
+
503
+ def _get_field_metadata(
504
+ self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
505
+ ) -> dict[str, Any] | None:
506
+ table_group = self._get_table_group(table)
507
+ if table_group is None:
508
+ return None
509
+ enc_field = self._encode_component(field_name)
510
+ enc_uid = self._encode_component(obj_uid)
511
+ try:
512
+ field_missing = (
513
+ enc_field not in table_group
514
+ or enc_uid not in table_group[enc_field]
515
+ )
516
+ if field_missing:
517
+ return None
518
+ arr = table_group[enc_field][enc_uid]
519
+ raw_meta = arr.attrs.get(STORAGE_ATTR_META)
520
+ if raw_meta is None:
521
+ return None
522
+ if isinstance(raw_meta, bytes):
523
+ raw_meta = raw_meta.decode("utf-8")
524
+ return json.loads(raw_meta)
525
+ except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
526
+ return None
527
+ except Exception as exc:
528
+ msg = (
529
+ f"Failed to read metadata for '{field_name}/{obj_uid}': "
530
+ f"{format_exception_message(exc)}"
531
+ )
532
+ raise OSError(msg) from exc
533
+
534
+ def _list_fields(
535
+ self, obj_uid: UID = None, *, table: str = DEFAULT_TABLE
536
+ ) -> list[str]:
537
+ table_group = self._get_table_group(table)
538
+ if table_group is None:
539
+ return []
540
+
541
+ if obj_uid is None:
542
+ fields = self._load_index_or_scan(
543
+ table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
544
+ )
545
+ return [self._decode_component(f) for f in sorted(fields)]
546
+
547
+ enc_uid = self._encode_component(obj_uid)
548
+ fields = self._load_index_or_scan(
549
+ table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
550
+ )
551
+ out = []
552
+ for field in fields:
553
+ if field not in table_group:
554
+ continue
555
+ # Direct membership check avoids creating intermediate set
556
+ if enc_uid in table_group[field]:
557
+ out.append(self._decode_component(field))
558
+ return sorted(out)
559
+
560
+ def _list_objs(self, *, table: str = DEFAULT_TABLE) -> list[str]:
561
+ table_group = self._get_table_group(table)
562
+ if table_group is None:
563
+ return []
564
+ objs = self._load_index_or_scan(
565
+ table_group, table, ZARR_INDEX_OBJS, self._scan_actual_objs
566
+ )
567
+ decoded = [self._decode_component(o) for o in objs]
568
+ return sorted(decoded) if self._sort_list_objs else decoded
569
+
570
+ def _list_objs_has_field(
571
+ self, field_name: str, *, table: str = DEFAULT_TABLE
572
+ ) -> list[UID]:
573
+ table_group = self._get_table_group(table)
574
+ if table_group is None:
575
+ return []
576
+ enc_field = self._encode_component(field_name)
577
+ try:
578
+ if enc_field not in table_group:
579
+ return []
580
+ return [self._decode_component(x) for x in table_group[enc_field]]
581
+ except Exception as exc:
582
+ msg = (
583
+ f"Failed to list objects for field '{field_name}': "
584
+ f"{format_exception_message(exc)}"
585
+ )
586
+ raise OSError(msg) from exc
587
+
588
+ def _serialize_value(
589
+ self, field_group: Any, enc_uid: str, value: Any
590
+ ) -> tuple[Any, str]:
591
+ """Serialize value to Zarr array. Returns (array, type_marker)."""
592
+ if isinstance(value, np.ndarray):
593
+ chunks = (
594
+ self._chunks
595
+ if isinstance(self._chunks, tuple)
596
+ else self._compute_optimal_chunks(value.shape, value.dtype)
597
+ )
598
+ compressors = self._get_compressors(value.nbytes)
599
+ arr = field_group.create_array(
600
+ enc_uid,
601
+ data=value,
602
+ chunks=chunks,
603
+ compressors=compressors,
604
+ overwrite=True,
605
+ )
606
+ return arr, _TYPE_NDARRAY
607
+
608
+ if isinstance(value, np.generic):
609
+ value = value.item()
610
+
611
+ try:
612
+ payload = json.dumps(value, ensure_ascii=False).encode("utf-8")
613
+ data = np.frombuffer(payload, dtype=np.uint8)
614
+ arr = field_group.create_array(
615
+ enc_uid, data=data, chunks=(len(data),), overwrite=True
616
+ )
617
+ except (TypeError, ValueError):
618
+ payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
619
+ data = np.frombuffer(payload, dtype=np.uint8)
620
+ arr = field_group.create_array(
621
+ enc_uid, data=data, chunks=(len(data),), overwrite=True
622
+ )
623
+ return arr, _TYPE_PICKLE
624
+ else:
625
+ return arr, _TYPE_JSON
626
+
627
+ def _flush_set_field_batch(self) -> None:
628
+ if not self._set_field_batch:
629
+ return
630
+ if self._readonly:
631
+ raise OSError(_READONLY_ERROR)
632
+
633
+ done: list[tuple[str, UID, str]] = []
634
+ new_objs_by_table: dict[str, set[str]] = {}
635
+ new_fields_by_table: dict[str, set[str]] = {}
636
+
637
+ # Cache table_groups and field_group.keys() to reduce remote calls
638
+ table_groups: dict[str, Any] = {}
639
+ field_keys_cache: dict[tuple[str, str], set[str]] = {}
640
+
641
+ with self._write_lock:
642
+ for (tbl, obj_uid, field_name), value in self._set_field_batch.items():
643
+ # Cache table_group lookup
644
+ if tbl not in table_groups:
645
+ table_groups[tbl] = self._get_table_group(tbl)
646
+ table_group = table_groups[tbl]
647
+
648
+ enc_field = self._encode_component(field_name)
649
+ enc_uid = self._encode_component(obj_uid)
650
+ field_group = table_group.require_group(enc_field)
651
+
652
+ # Cache field keys lookup
653
+ cache_key = (tbl, enc_field)
654
+ if cache_key not in field_keys_cache:
655
+ field_keys_cache[cache_key] = set(field_group.keys())
656
+
657
+ if enc_uid in field_keys_cache[cache_key]:
658
+ del field_group[enc_uid]
659
+ field_keys_cache[cache_key].discard(enc_uid)
660
+
661
+ meta_json = json.dumps(
662
+ infer_value_metadata(value).to_jsonable(), ensure_ascii=False
663
+ )
664
+ arr, type_marker = self._serialize_value(
665
+ field_group, enc_uid, value
666
+ )
667
+ arr.attrs[STORAGE_ATTR_TYPE] = type_marker
668
+ arr.attrs[STORAGE_ATTR_META] = meta_json
669
+
670
+ new_objs_by_table.setdefault(tbl, set()).add(enc_uid)
671
+ new_fields_by_table.setdefault(tbl, set()).add(enc_field)
672
+ done.append((tbl, obj_uid, field_name))
673
+
674
+ # Update indexes once per table
675
+ for tbl, table_group in table_groups.items():
676
+ self._update_index_sets(
677
+ table_group,
678
+ tbl,
679
+ add_objs=new_objs_by_table.get(tbl),
680
+ add_fields=new_fields_by_table.get(tbl),
681
+ )
682
+
683
+ self._clear_done_from_batch(done)
684
+
685
+ def _clear_done_from_batch(self, done: list[tuple[str, UID, str]]) -> None:
686
+ """Remove completed items from batch and update pending bytes."""
687
+ for key in done:
688
+ self._set_field_batch.pop(key, None)
689
+ size = self._set_field_sizes.pop(key, 0)
690
+ if size:
691
+ self._pending_set_bytes = max(0, self._pending_set_bytes - size)
692
+ if not self._set_field_sizes:
693
+ self._pending_set_bytes = 0
694
+
695
+ def _group_erase_batch_by_table_field(
696
+ self,
697
+ ) -> dict[tuple[str, str], set[str]]:
698
+ """Group erase_field_batch by (table, enc_field) -> set of enc UIDs."""
699
+ grouped: dict[tuple[str, str], set[str]] = {}
700
+ for tbl, obj_uid, field_name in self._erase_field_batch:
701
+ enc_field = self._encode_component(field_name)
702
+ enc_uid = self._encode_component(obj_uid)
703
+ grouped.setdefault((tbl, enc_field), set()).add(enc_uid)
704
+ return grouped
705
+
706
+ def _delete_uids_from_field(
707
+ self,
708
+ table_group: Any,
709
+ enc_field: str,
710
+ enc_uids_to_remove: set[str],
711
+ ) -> tuple[set[str], bool]:
712
+ """Delete UIDs from a field group.
713
+
714
+ Returns:
715
+ Tuple of (actually deleted UIDs, whether field became empty).
716
+ """
717
+ if enc_field not in table_group:
718
+ return set(), False
719
+
720
+ field_group = table_group[enc_field]
721
+ existing_uids = set(field_group.keys())
722
+ uids_to_delete = enc_uids_to_remove & existing_uids
723
+
724
+ for enc_uid in uids_to_delete:
725
+ del field_group[enc_uid]
726
+
727
+ if uids_to_delete:
728
+ remaining = existing_uids - uids_to_delete
729
+ if not remaining:
730
+ del table_group[enc_field]
731
+ return uids_to_delete, True
732
+
733
+ return uids_to_delete, False
734
+
735
+ def _find_orphaned_objs(
736
+ self,
737
+ table_group: Any,
738
+ affected_objs: set[str],
739
+ ) -> set[str]:
740
+ """Find objects that no longer exist in any field."""
741
+ cached_fields = [f for f in table_group if f != ZARR_INDEX_GROUP]
742
+ # Build set of all existing UIDs across fields in one pass
743
+ all_existing_uids: set[str] = set()
744
+ for field in cached_fields:
745
+ all_existing_uids.update(table_group[field].keys())
746
+
747
+ return affected_objs - all_existing_uids
748
+
749
+ def _update_indexes_for_removals(
750
+ self,
751
+ table_groups: dict[str, Any],
752
+ removed_by_table: dict[str, _TableRemovalInfo],
753
+ ) -> None:
754
+ """Update indexes for all tables with removals."""
755
+ for tbl, removal_info in removed_by_table.items():
756
+ if not removal_info.removed_fields and not removal_info.removed_objs:
757
+ continue
758
+ table_group = table_groups.get(tbl)
759
+ if table_group is not None:
760
+ self._update_index_sets(
761
+ table_group,
762
+ tbl,
763
+ remove_fields=removal_info.removed_fields,
764
+ remove_objs=removal_info.removed_objs,
765
+ )
766
+
767
+ def _flush_erase_field_batch(self) -> None:
768
+ if not self._erase_field_batch:
769
+ return
770
+ if self._readonly:
771
+ raise OSError(_READONLY_ERROR)
772
+
773
+ by_table_field = self._group_erase_batch_by_table_field()
774
+ removed_by_table: dict[str, _TableRemovalInfo] = {}
775
+ affected_by_table: dict[str, set[str]] = {}
776
+ table_groups: dict[str, Any] = {}
777
+
778
+ with self._write_lock:
779
+ self._process_field_deletions(
780
+ by_table_field, table_groups, removed_by_table, affected_by_table
781
+ )
782
+ self._process_orphaned_objects(
783
+ table_groups, affected_by_table, removed_by_table
784
+ )
785
+ self._update_indexes_for_removals(table_groups, removed_by_table)
786
+
787
+ self._erase_field_batch.clear()
788
+
789
+ def _process_field_deletions(
790
+ self,
791
+ by_table_field: dict[tuple[str, str], set[str]],
792
+ table_groups: dict[str, Any],
793
+ removed_by_table: dict[str, _TableRemovalInfo],
794
+ affected_by_table: dict[str, set[str]],
795
+ ) -> None:
796
+ """Delete UIDs from fields and track affected state."""
797
+ for (tbl, enc_field), enc_uids in by_table_field.items():
798
+ if tbl not in table_groups:
799
+ table_groups[tbl] = self._get_table_group(tbl)
800
+ table_group = table_groups[tbl]
801
+ if table_group is None:
802
+ continue
803
+
804
+ deleted, field_empty = self._delete_uids_from_field(
805
+ table_group, enc_field, enc_uids
806
+ )
807
+ if deleted:
808
+ affected_by_table.setdefault(tbl, set()).update(deleted)
809
+ if field_empty:
810
+ self._ensure_removal_info(removed_by_table, tbl).removed_fields.add(
811
+ enc_field
812
+ )
813
+
814
+ def _ensure_removal_info(
815
+ self, removed_by_table: dict[str, _TableRemovalInfo], tbl: str
816
+ ) -> _TableRemovalInfo:
817
+ """Get or create removal info for a table."""
818
+ if tbl not in removed_by_table:
819
+ removed_by_table[tbl] = _TableRemovalInfo(set(), set())
820
+ return removed_by_table[tbl]
821
+
822
+ def _process_orphaned_objects(
823
+ self,
824
+ table_groups: dict[str, Any],
825
+ affected_by_table: dict[str, set[str]],
826
+ removed_by_table: dict[str, _TableRemovalInfo],
827
+ ) -> None:
828
+ """Find and mark orphaned objects for removal."""
829
+ for tbl, affected_objs in affected_by_table.items():
830
+ table_group = table_groups.get(tbl)
831
+ if table_group is None:
832
+ continue
833
+ orphaned = self._find_orphaned_objs(table_group, affected_objs)
834
+ self._ensure_removal_info(removed_by_table, tbl).removed_objs.update(
835
+ orphaned
836
+ )
837
+
838
+ def _group_erase_obj_batch_by_table(self) -> dict[str, set[str]]:
839
+ """Group erase_obj_batch by table -> set of encoded UIDs."""
840
+ grouped: dict[str, set[str]] = {}
841
+ for tbl, obj_uid in self._erase_obj_batch:
842
+ enc_uid = self._encode_component(obj_uid)
843
+ grouped.setdefault(tbl, set()).add(enc_uid)
844
+ return grouped
845
+
846
+ def _erase_objs_from_table(
847
+ self,
848
+ table_group: Any,
849
+ enc_uids_to_remove: set[str],
850
+ ) -> set[str]:
851
+ """Erase objects from all fields in table.
852
+
853
+ Returns:
854
+ Set of fields that became empty.
855
+ """
856
+ cached_fields = [f for f in table_group if f != ZARR_INDEX_GROUP]
857
+ empty_fields: set[str] = set()
858
+
859
+ for field in cached_fields:
860
+ field_group = table_group[field]
861
+ existing_uids = set(field_group.keys())
862
+ uids_to_delete = enc_uids_to_remove & existing_uids
863
+ for enc_uid in uids_to_delete:
864
+ del field_group[enc_uid]
865
+ # Track remaining to avoid extra remote call for emptiness check
866
+ remaining = existing_uids - uids_to_delete
867
+ if not remaining:
868
+ del table_group[field]
869
+ empty_fields.add(field)
870
+
871
+ return empty_fields
872
+
873
+ def _flush_erase_obj_batch(self) -> None:
874
+ if not self._erase_obj_batch:
875
+ return
876
+ if self._readonly:
877
+ raise OSError(_READONLY_ERROR)
878
+
879
+ objs_by_table = self._group_erase_obj_batch_by_table()
880
+ removed_by_table: dict[str, _TableRemovalInfo] = {}
881
+ table_groups: dict[str, Any] = {}
882
+
883
+ with self._write_lock:
884
+ for tbl, enc_uids_to_remove in objs_by_table.items():
885
+ if tbl not in table_groups:
886
+ table_groups[tbl] = self._get_table_group(tbl)
887
+ table_group = table_groups[tbl]
888
+ if table_group is None:
889
+ continue
890
+
891
+ empty_fields = self._erase_objs_from_table(
892
+ table_group, enc_uids_to_remove
893
+ )
894
+ removed_by_table[tbl] = _TableRemovalInfo(
895
+ enc_uids_to_remove, empty_fields
896
+ )
897
+
898
+ self._update_indexes_for_removals(table_groups, removed_by_table)
899
+
900
+ self._erase_obj_batch.clear()
901
+
902
+ def _erase_field_from_table(
903
+ self,
904
+ table_group: Any,
905
+ enc_field: str,
906
+ ) -> set[str]:
907
+ """Erase field from table and return affected UIDs."""
908
+ if enc_field not in table_group:
909
+ return set()
910
+ field_group = table_group[enc_field]
911
+ affected_uids = set(field_group.keys())
912
+ del table_group[enc_field]
913
+ return affected_uids
914
+
915
+ def _flush_erase_field_for_all_batch(self) -> None:
916
+ if not self._erase_field_for_all_batch:
917
+ return
918
+ if self._readonly:
919
+ raise OSError(_READONLY_ERROR)
920
+
921
+ removed_by_table: dict[str, _TableRemovalInfo] = {}
922
+ affected_by_table: dict[str, set[str]] = {}
923
+ table_groups: dict[str, Any] = {}
924
+
925
+ with self._write_lock:
926
+ self._collect_fields_to_erase(
927
+ table_groups, removed_by_table, affected_by_table
928
+ )
929
+ self._process_orphaned_objects(
930
+ table_groups, affected_by_table, removed_by_table
931
+ )
932
+ self._update_indexes_for_removals(table_groups, removed_by_table)
933
+
934
+ self._erase_field_for_all_batch.clear()
935
+
936
+ def _collect_fields_to_erase(
937
+ self,
938
+ table_groups: dict[str, Any],
939
+ removed_by_table: dict[str, _TableRemovalInfo],
940
+ affected_by_table: dict[str, set[str]],
941
+ ) -> None:
942
+ """Delete fields and collect affected UIDs for erase_field_for_all."""
943
+ for tbl, field_name in self._erase_field_for_all_batch:
944
+ if tbl not in table_groups:
945
+ table_groups[tbl] = self._get_table_group(tbl)
946
+ table_group = table_groups[tbl]
947
+ if table_group is None:
948
+ continue
949
+
950
+ enc_field = self._encode_component(field_name)
951
+ affected = self._erase_field_from_table(table_group, enc_field)
952
+ if affected:
953
+ affected_by_table.setdefault(tbl, set()).update(affected)
954
+ self._ensure_removal_info(removed_by_table, tbl).removed_fields.add(
955
+ enc_field
956
+ )
957
+
958
+ def _clear(self, *, table: str | None = None) -> None:
959
+ if self._readonly:
960
+ raise OSError(_READONLY_ERROR)
961
+
962
+ with self._write_lock:
963
+ root = self._get_root()
964
+ if table is None:
965
+ self._clear_all_tables(root)
966
+ else:
967
+ self._clear_single_table(root, table)
968
+
969
+ def _clear_all_tables(self, root: Any) -> None:
970
+ """Clear all tables from root."""
971
+ for key in tuple(root.keys()):
972
+ del root[key]
973
+ self._index_cache_objs.clear()
974
+ self._index_cache_fields.clear()
975
+ self._index_dirty.clear()
976
+
977
+ def _clear_single_table(self, root: Any, table: str) -> None:
978
+ """Clear and recreate a single table."""
979
+ if table in root:
980
+ del root[table]
981
+ table_group = root.require_group(table)
982
+ idx_group = table_group.require_group(ZARR_INDEX_GROUP)
983
+ self._write_index_set(idx_group, ZARR_INDEX_OBJS, set())
984
+ self._write_index_set(idx_group, ZARR_INDEX_FIELDS, set())
985
+ self._set_index_cache(table, ZARR_INDEX_OBJS, frozenset())
986
+ self._set_index_cache(table, ZARR_INDEX_FIELDS, frozenset())
987
+ self._index_dirty[table] = False
988
+
989
+ def __del__(self) -> None:
990
+ """Clean up resources on deletion."""
991
+ with contextlib.suppress(Exception):
992
+ self.close()