massive-workflows 0.1.0__py3-none-win_arm64.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.
massive/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Typed, portable workflow authoring for Massive."""
2
+
3
+ from .builder import (
4
+ DEFAULT_MAP_CONCURRENCY,
5
+ MAX_MAP_CONCURRENCY,
6
+ CaseHandle,
7
+ DecisionHandle,
8
+ GraphBuilder,
9
+ NodeHandle,
10
+ WorkflowSpec,
11
+ )
12
+ from .canonical import JsonValue, canonical_json, sha256_ref
13
+ from .context import InvocationContext, NonRetryableError, StepContext
14
+ from .contracts import (
15
+ Container,
16
+ ExecutionContract,
17
+ Retry,
18
+ container,
19
+ execution,
20
+ retry,
21
+ )
22
+ from .files import ArtifactFiles, Blob, Tree
23
+ from .source_package import SourcePackage, source_package
24
+
25
+ __all__ = [
26
+ "DEFAULT_MAP_CONCURRENCY",
27
+ "MAX_MAP_CONCURRENCY",
28
+ "ArtifactFiles",
29
+ "Blob",
30
+ "CaseHandle",
31
+ "Container",
32
+ "DecisionHandle",
33
+ "ExecutionContract",
34
+ "GraphBuilder",
35
+ "InvocationContext",
36
+ "JsonValue",
37
+ "NodeHandle",
38
+ "NonRetryableError",
39
+ "Retry",
40
+ "SourcePackage",
41
+ "StepContext",
42
+ "Tree",
43
+ "WorkflowSpec",
44
+ "canonical_json",
45
+ "container",
46
+ "execution",
47
+ "retry",
48
+ "sha256_ref",
49
+ "source_package",
50
+ ]
Binary file
massive/_step.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any, Generic, TypeVar, get_args, get_origin, get_type_hints
7
+
8
+ from .context import InputT, StepContext
9
+ from .contracts import ExecutionContract
10
+
11
+ OutputT = TypeVar("OutputT")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class StepDefinition(Generic[InputT, OutputT]):
16
+ function: Callable[[StepContext[InputT]], OutputT | Awaitable[OutputT]]
17
+ input_type: Any
18
+ output_type: Any
19
+ contract: ExecutionContract | None
20
+
21
+ @classmethod
22
+ def from_callable(
23
+ cls,
24
+ function: Callable[[StepContext[InputT]], OutputT | Awaitable[OutputT]],
25
+ *,
26
+ contract: ExecutionContract | None = None,
27
+ ) -> StepDefinition[InputT, OutputT]:
28
+ if (
29
+ not inspect.isfunction(function)
30
+ or function.__qualname__ != function.__name__
31
+ or function.__name__ == "<lambda>"
32
+ ):
33
+ raise TypeError("workflow steps must be top-level named functions")
34
+ hints = get_type_hints(function, include_extras=True)
35
+ parameters = list(inspect.signature(function).parameters.values())
36
+ if (
37
+ len(parameters) != 1
38
+ or parameters[0].name not in hints
39
+ or parameters[0].kind
40
+ not in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
41
+ ):
42
+ raise TypeError(
43
+ "a workflow step requires one annotated positional StepContext parameter"
44
+ )
45
+ context_type = hints[parameters[0].name]
46
+ if get_origin(context_type) is not StepContext:
47
+ raise TypeError("a workflow step parameter must be StepContext[Input]")
48
+ (input_type,) = get_args(context_type)
49
+ if hints.get("return", inspect.Signature.empty) is inspect.Signature.empty:
50
+ raise TypeError("a workflow step requires a return annotation")
51
+ return cls(
52
+ function=function,
53
+ input_type=input_type,
54
+ output_type=hints["return"],
55
+ contract=contract,
56
+ )
massive/artifact.py ADDED
@@ -0,0 +1,356 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from functools import cache
8
+ from pathlib import Path
9
+ from typing import Protocol, cast
10
+
11
+ from jsonschema import Draft202012Validator
12
+ from jsonschema.exceptions import SchemaError, ValidationError
13
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictStr
14
+ from referencing.exceptions import Unresolvable
15
+
16
+ from .canonical import (
17
+ CanonicalJsonError,
18
+ JsonValue,
19
+ canonical_json,
20
+ parse_canonical_json,
21
+ sha256_ref,
22
+ )
23
+ from .datastore import Datastore, DatastoreConflictError, DatastoreNotFoundError
24
+ from .identity import (
25
+ ExecutionScope,
26
+ PositiveAttempt,
27
+ ProjectKey,
28
+ SafePathSegment,
29
+ Sha256Reference,
30
+ )
31
+
32
+ JSON_CONTENT_TYPE = "application/json"
33
+ MANIFEST_CONTENT_TYPE = "application/vnd.massive.data-artifact-manifest+json"
34
+
35
+
36
+ class ArtifactError(Exception):
37
+ pass
38
+
39
+
40
+ class ArtifactValidationError(ArtifactError):
41
+ pass
42
+
43
+
44
+ class ArtifactIntegrityError(ArtifactError):
45
+ pass
46
+
47
+
48
+ class ArtifactNotFoundError(ArtifactError):
49
+ pass
50
+
51
+
52
+ class ArtifactBodyConflictError(ArtifactError):
53
+ pass
54
+
55
+
56
+ class ArtifactManifestConflictError(ArtifactError):
57
+ pass
58
+
59
+
60
+ class SchemaValidator(Protocol):
61
+ def validate(self, value: object) -> None: ...
62
+
63
+
64
+ class Destination(BaseModel):
65
+ model_config = ConfigDict(frozen=True, extra="forbid")
66
+
67
+ manifest_key: StrictStr = Field(
68
+ validation_alias=AliasChoices("manifest_key", "manifestKey"),
69
+ serialization_alias="manifestKey",
70
+ min_length=1,
71
+ )
72
+ schema_ref: Sha256Reference = Field(
73
+ validation_alias=AliasChoices("schema_ref", "schema"), serialization_alias="schema"
74
+ )
75
+
76
+
77
+ class Producer(BaseModel):
78
+ model_config = ConfigDict(frozen=True, extra="forbid")
79
+
80
+ project_key: ProjectKey = Field(
81
+ validation_alias=AliasChoices("project_key", "projectKey"), serialization_alias="projectKey"
82
+ )
83
+ plan_hash: Sha256Reference = Field(
84
+ validation_alias=AliasChoices("plan_hash", "planHash"), serialization_alias="planHash"
85
+ )
86
+ run_id: SafePathSegment = Field(
87
+ validation_alias=AliasChoices("run_id", "runId"), serialization_alias="runId"
88
+ )
89
+ node_id: SafePathSegment = Field(
90
+ validation_alias=AliasChoices("node_id", "nodeId"), serialization_alias="nodeId"
91
+ )
92
+ attempt: PositiveAttempt
93
+ scope: ExecutionScope | None = None
94
+
95
+ def identity_json(self) -> dict[str, JsonValue]:
96
+ return cast(
97
+ dict[str, JsonValue], self.model_dump(mode="json", by_alias=True, exclude_none=True)
98
+ )
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class ArtifactRef:
103
+ key: str
104
+ hash: str
105
+ size: int
106
+ content_type: str
107
+
108
+ def json(self) -> dict[str, JsonValue]:
109
+ return {
110
+ "key": self.key,
111
+ "hash": self.hash,
112
+ "size": self.size,
113
+ "contentType": self.content_type,
114
+ }
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class PublishedJSON:
119
+ manifest: ArtifactRef
120
+ body: ArtifactRef
121
+ schema: str
122
+
123
+
124
+ class ArtifactRuntime:
125
+ """Publishes JSON through an immutable body followed by a manifest commit point."""
126
+
127
+ def __init__(self, datastore: Datastore) -> None:
128
+ self._datastore = datastore
129
+
130
+ def validate_destination(self, destination: Destination, producer: Producer) -> None:
131
+ """Validate an immutable producer slot before invoking user code."""
132
+ _validate_destination(destination, producer)
133
+
134
+ def publish_json(
135
+ self, destination: Destination, producer: Producer, body: bytes
136
+ ) -> PublishedJSON:
137
+ _validate_destination(destination, producer)
138
+ _validate_canonical_json(self._datastore, destination.schema_ref, body)
139
+ body_hash = sha256_ref(body)
140
+ body_ref = ArtifactRef(
141
+ key=blob_key(body_hash),
142
+ hash=body_hash,
143
+ size=len(body),
144
+ content_type=JSON_CONTENT_TYPE,
145
+ )
146
+ manifest = cast(
147
+ dict[str, JsonValue],
148
+ {
149
+ "kind": "DataArtifactManifest",
150
+ "schemaVersion": 1,
151
+ "encoding": "canonical-json-v0",
152
+ "producer": producer.identity_json(),
153
+ "schema": destination.schema_ref,
154
+ "body": body_ref.json(),
155
+ },
156
+ )
157
+ manifest_body = _canonical_manifest(manifest)
158
+ put_immutable(
159
+ self._datastore, body_ref.key, body, JSON_CONTENT_TYPE, ArtifactBodyConflictError
160
+ )
161
+ put_immutable(
162
+ self._datastore,
163
+ destination.manifest_key,
164
+ manifest_body,
165
+ MANIFEST_CONTENT_TYPE,
166
+ ArtifactManifestConflictError,
167
+ )
168
+ return PublishedJSON(
169
+ manifest=ArtifactRef(
170
+ key=destination.manifest_key,
171
+ hash=sha256_ref(manifest_body),
172
+ size=len(manifest_body),
173
+ content_type=MANIFEST_CONTENT_TYPE,
174
+ ),
175
+ body=body_ref,
176
+ schema=destination.schema_ref,
177
+ )
178
+
179
+ def resolve_json(
180
+ self, destination: Destination, producer: Producer
181
+ ) -> tuple[PublishedJSON, bytes]:
182
+ _validate_destination(destination, producer)
183
+ try:
184
+ manifest_object = self._datastore.get(destination.manifest_key)
185
+ except DatastoreNotFoundError as error:
186
+ raise ArtifactNotFoundError(
187
+ f"artifact manifest {destination.manifest_key} is missing"
188
+ ) from error
189
+ if manifest_object.info.content_type != MANIFEST_CONTENT_TYPE:
190
+ raise ArtifactIntegrityError(
191
+ f"manifest {destination.manifest_key} has unexpected content type"
192
+ )
193
+ manifest = _parse_canonical_json(manifest_object.body, "manifest", ArtifactIntegrityError)
194
+ try:
195
+ _manifest_validator().validate(manifest)
196
+ except ValidationError as error:
197
+ raise ArtifactIntegrityError("manifest does not satisfy its schema") from error
198
+ if not isinstance(manifest, dict):
199
+ raise ArtifactIntegrityError("manifest must be an object")
200
+ expected_producer = producer.identity_json()
201
+ if (
202
+ manifest.get("producer") != expected_producer
203
+ or manifest.get("schema") != destination.schema_ref
204
+ ):
205
+ raise ArtifactIntegrityError("manifest does not match its expected producer and schema")
206
+ body = manifest["body"]
207
+ if not isinstance(body, dict):
208
+ raise ArtifactIntegrityError("manifest body must be an object")
209
+ body_ref = ArtifactRef(
210
+ key=cast(str, body["key"]),
211
+ hash=cast(str, body["hash"]),
212
+ size=cast(int, body["size"]),
213
+ content_type=cast(str, body["contentType"]),
214
+ )
215
+ if body_ref.key != blob_key(body_ref.hash):
216
+ raise ArtifactIntegrityError("manifest body key does not match its digest")
217
+ try:
218
+ body_object = self._datastore.get(body_ref.key)
219
+ except DatastoreNotFoundError as error:
220
+ raise ArtifactIntegrityError(f"manifest body {body_ref.key} is missing") from error
221
+ if (
222
+ body_object.info.content_type != JSON_CONTENT_TYPE
223
+ or len(body_object.body) != body_ref.size
224
+ or sha256_ref(body_object.body) != body_ref.hash
225
+ ):
226
+ raise ArtifactIntegrityError("manifest body does not match its reference")
227
+ try:
228
+ _validate_canonical_json(self._datastore, destination.schema_ref, body_object.body)
229
+ except ArtifactValidationError as error:
230
+ raise ArtifactIntegrityError("manifest body does not satisfy its schema") from error
231
+ return (
232
+ PublishedJSON(
233
+ manifest=ArtifactRef(
234
+ key=destination.manifest_key,
235
+ hash=sha256_ref(manifest_object.body),
236
+ size=len(manifest_object.body),
237
+ content_type=MANIFEST_CONTENT_TYPE,
238
+ ),
239
+ body=body_ref,
240
+ schema=destination.schema_ref,
241
+ ),
242
+ body_object.body,
243
+ )
244
+
245
+
246
+ def _validate_destination(destination: Destination, producer: Producer) -> None:
247
+ expected_key = (
248
+ f"projects/{producer.project_key}/runs/{producer.run_id}/steps/"
249
+ f"{producer.node_id}{_scope_key_suffix(producer.scope)}/{producer.attempt}/output-manifest.json"
250
+ )
251
+ if destination.manifest_key != expected_key:
252
+ raise ArtifactValidationError(
253
+ f"manifest destination {destination.manifest_key!r} does not match producer slot"
254
+ )
255
+ try:
256
+ blob_key(destination.schema_ref)
257
+ except ArtifactValidationError as error:
258
+ raise ArtifactValidationError("schema reference must be a SHA-256 reference") from error
259
+
260
+
261
+ def _scope_key_suffix(scope: ExecutionScope | None) -> str:
262
+ if scope is None:
263
+ return ""
264
+ return "/scopes" + "".join(
265
+ f"/maps/{frame.map_id}/items/{frame.index}" for frame in scope.frames
266
+ )
267
+
268
+
269
+ def _validate_canonical_json(datastore: Datastore, schema_ref: str, body: bytes) -> None:
270
+ document = _parse_canonical_json(body, "value", ArtifactValidationError)
271
+ schema_key = blob_key(schema_ref)
272
+ try:
273
+ schema_body = datastore.get(schema_key).body
274
+ except DatastoreNotFoundError as error:
275
+ raise ArtifactValidationError(f"schema {schema_ref} is missing") from error
276
+ schema = _parse_canonical_json(schema_body, "schema", ArtifactValidationError)
277
+ if sha256_ref(schema_body) != schema_ref:
278
+ raise ArtifactValidationError(f"schema {schema_ref} does not match its digest")
279
+ if not isinstance(schema, dict):
280
+ raise ArtifactValidationError("schema must be an object")
281
+ try:
282
+ Draft202012Validator.check_schema(schema)
283
+ validator = cast(SchemaValidator, Draft202012Validator(schema))
284
+ except (SchemaError, re.error, Unresolvable) as error:
285
+ raise ArtifactValidationError(f"schema {schema_ref} cannot be used") from error
286
+ try:
287
+ validator.validate(document)
288
+ except ValidationError as error:
289
+ raise ArtifactValidationError(f"value does not satisfy schema {schema_ref}") from error
290
+ except (re.error, Unresolvable) as error:
291
+ raise ArtifactValidationError(f"schema {schema_ref} cannot be used") from error
292
+
293
+
294
+ def _parse_canonical_json(body: bytes, label: str, error_type: type[ArtifactError]) -> JsonValue:
295
+ try:
296
+ return parse_canonical_json(body)
297
+ except CanonicalJsonError as error:
298
+ raise error_type(f"{label} is not canonical JSON") from error
299
+
300
+
301
+ def _canonical_manifest(manifest: dict[str, JsonValue]) -> bytes:
302
+ try:
303
+ _manifest_validator().validate(manifest)
304
+ return canonical_json(manifest).encode()
305
+ except (TypeError, ValueError, ValidationError) as error:
306
+ raise ArtifactValidationError("artifact manifest does not satisfy its schema") from error
307
+
308
+
309
+ def put_immutable(
310
+ datastore: Datastore,
311
+ key: str,
312
+ body: bytes,
313
+ content_type: str,
314
+ conflict_error: type[ArtifactBodyConflictError | ArtifactManifestConflictError],
315
+ ) -> None:
316
+ try:
317
+ datastore.put(key, body, content_type=content_type, if_absent=True)
318
+ return
319
+ except DatastoreConflictError:
320
+ pass
321
+ try:
322
+ existing = datastore.get(key)
323
+ except DatastoreNotFoundError as error:
324
+ raise conflict_error(f"cannot inspect existing immutable object {key}") from error
325
+ if existing.info.content_type != content_type or existing.body != body:
326
+ raise conflict_error(f"existing immutable object {key} differs")
327
+
328
+
329
+ def blob_key(hash_ref: str) -> str:
330
+ prefix = "sha256:"
331
+ digest = hash_ref.removeprefix(prefix)
332
+ if (
333
+ not hash_ref.startswith(prefix)
334
+ or len(digest) != 64
335
+ or any(char not in "0123456789abcdef" for char in digest)
336
+ ):
337
+ raise ArtifactValidationError(f"invalid SHA-256 reference {hash_ref!r}")
338
+ return f"blobs/sha256/{digest}"
339
+
340
+
341
+ @cache
342
+ def _manifest_validator() -> SchemaValidator:
343
+ source = importlib.resources.files("massive").joinpath(
344
+ "schemas", "data-artifact-manifest.schema.json"
345
+ )
346
+ if source.is_file():
347
+ document = json.loads(source.read_text(encoding="utf-8"))
348
+ else:
349
+ document = json.loads(
350
+ (
351
+ Path(__file__).resolve().parents[4]
352
+ / "conformance/schema/data-artifact-manifest.schema.json"
353
+ ).read_text()
354
+ )
355
+ Draft202012Validator.check_schema(document)
356
+ return cast(SchemaValidator, Draft202012Validator(document))