sonata-engine 0.6.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.
- sonata_engine/__init__.py +101 -0
- sonata_engine/core/__init__.py +38 -0
- sonata_engine/core/compiled.py +116 -0
- sonata_engine/core/inputs.py +98 -0
- sonata_engine/core/outcome.py +37 -0
- sonata_engine/core/resource_task.py +77 -0
- sonata_engine/core/selection.py +35 -0
- sonata_engine/core/slug.py +15 -0
- sonata_engine/core/step_scope.py +28 -0
- sonata_engine/core/steps.py +88 -0
- sonata_engine/core/task.py +70 -0
- sonata_engine/core/workflow.py +765 -0
- sonata_engine/errors.py +83 -0
- sonata_engine/journal.py +500 -0
- sonata_engine/py.typed +0 -0
- sonata_engine/retention.py +140 -0
- sonata_engine/workflow/__init__.py +28 -0
- sonata_engine/workflow/context.py +79 -0
- sonata_engine/workflow/event_builders.py +109 -0
- sonata_engine/workflow/events.py +73 -0
- sonata_engine/workflow/models.py +57 -0
- sonata_engine/workflow/observers.py +41 -0
- sonata_engine/workflow/reporting.py +170 -0
- sonata_engine-0.6.0.dist-info/METADATA +404 -0
- sonata_engine-0.6.0.dist-info/RECORD +28 -0
- sonata_engine-0.6.0.dist-info/WHEEL +5 -0
- sonata_engine-0.6.0.dist-info/licenses/LICENSE +21 -0
- sonata_engine-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Sonata workflow engine."""
|
|
2
|
+
|
|
3
|
+
from sonata_engine.core import (
|
|
4
|
+
CompiledTask,
|
|
5
|
+
CompiledWorkflow,
|
|
6
|
+
Evidence,
|
|
7
|
+
Resource,
|
|
8
|
+
ReusableTask,
|
|
9
|
+
Selection,
|
|
10
|
+
Steps,
|
|
11
|
+
Task,
|
|
12
|
+
TaskExecution,
|
|
13
|
+
TaskInputs,
|
|
14
|
+
TaskOutcome,
|
|
15
|
+
Workflow,
|
|
16
|
+
WorkflowResult,
|
|
17
|
+
)
|
|
18
|
+
from sonata_engine.errors import (
|
|
19
|
+
AmbiguousTaskStateError,
|
|
20
|
+
CorruptJournalError,
|
|
21
|
+
InvalidTaskOutcomeError,
|
|
22
|
+
MissingAcquireUnitError,
|
|
23
|
+
NoUpstreamValueError,
|
|
24
|
+
ResourceDependencyCycleError,
|
|
25
|
+
ResourceUnavailableError,
|
|
26
|
+
ResumeConfigurationError,
|
|
27
|
+
SelectionError,
|
|
28
|
+
StepScopeUnavailableError,
|
|
29
|
+
UndeclaredResourceError,
|
|
30
|
+
UnsupportedJournalSchemaError,
|
|
31
|
+
WorkflowTopologyMismatchError,
|
|
32
|
+
)
|
|
33
|
+
from sonata_engine.journal import JournalConfig, Verifier
|
|
34
|
+
from sonata_engine.retention import (
|
|
35
|
+
UnknownRetainedResourceError,
|
|
36
|
+
release_retained,
|
|
37
|
+
)
|
|
38
|
+
from sonata_engine.workflow import (
|
|
39
|
+
TaskDefinition,
|
|
40
|
+
TaskRun,
|
|
41
|
+
WorkflowCompletion,
|
|
42
|
+
WorkflowContext,
|
|
43
|
+
WorkflowEvent,
|
|
44
|
+
WorkflowObserver,
|
|
45
|
+
WorkflowRun,
|
|
46
|
+
WorkflowSink,
|
|
47
|
+
WorkflowState,
|
|
48
|
+
bind_workflow_sink,
|
|
49
|
+
status,
|
|
50
|
+
subtask,
|
|
51
|
+
workflow_log,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__version__ = "0.6.0"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"AmbiguousTaskStateError",
|
|
58
|
+
"CompiledTask",
|
|
59
|
+
"CompiledWorkflow",
|
|
60
|
+
"CorruptJournalError",
|
|
61
|
+
"Evidence",
|
|
62
|
+
"InvalidTaskOutcomeError",
|
|
63
|
+
"JournalConfig",
|
|
64
|
+
"MissingAcquireUnitError",
|
|
65
|
+
"NoUpstreamValueError",
|
|
66
|
+
"Resource",
|
|
67
|
+
"ResourceDependencyCycleError",
|
|
68
|
+
"ResourceUnavailableError",
|
|
69
|
+
"ResumeConfigurationError",
|
|
70
|
+
"ReusableTask",
|
|
71
|
+
"Selection",
|
|
72
|
+
"SelectionError",
|
|
73
|
+
"StepScopeUnavailableError",
|
|
74
|
+
"Steps",
|
|
75
|
+
"Task",
|
|
76
|
+
"TaskDefinition",
|
|
77
|
+
"TaskExecution",
|
|
78
|
+
"TaskInputs",
|
|
79
|
+
"TaskOutcome",
|
|
80
|
+
"TaskRun",
|
|
81
|
+
"UndeclaredResourceError",
|
|
82
|
+
"UnknownRetainedResourceError",
|
|
83
|
+
"UnsupportedJournalSchemaError",
|
|
84
|
+
"Verifier",
|
|
85
|
+
"Workflow",
|
|
86
|
+
"WorkflowCompletion",
|
|
87
|
+
"WorkflowContext",
|
|
88
|
+
"WorkflowEvent",
|
|
89
|
+
"WorkflowObserver",
|
|
90
|
+
"WorkflowResult",
|
|
91
|
+
"WorkflowRun",
|
|
92
|
+
"WorkflowSink",
|
|
93
|
+
"WorkflowState",
|
|
94
|
+
"WorkflowTopologyMismatchError",
|
|
95
|
+
"__version__",
|
|
96
|
+
"bind_workflow_sink",
|
|
97
|
+
"release_retained",
|
|
98
|
+
"status",
|
|
99
|
+
"subtask",
|
|
100
|
+
"workflow_log",
|
|
101
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Core workflow primitives: tasks, resources, selection, and compiled results.
|
|
2
|
+
|
|
3
|
+
This package is the stable surface for building and running a workflow: the
|
|
4
|
+
`Task` hierarchy and its `Steps` composite, the `Resource` lifecycle pairing,
|
|
5
|
+
`Workflow` itself, and the immutable compiled/result types the runner returns.
|
|
6
|
+
Everything here is re-exported for import from `sonata_engine.core`; the
|
|
7
|
+
sibling modules are implementation detail behind that list.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sonata_engine.core.compiled import (
|
|
11
|
+
CompiledTask,
|
|
12
|
+
CompiledWorkflow,
|
|
13
|
+
TaskExecution,
|
|
14
|
+
WorkflowResult,
|
|
15
|
+
)
|
|
16
|
+
from sonata_engine.core.inputs import TaskInputs
|
|
17
|
+
from sonata_engine.core.outcome import Evidence, TaskOutcome
|
|
18
|
+
from sonata_engine.core.resource_task import Resource
|
|
19
|
+
from sonata_engine.core.selection import Selection
|
|
20
|
+
from sonata_engine.core.steps import Steps
|
|
21
|
+
from sonata_engine.core.task import ReusableTask, Task
|
|
22
|
+
from sonata_engine.core.workflow import Workflow
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CompiledTask",
|
|
26
|
+
"CompiledWorkflow",
|
|
27
|
+
"Evidence",
|
|
28
|
+
"Resource",
|
|
29
|
+
"ReusableTask",
|
|
30
|
+
"Selection",
|
|
31
|
+
"Steps",
|
|
32
|
+
"Task",
|
|
33
|
+
"TaskExecution",
|
|
34
|
+
"TaskInputs",
|
|
35
|
+
"TaskOutcome",
|
|
36
|
+
"Workflow",
|
|
37
|
+
"WorkflowResult",
|
|
38
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Immutable artifacts produced by compiling and running a workflow.
|
|
2
|
+
|
|
3
|
+
`CompiledWorkflow` is what `Workflow.compile()` returns: the recorded tasks
|
|
4
|
+
rewritten into a flat sequence of `CompiledTask` units with compiler-assigned
|
|
5
|
+
IDs. `WorkflowResult` is what a run returns: one `TaskExecution` per unit, in
|
|
6
|
+
execution order.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from sonata_engine.core.outcome import TaskOutcome
|
|
17
|
+
from sonata_engine.core.resource_task import Resource
|
|
18
|
+
from sonata_engine.core.task import Task
|
|
19
|
+
from sonata_engine.errors import MissingAcquireUnitError
|
|
20
|
+
|
|
21
|
+
TaskKind = Literal["consumer", "acquire", "release"]
|
|
22
|
+
TaskExecutionStatus = Literal["passed", "skipped"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class CompiledTask[T]:
|
|
27
|
+
"""A task with its compiler-assigned, stable identity.
|
|
28
|
+
|
|
29
|
+
`task_id` only ever exists here; `Task` instances never carry one.
|
|
30
|
+
|
|
31
|
+
`kind` discriminates ordinary consumer units from the acquire/release units
|
|
32
|
+
the compiler splices in for resources. For an `acquire`/`release` unit,
|
|
33
|
+
`resource` names which `Resource` it belongs to, letting the runner
|
|
34
|
+
pair a release with its acquire and release out of linear order on failure.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
task_id: str
|
|
38
|
+
task: Task[T]
|
|
39
|
+
required_resources: tuple[Resource, ...] = ()
|
|
40
|
+
kind: TaskKind = "consumer"
|
|
41
|
+
resource: Resource | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class CompiledWorkflow:
|
|
46
|
+
"""The immutable result of `Workflow.compile()`."""
|
|
47
|
+
|
|
48
|
+
workflow_id: str
|
|
49
|
+
tasks: tuple[CompiledTask[object], ...]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def fingerprint(self) -> str:
|
|
53
|
+
"""Deterministic identity of topology and reusable-task semantics."""
|
|
54
|
+
acquire_ids = {
|
|
55
|
+
id(task.resource): task.task_id
|
|
56
|
+
for task in self.tasks
|
|
57
|
+
if task.kind == "acquire" and task.resource is not None
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def _acquire_id(resource: Resource[Any]) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return acquire_ids[id(resource)]
|
|
63
|
+
except KeyError as exc:
|
|
64
|
+
raise MissingAcquireUnitError(
|
|
65
|
+
f"required_resources names {resource.title!r}, which has no "
|
|
66
|
+
"acquire unit in this compiled workflow"
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
topology = [
|
|
70
|
+
(
|
|
71
|
+
task.task_id,
|
|
72
|
+
task.kind,
|
|
73
|
+
f"{type(task.task).__module__}.{type(task.task).__qualname__}",
|
|
74
|
+
task.task._fingerprint_payload(),
|
|
75
|
+
tuple(_acquire_id(resource) for resource in task.required_resources),
|
|
76
|
+
)
|
|
77
|
+
for task in self.tasks
|
|
78
|
+
]
|
|
79
|
+
canonical = json.dumps(
|
|
80
|
+
[self.workflow_id, topology],
|
|
81
|
+
ensure_ascii=True,
|
|
82
|
+
separators=(",", ":"),
|
|
83
|
+
).encode()
|
|
84
|
+
return f"sha256:{hashlib.sha256(canonical).hexdigest()}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class TaskExecution:
|
|
89
|
+
"""The runtime result of one compiled task unit.
|
|
90
|
+
|
|
91
|
+
`outcome` is `None` exactly when `status` is `"skipped"` — the `None`
|
|
92
|
+
reports the skip, not a task that ran and returned nothing. A task that
|
|
93
|
+
ran always has a `TaskOutcome`, whose `value` may itself be `None`. So
|
|
94
|
+
read a result as `execution.outcome.value if execution.outcome else ...`;
|
|
95
|
+
there is deliberately no `execution.value` shortcut, because collapsing
|
|
96
|
+
the two cases would throw that distinction away.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
task_id: str
|
|
100
|
+
status: TaskExecutionStatus
|
|
101
|
+
outcome: TaskOutcome[Any] | None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class WorkflowResult:
|
|
106
|
+
"""Immutable results from one successful workflow run."""
|
|
107
|
+
|
|
108
|
+
workflow_id: str
|
|
109
|
+
tasks: tuple[TaskExecution, ...]
|
|
110
|
+
|
|
111
|
+
def by_id(self, task_id: str) -> TaskExecution:
|
|
112
|
+
"""Return the execution whose `task_id` matches, or raise `KeyError`."""
|
|
113
|
+
for execution in self.tasks:
|
|
114
|
+
if execution.task_id == task_id:
|
|
115
|
+
return execution
|
|
116
|
+
raise KeyError(task_id)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What a task is handed when it runs.
|
|
2
|
+
|
|
3
|
+
A `TaskInputs` carries the acquired resource values a task declared, plus the
|
|
4
|
+
value the preceding step produced when the task sits inside a composite. Both
|
|
5
|
+
are reads through a running workflow: a task cannot obtain a resource it never
|
|
6
|
+
declared, and the container is frozen once built.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Mapping, Set
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
15
|
+
|
|
16
|
+
from sonata_engine.errors import (
|
|
17
|
+
NoUpstreamValueError,
|
|
18
|
+
ResourceUnavailableError,
|
|
19
|
+
UndeclaredResourceError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from sonata_engine.core.resource_task import Resource
|
|
24
|
+
from sonata_engine.core.step_scope import _StepScopeProtocol
|
|
25
|
+
|
|
26
|
+
# Distinct from `None`, which is a legitimate and reconstructible step value.
|
|
27
|
+
_NO_UPSTREAM: Any = object()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class TaskInputs:
|
|
32
|
+
"""The resource values a task is permitted to observe during one run."""
|
|
33
|
+
|
|
34
|
+
_values: Mapping[int, object]
|
|
35
|
+
_accessible: Set[int]
|
|
36
|
+
_upstream: Any = _NO_UPSTREAM
|
|
37
|
+
# compare=False is load-bearing, not cosmetic: `_StepScope` holds a `TaskInputs`
|
|
38
|
+
# in its own `base_inputs` field, so if this field participated in `__eq__`,
|
|
39
|
+
# comparing two `TaskInputs` would walk into a scope and back into inputs.
|
|
40
|
+
_step_scope: _StepScopeProtocol | None = field(
|
|
41
|
+
default=None, compare=False, repr=False
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
"""Freeze the resource-value mapping and the accessible-id set."""
|
|
46
|
+
object.__setattr__(self, "_values", MappingProxyType(dict(self._values)))
|
|
47
|
+
object.__setattr__(self, "_accessible", frozenset(self._accessible))
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def empty(cls) -> TaskInputs:
|
|
51
|
+
"""Return inputs that grant access to no resource and have no upstream."""
|
|
52
|
+
return cls({}, frozenset())
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def _for_resources(
|
|
56
|
+
cls, values: Mapping[Resource[Any], object], accessible: Set[Resource[Any]]
|
|
57
|
+
) -> TaskInputs:
|
|
58
|
+
return cls._for_resource_values(
|
|
59
|
+
{id(resource): value for resource, value in values.items()}, accessible
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _for_resource_values(
|
|
64
|
+
cls, values: Mapping[int, object], accessible: Iterable[Resource[Any]]
|
|
65
|
+
) -> TaskInputs:
|
|
66
|
+
return cls(values, frozenset(map(id, accessible)))
|
|
67
|
+
|
|
68
|
+
def resource[T](self, resource: Resource[T]) -> T:
|
|
69
|
+
"""Return the acquired value of a declared `Resource`, typed as its value.
|
|
70
|
+
|
|
71
|
+
Raises `UndeclaredResourceError` if this task never declared the
|
|
72
|
+
resource, or `ResourceUnavailableError` if it declared it but no value
|
|
73
|
+
was published for it.
|
|
74
|
+
"""
|
|
75
|
+
resource_id = id(resource)
|
|
76
|
+
if resource_id not in self._accessible:
|
|
77
|
+
raise UndeclaredResourceError(
|
|
78
|
+
f"resource {resource.title!r} is not declared"
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
value = self._values[resource_id]
|
|
82
|
+
except KeyError as exc:
|
|
83
|
+
raise ResourceUnavailableError(
|
|
84
|
+
f"resource {resource.title!r} is unavailable"
|
|
85
|
+
) from exc
|
|
86
|
+
return cast(T, value)
|
|
87
|
+
|
|
88
|
+
def upstream(self) -> Any: # noqa: ANN401
|
|
89
|
+
"""Return the value the preceding step produced.
|
|
90
|
+
|
|
91
|
+
Raises when nothing precedes this step: the first step of a top-level
|
|
92
|
+
composite, or a task not running inside one.
|
|
93
|
+
"""
|
|
94
|
+
if self._upstream is _NO_UPSTREAM:
|
|
95
|
+
raise NoUpstreamValueError(
|
|
96
|
+
"no upstream value: nothing ran before this step"
|
|
97
|
+
)
|
|
98
|
+
return self._upstream
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""What a task reports when it finishes.
|
|
2
|
+
|
|
3
|
+
A `TaskOutcome` pairs the value a task produced with the `Evidence` that
|
|
4
|
+
justifies reusing it on a later run. The value never leaves the process; the
|
|
5
|
+
evidence is what the journal stores and what resume re-verifies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Evidence:
|
|
15
|
+
"""A checkable claim that a task's output still exists and is unchanged.
|
|
16
|
+
|
|
17
|
+
`kind` names the verifier that knows how to check it (for example
|
|
18
|
+
`file-digest`), `reference` is what that verifier looks at, and `digest`
|
|
19
|
+
is the optional expected fingerprint. On resume, every entry of a reusable
|
|
20
|
+
task must verify before its evidence is trusted.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
kind: str
|
|
24
|
+
reference: str
|
|
25
|
+
digest: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class TaskOutcome[T]:
|
|
30
|
+
"""The value a task returned, plus the evidence a later resume can check.
|
|
31
|
+
|
|
32
|
+
`value` is an in-process data channel and is never journalled, so a skipped
|
|
33
|
+
task is reconstructed with `None` rather than its original value.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
value: T | None = None
|
|
37
|
+
evidence: tuple[Evidence, ...] = field(default_factory=tuple)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Resources and the acquire/release units that manage their lifetime.
|
|
2
|
+
|
|
3
|
+
A `Resource` bundles the two callbacks a task needs to obtain and later give
|
|
4
|
+
back an expensive thing (a VM, a token, a mounted image). The compiler turns
|
|
5
|
+
each one into `ResourceOp` units spliced around its consumers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any, override
|
|
14
|
+
|
|
15
|
+
from sonata_engine.core.inputs import TaskInputs
|
|
16
|
+
from sonata_engine.core.outcome import TaskOutcome
|
|
17
|
+
from sonata_engine.core.task import Task
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
21
|
+
class Resource[T]:
|
|
22
|
+
"""A pair of acquire/release side effects a task depends on.
|
|
23
|
+
|
|
24
|
+
A `Resource` is not itself added to a workflow; consumer tasks reference it
|
|
25
|
+
via `Workflow.add(task, requires=(resource,))`. `compile()` splices an
|
|
26
|
+
acquire unit before the first consumer and a release unit after the last.
|
|
27
|
+
|
|
28
|
+
Identity semantics (`eq=False`): the compiler keys resources by `id()` and
|
|
29
|
+
deliberately treats two structurally identical `Resource` instances as
|
|
30
|
+
distinct (each gets its own acquire/release pair and runtime value). Value
|
|
31
|
+
equality would contradict that -- and would also make `hash()` recurse
|
|
32
|
+
forever on a cyclic `requires` graph.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
title: str
|
|
36
|
+
acquire: Callable[[TaskInputs], T] = field(repr=False)
|
|
37
|
+
release: Callable[[TaskInputs, T], None] = field(repr=False)
|
|
38
|
+
requires: tuple[Resource[Any], ...] = ()
|
|
39
|
+
# `keep` retains a resource so a later run need not rebuild it. A resource
|
|
40
|
+
# that holds a secret -- a staged token, a signing key, an open credential
|
|
41
|
+
# lease -- declares always_release instead, so leaving it behind is not
|
|
42
|
+
# something a caller can cause by forgetting to classify it.
|
|
43
|
+
always_release: bool = False
|
|
44
|
+
# Rebuilds an acquired value from its journal record, for a teardown running
|
|
45
|
+
# in a later process. The journal can only hold JSON, so a release written
|
|
46
|
+
# against a dataclass needs its own type back rather than a dict.
|
|
47
|
+
revive: Callable[[Any], T] | None = field(default=None, repr=False)
|
|
48
|
+
acquire_idempotent: bool = False
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def release_title(self) -> str:
|
|
52
|
+
"""Title for the release unit, derived from the resource's own title."""
|
|
53
|
+
return f"Release {self.title.removeprefix('Acquire ')}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResourceOperation(StrEnum):
|
|
57
|
+
"""Which half of a resource's lifecycle a `ResourceOp` performs."""
|
|
58
|
+
|
|
59
|
+
ACQUIRE = "acquire"
|
|
60
|
+
RELEASE = "release"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True, eq=True)
|
|
64
|
+
class ResourceOp(Task[object]):
|
|
65
|
+
"""Engine-owned lifecycle unit identified by a resource and operation."""
|
|
66
|
+
|
|
67
|
+
title: str
|
|
68
|
+
resource: Resource[Any] = field(repr=False)
|
|
69
|
+
operation: ResourceOperation = ResourceOperation.ACQUIRE
|
|
70
|
+
idempotent: bool = False
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def run(self, inputs: TaskInputs) -> TaskOutcome[object]:
|
|
74
|
+
if self.operation is ResourceOperation.ACQUIRE:
|
|
75
|
+
return TaskOutcome(value=self.resource.acquire(inputs))
|
|
76
|
+
self.resource.release(inputs, inputs.resource(self.resource))
|
|
77
|
+
return TaskOutcome(value=None)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Narrowing a workflow to a slice of its consumer tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sonata_engine.errors import SelectionError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Selection:
|
|
12
|
+
"""Which consumer tasks survive compilation, addressed by title slug.
|
|
13
|
+
|
|
14
|
+
Selection deliberately names tasks by slug rather than by compiled
|
|
15
|
+
`task_id`: ordinals renumber over the survivors, so an ID is not a stable
|
|
16
|
+
handle for the very operation that changes it.
|
|
17
|
+
|
|
18
|
+
Only consumer tasks are selectable. Acquire/release units are engine-owned
|
|
19
|
+
and are re-spliced around whichever consumers survive, so a slice keeps its
|
|
20
|
+
cleanup without the caller arranging anything.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
only: str | None = None
|
|
24
|
+
start: str | None = None
|
|
25
|
+
until: str | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
"""Reject `only` combined with `start` or `until`."""
|
|
29
|
+
if self.only is not None and (self.start is not None or self.until is not None):
|
|
30
|
+
raise SelectionError("only is mutually exclusive with start and until")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_empty(self) -> bool:
|
|
34
|
+
"""True when this selection filters nothing."""
|
|
35
|
+
return self.only is None and self.start is None and self.until is None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Turning a task title into the addressable slug used by `Selection`."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_SLUG_INVALID_CHARS = re.compile(r"[^a-z0-9]+")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def slugify(title: str) -> str:
|
|
9
|
+
"""Return `title` as a lowercase, hyphen-only slug.
|
|
10
|
+
|
|
11
|
+
Lowercases first, then collapses each run of characters outside `[a-z0-9]`
|
|
12
|
+
into a single hyphen and trims the hyphens off both ends. A title made
|
|
13
|
+
entirely of non-alphanumeric characters therefore slugs to the empty string.
|
|
14
|
+
"""
|
|
15
|
+
return _SLUG_INVALID_CHARS.sub("-", title.lower()).strip("-")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The runner-side handle a composite needs to execute its steps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from sonata_engine.core.compiled import TaskExecution
|
|
9
|
+
from sonata_engine.core.task import Task
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _StepScopeProtocol(Protocol):
|
|
13
|
+
"""The runner-side handle a composite uses to execute its steps.
|
|
14
|
+
|
|
15
|
+
It holds the compiled unit id, the journal and the resume flag — none of
|
|
16
|
+
which a task is told — so a composite can run a step without knowing where
|
|
17
|
+
it sits. A composite passes a step and a slug; the scope names it, decides,
|
|
18
|
+
records, reports and runs it.
|
|
19
|
+
|
|
20
|
+
Private (leading underscore) and exported from nowhere: `TaskInputs._step_scope`,
|
|
21
|
+
the field this types, is itself underscore-private engine plumbing that a
|
|
22
|
+
hand-written task is not meant to reach for. There is no public field to type
|
|
23
|
+
against, so there is nothing to gain from making this Protocol public.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def run_step(self, step: Task[Any], slug: str, upstream: Any) -> TaskExecution: # noqa: ANN401
|
|
27
|
+
"""Run one step beneath this scope and return how it went."""
|
|
28
|
+
...
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Composites assembled from smaller tasks rather than written as one body."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, override
|
|
6
|
+
|
|
7
|
+
from sonata_engine.core.inputs import TaskInputs
|
|
8
|
+
from sonata_engine.core.outcome import TaskOutcome
|
|
9
|
+
from sonata_engine.core.slug import slugify
|
|
10
|
+
from sonata_engine.core.task import Task
|
|
11
|
+
from sonata_engine.errors import StepScopeUnavailableError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Steps(Task[Any]):
|
|
15
|
+
"""A task assembled from steps rather than written.
|
|
16
|
+
|
|
17
|
+
Each step is an ordinary `Task`, run in order, on this task's own thread.
|
|
18
|
+
Each receives the value the step before it produced, so a pipeline needs no
|
|
19
|
+
wiring; a step that needs nothing simply never asks. The whole thing stays
|
|
20
|
+
one compiled unit: one ordinal, one thing `Selection` can name, one fate.
|
|
21
|
+
|
|
22
|
+
Steps are journalled individually, so a resumed composite skips the steps it
|
|
23
|
+
already finished. What may be skipped is decided exactly as it is for a
|
|
24
|
+
compiled unit — a `ReusableTask` whose evidence still verifies. Its only
|
|
25
|
+
legal value is None, which is reconstructed when the step is skipped.
|
|
26
|
+
|
|
27
|
+
`idempotent` is always `True`. It says this coordinator is safe to re-enter
|
|
28
|
+
after a failed attempt, and says nothing about the steps: each carries its
|
|
29
|
+
own flag. Without it the enclosing unit's failed record would refuse the
|
|
30
|
+
resume this class exists to make cheap.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
idempotent = True
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, title: str, steps: tuple[Task[Any], ...]) -> None:
|
|
36
|
+
"""Assemble `steps` into one composite named `title`.
|
|
37
|
+
|
|
38
|
+
Each step is slugged from its own title to give it a stable name inside
|
|
39
|
+
the composite. An empty `steps` sequence, a title that slugs to nothing,
|
|
40
|
+
or two steps that slug the same are all rejected here, since the slug is
|
|
41
|
+
what the journal keys the step's records on.
|
|
42
|
+
"""
|
|
43
|
+
if not steps:
|
|
44
|
+
raise ValueError("Steps requires at least one step")
|
|
45
|
+
|
|
46
|
+
slugs: list[str] = []
|
|
47
|
+
for step in steps:
|
|
48
|
+
slug = slugify(step.title)
|
|
49
|
+
if not slug:
|
|
50
|
+
raise ValueError(f"step title {step.title!r} produces an empty slug")
|
|
51
|
+
if slug in slugs:
|
|
52
|
+
raise ValueError(f"duplicate step slug {slug!r} in {title!r}")
|
|
53
|
+
slugs.append(slug)
|
|
54
|
+
|
|
55
|
+
self.title = title
|
|
56
|
+
self._steps = steps
|
|
57
|
+
self._slugs = tuple(slugs)
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def _fingerprint_payload(self) -> object:
|
|
61
|
+
return tuple(
|
|
62
|
+
(
|
|
63
|
+
slug,
|
|
64
|
+
f"{type(step).__module__}.{type(step).__qualname__}",
|
|
65
|
+
step._fingerprint_payload(),
|
|
66
|
+
)
|
|
67
|
+
for slug, step in zip(self._slugs, self._steps, strict=True)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def run(self, inputs: TaskInputs) -> TaskOutcome[Any]:
|
|
72
|
+
scope = inputs._step_scope
|
|
73
|
+
if scope is None:
|
|
74
|
+
raise StepScopeUnavailableError(
|
|
75
|
+
f"{type(self).__name__} {self.title!r} must be run by the workflow "
|
|
76
|
+
"runner: add it to a Workflow rather than calling run() directly"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
upstream: Any = inputs._upstream
|
|
80
|
+
for slug, step in zip(self._slugs, self._steps, strict=True):
|
|
81
|
+
execution = scope.run_step(step, slug, upstream)
|
|
82
|
+
# A step that ran contributes its value, including a legitimate
|
|
83
|
+
# None; a skipped step contributes None, the only value a skippable
|
|
84
|
+
# ReusableTask may return.
|
|
85
|
+
upstream = (
|
|
86
|
+
execution.outcome.value if execution.outcome is not None else None
|
|
87
|
+
)
|
|
88
|
+
return TaskOutcome(value=upstream)
|