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