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.
@@ -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
@@ -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)