dr-graph 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.
dr_graph/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """Hashable computation-graph configs and a pure deterministic interpreter."""
2
+
3
+ from dr_graph.builders import as_node_input_source_ref, graph, node
4
+ from dr_graph.compose import inline_subgraph
5
+ from dr_graph.definition import GraphDefinition, NodeDefinition
6
+ from dr_graph.errors import (
7
+ CompletedNodeError,
8
+ GraphExecutionError,
9
+ GraphValidationError,
10
+ InputResolutionError,
11
+ NodeExecutionError,
12
+ )
13
+ from dr_graph.execution import (
14
+ RunNode,
15
+ execute_graph,
16
+ resolve_node_inputs,
17
+ )
18
+ from dr_graph.hashing import (
19
+ GRAPH_CONFIG_IDENTITY_SCHEMA,
20
+ GRAPH_CONFIG_IDENTITY_SCHEMA_VERSION,
21
+ graph_config_identity_document,
22
+ graph_config_identity_payload,
23
+ graph_hash,
24
+ )
25
+ from dr_graph.refs import (
26
+ NodeInputSourceKind,
27
+ NodeInputSourceRef,
28
+ )
29
+ from dr_graph.results import (
30
+ ClassifiedFailure,
31
+ GraphRunResult,
32
+ GraphRunStatus,
33
+ NodeError,
34
+ NodeOutcome,
35
+ NodeOutcomeStatus,
36
+ NodeOutput,
37
+ TerminalError,
38
+ )
39
+ from dr_graph.spec import (
40
+ FieldRole,
41
+ GraphConfig,
42
+ NodeConfig,
43
+ NodeFieldSpec,
44
+ )
45
+ from dr_graph.validation import validate_graph_external_inputs
46
+
47
+ __all__ = [
48
+ "GRAPH_CONFIG_IDENTITY_SCHEMA",
49
+ "GRAPH_CONFIG_IDENTITY_SCHEMA_VERSION",
50
+ "ClassifiedFailure",
51
+ "CompletedNodeError",
52
+ "FieldRole",
53
+ "GraphConfig",
54
+ "GraphDefinition",
55
+ "GraphExecutionError",
56
+ "GraphRunResult",
57
+ "GraphRunStatus",
58
+ "GraphValidationError",
59
+ "InputResolutionError",
60
+ "NodeConfig",
61
+ "NodeDefinition",
62
+ "NodeError",
63
+ "NodeExecutionError",
64
+ "NodeFieldSpec",
65
+ "NodeInputSourceKind",
66
+ "NodeInputSourceRef",
67
+ "NodeOutcome",
68
+ "NodeOutcomeStatus",
69
+ "NodeOutput",
70
+ "RunNode",
71
+ "TerminalError",
72
+ "as_node_input_source_ref",
73
+ "execute_graph",
74
+ "graph",
75
+ "graph_config_identity_document",
76
+ "graph_config_identity_payload",
77
+ "graph_hash",
78
+ "inline_subgraph",
79
+ "node",
80
+ "resolve_node_inputs",
81
+ "validate_graph_external_inputs",
82
+ ]
dr_graph/builders.py ADDED
@@ -0,0 +1,70 @@
1
+ """Neutral config-assembly helpers.
2
+
3
+ These cover the common case — node input sources, one declared output field,
4
+ open Variable assignments — without any prompt or provider awareness.
5
+ Domain-aware builders belong app-side.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping, Sequence
14
+
15
+ from dr_graph.refs import NodeInputSourceRef
16
+ from dr_graph.spec import FieldRole, GraphConfig, NodeConfig, NodeFieldSpec
17
+
18
+
19
+ def as_node_input_source_ref(
20
+ ref: str | NodeInputSourceRef,
21
+ ) -> NodeInputSourceRef:
22
+ if isinstance(ref, NodeInputSourceRef):
23
+ return ref
24
+ return NodeInputSourceRef.model_validate(ref)
25
+
26
+
27
+ def node( # noqa: PLR0913 -- the config surface, not incidental knobs
28
+ node_id: str,
29
+ *,
30
+ node_type: str,
31
+ output_field: str,
32
+ input_sources: Mapping[str, str | NodeInputSourceRef] | None = None,
33
+ fields: Sequence[NodeFieldSpec] | None = None,
34
+ variables: Mapping[str, Any] | None = None,
35
+ ) -> NodeConfig:
36
+ sources = {
37
+ name: as_node_input_source_ref(ref)
38
+ for name, ref in (input_sources or {}).items()
39
+ }
40
+ if fields is None:
41
+ derived = [
42
+ NodeFieldSpec(name=name, role=FieldRole.INPUT) for name in sources
43
+ ]
44
+ derived.append(NodeFieldSpec(name=output_field, role=FieldRole.OUTPUT))
45
+ field_specs = tuple(derived)
46
+ else:
47
+ field_specs = tuple(fields)
48
+ return NodeConfig.model_validate(
49
+ {
50
+ "node_id": node_id,
51
+ "node_type": node_type,
52
+ "fields": field_specs,
53
+ "input_sources": sources,
54
+ "output_field": output_field,
55
+ "variables": dict(variables or {}),
56
+ }
57
+ )
58
+
59
+
60
+ def graph(
61
+ nodes: Sequence[NodeConfig],
62
+ *,
63
+ terminal: str,
64
+ ) -> GraphConfig:
65
+ return GraphConfig.model_validate(
66
+ {
67
+ "nodes": tuple(nodes),
68
+ "terminal_node_id": terminal,
69
+ }
70
+ )
dr_graph/compose.py ADDED
@@ -0,0 +1,109 @@
1
+ """Inline subgraph composition.
2
+
3
+ v1 represents composition by flattening: `inline_subgraph` returns the
4
+ subgraph's nodes renamed under a prefix, with internal input sources rewired
5
+ and external inputs optionally rebound to parent-side sources. The composed
6
+ graph is an ordinary `GraphConfig`; its `graph_hash` is the hash of the
7
+ flattened config.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from dr_graph.builders import as_node_input_source_ref
15
+ from dr_graph.refs import (
16
+ REF_SEPARATOR,
17
+ NodeInputSourceKind,
18
+ NodeInputSourceRef,
19
+ )
20
+ from dr_graph.spec import GraphConfig, NodeConfig
21
+ from dr_graph.validation import external_input_fields
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+ DEFAULT_SUBGRAPH_SEPARATOR = ":"
27
+
28
+
29
+ def prefixed_node_id(
30
+ prefix: str,
31
+ node_id: str,
32
+ *,
33
+ separator: str = DEFAULT_SUBGRAPH_SEPARATOR,
34
+ ) -> str:
35
+ return f"{prefix}{separator}{node_id}"
36
+
37
+
38
+ def inline_subgraph(
39
+ subgraph: GraphConfig,
40
+ *,
41
+ prefix: str,
42
+ input_sources: Mapping[str, str | NodeInputSourceRef] | None = None,
43
+ separator: str = DEFAULT_SUBGRAPH_SEPARATOR,
44
+ ) -> tuple[NodeConfig, ...]:
45
+ """Return the subgraph's nodes renamed and rewired for a parent graph.
46
+
47
+ ``input_sources`` maps external input fields of the subgraph to parent-side
48
+ sources (parent node outputs or parent external inputs). Unmapped external
49
+ inputs pass through unchanged and must be satisfied by the parent graph's
50
+ external inputs.
51
+ """
52
+ if not prefix:
53
+ raise ValueError("prefix must be non-empty")
54
+ if REF_SEPARATOR in prefix:
55
+ raise ValueError(f"prefix {prefix!r} cannot contain {REF_SEPARATOR!r}")
56
+ if not separator or REF_SEPARATOR in separator:
57
+ raise ValueError(
58
+ f"separator {separator!r} must be non-empty and cannot "
59
+ f"contain {REF_SEPARATOR!r}"
60
+ )
61
+ remapped = {
62
+ name: as_node_input_source_ref(ref)
63
+ for name, ref in (input_sources or {}).items()
64
+ }
65
+ unknown = sorted(set(remapped) - external_input_fields(subgraph))
66
+ if unknown:
67
+ unknown_list = ", ".join(repr(name) for name in unknown)
68
+ raise ValueError(
69
+ f"input source(s) {unknown_list} are not external inputs "
70
+ "of the subgraph"
71
+ )
72
+ nodes: list[NodeConfig] = []
73
+ for node in subgraph.nodes:
74
+ node_input_sources: dict[str, NodeInputSourceRef] = {}
75
+ for field_name, ref in node.input_sources.items():
76
+ if ref.kind is NodeInputSourceKind.GRAPH_EXTERNAL:
77
+ if ref.field is not None and ref.field in remapped:
78
+ node_input_sources[field_name] = remapped[ref.field]
79
+ else:
80
+ node_input_sources[field_name] = ref
81
+ continue
82
+ node_input_sources[field_name] = NodeInputSourceRef.model_validate(
83
+ {
84
+ "kind": NodeInputSourceKind.NODE_OUTPUT,
85
+ "node_id": prefixed_node_id(
86
+ prefix,
87
+ str(ref.node_id),
88
+ separator=separator,
89
+ ),
90
+ "field": ref.field,
91
+ }
92
+ )
93
+ nodes.append(
94
+ NodeConfig.model_validate(
95
+ {
96
+ "node_id": prefixed_node_id(
97
+ prefix,
98
+ node.node_id,
99
+ separator=separator,
100
+ ),
101
+ "node_type": node.node_type,
102
+ "fields": node.fields,
103
+ "input_sources": node_input_sources,
104
+ "output_field": node.output_field,
105
+ "variables": dict(node.variables),
106
+ }
107
+ )
108
+ )
109
+ return tuple(nodes)
dr_graph/definition.py ADDED
@@ -0,0 +1,166 @@
1
+ """Versioned, variable-bearing Graph Definition artifact.
2
+
3
+ A Graph Definition declares the Node Definitions, Variables, DAG shape,
4
+ input/output contracts, and exactly one terminal Node. It materializes one
5
+ or more fully-set Graph Configs by binding concrete Variable assignments to
6
+ each declared Node.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from pydantic import (
14
+ BaseModel,
15
+ ConfigDict,
16
+ Field,
17
+ StrictInt,
18
+ StrictStr,
19
+ model_validator,
20
+ )
21
+
22
+ from dr_graph.errors import GraphValidationError
23
+ from dr_graph.refs import NodeInputSourceRef, validate_ref_identifier
24
+ from dr_graph.spec import (
25
+ GraphConfig,
26
+ NodeConfig,
27
+ NodeFieldSpec,
28
+ validate_node_fields,
29
+ )
30
+ from dr_graph.validation import (
31
+ topological_order_ids,
32
+ validate_single_terminal_ids,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Mapping
37
+
38
+
39
+ class NodeDefinition(BaseModel):
40
+ """Declares one Node's Definition, DAG wiring, and required Variables.
41
+
42
+ ``variable_names`` are the declared Variables a Graph Config must set for
43
+ this Node; ``input_sources`` fix the DAG shape and input contract;
44
+ ``fields`` and ``output_field`` declare the input/output contract.
45
+ """
46
+
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+ node_id: StrictStr
50
+ node_type: StrictStr = Field(min_length=1)
51
+ fields: tuple[NodeFieldSpec, ...] = ()
52
+ input_sources: dict[str, NodeInputSourceRef] = Field(default_factory=dict)
53
+ output_field: StrictStr
54
+ variable_names: frozenset[str] = frozenset()
55
+
56
+ def dependencies(self) -> set[str]:
57
+ return {
58
+ node_id
59
+ for ref in self.input_sources.values()
60
+ if (node_id := ref.dependency_node_id) is not None
61
+ }
62
+
63
+ @model_validator(mode="after")
64
+ def validate_definition(self) -> NodeDefinition:
65
+ validate_ref_identifier(self.node_id, kind="node id")
66
+ validate_node_fields(
67
+ self.fields,
68
+ self.input_sources,
69
+ self.output_field,
70
+ )
71
+ return self
72
+
73
+
74
+ class GraphDefinition(BaseModel):
75
+ """Versioned, variable-bearing DAG shape materializing Graph Configs."""
76
+
77
+ model_config = ConfigDict(extra="forbid")
78
+
79
+ schema_version: StrictInt = 1
80
+ nodes: tuple[NodeDefinition, ...]
81
+ terminal_node_id: StrictStr
82
+
83
+ def node_ids(self) -> list[str]:
84
+ return [node.node_id for node in self.nodes]
85
+
86
+ @model_validator(mode="after")
87
+ def validate_definition(self) -> GraphDefinition:
88
+ if not self.nodes:
89
+ raise GraphValidationError(
90
+ "graph definition must have at least one node"
91
+ )
92
+ node_ids = self.node_ids()
93
+ if len(node_ids) != len(set(node_ids)):
94
+ raise GraphValidationError("duplicate node ids")
95
+ known = set(node_ids)
96
+ if self.terminal_node_id not in known:
97
+ raise GraphValidationError(
98
+ f"terminal_node_id {self.terminal_node_id!r} not in "
99
+ "graph definition"
100
+ )
101
+ dependencies = {
102
+ node.node_id: node.dependencies() for node in self.nodes
103
+ }
104
+ for node_id, deps in dependencies.items():
105
+ unknown = sorted(deps - known)
106
+ if unknown:
107
+ joined = ", ".join(repr(dep) for dep in unknown)
108
+ raise GraphValidationError(
109
+ f"node {node_id!r} depends on unknown node(s) {joined}"
110
+ )
111
+ # Enforce acyclicity and exactly one terminal/sink at Definition level.
112
+ topological_order_ids(node_ids, dependencies)
113
+ validate_single_terminal_ids(
114
+ node_ids,
115
+ dependencies,
116
+ terminal_node_id=self.terminal_node_id,
117
+ )
118
+ return self
119
+
120
+ def materialize(
121
+ self,
122
+ variable_assignments: Mapping[str, Mapping[str, Any]] | None = None,
123
+ ) -> GraphConfig:
124
+ """Materialize one fully-set Graph Config.
125
+
126
+ ``variable_assignments`` maps each ``node_id`` to that Node's concrete
127
+ Variable values. Every declared Variable for every Node must be set.
128
+ """
129
+ assignments = variable_assignments or {}
130
+ unknown = sorted(set(assignments) - set(self.node_ids()))
131
+ if unknown:
132
+ joined = ", ".join(repr(node_id) for node_id in unknown)
133
+ raise ValueError(
134
+ f"variable assignment(s) {joined} reference unknown node id(s)"
135
+ )
136
+ nodes: list[NodeConfig] = []
137
+ for definition in self.nodes:
138
+ values = dict(assignments.get(definition.node_id, {}))
139
+ missing = sorted(definition.variable_names - set(values))
140
+ if missing:
141
+ joined = ", ".join(repr(name) for name in missing)
142
+ raise ValueError(
143
+ f"node {definition.node_id!r} is missing required "
144
+ f"variable assignment(s) {joined}"
145
+ )
146
+ extra = sorted(set(values) - definition.variable_names)
147
+ if extra:
148
+ joined = ", ".join(repr(name) for name in extra)
149
+ raise ValueError(
150
+ f"node {definition.node_id!r} sets undeclared "
151
+ f"variable(s) {joined}"
152
+ )
153
+ nodes.append(
154
+ NodeConfig(
155
+ node_id=definition.node_id,
156
+ node_type=definition.node_type,
157
+ fields=definition.fields,
158
+ input_sources=dict(definition.input_sources),
159
+ output_field=definition.output_field,
160
+ variables=values,
161
+ )
162
+ )
163
+ return GraphConfig(
164
+ nodes=tuple(nodes),
165
+ terminal_node_id=self.terminal_node_id,
166
+ )
dr_graph/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class GraphExecutionError(Exception):
5
+ """Base exception for pure graph execution errors."""
6
+
7
+
8
+ class GraphValidationError(GraphExecutionError, ValueError):
9
+ pass
10
+
11
+
12
+ class InputResolutionError(GraphExecutionError):
13
+ pass
14
+
15
+
16
+ class NodeExecutionError(GraphExecutionError):
17
+ pass
18
+
19
+
20
+ class CompletedNodeError(GraphExecutionError):
21
+ """Raised when a supplied completed-node output cannot be used."""
dr_graph/execution.py ADDED
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from dr_graph.errors import (
9
+ CompletedNodeError,
10
+ InputResolutionError,
11
+ NodeExecutionError,
12
+ )
13
+ from dr_graph.hashing import graph_hash
14
+ from dr_graph.refs import NodeInputSourceKind
15
+ from dr_graph.results import (
16
+ GraphRunResult,
17
+ GraphRunStatus,
18
+ NodeOutcome,
19
+ NodeOutcomeStatus,
20
+ NodeOutput,
21
+ TerminalError,
22
+ )
23
+ from dr_graph.spec import GraphConfig, NodeConfig
24
+
25
+ type RunNode = Callable[
26
+ [NodeConfig, Mapping[str, Any]],
27
+ NodeOutput | Mapping[str, Any],
28
+ ]
29
+
30
+
31
+ def execute_graph(
32
+ *,
33
+ graph: GraphConfig,
34
+ inputs: Mapping[str, Any],
35
+ run_node: RunNode,
36
+ completed: Mapping[str, NodeOutput | Mapping[str, Any]] | None = None,
37
+ ) -> GraphRunResult:
38
+ computed_graph_hash = graph_hash(graph)
39
+ completed_outputs = _validated_completed_outputs(
40
+ graph=graph,
41
+ completed=completed,
42
+ )
43
+ outcomes: dict[str, NodeOutcome] = {}
44
+ execution_order: list[str] = []
45
+
46
+ for node in graph.topological_order():
47
+ execution_order.append(node.node_id)
48
+ if node.node_id in completed_outputs:
49
+ outcomes[node.node_id] = NodeOutcome.success(
50
+ node_id=node.node_id,
51
+ output=completed_outputs[node.node_id],
52
+ )
53
+ continue
54
+ blocked_by = _blocked_dependencies(node, outcomes)
55
+ if blocked_by:
56
+ outcomes[node.node_id] = NodeOutcome.blocked(
57
+ node_id=node.node_id,
58
+ blocked_by=blocked_by,
59
+ )
60
+ continue
61
+
62
+ try:
63
+ node_inputs = resolve_node_inputs(
64
+ node=node,
65
+ inputs=inputs,
66
+ outcomes=outcomes,
67
+ graph=graph,
68
+ )
69
+ output = _run_node(
70
+ node=node,
71
+ node_inputs=node_inputs,
72
+ run_node=run_node,
73
+ )
74
+ except Exception as error: # noqa: BLE001 -- outcomes absorb node failures
75
+ outcomes[node.node_id] = NodeOutcome.from_error(
76
+ node_id=node.node_id,
77
+ error=error,
78
+ )
79
+ continue
80
+
81
+ outcomes[node.node_id] = NodeOutcome.success(
82
+ node_id=node.node_id,
83
+ output=output,
84
+ )
85
+
86
+ return _build_result(
87
+ graph=graph,
88
+ outcomes=outcomes,
89
+ execution_order=tuple(execution_order),
90
+ inputs=inputs,
91
+ graph_hash_value=computed_graph_hash,
92
+ )
93
+
94
+
95
+ def resolve_node_inputs(
96
+ *,
97
+ node: NodeConfig,
98
+ inputs: Mapping[str, Any],
99
+ outcomes: Mapping[str, NodeOutcome],
100
+ graph: GraphConfig,
101
+ ) -> dict[str, Any]:
102
+ resolved: dict[str, Any] = {}
103
+ for field_name, ref in node.input_sources.items():
104
+ if ref.kind is NodeInputSourceKind.GRAPH_EXTERNAL:
105
+ if ref.field is None or ref.field not in inputs:
106
+ raise InputResolutionError(
107
+ f"missing external input {ref.field!r} "
108
+ f"for node {node.node_id!r}"
109
+ )
110
+ resolved[field_name] = inputs[ref.field]
111
+ continue
112
+
113
+ if ref.node_id is None:
114
+ raise InputResolutionError(
115
+ f"node input source {ref.ref!r} has no node id"
116
+ )
117
+ upstream = outcomes.get(ref.node_id)
118
+ if (
119
+ upstream is None
120
+ or upstream.status is not NodeOutcomeStatus.SUCCESS
121
+ ):
122
+ raise InputResolutionError(
123
+ f"upstream node {ref.node_id!r} did not succeed"
124
+ )
125
+ if upstream.output is None:
126
+ raise InputResolutionError(
127
+ f"upstream node {ref.node_id!r} has no output"
128
+ )
129
+ output_field = ref.field or graph.node(ref.node_id).output_field
130
+ if output_field not in upstream.output.values:
131
+ raise InputResolutionError(
132
+ f"upstream node {ref.node_id!r} output missing "
133
+ f"field {output_field!r}"
134
+ )
135
+ resolved[field_name] = upstream.output.values[output_field]
136
+ return resolved
137
+
138
+
139
+ def _validated_completed_outputs(
140
+ *,
141
+ graph: GraphConfig,
142
+ completed: Mapping[str, NodeOutput | Mapping[str, Any]] | None,
143
+ ) -> dict[str, NodeOutput]:
144
+ if not completed:
145
+ return {}
146
+ node_ids = set(graph.node_ids())
147
+ outputs: dict[str, NodeOutput] = {}
148
+ for node_id, raw_output in completed.items():
149
+ if node_id not in node_ids:
150
+ raise CompletedNodeError(
151
+ f"completed node {node_id!r} is not in the graph"
152
+ )
153
+ try:
154
+ output = NodeOutput.model_validate(raw_output)
155
+ except ValidationError as error:
156
+ raise CompletedNodeError(
157
+ f"completed output for node {node_id!r} is invalid: {error}"
158
+ ) from error
159
+ output_field = graph.node(node_id).output_field
160
+ if output_field not in output.values:
161
+ raise CompletedNodeError(
162
+ f"completed output for node {node_id!r} missing "
163
+ f"field {output_field!r}"
164
+ )
165
+ outputs[node_id] = output
166
+ return outputs
167
+
168
+
169
+ def _run_node(
170
+ *,
171
+ node: NodeConfig,
172
+ node_inputs: Mapping[str, Any],
173
+ run_node: RunNode,
174
+ ) -> NodeOutput:
175
+ output = NodeOutput.model_validate(run_node(node, node_inputs))
176
+ if node.output_field not in output.values:
177
+ raise NodeExecutionError(
178
+ f"node {node.node_id!r} output missing field {node.output_field!r}"
179
+ )
180
+ return output
181
+
182
+
183
+ def _blocked_dependencies(
184
+ node: NodeConfig,
185
+ outcomes: Mapping[str, NodeOutcome],
186
+ ) -> tuple[str, ...]:
187
+ return tuple(
188
+ sorted(
189
+ dependency
190
+ for dependency in node.dependencies()
191
+ if outcomes[dependency].status is not NodeOutcomeStatus.SUCCESS
192
+ )
193
+ )
194
+
195
+
196
+ def _build_result(
197
+ *,
198
+ graph: GraphConfig,
199
+ outcomes: dict[str, NodeOutcome],
200
+ execution_order: tuple[str, ...],
201
+ inputs: Mapping[str, Any],
202
+ graph_hash_value: str,
203
+ ) -> GraphRunResult:
204
+ terminal = outcomes[graph.terminal_node_id]
205
+ terminal_output: Any | None = None
206
+ terminal_error: TerminalError | None = None
207
+
208
+ if terminal.status is NodeOutcomeStatus.SUCCESS:
209
+ if terminal.output is not None:
210
+ terminal_output = terminal.output.values[
211
+ graph.node(graph.terminal_node_id).output_field
212
+ ]
213
+ else:
214
+ terminal_error = TerminalError(
215
+ node_id=terminal.node_id,
216
+ status=terminal.status,
217
+ error=terminal.error,
218
+ blocked_by=terminal.blocked_by,
219
+ )
220
+
221
+ return GraphRunResult(
222
+ graph_hash=graph_hash_value,
223
+ external_inputs=dict(inputs),
224
+ status=_graph_status(
225
+ terminal=terminal,
226
+ outcomes=outcomes,
227
+ ),
228
+ outcomes=outcomes,
229
+ execution_order=execution_order,
230
+ terminal_node_id=graph.terminal_node_id,
231
+ terminal_output=terminal_output,
232
+ terminal_error=terminal_error,
233
+ )
234
+
235
+
236
+ def _graph_status(
237
+ *,
238
+ terminal: NodeOutcome,
239
+ outcomes: Mapping[str, NodeOutcome],
240
+ ) -> GraphRunStatus:
241
+ if terminal.status is not NodeOutcomeStatus.SUCCESS:
242
+ if terminal.status is NodeOutcomeStatus.BLOCKED:
243
+ return GraphRunStatus.BLOCKED
244
+ return GraphRunStatus.ERROR
245
+ if any(
246
+ outcome.status is not NodeOutcomeStatus.SUCCESS
247
+ for outcome in outcomes.values()
248
+ ):
249
+ return GraphRunStatus.PARTIAL
250
+ return GraphRunStatus.SUCCESS