flowgraph 0.1.2__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.
- flowgraph/__init__.py +9 -0
- flowgraph/__main__.py +6 -0
- flowgraph/adapters/__init__.py +141 -0
- flowgraph/adapters/controls.py +96 -0
- flowgraph/adapters/cosapp.py +195 -0
- flowgraph/adapters/data_types.py +59 -0
- flowgraph/adapters/documentation.py +45 -0
- flowgraph/adapters/field_ops.py +121 -0
- flowgraph/adapters/file_conversion.py +393 -0
- flowgraph/adapters/files.py +191 -0
- flowgraph/adapters/filters.py +173 -0
- flowgraph/adapters/image_tools.py +394 -0
- flowgraph/adapters/mesh_creation_tools.py +431 -0
- flowgraph/adapters/mesh_generations.py +58 -0
- flowgraph/adapters/mesh_ops.py +362 -0
- flowgraph/adapters/paraview.py +39 -0
- flowgraph/adapters/plaid_bridge.py +205 -0
- flowgraph/adapters/readers.py +215 -0
- flowgraph/adapters/remote_files.py +118 -0
- flowgraph/adapters/simple_sources.py +199 -0
- flowgraph/adapters/sinks.py +80 -0
- flowgraph/adapters/user_code.py +196 -0
- flowgraph/adapters/workflow.py +176 -0
- flowgraph/adapters/workflow_interfaces.py +78 -0
- flowgraph/adapters/writers.py +159 -0
- flowgraph/application/__init__.py +1 -0
- flowgraph/application/node_registry.py +14 -0
- flowgraph/application/port_conversions.py +63 -0
- flowgraph/application/wasm_execution.py +132 -0
- flowgraph/application/workflow_cli.py +417 -0
- flowgraph/application/workflow_core.py +1193 -0
- flowgraph/application/workflow_history.py +93 -0
- flowgraph/application/workflow_io.py +393 -0
- flowgraph/application/workflow_layout.py +170 -0
- flowgraph/application/workflow_web_catalog.py +143 -0
- flowgraph/application/workflows.py +7 -0
- flowgraph/core_cli.py +81 -0
- flowgraph/domain/__init__.py +5 -0
- flowgraph/domain/mesh_document.py +91 -0
- flowgraph/examples/AllFilters.py +19 -0
- flowgraph/examples/convert_format.py +21 -0
- flowgraph/resources.py +29 -0
- flowgraph/testdata/AllFilters.json +390 -0
- flowgraph/testdata/FlowGraph.h5 +0 -0
- flowgraph/testdata/FlowGraph.stl +0 -0
- flowgraph/testdata/FlowGraph.xdmf +22 -0
- flowgraph/testdata/SimpleReadMesh.json +70 -0
- flowgraph/testdata/SpherePoints.csv +83 -0
- flowgraph/testdata/muscat-muscat-mesh-conversion.json +101 -0
- flowgraph/version.py +3 -0
- flowgraph-0.1.2.dist-info/METADATA +150 -0
- flowgraph-0.1.2.dist-info/RECORD +56 -0
- flowgraph-0.1.2.dist-info/WHEEL +5 -0
- flowgraph-0.1.2.dist-info/entry_points.txt +2 -0
- flowgraph-0.1.2.dist-info/licenses/LICENSE +29 -0
- flowgraph-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
"""Typed, UI-independent workflow graph validation and execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, get_args, get_origin
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from flowgraph.application.port_conversions import PortConversion
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
NodeExecutor = Callable[[Mapping[str, Any], Mapping[str, Any]], Mapping[str, Any] | None]
|
|
17
|
+
PortResolver = Callable[[Mapping[str, Any]], tuple["PortDefinition", ...]]
|
|
18
|
+
InstancePortResolver = Callable[["NodeInstance"], tuple["PortDefinition", ...]]
|
|
19
|
+
InstanceExecutor = Callable[
|
|
20
|
+
["NodeInstance", Mapping[str, Any], Mapping[str, Any], "NodeRegistry"], Mapping[str, Any] | None
|
|
21
|
+
]
|
|
22
|
+
SubworkflowFactory = Callable[[], "WorkflowGraph"]
|
|
23
|
+
WorkflowExecutorFunction = Callable[
|
|
24
|
+
[Mapping[str, Any], Mapping[str, Any] | None], Mapping[str, Any]
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DataType(Generic[T]):
|
|
30
|
+
"""Stable workflow type identity paired with a runtime Python type."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
label: str
|
|
34
|
+
python_type: type[T] | tuple[type[Any], ...]
|
|
35
|
+
|
|
36
|
+
def accepts(self, value: object) -> bool:
|
|
37
|
+
"""Return whether *value* satisfies this workflow type at runtime."""
|
|
38
|
+
origin = get_origin(self.python_type)
|
|
39
|
+
if origin is not None:
|
|
40
|
+
if isinstance(value, origin):
|
|
41
|
+
if origin == list:
|
|
42
|
+
args = get_args(self.python_type)
|
|
43
|
+
for v in value:
|
|
44
|
+
if not isinstance(v, args):
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
else:
|
|
48
|
+
return False
|
|
49
|
+
return isinstance(value, self.python_type)
|
|
50
|
+
|
|
51
|
+
def is_compatible_with(self, target: DataType[Any]) -> bool:
|
|
52
|
+
"""Use stable type identity; conversions require an explicit node."""
|
|
53
|
+
if self.python_type is object or target.python_type is object:
|
|
54
|
+
return True
|
|
55
|
+
return self.python_type == target.python_type
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PortDirection(str, Enum):
|
|
59
|
+
INPUT = "input"
|
|
60
|
+
OUTPUT = "output"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ParameterOption:
|
|
65
|
+
"""One serializable choice offered by a parameter editor."""
|
|
66
|
+
|
|
67
|
+
value: str | int | float
|
|
68
|
+
label: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ParameterDefinition:
|
|
73
|
+
"""Serializable configuration contract declared alongside a workflow node."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
kind: DataType[Any]
|
|
77
|
+
label: str
|
|
78
|
+
default: Any
|
|
79
|
+
placeholder: str = ""
|
|
80
|
+
options: tuple[ParameterOption, ...] = ()
|
|
81
|
+
file_patterns: tuple[str, ...] = ()
|
|
82
|
+
port: bool = True # if this param must be export as a port
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class PortDefinition:
|
|
87
|
+
"""A named, typed endpoint on a node or workflow definition.
|
|
88
|
+
|
|
89
|
+
``accepted_data_types`` adds explicit alternatives for inputs that support
|
|
90
|
+
more than one wire type. The declared ``data_type`` remains the primary
|
|
91
|
+
type shown by existing clients and used for output validation.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
direction: PortDirection
|
|
96
|
+
data_type: DataType[Any]
|
|
97
|
+
label: str | None = None
|
|
98
|
+
required: bool = True
|
|
99
|
+
is_param: bool = False
|
|
100
|
+
accepted_data_types: tuple[DataType[Any], ...] = ()
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def display_label(self) -> str:
|
|
104
|
+
return self.label or self.name
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def accepted_types(self) -> tuple[DataType[Any], ...]:
|
|
108
|
+
"""Return the primary type followed by explicitly accepted alternatives."""
|
|
109
|
+
return (self.data_type, *self.accepted_data_types)
|
|
110
|
+
|
|
111
|
+
def accepts(self, value: object) -> bool:
|
|
112
|
+
"""Return whether this port accepts a runtime input value."""
|
|
113
|
+
return any(data_type.accepts(value) for data_type in self.accepted_types)
|
|
114
|
+
|
|
115
|
+
def accepts_type(self, source: DataType[Any]) -> bool:
|
|
116
|
+
"""Return whether this input port accepts a source port's type directly."""
|
|
117
|
+
return any(source.is_compatible_with(data_type) for data_type in self.accepted_types)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class WorkflowPort:
|
|
122
|
+
"""A public workflow port mapped to a port on one node instance."""
|
|
123
|
+
|
|
124
|
+
name: str
|
|
125
|
+
node_id: str
|
|
126
|
+
node_port: str
|
|
127
|
+
direction: PortDirection
|
|
128
|
+
data_type: DataType[Any]
|
|
129
|
+
label: str | None = None
|
|
130
|
+
is_param: bool = False
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def display_label(self) -> str:
|
|
134
|
+
return self.label or self.name
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class NodeDefinition:
|
|
139
|
+
"""Reusable behavior and port contract for a kind of workflow node."""
|
|
140
|
+
|
|
141
|
+
id: str
|
|
142
|
+
icon: str
|
|
143
|
+
label: str
|
|
144
|
+
ports: tuple[PortDefinition, ...]
|
|
145
|
+
executor: NodeExecutor
|
|
146
|
+
description: str = ""
|
|
147
|
+
parameters: tuple[ParameterDefinition, ...] = ()
|
|
148
|
+
default_parameters: Mapping[str, Any] = field(default_factory=dict)
|
|
149
|
+
presentation: str = "standard"
|
|
150
|
+
port_resolver: PortResolver | None = None
|
|
151
|
+
instance_port_resolver: InstancePortResolver | None = None
|
|
152
|
+
instance_executor: InstanceExecutor | None = None
|
|
153
|
+
subworkflow_factory: SubworkflowFactory | None = None
|
|
154
|
+
color: str | None = None
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
|
|
158
|
+
# add paramters as ports
|
|
159
|
+
parameter_ports = tuple(
|
|
160
|
+
PortDefinition(
|
|
161
|
+
p.name, PortDirection.INPUT, p.kind, p.label, required=False, is_param=True
|
|
162
|
+
)
|
|
163
|
+
for p in self.parameters
|
|
164
|
+
if p.port
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
object.__setattr__(
|
|
168
|
+
self,
|
|
169
|
+
"ports",
|
|
170
|
+
self.ports + parameter_ports,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
names = [(port.direction, port.name) for port in self.ports]
|
|
174
|
+
if len(names) != len(set(names)):
|
|
175
|
+
raise ValueError(f"Node definition {self.id!r} has duplicate port names")
|
|
176
|
+
parameter_names = [parameter.name for parameter in self.parameters]
|
|
177
|
+
if len(parameter_names) != len(set(parameter_names)):
|
|
178
|
+
raise ValueError(f"Node definition {self.id!r} has duplicate parameters")
|
|
179
|
+
schema_defaults = {
|
|
180
|
+
parameter.name: deepcopy(parameter.default) for parameter in self.parameters
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
self.parameters
|
|
184
|
+
and self.default_parameters
|
|
185
|
+
and schema_defaults != dict(self.default_parameters)
|
|
186
|
+
):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"Node definition {self.id!r} parameter defaults do not match its schema"
|
|
189
|
+
)
|
|
190
|
+
if self.parameters and not self.default_parameters:
|
|
191
|
+
object.__setattr__(self, "default_parameters", schema_defaults)
|
|
192
|
+
|
|
193
|
+
def ports_for(self, parameters: Mapping[str, Any] | None = None) -> tuple[PortDefinition, ...]:
|
|
194
|
+
"""Return this node's static ports plus any instance-specific ports."""
|
|
195
|
+
if self.port_resolver is None:
|
|
196
|
+
return self.ports
|
|
197
|
+
return self.port_resolver(parameters or {})
|
|
198
|
+
|
|
199
|
+
def ports_for_instance(self, instance: NodeInstance) -> tuple[PortDefinition, ...]:
|
|
200
|
+
"""Return ports resolved from an individual configured node instance."""
|
|
201
|
+
if self.instance_port_resolver is not None:
|
|
202
|
+
return self.instance_port_resolver(instance)
|
|
203
|
+
return self.ports_for(instance.parameters)
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _inputs(ports: tuple[PortDefinition, ...]) -> tuple[PortDefinition, ...]:
|
|
207
|
+
return tuple(port for port in ports if port.direction is PortDirection.INPUT)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _outputs(ports: tuple[PortDefinition, ...]) -> tuple[PortDefinition, ...]:
|
|
211
|
+
return tuple(port for port in ports if port.direction is PortDirection.OUTPUT)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def inputs(self) -> tuple[PortDefinition, ...]:
|
|
215
|
+
return self._inputs(self.ports)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def outputs(self) -> tuple[PortDefinition, ...]:
|
|
219
|
+
return self._outputs(self.ports)
|
|
220
|
+
|
|
221
|
+
def input(
|
|
222
|
+
self, name: str, parameters: Mapping[str, Any] | None = None
|
|
223
|
+
) -> PortDefinition | None:
|
|
224
|
+
return next(
|
|
225
|
+
(port for port in self._inputs(self.ports_for(parameters)) if port.name == name), None
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def output(
|
|
229
|
+
self, name: str, parameters: Mapping[str, Any] | None = None
|
|
230
|
+
) -> PortDefinition | None:
|
|
231
|
+
return next(
|
|
232
|
+
(port for port in self._outputs(self.ports_for(parameters)) if port.name == name), None
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def input_for_instance(self, name: str, instance: NodeInstance) -> PortDefinition | None:
|
|
236
|
+
"""Return one input port resolved from an individual node instance."""
|
|
237
|
+
return next(
|
|
238
|
+
(port for port in self._inputs(self.ports_for_instance(instance)) if port.name == name),
|
|
239
|
+
None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def output_for_instance(self, name: str, instance: NodeInstance) -> PortDefinition | None:
|
|
243
|
+
"""Return one output port resolved from an individual node instance."""
|
|
244
|
+
return next(
|
|
245
|
+
(
|
|
246
|
+
port
|
|
247
|
+
for port in self._outputs(self.ports_for_instance(instance))
|
|
248
|
+
if port.name == name
|
|
249
|
+
),
|
|
250
|
+
None,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def execute_instance(
|
|
254
|
+
self,
|
|
255
|
+
instance: NodeInstance,
|
|
256
|
+
inputs: Mapping[str, Any],
|
|
257
|
+
registry: NodeRegistry,
|
|
258
|
+
) -> Mapping[str, Any] | None:
|
|
259
|
+
"""Execute one instance through its specialized or regular executor."""
|
|
260
|
+
parameters = MappingProxyType(instance.parameters)
|
|
261
|
+
if self.instance_executor is not None:
|
|
262
|
+
return self.instance_executor(instance, inputs, parameters, registry)
|
|
263
|
+
return self.executor(inputs, parameters)
|
|
264
|
+
|
|
265
|
+
def create_instance(
|
|
266
|
+
self,
|
|
267
|
+
instance_id: str,
|
|
268
|
+
*,
|
|
269
|
+
parameters: Mapping[str, Any] | None = None,
|
|
270
|
+
x: float = 0,
|
|
271
|
+
y: float = 0,
|
|
272
|
+
) -> NodeInstance:
|
|
273
|
+
"""Create an independently configured instance of this definition."""
|
|
274
|
+
configured_parameters = deepcopy(dict(self.default_parameters))
|
|
275
|
+
if parameters is not None:
|
|
276
|
+
configured_parameters.update(deepcopy(dict(parameters)))
|
|
277
|
+
return NodeInstance(
|
|
278
|
+
id=instance_id,
|
|
279
|
+
definition_id=self.id,
|
|
280
|
+
parameters=configured_parameters,
|
|
281
|
+
x=x,
|
|
282
|
+
y=y,
|
|
283
|
+
subworkflow=None if self.subworkflow_factory is None else self.subworkflow_factory(),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def create_icon_arguments(self) -> dict[str, str]:
|
|
287
|
+
"""Return toolbar button arguments that add this registered node type."""
|
|
288
|
+
return {
|
|
289
|
+
"icon": self.icon,
|
|
290
|
+
"variant": "text",
|
|
291
|
+
"size": "small",
|
|
292
|
+
"title": self.label,
|
|
293
|
+
"click": f"trigger('AddNode', [{self.id!r}])",
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass
|
|
298
|
+
class NodeInstance:
|
|
299
|
+
"""A configured and positioned occurrence of a registered node definition."""
|
|
300
|
+
|
|
301
|
+
id: str
|
|
302
|
+
definition_id: str
|
|
303
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
304
|
+
x: float = 0
|
|
305
|
+
y: float = 0
|
|
306
|
+
subworkflow: WorkflowGraph | None = None
|
|
307
|
+
|
|
308
|
+
def pprint(self):
|
|
309
|
+
print(f"{self.id}")
|
|
310
|
+
print(f" type: {self.definition_id}")
|
|
311
|
+
for k, v in self.parameters.items():
|
|
312
|
+
print(f" {k}:{v}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass(frozen=True)
|
|
316
|
+
class WorkflowEdge:
|
|
317
|
+
"""A directed connection between one named output and one named input."""
|
|
318
|
+
|
|
319
|
+
source_node: str
|
|
320
|
+
source_port: str
|
|
321
|
+
target_node: str
|
|
322
|
+
target_port: str
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def id(self) -> str:
|
|
326
|
+
return f"{self.source_node}({self.source_port})->{self.target_node}({self.target_port})"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class ValidationCode(str, Enum):
|
|
330
|
+
UNKNOWN_DEFINITION = "unknown_definition"
|
|
331
|
+
UNKNOWN_NODE = "unknown_node"
|
|
332
|
+
UNKNOWN_SOURCE_PORT = "unknown_source_port"
|
|
333
|
+
UNKNOWN_TARGET_PORT = "unknown_target_port"
|
|
334
|
+
TYPE_MISMATCH = "type_mismatch"
|
|
335
|
+
DUPLICATE_EDGE = "duplicate_edge"
|
|
336
|
+
INPUT_ALREADY_CONNECTED = "input_already_connected"
|
|
337
|
+
MISSING_REQUIRED_INPUT = "missing_required_input"
|
|
338
|
+
CYCLE = "cycle"
|
|
339
|
+
EXPORTED_INPUT_CONNECTED = "exported_input_connected"
|
|
340
|
+
INVALID_EXPORTED_PORT = "invalid_exported_port"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class ValidationIssue:
|
|
345
|
+
code: ValidationCode
|
|
346
|
+
message: str
|
|
347
|
+
node_id: str | None = None
|
|
348
|
+
edge_id: str | None = None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class WorkflowError(Exception):
|
|
352
|
+
"""Base error for invalid graphs and failed workflow runs."""
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class WorkflowValidationError(WorkflowError):
|
|
356
|
+
def __init__(self, issues: tuple[ValidationIssue, ...]) -> None:
|
|
357
|
+
self.issues = issues
|
|
358
|
+
super().__init__("; ".join(issue.message for issue in issues))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class NodeExecutionError(WorkflowError):
|
|
362
|
+
def __init__(
|
|
363
|
+
self,
|
|
364
|
+
node_id: str,
|
|
365
|
+
definition_id: str,
|
|
366
|
+
message: str,
|
|
367
|
+
partial_result: WorkflowRunResult | None = None,
|
|
368
|
+
) -> None:
|
|
369
|
+
self.node_id = node_id
|
|
370
|
+
self.definition_id = definition_id
|
|
371
|
+
self.partial_result = partial_result
|
|
372
|
+
super().__init__(f"Node {node_id!r} ({definition_id!r}) failed: {message}")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class NodeRegistry:
|
|
376
|
+
"""Registry of node definitions available to workflow graphs."""
|
|
377
|
+
|
|
378
|
+
def __init__(self) -> None:
|
|
379
|
+
self._definitions: dict[str, NodeDefinition] = {}
|
|
380
|
+
|
|
381
|
+
def register(self, definition: NodeDefinition) -> None:
|
|
382
|
+
if definition.id in self._definitions:
|
|
383
|
+
raise ValueError(f"Node definition {definition.id!r} is already registered")
|
|
384
|
+
self._definitions[definition.id] = definition
|
|
385
|
+
|
|
386
|
+
def get(self, definition_id: str) -> NodeDefinition | None:
|
|
387
|
+
return self._definitions.get(definition_id)
|
|
388
|
+
|
|
389
|
+
def require(self, definition_id: str) -> NodeDefinition:
|
|
390
|
+
definition = self.get(definition_id)
|
|
391
|
+
if definition is None:
|
|
392
|
+
raise KeyError(f"Unknown node definition {definition_id!r}")
|
|
393
|
+
return definition
|
|
394
|
+
|
|
395
|
+
def create_instance(
|
|
396
|
+
self,
|
|
397
|
+
definition_id: str,
|
|
398
|
+
instance_id: str,
|
|
399
|
+
*,
|
|
400
|
+
parameters: Mapping[str, Any] | None = None,
|
|
401
|
+
x: float = 0,
|
|
402
|
+
y: float = 0,
|
|
403
|
+
) -> NodeInstance:
|
|
404
|
+
"""Create an instance through its registered definition."""
|
|
405
|
+
return self.require(definition_id).create_instance(
|
|
406
|
+
instance_id,
|
|
407
|
+
parameters=parameters,
|
|
408
|
+
x=x,
|
|
409
|
+
y=y,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def definitions(self) -> Mapping[str, NodeDefinition]:
|
|
414
|
+
return MappingProxyType(self._definitions)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class WorkflowGraph:
|
|
418
|
+
"""Authoritative mutable workflow structure."""
|
|
419
|
+
|
|
420
|
+
def __init__(
|
|
421
|
+
self,
|
|
422
|
+
nodes: tuple[NodeInstance, ...] | list[NodeInstance] = (),
|
|
423
|
+
edges: tuple[WorkflowEdge, ...] | list[WorkflowEdge] = (),
|
|
424
|
+
inputs: tuple[WorkflowPort, ...] | list[WorkflowPort] = (),
|
|
425
|
+
outputs: tuple[WorkflowPort, ...] | list[WorkflowPort] = (),
|
|
426
|
+
) -> None:
|
|
427
|
+
self._nodes: dict[str, NodeInstance] = {}
|
|
428
|
+
self._edges: list[WorkflowEdge] = []
|
|
429
|
+
self._inputs: dict[str, WorkflowPort] = {}
|
|
430
|
+
self._outputs: dict[str, WorkflowPort] = {}
|
|
431
|
+
for node in nodes:
|
|
432
|
+
self.add_node(node)
|
|
433
|
+
for edge in edges:
|
|
434
|
+
self._edges.append(edge)
|
|
435
|
+
self._inputs = {port.name: port for port in inputs}
|
|
436
|
+
self._outputs = {port.name: port for port in outputs}
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def nodes(self) -> tuple[NodeInstance, ...]:
|
|
440
|
+
return tuple(self._nodes.values())
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def edges(self) -> tuple[WorkflowEdge, ...]:
|
|
444
|
+
return tuple(self._edges)
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def inputs(self) -> tuple[WorkflowPort, ...]:
|
|
448
|
+
return tuple(self._inputs.values())
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def outputs(self) -> tuple[WorkflowPort, ...]:
|
|
452
|
+
return tuple(self._outputs.values())
|
|
453
|
+
|
|
454
|
+
def export_input(
|
|
455
|
+
self,
|
|
456
|
+
name: str,
|
|
457
|
+
node_id: str,
|
|
458
|
+
node_port: str,
|
|
459
|
+
registry: NodeRegistry,
|
|
460
|
+
*,
|
|
461
|
+
label: str | None = None,
|
|
462
|
+
) -> WorkflowPort:
|
|
463
|
+
"""Expose a free node input, including a parameter port, as a workflow input."""
|
|
464
|
+
port = self._workflow_port(name, node_id, node_port, PortDirection.INPUT, registry, label)
|
|
465
|
+
if any(
|
|
466
|
+
edge.target_node == node_id and edge.target_port == node_port for edge in self.edges
|
|
467
|
+
):
|
|
468
|
+
raise WorkflowValidationError(
|
|
469
|
+
(
|
|
470
|
+
ValidationIssue(
|
|
471
|
+
ValidationCode.EXPORTED_INPUT_CONNECTED,
|
|
472
|
+
f"Exported input {node_id!r}.{node_port!r} must not be connected",
|
|
473
|
+
node_id=node_id,
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
if name in self._inputs:
|
|
478
|
+
raise ValueError(f"Workflow input {name!r} already exists")
|
|
479
|
+
self._inputs[name] = port
|
|
480
|
+
return port
|
|
481
|
+
|
|
482
|
+
add_input = export_input
|
|
483
|
+
|
|
484
|
+
def export_output(
|
|
485
|
+
self,
|
|
486
|
+
name: str,
|
|
487
|
+
node_id: str,
|
|
488
|
+
node_port: str,
|
|
489
|
+
registry: NodeRegistry,
|
|
490
|
+
*,
|
|
491
|
+
label: str | None = None,
|
|
492
|
+
) -> WorkflowPort:
|
|
493
|
+
"""Expose a node output as a workflow output."""
|
|
494
|
+
port = self._workflow_port(name, node_id, node_port, PortDirection.OUTPUT, registry, label)
|
|
495
|
+
if name in self._outputs:
|
|
496
|
+
raise ValueError(f"Workflow output {name!r} already exists")
|
|
497
|
+
self._outputs[name] = port
|
|
498
|
+
return port
|
|
499
|
+
|
|
500
|
+
add_output = export_output
|
|
501
|
+
|
|
502
|
+
def input(self, name: str) -> WorkflowPort | None:
|
|
503
|
+
return self._inputs.get(name)
|
|
504
|
+
|
|
505
|
+
def output(self, name: str) -> WorkflowPort | None:
|
|
506
|
+
return self._outputs.get(name)
|
|
507
|
+
|
|
508
|
+
def executor(self, registry: NodeRegistry) -> WorkflowExecutorFunction:
|
|
509
|
+
"""Return a node-compatible callable for this workflow."""
|
|
510
|
+
return lambda inputs, _parameters=None: self.execute(inputs, registry)
|
|
511
|
+
|
|
512
|
+
def execute(self, inputs: Mapping[str, Any], registry: NodeRegistry) -> Mapping[str, Any]:
|
|
513
|
+
"""Execute this workflow with exported inputs and return exported outputs."""
|
|
514
|
+
values = dict(inputs)
|
|
515
|
+
unknown = set(values).difference(self._inputs)
|
|
516
|
+
if unknown:
|
|
517
|
+
raise WorkflowValidationError(
|
|
518
|
+
(
|
|
519
|
+
ValidationIssue(
|
|
520
|
+
ValidationCode.INVALID_EXPORTED_PORT,
|
|
521
|
+
f"Unknown workflow inputs: {sorted(unknown)!r}",
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
injected: dict[str, dict[str, Any]] = {}
|
|
526
|
+
for name, workflow_port in self._inputs.items():
|
|
527
|
+
if name not in values:
|
|
528
|
+
raise WorkflowValidationError(
|
|
529
|
+
(
|
|
530
|
+
ValidationIssue(
|
|
531
|
+
ValidationCode.MISSING_REQUIRED_INPUT,
|
|
532
|
+
f"Workflow input {name!r} is not provided",
|
|
533
|
+
),
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
value = values[name]
|
|
537
|
+
if not workflow_port.data_type.accepts(value):
|
|
538
|
+
raise WorkflowValidationError(
|
|
539
|
+
(
|
|
540
|
+
ValidationIssue(
|
|
541
|
+
ValidationCode.TYPE_MISMATCH,
|
|
542
|
+
f"Workflow input {name!r} expected {workflow_port.data_type.label}, got {type(value).__name__}",
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
injected.setdefault(workflow_port.node_id, {})[workflow_port.node_port] = value
|
|
547
|
+
result = WorkflowExecutor(registry).run(self, initial_inputs=injected)
|
|
548
|
+
return {
|
|
549
|
+
name: result.node_outputs[port.node_id][port.node_port]
|
|
550
|
+
for name, port in self._outputs.items()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
def _workflow_port(
|
|
554
|
+
self,
|
|
555
|
+
name: str,
|
|
556
|
+
node_id: str,
|
|
557
|
+
node_port: str,
|
|
558
|
+
direction: PortDirection,
|
|
559
|
+
registry: NodeRegistry,
|
|
560
|
+
label: str | None,
|
|
561
|
+
) -> WorkflowPort:
|
|
562
|
+
if not isinstance(name, str) or not name.strip():
|
|
563
|
+
raise ValueError("Workflow port names must be non-empty strings")
|
|
564
|
+
definition = registry.get(self.require_node(node_id).definition_id)
|
|
565
|
+
port = (
|
|
566
|
+
None
|
|
567
|
+
if definition is None
|
|
568
|
+
else (
|
|
569
|
+
definition.input_for_instance(node_port, self.require_node(node_id))
|
|
570
|
+
if direction is PortDirection.INPUT
|
|
571
|
+
else definition.output_for_instance(node_port, self.require_node(node_id))
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
if port is None:
|
|
575
|
+
raise WorkflowValidationError(
|
|
576
|
+
(
|
|
577
|
+
ValidationIssue(
|
|
578
|
+
ValidationCode.INVALID_EXPORTED_PORT,
|
|
579
|
+
f"Node {node_id!r} has no {direction.value} port {node_port!r}",
|
|
580
|
+
node_id=node_id,
|
|
581
|
+
),
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
return WorkflowPort(
|
|
585
|
+
name.strip(), node_id, node_port, direction, port.data_type, label, port.is_param
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def get_node(self, node_id: str) -> NodeInstance | None:
|
|
589
|
+
return self._nodes.get(node_id)
|
|
590
|
+
|
|
591
|
+
def require_node(self, node_id: str) -> NodeInstance:
|
|
592
|
+
node = self.get_node(node_id)
|
|
593
|
+
if node is None:
|
|
594
|
+
raise KeyError(f"Unknown workflow node {node_id!r}")
|
|
595
|
+
return node
|
|
596
|
+
|
|
597
|
+
def add_node(self, node: NodeInstance) -> None:
|
|
598
|
+
if node.id in self._nodes:
|
|
599
|
+
raise ValueError(f"Workflow node {node.id!r} already exists")
|
|
600
|
+
self._nodes[node.id] = node
|
|
601
|
+
|
|
602
|
+
def remove_node(self, node_id: str) -> None:
|
|
603
|
+
self.require_node(node_id)
|
|
604
|
+
del self._nodes[node_id]
|
|
605
|
+
self._edges = [
|
|
606
|
+
edge
|
|
607
|
+
for edge in self._edges
|
|
608
|
+
if edge.source_node != node_id and edge.target_node != node_id
|
|
609
|
+
]
|
|
610
|
+
self._inputs = {
|
|
611
|
+
name: port for name, port in self._inputs.items() if port.node_id != node_id
|
|
612
|
+
}
|
|
613
|
+
self._outputs = {
|
|
614
|
+
name: port for name, port in self._outputs.items() if port.node_id != node_id
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
def sync_boundary_nodes(self, registry: NodeRegistry) -> None:
|
|
618
|
+
"""Synchronize public ports declared by workflow boundary nodes.
|
|
619
|
+
|
|
620
|
+
Parameters
|
|
621
|
+
----------
|
|
622
|
+
registry:
|
|
623
|
+
Registry used to resolve boundary node port types.
|
|
624
|
+
|
|
625
|
+
Raises
|
|
626
|
+
------
|
|
627
|
+
ValueError
|
|
628
|
+
If a boundary node has a blank or duplicate public name.
|
|
629
|
+
"""
|
|
630
|
+
boundary_ids = {"workflow-input", "workflow-output"}
|
|
631
|
+
inputs = {
|
|
632
|
+
name: port
|
|
633
|
+
for name, port in self._inputs.items()
|
|
634
|
+
if self.require_node(port.node_id).definition_id not in boundary_ids
|
|
635
|
+
}
|
|
636
|
+
outputs = {
|
|
637
|
+
name: port
|
|
638
|
+
for name, port in self._outputs.items()
|
|
639
|
+
if self.require_node(port.node_id).definition_id not in boundary_ids
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
for node in self.nodes:
|
|
643
|
+
if node.definition_id not in boundary_ids:
|
|
644
|
+
continue
|
|
645
|
+
name = node.parameters.get("name")
|
|
646
|
+
if not isinstance(name, str) or not name.strip():
|
|
647
|
+
raise ValueError("Workflow boundary names must be non-empty strings")
|
|
648
|
+
name = name.strip()
|
|
649
|
+
if node.definition_id == "workflow-input":
|
|
650
|
+
if name in inputs:
|
|
651
|
+
raise ValueError(f"Workflow input {name!r} already exists")
|
|
652
|
+
inputs[name] = self._boundary_workflow_port(
|
|
653
|
+
name, node, PortDirection.INPUT, registry
|
|
654
|
+
)
|
|
655
|
+
else:
|
|
656
|
+
if name in outputs:
|
|
657
|
+
raise ValueError(f"Workflow output {name!r} already exists")
|
|
658
|
+
port = self._boundary_workflow_port(name, node, PortDirection.OUTPUT, registry)
|
|
659
|
+
outputs[name] = port
|
|
660
|
+
|
|
661
|
+
self._inputs = inputs
|
|
662
|
+
self._outputs = outputs
|
|
663
|
+
|
|
664
|
+
def boundary_port_type(self, node: NodeInstance, registry: NodeRegistry) -> DataType[Any]:
|
|
665
|
+
"""Infer the public type exposed by one workflow boundary node.
|
|
666
|
+
|
|
667
|
+
Workflow inputs use a type that can safely reach every connected internal
|
|
668
|
+
target through a direct connection or registered conversion. Workflow
|
|
669
|
+
outputs use the type supplied by their connected internal source. An
|
|
670
|
+
unconnected or ambiguous boundary retains its declared ``Any`` type.
|
|
671
|
+
"""
|
|
672
|
+
definition = registry.require(node.definition_id)
|
|
673
|
+
fallback = definition.input_for_instance("value", node)
|
|
674
|
+
if fallback is None:
|
|
675
|
+
raise ValueError(f"Workflow boundary node {node.id!r} has no value input")
|
|
676
|
+
if node.definition_id == "workflow-output":
|
|
677
|
+
source_edge = next(
|
|
678
|
+
(
|
|
679
|
+
edge
|
|
680
|
+
for edge in self.edges
|
|
681
|
+
if edge.target_node == node.id and edge.target_port == "value"
|
|
682
|
+
),
|
|
683
|
+
None,
|
|
684
|
+
)
|
|
685
|
+
if source_edge is None:
|
|
686
|
+
return fallback.data_type
|
|
687
|
+
source = self.get_node(source_edge.source_node)
|
|
688
|
+
if source is None:
|
|
689
|
+
return fallback.data_type
|
|
690
|
+
source_definition = registry.get(source.definition_id)
|
|
691
|
+
source_port = (
|
|
692
|
+
None
|
|
693
|
+
if source_definition is None
|
|
694
|
+
else source_definition.output_for_instance(source_edge.source_port, source)
|
|
695
|
+
)
|
|
696
|
+
return fallback.data_type if source_port is None else source_port.data_type
|
|
697
|
+
if node.definition_id != "workflow-input":
|
|
698
|
+
raise ValueError(f"Node {node.id!r} is not a workflow boundary node")
|
|
699
|
+
|
|
700
|
+
target_types: list[DataType[Any]] = []
|
|
701
|
+
for edge in self.edges:
|
|
702
|
+
if edge.source_node != node.id or edge.source_port != "value":
|
|
703
|
+
continue
|
|
704
|
+
target = self.get_node(edge.target_node)
|
|
705
|
+
if target is None:
|
|
706
|
+
continue
|
|
707
|
+
target_definition = registry.get(target.definition_id)
|
|
708
|
+
target_port = (
|
|
709
|
+
None
|
|
710
|
+
if target_definition is None
|
|
711
|
+
else target_definition.input_for_instance(edge.target_port, target)
|
|
712
|
+
)
|
|
713
|
+
if target_port is not None:
|
|
714
|
+
target_types.append(target_port.data_type)
|
|
715
|
+
return self._common_source_type(target_types, fallback.data_type)
|
|
716
|
+
|
|
717
|
+
def _boundary_workflow_port(
|
|
718
|
+
self,
|
|
719
|
+
name: str,
|
|
720
|
+
node: NodeInstance,
|
|
721
|
+
direction: PortDirection,
|
|
722
|
+
registry: NodeRegistry,
|
|
723
|
+
) -> WorkflowPort:
|
|
724
|
+
"""Build one inferred public interface port from a boundary node."""
|
|
725
|
+
return WorkflowPort(
|
|
726
|
+
name,
|
|
727
|
+
node.id,
|
|
728
|
+
"value",
|
|
729
|
+
direction,
|
|
730
|
+
self.boundary_port_type(node, registry),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
@staticmethod
|
|
734
|
+
def _common_source_type(
|
|
735
|
+
target_types: list[DataType[Any]], fallback: DataType[Any]
|
|
736
|
+
) -> DataType[Any]:
|
|
737
|
+
"""Return one specific type that can safely supply every target type."""
|
|
738
|
+
if not target_types:
|
|
739
|
+
return fallback
|
|
740
|
+
from flowgraph.application.port_conversions import resolve_port_conversion
|
|
741
|
+
|
|
742
|
+
candidates: list[DataType[Any]] = []
|
|
743
|
+
for target_type in target_types:
|
|
744
|
+
if target_type.python_type is object or any(
|
|
745
|
+
target_type.id == candidate.id for candidate in candidates
|
|
746
|
+
):
|
|
747
|
+
continue
|
|
748
|
+
candidates.append(target_type)
|
|
749
|
+
for candidate in candidates:
|
|
750
|
+
if all(
|
|
751
|
+
candidate.is_compatible_with(target_type)
|
|
752
|
+
or resolve_port_conversion(candidate, target_type) is not None
|
|
753
|
+
for target_type in target_types
|
|
754
|
+
):
|
|
755
|
+
return candidate
|
|
756
|
+
return fallback
|
|
757
|
+
|
|
758
|
+
def rename_node(self, node_id: str, new_id: str) -> None:
|
|
759
|
+
"""Rename a node and update all incident edges atomically."""
|
|
760
|
+
self.require_node(node_id)
|
|
761
|
+
if not isinstance(new_id, str) or not new_id.strip():
|
|
762
|
+
raise ValueError("Workflow node IDs must be non-empty strings")
|
|
763
|
+
new_id = new_id.strip()
|
|
764
|
+
if new_id != node_id and new_id in self._nodes:
|
|
765
|
+
raise ValueError(f"Workflow node {new_id!r} already exists")
|
|
766
|
+
if new_id == node_id:
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
node = self._nodes.pop(node_id)
|
|
770
|
+
node.id = new_id
|
|
771
|
+
self._nodes[new_id] = node
|
|
772
|
+
self._edges = [
|
|
773
|
+
replace(
|
|
774
|
+
edge,
|
|
775
|
+
source_node=new_id if edge.source_node == node_id else edge.source_node,
|
|
776
|
+
target_node=new_id if edge.target_node == node_id else edge.target_node,
|
|
777
|
+
)
|
|
778
|
+
for edge in self._edges
|
|
779
|
+
]
|
|
780
|
+
self._inputs = {
|
|
781
|
+
name: replace(port, node_id=new_id if port.node_id == node_id else port.node_id)
|
|
782
|
+
for name, port in self._inputs.items()
|
|
783
|
+
}
|
|
784
|
+
self._outputs = {
|
|
785
|
+
name: replace(port, node_id=new_id if port.node_id == node_id else port.node_id)
|
|
786
|
+
for name, port in self._outputs.items()
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
def add_edge(self, edge: WorkflowEdge, registry: NodeRegistry) -> None:
|
|
790
|
+
"""Add an edge transactionally if the resulting structure is valid."""
|
|
791
|
+
candidate = [*self._edges, edge]
|
|
792
|
+
issues = self._validate_edges(candidate, registry)
|
|
793
|
+
if (edge.target_node, edge.target_port) in {
|
|
794
|
+
(port.node_id, port.node_port) for port in self.inputs
|
|
795
|
+
}:
|
|
796
|
+
issues += (
|
|
797
|
+
ValidationIssue(
|
|
798
|
+
ValidationCode.EXPORTED_INPUT_CONNECTED,
|
|
799
|
+
f"Exported input {edge.target_node!r}.{edge.target_port!r} must not be connected",
|
|
800
|
+
node_id=edge.target_node,
|
|
801
|
+
edge_id=edge.id,
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
if issues:
|
|
805
|
+
raise WorkflowValidationError(issues)
|
|
806
|
+
if self._has_cycle(candidate):
|
|
807
|
+
raise WorkflowValidationError(
|
|
808
|
+
(
|
|
809
|
+
ValidationIssue(
|
|
810
|
+
ValidationCode.CYCLE,
|
|
811
|
+
f"Connection {edge.id} would create a directed cycle",
|
|
812
|
+
edge_id=edge.id,
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
)
|
|
816
|
+
self._edges.append(edge)
|
|
817
|
+
|
|
818
|
+
def remove_edge(self, edge: WorkflowEdge) -> None:
|
|
819
|
+
self._edges.remove(edge)
|
|
820
|
+
|
|
821
|
+
def edge_conversion(self, edge: WorkflowEdge, registry: NodeRegistry) -> PortConversion | None:
|
|
822
|
+
"""Return the registered conversion used by a valid edge, if it needs one."""
|
|
823
|
+
from flowgraph.application.port_conversions import resolve_port_conversion
|
|
824
|
+
|
|
825
|
+
source = self.get_node(edge.source_node)
|
|
826
|
+
target = self.get_node(edge.target_node)
|
|
827
|
+
if source is None or target is None:
|
|
828
|
+
return None
|
|
829
|
+
source_definition = registry.get(source.definition_id)
|
|
830
|
+
target_definition = registry.get(target.definition_id)
|
|
831
|
+
if source_definition is None or target_definition is None:
|
|
832
|
+
return None
|
|
833
|
+
source_port = source_definition.output_for_instance(edge.source_port, source)
|
|
834
|
+
target_port = target_definition.input_for_instance(edge.target_port, target)
|
|
835
|
+
if source_port is None or target_port is None:
|
|
836
|
+
return None
|
|
837
|
+
if target_port.accepts_type(source_port.data_type):
|
|
838
|
+
return None
|
|
839
|
+
return resolve_port_conversion(source_port.data_type, target_port.data_type)
|
|
840
|
+
|
|
841
|
+
def validate(
|
|
842
|
+
self, registry: NodeRegistry, *, require_complete: bool = True
|
|
843
|
+
) -> tuple[ValidationIssue, ...]:
|
|
844
|
+
issues: list[ValidationIssue] = []
|
|
845
|
+
for node in self.nodes:
|
|
846
|
+
if registry.get(node.definition_id) is None:
|
|
847
|
+
issues.append(
|
|
848
|
+
ValidationIssue(
|
|
849
|
+
ValidationCode.UNKNOWN_DEFINITION,
|
|
850
|
+
f"Node {node.id!r} uses unknown definition {node.definition_id!r}",
|
|
851
|
+
node_id=node.id,
|
|
852
|
+
)
|
|
853
|
+
)
|
|
854
|
+
issues.extend(self._validate_edges(self._edges, registry))
|
|
855
|
+
exported_inputs = {(port.node_id, port.node_port) for port in self.inputs}
|
|
856
|
+
issues.extend(
|
|
857
|
+
ValidationIssue(
|
|
858
|
+
ValidationCode.EXPORTED_INPUT_CONNECTED,
|
|
859
|
+
f"Exported input {edge.target_node!r}.{edge.target_port!r} must not be connected",
|
|
860
|
+
node_id=edge.target_node,
|
|
861
|
+
edge_id=edge.id,
|
|
862
|
+
)
|
|
863
|
+
for edge in self.edges
|
|
864
|
+
if (edge.target_node, edge.target_port) in exported_inputs
|
|
865
|
+
)
|
|
866
|
+
if self._has_cycle(self._edges):
|
|
867
|
+
issues.append(
|
|
868
|
+
ValidationIssue(ValidationCode.CYCLE, "Workflow contains a directed cycle")
|
|
869
|
+
)
|
|
870
|
+
if require_complete:
|
|
871
|
+
connected = {(edge.target_node, edge.target_port) for edge in self._edges}
|
|
872
|
+
for node in self.nodes:
|
|
873
|
+
definition = registry.get(node.definition_id)
|
|
874
|
+
if definition is None:
|
|
875
|
+
continue
|
|
876
|
+
for port in definition._inputs(definition.ports_for_instance(node)):
|
|
877
|
+
if port.required and (node.id, port.name) not in connected:
|
|
878
|
+
issues.append(
|
|
879
|
+
ValidationIssue(
|
|
880
|
+
ValidationCode.MISSING_REQUIRED_INPUT,
|
|
881
|
+
f"Required input {node.id!r}.{port.name!r} is not connected",
|
|
882
|
+
node_id=node.id,
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
return tuple(issues)
|
|
886
|
+
|
|
887
|
+
def topological_order(self) -> tuple[str, ...]:
|
|
888
|
+
"""Return stable insertion-ordered topological node IDs."""
|
|
889
|
+
successors: dict[str, list[str]] = {}
|
|
890
|
+
indegree = {node.id: 0 for node in self.nodes}
|
|
891
|
+
for edge in self._edges:
|
|
892
|
+
if edge.source_node in indegree and edge.target_node in indegree:
|
|
893
|
+
successors.setdefault(edge.source_node, []).append(edge.target_node)
|
|
894
|
+
indegree[edge.target_node] += 1
|
|
895
|
+
ready = [node.id for node in self.nodes if indegree[node.id] == 0]
|
|
896
|
+
order: list[str] = []
|
|
897
|
+
while ready:
|
|
898
|
+
node_id = ready.pop(0)
|
|
899
|
+
order.append(node_id)
|
|
900
|
+
for target in successors.get(node_id, []):
|
|
901
|
+
indegree[target] -= 1
|
|
902
|
+
if indegree[target] == 0:
|
|
903
|
+
ready.append(target)
|
|
904
|
+
if len(order) != len(self._nodes):
|
|
905
|
+
raise WorkflowValidationError(
|
|
906
|
+
(ValidationIssue(ValidationCode.CYCLE, "Workflow contains a directed cycle"),)
|
|
907
|
+
)
|
|
908
|
+
return tuple(order)
|
|
909
|
+
|
|
910
|
+
def _validate_edges(
|
|
911
|
+
self, edges: list[WorkflowEdge], registry: NodeRegistry
|
|
912
|
+
) -> tuple[ValidationIssue, ...]:
|
|
913
|
+
issues: list[ValidationIssue] = []
|
|
914
|
+
seen_edges: set[WorkflowEdge] = set()
|
|
915
|
+
connected_inputs: set[tuple[str, str]] = set()
|
|
916
|
+
for edge in edges:
|
|
917
|
+
if edge in seen_edges:
|
|
918
|
+
issues.append(
|
|
919
|
+
ValidationIssue(
|
|
920
|
+
ValidationCode.DUPLICATE_EDGE,
|
|
921
|
+
f"Duplicate connection {edge.id}",
|
|
922
|
+
edge_id=edge.id,
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
continue
|
|
926
|
+
seen_edges.add(edge)
|
|
927
|
+
input_address = (edge.target_node, edge.target_port)
|
|
928
|
+
if input_address in connected_inputs:
|
|
929
|
+
issues.append(
|
|
930
|
+
ValidationIssue(
|
|
931
|
+
ValidationCode.INPUT_ALREADY_CONNECTED,
|
|
932
|
+
f"Input {edge.target_node!r}.{edge.target_port!r} already has a connection",
|
|
933
|
+
edge_id=edge.id,
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
connected_inputs.add(input_address)
|
|
937
|
+
|
|
938
|
+
source = self.get_node(edge.source_node)
|
|
939
|
+
target = self.get_node(edge.target_node)
|
|
940
|
+
if source is None or target is None:
|
|
941
|
+
missing = edge.source_node if source is None else edge.target_node
|
|
942
|
+
issues.append(
|
|
943
|
+
ValidationIssue(
|
|
944
|
+
ValidationCode.UNKNOWN_NODE,
|
|
945
|
+
f"Connection {edge.id} references unknown node {missing!r}",
|
|
946
|
+
edge_id=edge.id,
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
continue
|
|
950
|
+
source_definition = registry.get(source.definition_id)
|
|
951
|
+
target_definition = registry.get(target.definition_id)
|
|
952
|
+
if source_definition is None or target_definition is None:
|
|
953
|
+
continue
|
|
954
|
+
source_port = source_definition.output_for_instance(edge.source_port, source)
|
|
955
|
+
target_port = target_definition.input_for_instance(edge.target_port, target)
|
|
956
|
+
if source_port is None:
|
|
957
|
+
issues.append(
|
|
958
|
+
ValidationIssue(
|
|
959
|
+
ValidationCode.UNKNOWN_SOURCE_PORT,
|
|
960
|
+
f"Node {source.id!r} has no output port {edge.source_port!r}",
|
|
961
|
+
edge_id=edge.id,
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
if target_port is None:
|
|
965
|
+
issues.append(
|
|
966
|
+
ValidationIssue(
|
|
967
|
+
ValidationCode.UNKNOWN_TARGET_PORT,
|
|
968
|
+
f"Node {target.id!r} has no input port {edge.target_port!r}",
|
|
969
|
+
edge_id=edge.id,
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
if (
|
|
973
|
+
source_port is not None
|
|
974
|
+
and target_port is not None
|
|
975
|
+
and not target_port.accepts_type(source_port.data_type)
|
|
976
|
+
and self.edge_conversion(edge, registry) is None
|
|
977
|
+
):
|
|
978
|
+
issues.append(
|
|
979
|
+
ValidationIssue(
|
|
980
|
+
ValidationCode.TYPE_MISMATCH,
|
|
981
|
+
f"Cannot connect {source_port.data_type.id!r} output to "
|
|
982
|
+
f"{target_port.data_type.id!r} input ({edge.id})",
|
|
983
|
+
edge_id=edge.id,
|
|
984
|
+
)
|
|
985
|
+
)
|
|
986
|
+
return tuple(issues)
|
|
987
|
+
|
|
988
|
+
def _has_cycle(self, edges: list[WorkflowEdge]) -> bool:
|
|
989
|
+
successors: dict[str, list[str]] = {}
|
|
990
|
+
for edge in edges:
|
|
991
|
+
if edge.source_node in self._nodes and edge.target_node in self._nodes:
|
|
992
|
+
successors.setdefault(edge.source_node, []).append(edge.target_node)
|
|
993
|
+
visiting: set[str] = set()
|
|
994
|
+
visited: set[str] = set()
|
|
995
|
+
|
|
996
|
+
def visit(node_id: str) -> bool:
|
|
997
|
+
if node_id in visiting:
|
|
998
|
+
return True
|
|
999
|
+
if node_id in visited:
|
|
1000
|
+
return False
|
|
1001
|
+
visiting.add(node_id)
|
|
1002
|
+
if any(visit(target) for target in successors.get(node_id, [])):
|
|
1003
|
+
return True
|
|
1004
|
+
visiting.remove(node_id)
|
|
1005
|
+
visited.add(node_id)
|
|
1006
|
+
return False
|
|
1007
|
+
|
|
1008
|
+
return any(visit(node.id) for node in self.nodes if node.id not in visited)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@dataclass(frozen=True)
|
|
1012
|
+
class WorkflowRunResult:
|
|
1013
|
+
execution_order: tuple[str, ...]
|
|
1014
|
+
node_outputs: Mapping[str, Mapping[str, Any]]
|
|
1015
|
+
node_inputs: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
class WorkflowExecutor:
|
|
1019
|
+
"""Execute a validated DAG synchronously and retain every node output."""
|
|
1020
|
+
|
|
1021
|
+
def __init__(self, registry: NodeRegistry) -> None:
|
|
1022
|
+
self._registry = registry
|
|
1023
|
+
|
|
1024
|
+
def run(
|
|
1025
|
+
self,
|
|
1026
|
+
graph: WorkflowGraph,
|
|
1027
|
+
*,
|
|
1028
|
+
node_ids: set[str] | None = None,
|
|
1029
|
+
initial_outputs: Mapping[str, Mapping[str, Any]] | None = None,
|
|
1030
|
+
initial_inputs: Mapping[str, Mapping[str, Any]] | None = None,
|
|
1031
|
+
) -> WorkflowRunResult:
|
|
1032
|
+
"""Execute all nodes, or only *node_ids* using previously resolved outputs."""
|
|
1033
|
+
# Missing required connections are execution-time node failures, not
|
|
1034
|
+
# graph-wide failures. A malformed node must not prevent independent
|
|
1035
|
+
# branches from running and producing useful partial results.
|
|
1036
|
+
issues = graph.validate(self._registry, require_complete=False)
|
|
1037
|
+
if issues:
|
|
1038
|
+
raise WorkflowValidationError(issues)
|
|
1039
|
+
full_order = graph.topological_order()
|
|
1040
|
+
selected_ids = set(full_order) if node_ids is None else set(node_ids)
|
|
1041
|
+
unknown_ids = selected_ids.difference(full_order)
|
|
1042
|
+
if unknown_ids:
|
|
1043
|
+
raise KeyError(f"Unknown workflow nodes {sorted(unknown_ids)!r}")
|
|
1044
|
+
order = tuple(node_id for node_id in full_order if node_id in selected_ids)
|
|
1045
|
+
outputs: dict[str, Mapping[str, Any]] = dict(initial_outputs or {})
|
|
1046
|
+
resolved_inputs: dict[str, Mapping[str, Any]] = {}
|
|
1047
|
+
first_error: NodeExecutionError | None = None
|
|
1048
|
+
incoming = {node.id: [] for node in graph.nodes}
|
|
1049
|
+
for edge in graph.edges:
|
|
1050
|
+
incoming[edge.target_node].append(edge)
|
|
1051
|
+
|
|
1052
|
+
for node_id in order:
|
|
1053
|
+
node = graph.require_node(node_id)
|
|
1054
|
+
definition = self._registry.require(node.definition_id)
|
|
1055
|
+
injected_inputs = dict((initial_inputs or {}).get(node_id, {}))
|
|
1056
|
+
connected_ports = {edge.target_port for edge in incoming[node_id]} | set(
|
|
1057
|
+
injected_inputs
|
|
1058
|
+
)
|
|
1059
|
+
missing_required = [
|
|
1060
|
+
port.name
|
|
1061
|
+
for port in definition._inputs(definition.ports_for_instance(node))
|
|
1062
|
+
if port.required and port.name not in connected_ports
|
|
1063
|
+
]
|
|
1064
|
+
if missing_required:
|
|
1065
|
+
if first_error is None:
|
|
1066
|
+
first_error = NodeExecutionError(
|
|
1067
|
+
node.id,
|
|
1068
|
+
node.definition_id,
|
|
1069
|
+
"required inputs are not connected: " + ", ".join(missing_required),
|
|
1070
|
+
)
|
|
1071
|
+
continue
|
|
1072
|
+
missing_sources = [
|
|
1073
|
+
edge for edge in incoming[node_id] if edge.source_node not in outputs
|
|
1074
|
+
]
|
|
1075
|
+
if missing_sources:
|
|
1076
|
+
missing = ", ".join(edge.source_node for edge in missing_sources)
|
|
1077
|
+
if first_error is None:
|
|
1078
|
+
first_error = NodeExecutionError(
|
|
1079
|
+
node.id,
|
|
1080
|
+
node.definition_id,
|
|
1081
|
+
f"upstream outputs are unavailable from: {missing}",
|
|
1082
|
+
)
|
|
1083
|
+
continue
|
|
1084
|
+
node_inputs: dict[str, Any] = dict(injected_inputs)
|
|
1085
|
+
input_error = False
|
|
1086
|
+
for edge in incoming[node_id]:
|
|
1087
|
+
value = outputs[edge.source_node][edge.source_port]
|
|
1088
|
+
conversion = graph.edge_conversion(edge, self._registry)
|
|
1089
|
+
if conversion is not None:
|
|
1090
|
+
try:
|
|
1091
|
+
value = conversion.convert(value)
|
|
1092
|
+
except Exception as error: # noqa: BLE001
|
|
1093
|
+
if first_error is None:
|
|
1094
|
+
first_error = NodeExecutionError(
|
|
1095
|
+
node.id,
|
|
1096
|
+
node.definition_id,
|
|
1097
|
+
f"conversion {conversion.label} failed for {edge.id}: {error}",
|
|
1098
|
+
)
|
|
1099
|
+
input_error = True
|
|
1100
|
+
continue
|
|
1101
|
+
target_port = definition.input_for_instance(edge.target_port, node)
|
|
1102
|
+
if target_port is None or not target_port.accepts(value):
|
|
1103
|
+
if first_error is None:
|
|
1104
|
+
expected = "unknown" if target_port is None else target_port.data_type.label
|
|
1105
|
+
first_error = NodeExecutionError(
|
|
1106
|
+
node.id,
|
|
1107
|
+
node.definition_id,
|
|
1108
|
+
f"input {edge.target_port!r} expected {expected}, "
|
|
1109
|
+
f"got {type(value).__name__}",
|
|
1110
|
+
)
|
|
1111
|
+
input_error = True
|
|
1112
|
+
continue
|
|
1113
|
+
node_inputs[edge.target_port] = value
|
|
1114
|
+
if input_error:
|
|
1115
|
+
continue
|
|
1116
|
+
resolved_inputs[node_id] = MappingProxyType(node_inputs)
|
|
1117
|
+
parameter_inputs = {
|
|
1118
|
+
port.name: node_inputs[port.name]
|
|
1119
|
+
for port in definition._inputs(definition.ports_for_instance(node))
|
|
1120
|
+
if port.is_param and port.name in node_inputs
|
|
1121
|
+
}
|
|
1122
|
+
# A connected parameter port is the authoritative source for that
|
|
1123
|
+
# parameter. Persist its resolved value so the UI and saved
|
|
1124
|
+
# workflow reflect the value used for this execution.
|
|
1125
|
+
node.parameters.update(parameter_inputs)
|
|
1126
|
+
try:
|
|
1127
|
+
raw_outputs = definition.execute_instance(
|
|
1128
|
+
node, MappingProxyType(node_inputs), self._registry
|
|
1129
|
+
)
|
|
1130
|
+
node_outputs = {} if raw_outputs is None else dict(raw_outputs)
|
|
1131
|
+
except WorkflowError as error:
|
|
1132
|
+
if isinstance(error, NodeExecutionError):
|
|
1133
|
+
if first_error is None:
|
|
1134
|
+
first_error = error
|
|
1135
|
+
continue
|
|
1136
|
+
raise
|
|
1137
|
+
except Exception as error: # noqa: BLE001
|
|
1138
|
+
if first_error is None:
|
|
1139
|
+
first_error = NodeExecutionError(node.id, node.definition_id, str(error))
|
|
1140
|
+
continue
|
|
1141
|
+
try:
|
|
1142
|
+
self._validate_outputs(node, definition, node_outputs)
|
|
1143
|
+
except NodeExecutionError as error:
|
|
1144
|
+
if first_error is None:
|
|
1145
|
+
first_error = error
|
|
1146
|
+
continue
|
|
1147
|
+
outputs[node_id] = MappingProxyType(node_outputs)
|
|
1148
|
+
|
|
1149
|
+
if first_error is not None:
|
|
1150
|
+
first_error.partial_result = self._partial_result(order, outputs, resolved_inputs)
|
|
1151
|
+
raise first_error
|
|
1152
|
+
|
|
1153
|
+
return WorkflowRunResult(
|
|
1154
|
+
order,
|
|
1155
|
+
MappingProxyType({node_id: outputs[node_id] for node_id in order}),
|
|
1156
|
+
MappingProxyType(resolved_inputs),
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
@staticmethod
|
|
1160
|
+
def _partial_result(
|
|
1161
|
+
order: tuple[str, ...],
|
|
1162
|
+
outputs: Mapping[str, Mapping[str, Any]],
|
|
1163
|
+
resolved_inputs: Mapping[str, Mapping[str, Any]],
|
|
1164
|
+
) -> WorkflowRunResult:
|
|
1165
|
+
"""Build a result containing every successfully completed node."""
|
|
1166
|
+
completed = tuple(node_id for node_id in order if node_id in outputs)
|
|
1167
|
+
return WorkflowRunResult(
|
|
1168
|
+
completed,
|
|
1169
|
+
MappingProxyType({node_id: outputs[node_id] for node_id in completed}),
|
|
1170
|
+
MappingProxyType({node_id: resolved_inputs[node_id] for node_id in completed}),
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
@staticmethod
|
|
1174
|
+
def _validate_outputs(
|
|
1175
|
+
node: NodeInstance, definition: NodeDefinition, outputs: dict[str, Any]
|
|
1176
|
+
) -> None:
|
|
1177
|
+
expected = {
|
|
1178
|
+
port.name: port for port in definition._outputs(definition.ports_for_instance(node))
|
|
1179
|
+
}
|
|
1180
|
+
if set(outputs) != set(expected):
|
|
1181
|
+
raise NodeExecutionError(
|
|
1182
|
+
node.id,
|
|
1183
|
+
node.definition_id,
|
|
1184
|
+
f"expected outputs {sorted(expected)}, received {sorted(outputs)}",
|
|
1185
|
+
)
|
|
1186
|
+
for name, value in outputs.items():
|
|
1187
|
+
data_type = expected[name].data_type
|
|
1188
|
+
if not data_type.accepts(value):
|
|
1189
|
+
raise NodeExecutionError(
|
|
1190
|
+
node.id,
|
|
1191
|
+
node.definition_id,
|
|
1192
|
+
f"output {name!r} expected {data_type.label}, got {type(value).__name__}",
|
|
1193
|
+
)
|