datalab-capsule 0.1.0__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.
- datalab_capsule/__init__.py +10 -0
- datalab_capsule/_schema.py +31 -0
- datalab_capsule/archive.py +420 -0
- datalab_capsule/calls.py +155 -0
- datalab_capsule/cli.py +496 -0
- datalab_capsule/compare.py +517 -0
- datalab_capsule/contexts/ro-crate-1.3.jsonld +3104 -0
- datalab_capsule/diff.py +199 -0
- datalab_capsule/environment.py +86 -0
- datalab_capsule/exchange.py +316 -0
- datalab_capsule/hdf5.py +703 -0
- datalab_capsule/inspection.py +749 -0
- datalab_capsule/integrity.py +637 -0
- datalab_capsule/ledger.py +473 -0
- datalab_capsule/legacy.py +279 -0
- datalab_capsule/manifest.py +816 -0
- datalab_capsule/recorder.py +1224 -0
- datalab_capsule/registry.py +455 -0
- datalab_capsule/registry_app.py +211 -0
- datalab_capsule/registry_client.py +269 -0
- datalab_capsule/replay.py +274 -0
- datalab_capsule/review.py +114 -0
- datalab_capsule/schemas/ledger-1.schema.json +255 -0
- datalab_capsule/schemas/report-1.schema.json +146 -0
- datalab_capsule/schemas/review-1.schema.json +40 -0
- datalab_capsule/verify.py +198 -0
- datalab_capsule-0.1.0.dist-info/METADATA +170 -0
- datalab_capsule-0.1.0.dist-info/RECORD +32 -0
- datalab_capsule-0.1.0.dist-info/WHEEL +5 -0
- datalab_capsule-0.1.0.dist-info/entry_points.txt +3 -0
- datalab_capsule-0.1.0.dist-info/licenses/LICENSE +28 -0
- datalab_capsule-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
|
|
2
|
+
|
|
3
|
+
"""Neutral provenance model and open capsules for DataLab workspaces.
|
|
4
|
+
|
|
5
|
+
This package never imports DataLab, Sigima, SigimaX or Qt. Its core only needs
|
|
6
|
+
the standard library and ``jsonschema``; HDF5 helpers import NumPy and h5py
|
|
7
|
+
lazily.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
|
|
2
|
+
|
|
3
|
+
"""Packaged JSON Schemas and validation helper."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__all__ = ["load_schema", "schema_errors"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache(maxsize=None)
|
|
16
|
+
def load_schema(name: str) -> dict[str, Any]:
|
|
17
|
+
"""Return a packaged JSON Schema (e.g. ``"ledger-1"``)."""
|
|
18
|
+
path = resources.files("datalab_capsule").joinpath(f"schemas/{name}.schema.json")
|
|
19
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def schema_errors(name: str, data: Any) -> list[str]:
|
|
23
|
+
"""Return the JSON Schema errors of *data*, sorted by location."""
|
|
24
|
+
import jsonschema # pylint: disable=import-outside-toplevel
|
|
25
|
+
|
|
26
|
+
validator = jsonschema.Draft202012Validator(load_schema(name))
|
|
27
|
+
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path))
|
|
28
|
+
return [
|
|
29
|
+
f"{'/'.join(str(p) for p in error.absolute_path) or '<root>'}: {error.message}"
|
|
30
|
+
for error in errors
|
|
31
|
+
]
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
|
|
2
|
+
|
|
3
|
+
"""Workspace capsules: ``.dlcapsule`` ZIP archives holding a workspace and its manifest.
|
|
4
|
+
|
|
5
|
+
A capsule holds ``ro-crate-metadata.json`` (see :mod:`datalab_capsule.manifest`)
|
|
6
|
+
and, when the workspace is included, ``workspace.h5`` (the DataLab HDF5 workspace,
|
|
7
|
+
provenance block included), both stored without compression. A capsule may
|
|
8
|
+
instead reference its workspace by an absolute IRI: the archive then holds the
|
|
9
|
+
manifest only, and the workspace is obtained through a resolver passed
|
|
10
|
+
explicitly by the caller; its SHA-256 and size are checked before use.
|
|
11
|
+
|
|
12
|
+
The reader never executes code, never accesses the network and refuses unsafe
|
|
13
|
+
archives before extracting anything: absolute paths, ``..``, drive letters,
|
|
14
|
+
backslashes, duplicate or unexpected entries, links, encrypted or compressed
|
|
15
|
+
entries (so no decompression bomb can be read), and entries over the limits
|
|
16
|
+
below.
|
|
17
|
+
|
|
18
|
+
Size limits form a :class:`SizePolicy`. The archive size is checked before the
|
|
19
|
+
archive is read; the size of each entry is checked against its header before
|
|
20
|
+
extraction and against the bytes actually extracted. Each edition can pass its
|
|
21
|
+
own policy to :func:`read_capsule`. :data:`DEFAULT_SIZE_POLICY` is an accepted
|
|
22
|
+
upper bound, not a tested capacity: the whole archive is held in memory.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import dataclasses
|
|
28
|
+
import io
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import stat
|
|
32
|
+
import zipfile
|
|
33
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from datalab_capsule.hdf5 import ProvenanceFormatError, read_block
|
|
37
|
+
from datalab_capsule.integrity import ProvenanceError, load_json, sha256_digest
|
|
38
|
+
from datalab_capsule.manifest import (
|
|
39
|
+
ATTACHMENTS_DIR,
|
|
40
|
+
MANIFEST_NAME,
|
|
41
|
+
REFERENCED_KEY,
|
|
42
|
+
WORKSPACE_NAME,
|
|
43
|
+
ManifestError,
|
|
44
|
+
attachment_entities,
|
|
45
|
+
attachment_name,
|
|
46
|
+
build_manifest,
|
|
47
|
+
check_attachment_name,
|
|
48
|
+
validate_manifest,
|
|
49
|
+
workspace_entity,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"CAPSULE_SUFFIX",
|
|
54
|
+
"DEFAULT_SIZE_POLICY",
|
|
55
|
+
"MAX_ARCHIVE_BYTES",
|
|
56
|
+
"MAX_ATTACHMENTS",
|
|
57
|
+
"MAX_ATTACHMENT_BYTES",
|
|
58
|
+
"MAX_ENTRIES",
|
|
59
|
+
"MAX_MANIFEST_BYTES",
|
|
60
|
+
"MAX_WORKSPACE_BYTES",
|
|
61
|
+
"Capsule",
|
|
62
|
+
"CapsuleError",
|
|
63
|
+
"SizePolicy",
|
|
64
|
+
"create_from_hdf5",
|
|
65
|
+
"read_capsule",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
CAPSULE_SUFFIX = ".dlcapsule"
|
|
69
|
+
#: Largest number of entries accepted in an archive.
|
|
70
|
+
MAX_ENTRIES = 16
|
|
71
|
+
#: Largest manifest accepted by default (bytes, uncompressed).
|
|
72
|
+
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
|
|
73
|
+
#: Largest workspace accepted by default (bytes, uncompressed).
|
|
74
|
+
MAX_WORKSPACE_BYTES = 4 * 1024**3
|
|
75
|
+
#: Largest number of light attachments in a capsule.
|
|
76
|
+
MAX_ATTACHMENTS = 8
|
|
77
|
+
#: Largest attachment accepted by default (bytes): attachments are light files.
|
|
78
|
+
MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024
|
|
79
|
+
#: Largest archive accepted by default (bytes): every entry plus ZIP headers.
|
|
80
|
+
MAX_ARCHIVE_BYTES = (
|
|
81
|
+
MAX_MANIFEST_BYTES
|
|
82
|
+
+ MAX_WORKSPACE_BYTES
|
|
83
|
+
+ MAX_ATTACHMENTS * MAX_ATTACHMENT_BYTES
|
|
84
|
+
+ 1024 * 1024
|
|
85
|
+
)
|
|
86
|
+
_ENTRY_NAMES = (MANIFEST_NAME, WORKSPACE_NAME)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CapsuleError(ProvenanceError, ValueError):
|
|
90
|
+
"""Raised when a capsule cannot be created or is not safe or valid."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclasses.dataclass(frozen=True)
|
|
94
|
+
class SizePolicy:
|
|
95
|
+
"""Size limits applied when reading a capsule (bytes).
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
max_archive_bytes: Largest archive, checked before it is read.
|
|
99
|
+
max_manifest_bytes: Largest manifest (uncompressed).
|
|
100
|
+
max_workspace_bytes: Largest workspace (uncompressed).
|
|
101
|
+
max_attachment_bytes: Largest attachment (uncompressed).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
max_archive_bytes: int
|
|
105
|
+
max_manifest_bytes: int
|
|
106
|
+
max_workspace_bytes: int
|
|
107
|
+
max_attachment_bytes: int = MAX_ATTACHMENT_BYTES
|
|
108
|
+
|
|
109
|
+
def entry_limit(self, name: str) -> int:
|
|
110
|
+
"""Return the size limit of entry *name*."""
|
|
111
|
+
if name == MANIFEST_NAME:
|
|
112
|
+
return self.max_manifest_bytes
|
|
113
|
+
if name.startswith(ATTACHMENTS_DIR):
|
|
114
|
+
return self.max_attachment_bytes
|
|
115
|
+
return self.max_workspace_bytes
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
#: Policy used when an edition does not pass its own.
|
|
119
|
+
DEFAULT_SIZE_POLICY = SizePolicy(
|
|
120
|
+
max_archive_bytes=MAX_ARCHIVE_BYTES,
|
|
121
|
+
max_manifest_bytes=MAX_MANIFEST_BYTES,
|
|
122
|
+
max_workspace_bytes=MAX_WORKSPACE_BYTES,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclasses.dataclass(frozen=True)
|
|
127
|
+
class Capsule:
|
|
128
|
+
"""A validated capsule.
|
|
129
|
+
|
|
130
|
+
Attributes:
|
|
131
|
+
manifest: RO-Crate manifest.
|
|
132
|
+
workspace: Bytes of the workspace, to pass to the editions' open path, or
|
|
133
|
+
None when it is referenced and no resolver was given.
|
|
134
|
+
workspace_ref: IRI of a referenced workspace, or None when included.
|
|
135
|
+
attachments: ``name -> bytes`` of the declared attachments, checked
|
|
136
|
+
against the manifest. They are data for the user: nothing in this
|
|
137
|
+
package opens, imports or runs them.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
manifest: dict[str, Any]
|
|
141
|
+
workspace: bytes | None
|
|
142
|
+
workspace_ref: str | None = None
|
|
143
|
+
attachments: dict[str, bytes] = dataclasses.field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_source(source: str | os.PathLike | bytes, limit: int | None = None) -> bytes:
|
|
147
|
+
"""Return the bytes of *source*, refusing more than *limit* before reading."""
|
|
148
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
149
|
+
if limit is not None and len(source) > limit:
|
|
150
|
+
raise CapsuleError(f"Archive too large: {len(source)} > {limit} bytes")
|
|
151
|
+
return bytes(source)
|
|
152
|
+
with open(source, "rb") as file:
|
|
153
|
+
if limit is None:
|
|
154
|
+
return file.read()
|
|
155
|
+
size = os.fstat(file.fileno()).st_size
|
|
156
|
+
if size > limit:
|
|
157
|
+
raise CapsuleError(f"Archive too large: {size} > {limit} bytes")
|
|
158
|
+
data = file.read(size + 1)
|
|
159
|
+
if len(data) > limit:
|
|
160
|
+
raise CapsuleError(f"Archive too large: more than {limit} bytes")
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_from_hdf5(
|
|
165
|
+
source: str | os.PathLike | bytes,
|
|
166
|
+
*,
|
|
167
|
+
name: str | None = None,
|
|
168
|
+
description: str | None = None,
|
|
169
|
+
license_: str | None = None,
|
|
170
|
+
date_published: str | None = None,
|
|
171
|
+
revision_of: str | None = None,
|
|
172
|
+
workspace_ref: str | None = None,
|
|
173
|
+
attachments: Sequence[Mapping[str, Any]] = (),
|
|
174
|
+
) -> bytes:
|
|
175
|
+
"""Create a capsule from a workspace file holding a provenance block.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
source: Path or bytes of the HDF5 workspace.
|
|
179
|
+
name: Capsule name.
|
|
180
|
+
description: Capsule description.
|
|
181
|
+
license_: Licence statement; a neutral text is used when None.
|
|
182
|
+
date_published: ISO 8601 date (defaults to now).
|
|
183
|
+
revision_of: Manifest fingerprint of the parent revision, if any.
|
|
184
|
+
workspace_ref: Absolute IRI where the workspace will be published. When
|
|
185
|
+
given, the workspace is referenced, not included: the archive holds
|
|
186
|
+
the manifest only, with the workspace's SHA-256 and size.
|
|
187
|
+
attachments: Light files selected by the author, stored as
|
|
188
|
+
``attachments/<name>`` and declared in the manifest with their size and
|
|
189
|
+
SHA-256: ``{"name", "data", "media_type", "description"}``
|
|
190
|
+
(``description`` optional).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The capsule bytes.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
CapsuleError: If the workspace has no (valid) provenance block, or the
|
|
197
|
+
attachments are too many or too large.
|
|
198
|
+
ManifestError: If a manifest field is invalid (parent revision,
|
|
199
|
+
workspace IRI, attachment name, media type).
|
|
200
|
+
"""
|
|
201
|
+
import h5py # pylint: disable=import-outside-toplevel
|
|
202
|
+
|
|
203
|
+
if len(attachments) > MAX_ATTACHMENTS:
|
|
204
|
+
raise CapsuleError(f"Too many attachments (at most {MAX_ATTACHMENTS})")
|
|
205
|
+
declared = []
|
|
206
|
+
for item in attachments:
|
|
207
|
+
data = bytes(item["data"])
|
|
208
|
+
if len(data) > MAX_ATTACHMENT_BYTES:
|
|
209
|
+
raise CapsuleError(f"Attachment too large: {item['name']!r}")
|
|
210
|
+
declared.append(
|
|
211
|
+
{
|
|
212
|
+
"name": item["name"],
|
|
213
|
+
"size": len(data),
|
|
214
|
+
"sha256": sha256_digest(data),
|
|
215
|
+
"media_type": item.get("media_type") or "application/octet-stream",
|
|
216
|
+
"description": item.get("description"),
|
|
217
|
+
"data": data,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
workspace = _read_source(source)
|
|
222
|
+
try:
|
|
223
|
+
with h5py.File(io.BytesIO(workspace), "r") as h5file:
|
|
224
|
+
block = read_block(h5file)
|
|
225
|
+
except (OSError, ProvenanceFormatError) as exc:
|
|
226
|
+
raise CapsuleError(f"Invalid workspace: {exc}") from exc
|
|
227
|
+
if block is None:
|
|
228
|
+
raise CapsuleError("The workspace has no provenance block")
|
|
229
|
+
ledger, locators = block
|
|
230
|
+
options = {
|
|
231
|
+
key: value
|
|
232
|
+
for key, value in (
|
|
233
|
+
("name", name),
|
|
234
|
+
("description", description),
|
|
235
|
+
("license_", license_),
|
|
236
|
+
("date_published", date_published),
|
|
237
|
+
("revision_of", revision_of),
|
|
238
|
+
("workspace_ref", workspace_ref),
|
|
239
|
+
)
|
|
240
|
+
if value is not None
|
|
241
|
+
}
|
|
242
|
+
manifest = build_manifest(
|
|
243
|
+
ledger,
|
|
244
|
+
locators,
|
|
245
|
+
workspace_sha256=sha256_digest(workspace),
|
|
246
|
+
workspace_size=len(workspace),
|
|
247
|
+
attachments=declared,
|
|
248
|
+
**options,
|
|
249
|
+
)
|
|
250
|
+
entries = [(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=1))]
|
|
251
|
+
if workspace_ref is None:
|
|
252
|
+
entries.append((WORKSPACE_NAME, workspace))
|
|
253
|
+
entries.extend((ATTACHMENTS_DIR + item["name"], item["data"]) for item in declared)
|
|
254
|
+
buffer = io.BytesIO()
|
|
255
|
+
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive:
|
|
256
|
+
for entry, data in entries:
|
|
257
|
+
info = zipfile.ZipInfo(entry, date_time=(1980, 1, 1, 0, 0, 0))
|
|
258
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
259
|
+
# Unix permissions, whatever the host: same bytes on every platform.
|
|
260
|
+
info.create_system = 3
|
|
261
|
+
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
|
262
|
+
archive.writestr(info, data)
|
|
263
|
+
return buffer.getvalue()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _check_entry(info: zipfile.ZipInfo, seen: set[str], policy: SizePolicy) -> None:
|
|
267
|
+
name = info.filename
|
|
268
|
+
if (
|
|
269
|
+
not name
|
|
270
|
+
or name.startswith("/")
|
|
271
|
+
or "\\" in name
|
|
272
|
+
or ".." in name.split("/")
|
|
273
|
+
or (len(name) > 1 and name[1] == ":")
|
|
274
|
+
):
|
|
275
|
+
raise CapsuleError(f"Unsafe entry name: {name!r}")
|
|
276
|
+
if name in seen:
|
|
277
|
+
raise CapsuleError(f"Duplicate entry: {name}")
|
|
278
|
+
seen.add(name)
|
|
279
|
+
if name.startswith(ATTACHMENTS_DIR):
|
|
280
|
+
try:
|
|
281
|
+
check_attachment_name(name[len(ATTACHMENTS_DIR) :])
|
|
282
|
+
except ManifestError as exc:
|
|
283
|
+
raise CapsuleError(f"Unsafe entry name: {name!r}") from exc
|
|
284
|
+
elif name not in _ENTRY_NAMES:
|
|
285
|
+
raise CapsuleError(f"Unexpected entry: {name}")
|
|
286
|
+
if info.flag_bits & 0x1:
|
|
287
|
+
raise CapsuleError(f"Encrypted entry: {name}")
|
|
288
|
+
if stat.S_ISLNK(info.external_attr >> 16):
|
|
289
|
+
raise CapsuleError(f"Link entry: {name}")
|
|
290
|
+
if info.compress_type != zipfile.ZIP_STORED:
|
|
291
|
+
raise CapsuleError(f"Compressed entry: {name}")
|
|
292
|
+
if info.file_size > policy.entry_limit(name):
|
|
293
|
+
raise CapsuleError(f"Entry too large: {name}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _read_entry(archive: zipfile.ZipFile, info: zipfile.ZipInfo, limit: int) -> bytes:
|
|
297
|
+
try:
|
|
298
|
+
with archive.open(info) as stream:
|
|
299
|
+
# The header size was checked against *limit*: never read past it.
|
|
300
|
+
data = stream.read(info.file_size + 1)
|
|
301
|
+
except (zipfile.BadZipFile, EOFError, ValueError) as exc:
|
|
302
|
+
# Raised while reading, e.g. on a CRC mismatch, a truncated entry or a
|
|
303
|
+
# local header name that cannot be decoded
|
|
304
|
+
raise CapsuleError(f"Damaged entry {info.filename}: {exc}") from exc
|
|
305
|
+
if len(data) > limit or len(data) != info.file_size:
|
|
306
|
+
raise CapsuleError(f"Entry size does not match its header: {info.filename}")
|
|
307
|
+
return data
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def read_capsule(
|
|
311
|
+
source: str | os.PathLike | bytes,
|
|
312
|
+
policy: SizePolicy = DEFAULT_SIZE_POLICY,
|
|
313
|
+
resolve_workspace: Callable[[str], bytes] | None = None,
|
|
314
|
+
) -> Capsule:
|
|
315
|
+
"""Read, check and validate a capsule without extracting it to disk.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
source: Path or bytes of the capsule.
|
|
319
|
+
policy: Size limits of the calling edition.
|
|
320
|
+
resolve_workspace: ``IRI -> bytes`` used for a referenced workspace. It
|
|
321
|
+
is the only way a referenced workspace is obtained: nothing is fetched
|
|
322
|
+
by default. The bytes are checked against the manifest.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
The capsule; ``workspace`` is None for a referenced workspace without
|
|
326
|
+
resolver.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
CapsuleError: If the archive is unsafe, too large, incomplete or
|
|
330
|
+
inconsistent, or the workspace does not match the manifest.
|
|
331
|
+
ManifestError: If the manifest is invalid.
|
|
332
|
+
"""
|
|
333
|
+
data = _read_source(source, policy.max_archive_bytes)
|
|
334
|
+
try:
|
|
335
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
336
|
+
except (zipfile.BadZipFile, ValueError) as exc:
|
|
337
|
+
# ValueError: e.g. an entry name flagged UTF-8 that is not UTF-8
|
|
338
|
+
raise CapsuleError(f"Not a ZIP archive: {exc}") from exc
|
|
339
|
+
with archive:
|
|
340
|
+
infos = archive.infolist()
|
|
341
|
+
if len(infos) > MAX_ENTRIES:
|
|
342
|
+
raise CapsuleError("Too many entries")
|
|
343
|
+
seen: set[str] = set()
|
|
344
|
+
for info in infos:
|
|
345
|
+
_check_entry(info, seen, policy)
|
|
346
|
+
missing = {MANIFEST_NAME} - seen
|
|
347
|
+
if missing:
|
|
348
|
+
raise CapsuleError(f"Missing entries: {', '.join(sorted(missing))}")
|
|
349
|
+
contents = {
|
|
350
|
+
info.filename: _read_entry(archive, info, policy.entry_limit(info.filename))
|
|
351
|
+
for info in infos
|
|
352
|
+
}
|
|
353
|
+
try:
|
|
354
|
+
manifest = load_json(contents[MANIFEST_NAME].decode("utf-8"))
|
|
355
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
356
|
+
raise ManifestError(f"Invalid manifest JSON: {exc}") from exc
|
|
357
|
+
validate_manifest(manifest)
|
|
358
|
+
attachments = _checked_attachments(manifest, contents)
|
|
359
|
+
entity = workspace_entity(manifest)
|
|
360
|
+
if not entity.get(REFERENCED_KEY):
|
|
361
|
+
if WORKSPACE_NAME not in contents:
|
|
362
|
+
raise CapsuleError(f"Missing entries: {WORKSPACE_NAME}")
|
|
363
|
+
return Capsule(
|
|
364
|
+
manifest,
|
|
365
|
+
_checked(entity, contents[WORKSPACE_NAME]),
|
|
366
|
+
attachments=attachments,
|
|
367
|
+
)
|
|
368
|
+
if WORKSPACE_NAME in contents:
|
|
369
|
+
raise CapsuleError(
|
|
370
|
+
f"Unexpected entry for a referenced workspace: {WORKSPACE_NAME}"
|
|
371
|
+
)
|
|
372
|
+
ref = entity["@id"]
|
|
373
|
+
if resolve_workspace is None:
|
|
374
|
+
return Capsule(manifest, None, ref, attachments)
|
|
375
|
+
workspace = resolve_workspace(ref)
|
|
376
|
+
if len(workspace) > policy.max_workspace_bytes:
|
|
377
|
+
raise CapsuleError(f"Referenced workspace too large: {ref}")
|
|
378
|
+
return Capsule(manifest, _checked(entity, bytes(workspace)), ref, attachments)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _checked_attachments(
|
|
382
|
+
manifest: dict[str, Any], contents: Mapping[str, bytes]
|
|
383
|
+
) -> dict[str, bytes]:
|
|
384
|
+
"""Return the declared attachments, each matching its size and SHA-256.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
CapsuleError: If an attachment is missing, undeclared or altered.
|
|
388
|
+
"""
|
|
389
|
+
declared = {entity["@id"]: entity for entity in attachment_entities(manifest)}
|
|
390
|
+
if len(declared) > MAX_ATTACHMENTS:
|
|
391
|
+
raise CapsuleError(f"Too many attachments (at most {MAX_ATTACHMENTS})")
|
|
392
|
+
present = {name for name in contents if name.startswith(ATTACHMENTS_DIR)}
|
|
393
|
+
if present - set(declared):
|
|
394
|
+
raise CapsuleError(
|
|
395
|
+
f"Undeclared attachments: {', '.join(sorted(present - set(declared)))}"
|
|
396
|
+
)
|
|
397
|
+
if set(declared) - present:
|
|
398
|
+
raise CapsuleError(
|
|
399
|
+
f"Missing attachments: {', '.join(sorted(set(declared) - present))}"
|
|
400
|
+
)
|
|
401
|
+
result = {}
|
|
402
|
+
for entry, entity in declared.items():
|
|
403
|
+
data = contents[entry]
|
|
404
|
+
if entity["sha256"] != sha256_digest(data).split(":", 1)[1] or entity[
|
|
405
|
+
"contentSize"
|
|
406
|
+
] != str(len(data)):
|
|
407
|
+
raise CapsuleError(f"The attachment does not match the manifest: {entry}")
|
|
408
|
+
result[attachment_name(entity)] = data
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _checked(entity: dict[str, Any], workspace: bytes) -> bytes:
|
|
413
|
+
"""Return *workspace* if its SHA-256 and size match the manifest."""
|
|
414
|
+
if entity["sha256"] != sha256_digest(workspace).split(":", 1)[1] or entity[
|
|
415
|
+
"contentSize"
|
|
416
|
+
] != str(len(workspace)):
|
|
417
|
+
raise CapsuleError(
|
|
418
|
+
f"The workspace does not match the manifest: {entity['@id']}"
|
|
419
|
+
)
|
|
420
|
+
return workspace
|
datalab_capsule/calls.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
|
|
2
|
+
|
|
3
|
+
"""Operation calls: the neutral description of one function call.
|
|
4
|
+
|
|
5
|
+
A call names an operation (identifier and contract version), its parameters as
|
|
6
|
+
plain JSON values and one binding per input role. Repeated roles appear once per
|
|
7
|
+
input, in call order. A call whose ``operation`` is ``None`` is *opaque*: it
|
|
8
|
+
names no operation (a function unknown to the computation library, such as a
|
|
9
|
+
plugin, or a record written without identity by an older version) and it is
|
|
10
|
+
never replayed. A call that names an operation keeps it whether or not the
|
|
11
|
+
contract is qualified for replay: a replay checks the qualification of that
|
|
12
|
+
contract version against the local library.
|
|
13
|
+
|
|
14
|
+
Binding kinds:
|
|
15
|
+
|
|
16
|
+
- ``{"state_id": ...}``: a concrete state (used by activities);
|
|
17
|
+
- ``{"object_uuids": [...]}``: objects whose state at execution time is unknown
|
|
18
|
+
(only in ``legacy_metadata`` activities, see :mod:`datalab_capsule.legacy`);
|
|
19
|
+
- ``{"step": ..., "role": ...}``: the output of an upstream step (reserved).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import copy
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from datalab_capsule.integrity import ProvenanceError
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BINDING_KINDS",
|
|
32
|
+
"OPERATION_ID_PATTERN",
|
|
33
|
+
"CallError",
|
|
34
|
+
"ReservedBindingError",
|
|
35
|
+
"is_opaque",
|
|
36
|
+
"make_call",
|
|
37
|
+
"validate_call",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
OPERATION_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
|
|
41
|
+
BINDING_KINDS = ("state_id", "object_uuids", "step")
|
|
42
|
+
_IMPLEMENTED_BINDINGS = ("state_id",)
|
|
43
|
+
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CallError(ProvenanceError, ValueError):
|
|
47
|
+
"""Raised when an operation call is malformed."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReservedBindingError(CallError):
|
|
51
|
+
"""Raised when a call uses a binding kind that is not implemented yet."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def make_call(
|
|
55
|
+
operation_id: str | None,
|
|
56
|
+
contract_version: int | None,
|
|
57
|
+
parameters: dict[str, Any] | None,
|
|
58
|
+
inputs: list[tuple[str, str]],
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Build and validate an operation call bound to states.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
operation_id: Operation identifier, or None for an opaque call.
|
|
64
|
+
contract_version: Contract version (required with *operation_id*).
|
|
65
|
+
parameters: Encoded parameter values, or None if they could not be
|
|
66
|
+
encoded.
|
|
67
|
+
inputs: Ordered ``(role, state_id)`` pairs.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
JSON-compatible call.
|
|
71
|
+
"""
|
|
72
|
+
operation = None
|
|
73
|
+
if operation_id is not None:
|
|
74
|
+
operation = {"id": operation_id, "contract_version": contract_version}
|
|
75
|
+
call = {
|
|
76
|
+
"operation": operation,
|
|
77
|
+
"parameters": copy.deepcopy(parameters),
|
|
78
|
+
"inputs": [
|
|
79
|
+
{"role": role, "binding": {"state_id": state_id}}
|
|
80
|
+
for role, state_id in inputs
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
validate_call(call)
|
|
84
|
+
return call
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_opaque(call: dict[str, Any]) -> bool:
|
|
88
|
+
"""Return True if *call* names no operation."""
|
|
89
|
+
return call.get("operation") is None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_binding(binding: Any, where: str, allow_objects: bool) -> None:
|
|
93
|
+
if not isinstance(binding, dict) or len(binding) == 0:
|
|
94
|
+
raise CallError(f"{where}: binding must be a non-empty object")
|
|
95
|
+
kinds = [kind for kind in BINDING_KINDS if kind in binding]
|
|
96
|
+
if len(kinds) != 1:
|
|
97
|
+
raise CallError(f"{where}: binding must hold exactly one of {BINDING_KINDS}")
|
|
98
|
+
kind = kinds[0]
|
|
99
|
+
if kind == "object_uuids" and allow_objects:
|
|
100
|
+
uuids = binding["object_uuids"]
|
|
101
|
+
if (
|
|
102
|
+
set(binding) != {"object_uuids"}
|
|
103
|
+
or not isinstance(uuids, list)
|
|
104
|
+
or not uuids
|
|
105
|
+
or not all(isinstance(u, str) and _UUID.match(u) for u in uuids)
|
|
106
|
+
):
|
|
107
|
+
raise CallError(f"{where}: object binding must list object UUIDs")
|
|
108
|
+
return
|
|
109
|
+
if kind not in _IMPLEMENTED_BINDINGS:
|
|
110
|
+
raise ReservedBindingError(f"{where}: binding kind {kind!r} is reserved")
|
|
111
|
+
if set(binding) != {"state_id"} or not isinstance(binding["state_id"], str):
|
|
112
|
+
raise CallError(f"{where}: state binding must be {{'state_id': <str>}}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_call(call: Any, *, allow_object_bindings: bool = False) -> None:
|
|
116
|
+
"""Validate the structure of an operation call.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
call: Call to validate.
|
|
120
|
+
allow_object_bindings: Accept ``object_uuids`` bindings (legacy
|
|
121
|
+
activities only).
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ReservedBindingError: If a reserved binding kind is used.
|
|
125
|
+
CallError: If the call is malformed.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(call, dict) or set(call) != {"operation", "parameters", "inputs"}:
|
|
128
|
+
raise CallError("A call holds exactly: operation, parameters, inputs")
|
|
129
|
+
operation = call["operation"]
|
|
130
|
+
if operation is not None:
|
|
131
|
+
if not isinstance(operation, dict) or set(operation) != {
|
|
132
|
+
"id",
|
|
133
|
+
"contract_version",
|
|
134
|
+
}:
|
|
135
|
+
raise CallError("operation must be null or {id, contract_version}")
|
|
136
|
+
if not isinstance(operation["id"], str) or not OPERATION_ID_PATTERN.match(
|
|
137
|
+
operation["id"]
|
|
138
|
+
):
|
|
139
|
+
raise CallError(f"Invalid operation identifier: {operation['id']!r}")
|
|
140
|
+
version = operation["contract_version"]
|
|
141
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
142
|
+
raise CallError("contract_version must be an integer >= 1")
|
|
143
|
+
parameters = call["parameters"]
|
|
144
|
+
if parameters is not None and not isinstance(parameters, dict):
|
|
145
|
+
raise CallError("parameters must be an object or null")
|
|
146
|
+
inputs = call["inputs"]
|
|
147
|
+
if not isinstance(inputs, list):
|
|
148
|
+
raise CallError("inputs must be a list")
|
|
149
|
+
for index, item in enumerate(inputs):
|
|
150
|
+
where = f"inputs[{index}]"
|
|
151
|
+
if not isinstance(item, dict) or set(item) != {"role", "binding"}:
|
|
152
|
+
raise CallError(f"{where}: an input holds exactly role and binding")
|
|
153
|
+
if not isinstance(item["role"], str) or not item["role"]:
|
|
154
|
+
raise CallError(f"{where}: role must be a non-empty string")
|
|
155
|
+
_check_binding(item["binding"], where, allow_object_bindings)
|