artest-runtime 0.2.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,36 @@
1
+ """Typed DAG runtime with an event journal and a content-addressed artifact store.
2
+
3
+ Steps are typed async functions. Every state change is appended to a journal, which is what makes
4
+ runs resumable after a crash or a pause, and what feeds the live UI and the exported report.
5
+ """
6
+
7
+ from artest.runtime.artifacts import ArtifactMissingError, ArtifactStore, StoredBlob
8
+ from artest.runtime.events import ControlEvent, RunStatus, control_event
9
+ from artest.runtime.journal import Entry, Journal, RunRecord, RunTakenError, utc_now
10
+ from artest.runtime.pipeline import Pipeline, PipelineError
11
+ from artest.runtime.runner import Pause, Runner, RunOutcome, StepContext
12
+ from artest.runtime.telemetry import TelemetryUnavailableError, configure_telemetry
13
+ from artest.runtime.views import effective
14
+
15
+ __all__ = [
16
+ "ArtifactMissingError",
17
+ "ArtifactStore",
18
+ "ControlEvent",
19
+ "Entry",
20
+ "Journal",
21
+ "Pause",
22
+ "Pipeline",
23
+ "PipelineError",
24
+ "RunOutcome",
25
+ "RunRecord",
26
+ "RunStatus",
27
+ "RunTakenError",
28
+ "Runner",
29
+ "StepContext",
30
+ "StoredBlob",
31
+ "TelemetryUnavailableError",
32
+ "configure_telemetry",
33
+ "control_event",
34
+ "effective",
35
+ "utc_now",
36
+ ]
@@ -0,0 +1,68 @@
1
+ """The content-addressed artifact store: immutable bytes on disk, named by their SHA-256.
2
+
3
+ The same screenshot stored twice takes space once. Writes go to a temporary file that is then renamed,
4
+ so a reader never sees half an artifact. Callers write an artifact before appending any event that
5
+ references it, so a crash can leave an unreferenced file but never a reference to a missing one.
6
+ """
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import secrets
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from artest.runtime.journal import Journal
17
+
18
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class StoredBlob:
23
+ sha256: str
24
+ media_type: str
25
+ size: int
26
+
27
+
28
+ class ArtifactMissingError(LookupError):
29
+ pass
30
+
31
+
32
+ class ArtifactStore:
33
+ def __init__(self, root: Path, journal: Journal) -> None:
34
+ self._root = root
35
+ self._journal = journal
36
+
37
+ def path(self, sha256: str) -> Path:
38
+ if not _SHA256.match(sha256):
39
+ msg = f"not a SHA-256: {sha256!r}"
40
+ raise ValueError(msg)
41
+ return self._root / sha256[:2] / sha256
42
+
43
+ async def put(self, data: bytes, media_type: str) -> StoredBlob:
44
+ sha256 = hashlib.sha256(data).hexdigest()
45
+ path = self.path(sha256)
46
+ if not path.exists():
47
+ await asyncio.to_thread(_write_atomically, path, data)
48
+ await self._journal.record_artifact(sha256, media_type, len(data))
49
+ return StoredBlob(sha256=sha256, media_type=media_type, size=len(data))
50
+
51
+ async def get(self, sha256: str) -> bytes:
52
+ try:
53
+ return await asyncio.to_thread(self.path(sha256).read_bytes)
54
+ except FileNotFoundError as error:
55
+ raise ArtifactMissingError(sha256) from error
56
+
57
+ def exists(self, sha256: str) -> bool:
58
+ return self.path(sha256).exists()
59
+
60
+
61
+ def _write_atomically(path: Path, data: bytes) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ temporary = path.with_name(f".{path.name}.{secrets.token_hex(4)}.tmp")
64
+ with temporary.open("wb") as file:
65
+ file.write(data)
66
+ file.flush()
67
+ os.fsync(file.fileno())
68
+ temporary.replace(path)
@@ -0,0 +1,108 @@
1
+ """Control events: what the runtime records about runs and steps.
2
+
3
+ They share the journal with the pipeline's own domain events. The runtime knows nothing about what steps
4
+ do, so it never looks inside a domain event beyond its ``type``. The step and attempt an event belongs
5
+ to live in the journal entry, not in the payload.
6
+ """
7
+
8
+ from enum import StrEnum
9
+ from typing import Annotated, Literal
10
+
11
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StringConstraints, TypeAdapter
12
+
13
+ Sha256 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
14
+
15
+
16
+ class RuntimeModel(BaseModel):
17
+ model_config = ConfigDict(frozen=True, extra="forbid", json_schema_serialization_defaults_required=True)
18
+
19
+
20
+ class RunStatus(StrEnum):
21
+ RUNNING = "running"
22
+ PAUSED = "paused"
23
+ COMPLETED = "completed"
24
+ FAILED = "failed"
25
+
26
+
27
+ class RunStarted(RuntimeModel):
28
+ type: Literal["run_started"] = "run_started"
29
+ pipeline: str
30
+ inputs: dict[str, Sha256] = Field(
31
+ default_factory=dict[str, Sha256], description="Each run input, stored as JSON in the artifact store"
32
+ )
33
+
34
+
35
+ class RunResumed(RuntimeModel):
36
+ type: Literal["run_resumed"] = "run_resumed"
37
+
38
+
39
+ class StepStarted(RuntimeModel):
40
+ """A new attempt of a step. Consumers drop the step's domain events from earlier attempts."""
41
+
42
+ type: Literal["step_started"] = "step_started"
43
+
44
+
45
+ class StepCompleted(RuntimeModel):
46
+ type: Literal["step_completed"] = "step_completed"
47
+ output: Sha256 = Field(description="The step's output, stored as JSON in the artifact store")
48
+
49
+
50
+ class StepFailed(RuntimeModel):
51
+ type: Literal["step_failed"] = "step_failed"
52
+ error: str
53
+
54
+
55
+ class StepPaused(RuntimeModel):
56
+ type: Literal["step_paused"] = "step_paused"
57
+ reason: str
58
+ resume_after: AwareDatetime | None = None
59
+ detail: str = ""
60
+
61
+
62
+ class RunPaused(RuntimeModel):
63
+ type: Literal["run_paused"] = "run_paused"
64
+ reason: str
65
+ resume_after: AwareDatetime | None = None
66
+ steps: tuple[str, ...]
67
+
68
+
69
+ class RunCompleted(RuntimeModel):
70
+ type: Literal["run_completed"] = "run_completed"
71
+
72
+
73
+ class RunFailed(RuntimeModel):
74
+ type: Literal["run_failed"] = "run_failed"
75
+ steps: tuple[str, ...]
76
+ interrupted: bool = False
77
+ """No step failed: the run was stopped, it crashed between steps, or the process running it died. Its
78
+ steps stay as they were, and a later run of the same ID goes on from them."""
79
+
80
+
81
+ ControlEvent = Annotated[
82
+ RunStarted
83
+ | RunResumed
84
+ | StepStarted
85
+ | StepCompleted
86
+ | StepFailed
87
+ | StepPaused
88
+ | RunPaused
89
+ | RunCompleted
90
+ | RunFailed,
91
+ Field(discriminator="type"),
92
+ ]
93
+
94
+ control_event: TypeAdapter[ControlEvent] = TypeAdapter(ControlEvent)
95
+
96
+ CONTROL_EVENT_TYPES = frozenset(
97
+ {
98
+ "run_started",
99
+ "run_resumed",
100
+ "step_started",
101
+ "step_completed",
102
+ "step_failed",
103
+ "step_paused",
104
+ "run_paused",
105
+ "run_completed",
106
+ "run_failed",
107
+ }
108
+ )