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.
- arkitekt_datalayer-1.0.0/.gitignore +6 -0
- arkitekt_datalayer-1.0.0/LICENSE +19 -0
- arkitekt_datalayer-1.0.0/PKG-INFO +43 -0
- arkitekt_datalayer-1.0.0/README.md +25 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/__init__.py +1 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/admin.py +12 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/apps.py +14 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/base_models.py +552 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/codecs.py +74 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/configuration.py +63 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/context.py +16 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/datalayer.py +1889 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/duck.py +62 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/fabriks.py +265 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/inputs.py +190 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/konnektion.py +346 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/management/__init__.py +0 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/management/commands/__init__.py +0 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/management/commands/purge_orphaned_stores.py +88 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/migrations/0001_initial.py +138 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/migrations/__init__.py +0 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/models.py +708 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/__init__.py +41 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/bigfile.py +31 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/fabriks.py +48 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/konnektion.py +48 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/media.py +44 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/parquet.py +42 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/sparse.py +56 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/mutations/zarr.py +49 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/quota.py +126 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/scalars.py +62 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/sporadik.py +291 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/storage.py +143 -0
- arkitekt_datalayer-1.0.0/arkitekt_datalayer/types.py +599 -0
- arkitekt_datalayer-1.0.0/pyproject.toml +67 -0
|
@@ -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
|
+
"""
|