arkitekt-datalayer 1.0.0__tar.gz

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 (36) hide show
  1. arkitekt_datalayer-1.0.0/.gitignore +6 -0
  2. arkitekt_datalayer-1.0.0/LICENSE +19 -0
  3. arkitekt_datalayer-1.0.0/PKG-INFO +43 -0
  4. arkitekt_datalayer-1.0.0/README.md +25 -0
  5. arkitekt_datalayer-1.0.0/arkitekt_datalayer/__init__.py +1 -0
  6. arkitekt_datalayer-1.0.0/arkitekt_datalayer/admin.py +12 -0
  7. arkitekt_datalayer-1.0.0/arkitekt_datalayer/apps.py +14 -0
  8. arkitekt_datalayer-1.0.0/arkitekt_datalayer/base_models.py +552 -0
  9. arkitekt_datalayer-1.0.0/arkitekt_datalayer/codecs.py +74 -0
  10. arkitekt_datalayer-1.0.0/arkitekt_datalayer/configuration.py +63 -0
  11. arkitekt_datalayer-1.0.0/arkitekt_datalayer/context.py +16 -0
  12. arkitekt_datalayer-1.0.0/arkitekt_datalayer/datalayer.py +1889 -0
  13. arkitekt_datalayer-1.0.0/arkitekt_datalayer/duck.py +62 -0
  14. arkitekt_datalayer-1.0.0/arkitekt_datalayer/fabriks.py +265 -0
  15. arkitekt_datalayer-1.0.0/arkitekt_datalayer/inputs.py +190 -0
  16. arkitekt_datalayer-1.0.0/arkitekt_datalayer/konnektion.py +346 -0
  17. arkitekt_datalayer-1.0.0/arkitekt_datalayer/management/__init__.py +0 -0
  18. arkitekt_datalayer-1.0.0/arkitekt_datalayer/management/commands/__init__.py +0 -0
  19. arkitekt_datalayer-1.0.0/arkitekt_datalayer/management/commands/purge_orphaned_stores.py +88 -0
  20. arkitekt_datalayer-1.0.0/arkitekt_datalayer/migrations/0001_initial.py +138 -0
  21. arkitekt_datalayer-1.0.0/arkitekt_datalayer/migrations/__init__.py +0 -0
  22. arkitekt_datalayer-1.0.0/arkitekt_datalayer/models.py +708 -0
  23. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/__init__.py +41 -0
  24. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/bigfile.py +31 -0
  25. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/fabriks.py +48 -0
  26. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/konnektion.py +48 -0
  27. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/media.py +44 -0
  28. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/parquet.py +42 -0
  29. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/sparse.py +56 -0
  30. arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/zarr.py +49 -0
  31. arkitekt_datalayer-1.0.0/arkitekt_datalayer/quota.py +126 -0
  32. arkitekt_datalayer-1.0.0/arkitekt_datalayer/scalars.py +62 -0
  33. arkitekt_datalayer-1.0.0/arkitekt_datalayer/sporadik.py +291 -0
  34. arkitekt_datalayer-1.0.0/arkitekt_datalayer/storage.py +143 -0
  35. arkitekt_datalayer-1.0.0/arkitekt_datalayer/types.py +599 -0
  36. arkitekt_datalayer-1.0.0/pyproject.toml +67 -0
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .coverage
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2023 Johannes Roos <jhnnsrs@gmail.com>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.5
2
+ Name: arkitekt-datalayer
3
+ Version: 1.0.0
4
+ Summary: S3-backed stores for arkitekt services: bookkeeping rows, scoped upload/read grants, quotas and purge
5
+ Author-email: jhnnsrs <jhnnsrs@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: <4,>=3.12
9
+ Requires-Dist: authentikate>=5.1.0
10
+ Requires-Dist: boto3>=1.38.9
11
+ Requires-Dist: django-polymorphic>=4.11.2
12
+ Requires-Dist: django>=5.2
13
+ Requires-Dist: duckdb>=1.2.2
14
+ Requires-Dist: kante>=2.4.1
15
+ Requires-Dist: pydantic<3,>2
16
+ Requires-Dist: strawberry-graphql>=0.260
17
+ Description-Content-Type: text/markdown
18
+
19
+ # arkitekt-datalayer
20
+
21
+ The `datalayer` Django app shared by arkitekt services: S3-backed store rows (media, big file,
22
+ zarr, parquet, sparse, fabriks, konnektion), scoped upload and read grants minted as STS
23
+ sessions, per-organization/user/upload quotas, and an orphan purge.
24
+
25
+ Install it as `arkitekt_datalayer`; its app label is `datalayer`, so tables are `datalayer_*`
26
+ and models are referenced as `"datalayer.MediaStore"`.
27
+
28
+ ```python
29
+ INSTALLED_APPS = [..., "polymorphic", "authentikate", "arkitekt_datalayer"]
30
+ DATALAYER = conf.datalayer.model_dump(exclude_none=True) if conf.datalayer else {}
31
+ ```
32
+
33
+ Embed `arkitekt_datalayer.configuration.DatalayerSettings` in the service's config model (narrow
34
+ the buckets it needs in a subclass). Without a `DATALAYER` setting the app still installs; grants
35
+ and writes then raise, naming the missing bucket.
36
+
37
+ What a service wires itself: the GraphQL mutations it exposes (`arkitekt_datalayer.mutations`,
38
+ gated by `load_config().upload_roles`), `scalars.SCALAR_MAP` in its schema config, and the store
39
+ types it declares in its hub contract. Deleting data rows should straddle the delete with
40
+ `storage.stores_orphaned_by` / `storage.flag_orphaned`; `manage.py purge_orphaned_stores`
41
+ collects them after `store_grace_days`.
42
+
43
+ Tests run against a real postgres and RustFS via dokker: `uv run pytest`.
@@ -0,0 +1,25 @@
1
+ # arkitekt-datalayer
2
+
3
+ The `datalayer` Django app shared by arkitekt services: S3-backed store rows (media, big file,
4
+ zarr, parquet, sparse, fabriks, konnektion), scoped upload and read grants minted as STS
5
+ sessions, per-organization/user/upload quotas, and an orphan purge.
6
+
7
+ Install it as `arkitekt_datalayer`; its app label is `datalayer`, so tables are `datalayer_*`
8
+ and models are referenced as `"datalayer.MediaStore"`.
9
+
10
+ ```python
11
+ INSTALLED_APPS = [..., "polymorphic", "authentikate", "arkitekt_datalayer"]
12
+ DATALAYER = conf.datalayer.model_dump(exclude_none=True) if conf.datalayer else {}
13
+ ```
14
+
15
+ Embed `arkitekt_datalayer.configuration.DatalayerSettings` in the service's config model (narrow
16
+ the buckets it needs in a subclass). Without a `DATALAYER` setting the app still installs; grants
17
+ and writes then raise, naming the missing bucket.
18
+
19
+ What a service wires itself: the GraphQL mutations it exposes (`arkitekt_datalayer.mutations`,
20
+ gated by `load_config().upload_roles`), `scalars.SCALAR_MAP` in its schema config, and the store
21
+ types it declares in its hub contract. Deleting data rows should straddle the delete with
22
+ `storage.stores_orphaned_by` / `storage.flag_orphaned`; `manage.py purge_orphaned_stores`
23
+ collects them after `store_grace_days`.
24
+
25
+ Tests run against a real postgres and RustFS via dokker: `uv run pytest`.
@@ -0,0 +1 @@
1
+ """S3-backed stores for arkitekt services: bookkeeping rows, scoped grants, quotas and purge."""
@@ -0,0 +1,12 @@
1
+ from django.contrib import admin
2
+
3
+ from arkitekt_datalayer import models
4
+
5
+ admin.site.register(models.DatalayerStore)
6
+ admin.site.register(models.MediaStore)
7
+ admin.site.register(models.BigFileStore)
8
+ admin.site.register(models.ZarrStore)
9
+ admin.site.register(models.ParquetStore)
10
+ admin.site.register(models.FabriksStore)
11
+ admin.site.register(models.KonnektionStore)
12
+ admin.site.register(models.SparseStore)
@@ -0,0 +1,14 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DatalayerConfig(AppConfig):
5
+ """The datalayer app: S3-backed stores, their grants, quotas and purge.
6
+
7
+ Labelled ``datalayer`` rather than after its module, so table names (``datalayer_*``) and
8
+ model references (``"datalayer.MediaStore"``) read the same in every service that installs it.
9
+ """
10
+
11
+ default_auto_field = "django.db.models.BigAutoField"
12
+ name = "arkitekt_datalayer"
13
+ label = "datalayer"
14
+ verbose_name = "Datalayer"
@@ -0,0 +1,552 @@
1
+ from typing import Literal, cast
2
+
3
+ from pydantic import BaseModel, JsonValue
4
+
5
+
6
+ class RequestMediaUploadInput(BaseModel):
7
+ """Request temporary S3 upload credentials for a media object."""
8
+
9
+ original_file_name: str
10
+ file_size: int | None = None
11
+ content_type: str | None = None
12
+
13
+
14
+ class FinishMediaUploadInput(BaseModel):
15
+ """Mark a MediaStore as populated after a successful upload."""
16
+
17
+ store_id: str
18
+ valid: bool = True
19
+
20
+
21
+ class RequestMediaAccessInput(BaseModel):
22
+ """Request temporary S3 access credentials for a media object."""
23
+
24
+ store_id: str
25
+
26
+
27
+ class RequestGeneralMediaAccessInput(BaseModel):
28
+ """Request temporary S3 access credentials for media objects in the organization."""
29
+
30
+ expires_in: int | None = None
31
+
32
+
33
+ class RequestGeneralZarrAccessInput(BaseModel):
34
+ """Request temporary S3 access credentials for media objects in the organization."""
35
+
36
+ expires_in: int | None = None
37
+
38
+
39
+ class RequestGeneralFabriksAccessInput(BaseModel):
40
+ """Request temporary S3 access credentials for fabriks stores in the organization."""
41
+
42
+ expires_in: int | None = None
43
+
44
+
45
+ class RequestGeneralKonnektionAccessInput(BaseModel):
46
+ """Request temporary S3 access credentials for konnektion stores in the organization."""
47
+
48
+ expires_in: int | None = None
49
+
50
+
51
+ class RequestGeneralSparseAccessInput(BaseModel):
52
+ """Request temporary S3 access credentials for sparse stores in the organization."""
53
+
54
+ expires_in: int | None = None
55
+
56
+
57
+ class RequestGeneralParquetAccessInput(BaseModel):
58
+ """Request temporary S3 access credentials for media objects in the organization."""
59
+
60
+ expires_in: int | None = None
61
+
62
+
63
+ class RequestBigFileUploadInput(BaseModel):
64
+ """Request temporary S3 upload credentials for a big file."""
65
+
66
+ original_file_name: str
67
+ file_size: int | None = None
68
+ content_type: str | None = None
69
+
70
+
71
+ class FinishBigFileUploadInput(BaseModel):
72
+ """Mark a BigFileStore as populated after a successful upload."""
73
+
74
+ store_id: str
75
+ valid: bool = True
76
+
77
+
78
+ class RequestBigFileAccessInput(BaseModel):
79
+ """Request temporary S3 access credentials for a media object."""
80
+
81
+ store_id: str
82
+
83
+
84
+ class RequestZarrUploadInput(BaseModel):
85
+ """Request temporary S3 upload credentials for a Zarr store."""
86
+
87
+ shape: list[int] | None = None
88
+ chunks: list[int] | None = None
89
+ version: str | None = None
90
+
91
+
92
+ class FinishZarrUploadInput(BaseModel):
93
+ """Mark a ZarrStore as populated after a successful upload."""
94
+
95
+ store_id: str
96
+ valid: bool = True
97
+
98
+
99
+ class RefreshZarrUploadInput(BaseModel):
100
+ """Reissue upload credentials for a Zarr store whose upload is still in flight."""
101
+
102
+ store_id: str
103
+
104
+
105
+ class RequestZarrAccessInput(BaseModel):
106
+ """Request temporary S3 access credentials for a media object."""
107
+
108
+ store_id: str
109
+
110
+
111
+ class FinishFabriksUploadInput(BaseModel):
112
+ """Mark a FabriksStore as populated after a successful upload."""
113
+
114
+ store_id: str
115
+ valid: bool = True
116
+
117
+
118
+ class RequestFabriksAccessInput(BaseModel):
119
+ """Request temporary S3 access credentials for a fabriks store."""
120
+
121
+ store_id: str
122
+
123
+
124
+ class FinishKonnektionUploadInput(BaseModel):
125
+ """Mark a KonnektionStore as populated after a successful upload."""
126
+
127
+ store_id: str
128
+ valid: bool = True
129
+
130
+
131
+ class RequestKonnektionAccessInput(BaseModel):
132
+ """Request temporary S3 access credentials for a konnektion store."""
133
+
134
+ store_id: str
135
+
136
+
137
+ class FinishSparseUploadInput(BaseModel):
138
+ """Mark a SparseStore as populated after a successful upload."""
139
+
140
+ store_id: str
141
+ valid: bool = True
142
+
143
+
144
+ class RefreshSparseUploadInput(BaseModel):
145
+ """Reissue upload credentials for a sparse store whose upload is still in flight.
146
+
147
+ Present for the same reason `RefreshZarrUploadInput` is: a prefix is written incrementally,
148
+ and a session token can expire in the middle of a matrix that takes minutes to upload.
149
+ """
150
+
151
+ store_id: str
152
+
153
+
154
+ class RequestSparseAccessInput(BaseModel):
155
+ """Request temporary S3 access credentials for a sparse store."""
156
+
157
+ store_id: str
158
+
159
+
160
+ class SparseLayoutMetadata(BaseModel):
161
+ """One stored layout of a sparse matrix, as its own group declares it.
162
+
163
+ ``encoding`` is the whole of what the two layouts differ in -- ``csr_matrix`` means
164
+ ``indptr`` indexes axis 0, ``csc_matrix`` axis 1 -- and therefore which question this layout
165
+ answers in one contiguous read. It is never taken from a caller.
166
+ """
167
+
168
+ path: str
169
+ encoding: str
170
+ encoding_version: str | None = None
171
+ indexed_axis: int
172
+ #: The axes this layout did *not* compress, in the order ``indices`` was raveled over them.
173
+ #: At rank two it has one member and says nothing; above it, it is the one fact in the format
174
+ #: that cannot be recovered from the bytes -- a wrong one does not fail, it puts every value
175
+ #: in a different cell -- which is why the writer states it and the reader checks it.
176
+ index_order: list[int]
177
+ nnz: int
178
+ dtype: str
179
+ chunks: JsonValue = None
180
+ #: Whether a slice of this layout can be fetched as an exact byte range rather than as whole
181
+ #: chunks. **Derived, never declared** -- true exactly when each array is one uncompressed
182
+ #: chunk, so the stored object is the raw buffer and `indptr` names byte offsets into it.
183
+ #:
184
+ #: False is the ordinary case and not a defect: the default trades bytes for reuse, because on
185
+ #: an object store the cost is requests, and a chunk is a cache unit that the next lookup along
186
+ #: an adjacent slice hits again.
187
+ range_readable: bool = False
188
+
189
+
190
+ class SparseMetadata(BaseModel):
191
+ """What a sparse store states about itself, as discovered from its own zarr metadata.
192
+
193
+ The sparse analogue of :class:`ZarrMetadata` and :class:`FabriksMetadata`, and read for the
194
+ same reason: a fact derived from the artifact cannot be declared wrong.
195
+
196
+ Unlike those two it is *nested*, because one matrix is one upload and may hold a layout per
197
+ axis. ``shape`` is the store's, at whatever rank it has, and every layout is checked against
198
+ it; everything that differs between layouts lives in :class:`SparseLayoutMetadata`.
199
+
200
+ **Two axes is one case, not the definition.** A layout is one axis made contiguous, so an
201
+ array of rank *n* has up to *n* of them -- a (object, feature, timepoint) matrix can answer
202
+ "this object", "this feature" and "this timepoint" in one contiguous read each.
203
+
204
+ ``spec`` comes from the root block, which the writer lands **last**. That ordering is the
205
+ only reason an interrupted upload is detectable at all: zarr writes an array's metadata
206
+ ahead of its chunks and substitutes the fill value for a chunk it cannot fetch, so a torn
207
+ prefix otherwise reads back as the right number of zeros and raises nothing.
208
+ """
209
+
210
+ spec: str
211
+ shape: list[int]
212
+ layouts: list[SparseLayoutMetadata]
213
+
214
+
215
+ class FabriksMetadata(BaseModel):
216
+ """The manifest of a fabriks store, as discovered from ``fabriks.json``.
217
+
218
+ The fabriks analogue of :class:`ZarrMetadata`, and the reason a fabriks store is one store
219
+ rather than a handful: everything a reader needs to decode the geometry travels with the
220
+ geometry.
221
+ """
222
+
223
+ spec_version: str
224
+ grid: JsonValue
225
+ encoding: JsonValue
226
+ axes: list[str] | None = None
227
+ counts: JsonValue = None
228
+ files: dict[str, JsonValue] = {}
229
+
230
+
231
+ class KonnektionMetadata(BaseModel):
232
+ """The manifest of a konnektion store, as discovered from ``konnektion.json``.
233
+
234
+ The konnektion analogue of :class:`FabriksMetadata`, and read for the same reason:
235
+ everything a reader needs to decode the nodes and edges travels with them.
236
+ """
237
+
238
+ spec_version: str
239
+ grid: JsonValue
240
+ encoding: JsonValue
241
+ axes: list[str] | None = None
242
+ #: The per-node value columns the manifest declares, each ``{name, encoding, semantics}``.
243
+ #: Empty for a manifest that declares none or predates the key -- the same fact either way.
244
+ attributes: list[dict[str, JsonValue]] = []
245
+ counts: JsonValue = None
246
+ files: dict[str, JsonValue] = {}
247
+
248
+
249
+ class ParquetColumn(BaseModel):
250
+ """One column, as the file itself declares it.
251
+
252
+ The three fields are the first three of a DuckDB ``DESCRIBE`` row. ``type`` is therefore a
253
+ DuckDB type name (``BIGINT``, ``DOUBLE``, ``VARCHAR``) -- the same vocabulary a caller used
254
+ to have to guess at, now read rather than declared.
255
+ """
256
+
257
+ name: str
258
+ type: str
259
+ nullable: bool
260
+
261
+
262
+ class ZarrMetadata(BaseModel):
263
+ """Structured metadata discovered from a Zarr store."""
264
+
265
+ zarr_format: int
266
+ node_type: Literal["array"]
267
+ shape: list[int]
268
+ data_type: JsonValue
269
+ chunk_grid: JsonValue
270
+ chunk_key_encoding: JsonValue
271
+ fill_value: JsonValue
272
+ codecs: list[JsonValue]
273
+ attributes: dict[str, JsonValue] | None = None
274
+ storage_transformers: list[JsonValue] | None = None
275
+ dimension_names: list[str | None] | None = None
276
+
277
+ @property
278
+ def version(self) -> str:
279
+ """Return the Zarr format version as a string for legacy callers."""
280
+
281
+ return str(self.zarr_format)
282
+
283
+ @property
284
+ def dtype(self) -> str | None:
285
+ """Return the data type identifier when it is a plain string."""
286
+
287
+ return self.data_type if isinstance(self.data_type, str) else None
288
+
289
+ def _chunk_grid_shape(self) -> list[int] | None:
290
+ """Return the chunk-grid shape from ``chunk_grid.configuration.chunk_shape``.
291
+
292
+ For a sharded array this is the *shard* shape — zarr v3 puts the outer
293
+ (storage-object) shape in the chunk grid and hides the readable inner
294
+ chunk shape inside the sharding codec's configuration.
295
+ """
296
+
297
+ if not isinstance(self.chunk_grid, dict):
298
+ return None
299
+
300
+ configuration = self.chunk_grid.get("configuration")
301
+ if not isinstance(configuration, dict):
302
+ return None
303
+
304
+ chunk_shape = configuration.get("chunk_shape")
305
+ if not isinstance(chunk_shape, list) or not all(isinstance(item, int) for item in chunk_shape):
306
+ return None
307
+
308
+ return cast(list[int], chunk_shape)
309
+
310
+ def _sharding_configuration(self) -> dict[str, JsonValue] | None:
311
+ """Return the ``sharding_indexed`` codec configuration, or None when unsharded.
312
+
313
+ Only ``codecs[0]`` is considered: readers require the sharding codec to sit
314
+ first (and alone) at the top level, and :meth:`validate_sharding` rejects any
315
+ other placement, so a sharding codec elsewhere must not silently flip the
316
+ chunks/shards split here.
317
+ """
318
+
319
+ if not self.codecs:
320
+ return None
321
+ first = self.codecs[0]
322
+ if not isinstance(first, dict) or first.get("name") != "sharding_indexed":
323
+ return None
324
+ configuration = first.get("configuration")
325
+ return configuration if isinstance(configuration, dict) else None
326
+
327
+ @property
328
+ def shards(self) -> list[int] | None:
329
+ """Return the shard (outer storage-object) shape, or None for unsharded arrays."""
330
+
331
+ if self._sharding_configuration() is None:
332
+ return None
333
+ return self._chunk_grid_shape()
334
+
335
+ @property
336
+ def chunks(self) -> list[int] | None:
337
+ """Return the effective inner chunk shape — the unit a reader can decode.
338
+
339
+ For a sharded array that is the sharding codec's inner ``chunk_shape``,
340
+ not the chunk grid's (which is the shard shape); unsharded arrays fall
341
+ back to the chunk grid.
342
+ """
343
+
344
+ configuration = self._sharding_configuration()
345
+ if configuration is not None:
346
+ inner = configuration.get("chunk_shape")
347
+ if isinstance(inner, list) and all(isinstance(item, int) for item in inner):
348
+ return cast(list[int], inner)
349
+ return None
350
+ return self._chunk_grid_shape()
351
+
352
+ def validate_sharding(self) -> None:
353
+ """Reject sharded layouts the platform's readers cannot decode.
354
+
355
+ Mirrors the frontend's sharding contract: ``sharding_indexed`` must be the
356
+ sole top-level codec, ranks must match, the shard must be an exact per-axis
357
+ multiple of the inner chunk, sharding must not nest, and the index location
358
+ must be a known value. Unsharded arrays pass untouched.
359
+
360
+ Raises:
361
+ ValueError: If a sharding codec is present but the layout is unreadable.
362
+ """
363
+
364
+ sharding_positions = [i for i, codec in enumerate(self.codecs) if isinstance(codec, dict) and codec.get("name") == "sharding_indexed"]
365
+ if not sharding_positions:
366
+ return
367
+ if sharding_positions != [0] or len(self.codecs) != 1:
368
+ raise ValueError(f"'sharding_indexed' must be the sole top-level codec (found it at position {sharding_positions[0]} of {len(self.codecs)}); readers reject any other layout.")
369
+
370
+ configuration = self._sharding_configuration()
371
+ inner = configuration.get("chunk_shape") if configuration else None
372
+ inner_codecs = configuration.get("codecs") if configuration else None
373
+ if not isinstance(inner, list) or not inner or not all(isinstance(item, int) and item > 0 for item in inner) or not isinstance(inner_codecs, list):
374
+ raise ValueError("Malformed 'sharding_indexed' configuration: missing or invalid chunk_shape or codecs.")
375
+
376
+ shard = self._chunk_grid_shape()
377
+ if shard is None:
378
+ raise ValueError("Malformed zarr.json metadata: sharded array without a regular chunk grid shape.")
379
+ if len(inner) != len(shard):
380
+ raise ValueError(f"Sharding inner chunk rank {len(inner)} does not match the shard rank {len(shard)}.")
381
+ inner_sizes = [item for item in inner if isinstance(item, int)]
382
+ for axis, (shard_size, inner_size) in enumerate(zip(shard, inner_sizes)):
383
+ if shard_size % inner_size != 0:
384
+ raise ValueError(f"Shard shape {shard} is not an exact multiple of the inner chunk shape {inner} on axis {axis}.")
385
+
386
+ for codec in inner_codecs:
387
+ if isinstance(codec, dict) and codec.get("name") == "sharding_indexed":
388
+ raise ValueError("Nested 'sharding_indexed' codecs are not supported by readers.")
389
+
390
+ index_location = configuration.get("index_location") if configuration else None
391
+ if index_location is not None and index_location not in ("start", "end"):
392
+ raise ValueError(f"Unknown sharding index_location {index_location!r}; readers only support 'start' and 'end'.")
393
+
394
+
395
+ class RequestParquetUploadInput(BaseModel):
396
+ """Request temporary S3 upload credentials for a Parquet store."""
397
+
398
+ content_type: str | None = None
399
+
400
+
401
+ class FinishParquetUploadInput(BaseModel):
402
+ """Mark a ParquetStore as populated after a successful upload."""
403
+
404
+ store_id: str
405
+ valid: bool = True
406
+
407
+
408
+ class RequestParquetAccessInput(BaseModel):
409
+ """Request temporary S3 access credentials for a media object."""
410
+
411
+ store_id: str
412
+
413
+
414
+ class AccessGrant(BaseModel):
415
+ """Temporary S3 credentials scoped to a datalayer action."""
416
+
417
+ status: str = "granted"
418
+ access_key: str
419
+ secret_key: str
420
+ session_token: str
421
+ region: str
422
+ bucket: str
423
+ key: str
424
+ path: str
425
+ expires_in: int
426
+ store: str | None = None
427
+
428
+
429
+ class GeneralAccessGrant(BaseModel):
430
+ """Temporary S3 credentials for an existing media object, without a store reference."""
431
+
432
+ status: str = "granted"
433
+ access_key: str
434
+ secret_key: str
435
+ session_token: str
436
+ region: str
437
+ bucket: str
438
+ expires_in: int
439
+
440
+
441
+ class GeneralMediaAccessGrant(GeneralAccessGrant):
442
+ """Temporary S3 credentials for an existing media object, without a store reference."""
443
+
444
+
445
+ class GeneralZarrAccessGrant(GeneralAccessGrant):
446
+ """Temporary S3 credentials for an existing media object, without a store reference."""
447
+
448
+
449
+ class GeneralParquetAccessGrant(GeneralAccessGrant):
450
+ """Temporary S3 credentials for an existing media object, without a store reference."""
451
+
452
+
453
+ class GeneralFabriksAccessGrant(GeneralAccessGrant):
454
+ """Temporary S3 credentials for existing fabriks stores, without a store reference."""
455
+
456
+
457
+ class GeneralKonnektionAccessGrant(GeneralAccessGrant):
458
+ """Temporary S3 credentials for existing konnektion stores, without a store reference."""
459
+
460
+
461
+ class GeneralSparseAccessGrant(GeneralAccessGrant):
462
+ """Temporary S3 credentials for existing sparse stores, without a store reference."""
463
+
464
+
465
+ class BigFileAccessGrant(AccessGrant):
466
+ """Temporary S3 credentials for an existing big file."""
467
+
468
+
469
+ class MediaAccessGrant(AccessGrant):
470
+ """Temporary S3 credentials for an existing media object."""
471
+
472
+
473
+ class ZarrAccessGrant(AccessGrant):
474
+ """Temporary S3 credentials for an existing Zarr store."""
475
+
476
+
477
+ class ParquetAccessGrant(AccessGrant):
478
+ """Temporary S3 credentials for an existing parquet store."""
479
+
480
+
481
+ class FabriksAccessGrant(AccessGrant):
482
+ """Temporary S3 credentials for an existing fabriks store.
483
+
484
+ Covers the whole prefix, so one grant reads the manifest, both catalogs and every level --
485
+ where the same collection stored as separate objects needed one grant each.
486
+ """
487
+
488
+
489
+ class KonnektionAccessGrant(AccessGrant):
490
+ """Temporary S3 credentials for an existing konnektion store.
491
+
492
+ Covers the whole prefix, so one grant reads the manifest, both catalogs and every level.
493
+ """
494
+
495
+
496
+ class SparseAccessGrant(AccessGrant):
497
+ """Temporary S3 credentials for an existing sparse store.
498
+
499
+ Covers the whole prefix, so one grant reads the group's attributes and all three of its
500
+ arrays -- which is the minimum that answers anything, since a lookup needs `indptr` before
501
+ it knows which range of `data` to fetch.
502
+ """
503
+
504
+
505
+ class BaseUploadGrant(AccessGrant):
506
+ """Temporary S3 credentials for uploads bound to a specific store."""
507
+
508
+ region: str
509
+ max_bytes: int
510
+ original_file_name: str | None = None
511
+ upload_file_name: str
512
+ upload_content_type: str | None = None
513
+
514
+
515
+ class MediaUploadGrant(BaseUploadGrant):
516
+ """Temporary S3 credentials for a media upload."""
517
+
518
+
519
+ class BigFileUploadGrant(BaseUploadGrant):
520
+ """Temporary S3 credentials for a big file upload."""
521
+
522
+
523
+ class ZarrUploadGrant(BaseUploadGrant):
524
+ """Temporary S3 credentials for a Zarr upload."""
525
+
526
+
527
+ class ParquetUploadGrant(BaseUploadGrant):
528
+ """Temporary S3 credentials for a parquet upload."""
529
+
530
+
531
+ class SparseUploadGrant(BaseUploadGrant):
532
+ """Temporary S3 credentials for a sparse upload.
533
+
534
+ Scoped to the prefix and permitted to read back and delete inside it, because the three
535
+ arrays are written incrementally.
536
+ """
537
+
538
+
539
+ class FabriksUploadGrant(BaseUploadGrant):
540
+ """Temporary S3 credentials for a fabriks upload.
541
+
542
+ Scoped to the prefix, and -- unlike an object upload -- permitted to read back and delete
543
+ inside it, because a tree is written incrementally and its manifest lands last.
544
+ """
545
+
546
+
547
+ class KonnektionUploadGrant(BaseUploadGrant):
548
+ """Temporary S3 credentials for a konnektion upload.
549
+
550
+ Scoped to the prefix, and -- unlike an object upload -- permitted to read back and delete
551
+ inside it, because a tree is written incrementally and its manifest lands last.
552
+ """