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 +82 -0
- dr_graph/builders.py +70 -0
- dr_graph/compose.py +109 -0
- dr_graph/definition.py +166 -0
- dr_graph/errors.py +21 -0
- dr_graph/execution.py +250 -0
- dr_graph/hashing.py +61 -0
- dr_graph/py.typed +0 -0
- dr_graph/refs.py +109 -0
- dr_graph/results.py +312 -0
- dr_graph/spec.py +154 -0
- dr_graph/validation.py +145 -0
- dr_graph-0.1.0.dist-info/METADATA +107 -0
- dr_graph-0.1.0.dist-info/RECORD +16 -0
- dr_graph-0.1.0.dist-info/WHEEL +4 -0
- dr_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|