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.
Files changed (56) hide show
  1. flowgraph/__init__.py +9 -0
  2. flowgraph/__main__.py +6 -0
  3. flowgraph/adapters/__init__.py +141 -0
  4. flowgraph/adapters/controls.py +96 -0
  5. flowgraph/adapters/cosapp.py +195 -0
  6. flowgraph/adapters/data_types.py +59 -0
  7. flowgraph/adapters/documentation.py +45 -0
  8. flowgraph/adapters/field_ops.py +121 -0
  9. flowgraph/adapters/file_conversion.py +393 -0
  10. flowgraph/adapters/files.py +191 -0
  11. flowgraph/adapters/filters.py +173 -0
  12. flowgraph/adapters/image_tools.py +394 -0
  13. flowgraph/adapters/mesh_creation_tools.py +431 -0
  14. flowgraph/adapters/mesh_generations.py +58 -0
  15. flowgraph/adapters/mesh_ops.py +362 -0
  16. flowgraph/adapters/paraview.py +39 -0
  17. flowgraph/adapters/plaid_bridge.py +205 -0
  18. flowgraph/adapters/readers.py +215 -0
  19. flowgraph/adapters/remote_files.py +118 -0
  20. flowgraph/adapters/simple_sources.py +199 -0
  21. flowgraph/adapters/sinks.py +80 -0
  22. flowgraph/adapters/user_code.py +196 -0
  23. flowgraph/adapters/workflow.py +176 -0
  24. flowgraph/adapters/workflow_interfaces.py +78 -0
  25. flowgraph/adapters/writers.py +159 -0
  26. flowgraph/application/__init__.py +1 -0
  27. flowgraph/application/node_registry.py +14 -0
  28. flowgraph/application/port_conversions.py +63 -0
  29. flowgraph/application/wasm_execution.py +132 -0
  30. flowgraph/application/workflow_cli.py +417 -0
  31. flowgraph/application/workflow_core.py +1193 -0
  32. flowgraph/application/workflow_history.py +93 -0
  33. flowgraph/application/workflow_io.py +393 -0
  34. flowgraph/application/workflow_layout.py +170 -0
  35. flowgraph/application/workflow_web_catalog.py +143 -0
  36. flowgraph/application/workflows.py +7 -0
  37. flowgraph/core_cli.py +81 -0
  38. flowgraph/domain/__init__.py +5 -0
  39. flowgraph/domain/mesh_document.py +91 -0
  40. flowgraph/examples/AllFilters.py +19 -0
  41. flowgraph/examples/convert_format.py +21 -0
  42. flowgraph/resources.py +29 -0
  43. flowgraph/testdata/AllFilters.json +390 -0
  44. flowgraph/testdata/FlowGraph.h5 +0 -0
  45. flowgraph/testdata/FlowGraph.stl +0 -0
  46. flowgraph/testdata/FlowGraph.xdmf +22 -0
  47. flowgraph/testdata/SimpleReadMesh.json +70 -0
  48. flowgraph/testdata/SpherePoints.csv +83 -0
  49. flowgraph/testdata/muscat-muscat-mesh-conversion.json +101 -0
  50. flowgraph/version.py +3 -0
  51. flowgraph-0.1.2.dist-info/METADATA +150 -0
  52. flowgraph-0.1.2.dist-info/RECORD +56 -0
  53. flowgraph-0.1.2.dist-info/WHEEL +5 -0
  54. flowgraph-0.1.2.dist-info/entry_points.txt +2 -0
  55. flowgraph-0.1.2.dist-info/licenses/LICENSE +29 -0
  56. flowgraph-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,93 @@
1
+ """Snapshot-based undo and redo support for editable workflow graphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class WorkflowHistoryEntry:
12
+ """One reversible workflow edit represented by complete graph snapshots.
13
+
14
+ Parameters
15
+ ----------
16
+ label:
17
+ Short user-facing description of the recorded edit.
18
+ before:
19
+ JSON-compatible workflow state before the edit.
20
+ after:
21
+ JSON-compatible workflow state after the edit.
22
+ """
23
+
24
+ label: str
25
+ before: dict[str, Any]
26
+ after: dict[str, Any]
27
+
28
+
29
+ class WorkflowHistory:
30
+ """Maintain a bounded linear undo/redo history for workflow snapshots."""
31
+
32
+ MAX_ENTRIES = 50
33
+
34
+ def __init__(self) -> None:
35
+ self._entries: list[WorkflowHistoryEntry] = []
36
+ self._index = 0
37
+
38
+ @property
39
+ def can_undo(self) -> bool:
40
+ """Return whether an earlier workflow state can be restored."""
41
+ return self._index > 0
42
+
43
+ @property
44
+ def can_redo(self) -> bool:
45
+ """Return whether an undone workflow state can be reapplied."""
46
+ return self._index < len(self._entries)
47
+
48
+ def clear(self) -> None:
49
+ """Discard all recorded states."""
50
+ self._entries.clear()
51
+ self._index = 0
52
+
53
+ def record(self, label: str, before: dict[str, Any], after: dict[str, Any]) -> bool:
54
+ """Record a changed workflow state and discard its obsolete redo branch.
55
+
56
+ Parameters
57
+ ----------
58
+ label:
59
+ Short description of the edit.
60
+ before:
61
+ JSON-compatible workflow state before the edit.
62
+ after:
63
+ JSON-compatible workflow state after the edit.
64
+
65
+ Returns
66
+ -------
67
+ bool
68
+ ``True`` when the states differed and an entry was recorded.
69
+ """
70
+ if before == after:
71
+ return False
72
+ self._entries[self._index :] = []
73
+ self._entries.append(WorkflowHistoryEntry(label, deepcopy(before), deepcopy(after)))
74
+ self._index += 1
75
+ if len(self._entries) > self.MAX_ENTRIES:
76
+ del self._entries[0]
77
+ self._index -= 1
78
+ return True
79
+
80
+ def undo(self) -> WorkflowHistoryEntry | None:
81
+ """Step backward and return the entry whose previous state should be restored."""
82
+ if not self.can_undo:
83
+ return None
84
+ self._index -= 1
85
+ return self._entries[self._index]
86
+
87
+ def redo(self) -> WorkflowHistoryEntry | None:
88
+ """Step forward and return the entry whose next state should be restored."""
89
+ if not self.can_redo:
90
+ return None
91
+ entry = self._entries[self._index]
92
+ self._index += 1
93
+ return entry
@@ -0,0 +1,393 @@
1
+ """Versioned JSON persistence and headless execution for FlowGraph workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from copy import deepcopy
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from flowgraph.application.workflow_core import (
12
+ NodeInstance,
13
+ NodeRegistry,
14
+ PortDirection,
15
+ WorkflowEdge,
16
+ WorkflowExecutor,
17
+ WorkflowGraph,
18
+ WorkflowPort,
19
+ WorkflowRunResult,
20
+ WorkflowValidationError,
21
+ )
22
+ from flowgraph.resources import package_resource_path
23
+
24
+ WORKFLOW_FORMAT = "flowgraph-workflow"
25
+ WORKFLOW_FORMAT_VERSION = 2
26
+ LEGACY_WORKFLOW_FORMAT_VERSION = 1
27
+
28
+
29
+ class WorkflowPersistenceError(ValueError):
30
+ """Raised when a workflow cannot be serialized or reconstructed safely."""
31
+
32
+
33
+ def workflow_to_dict(workflow: WorkflowGraph) -> dict[str, Any]:
34
+ """Return the authoritative graph as a versioned JSON-compatible mapping."""
35
+ return _workflow_to_dict(workflow, set())
36
+
37
+
38
+ def _workflow_to_dict(workflow: WorkflowGraph, ancestors: set[int]) -> dict[str, Any]:
39
+ """Serialize one workflow and its owned child workflows recursively."""
40
+ if id(workflow) in ancestors:
41
+ raise WorkflowPersistenceError("Workflow contains a recursive nested workflow")
42
+ ancestors = {*ancestors, id(workflow)}
43
+ testdata_root = package_resource_path("testdata").resolve()
44
+ payload = {
45
+ "format": WORKFLOW_FORMAT,
46
+ "version": WORKFLOW_FORMAT_VERSION,
47
+ "nodes": [
48
+ {
49
+ "id": node.id,
50
+ "definition_id": node.definition_id,
51
+ "parameters": {
52
+ name: _serialize_testdata_value(value, testdata_root)
53
+ for name, value in node.parameters.items()
54
+ },
55
+ "position": {"x": node.x, "y": node.y},
56
+ **(
57
+ {"subworkflow": _workflow_to_dict(node.subworkflow, ancestors)}
58
+ if node.subworkflow is not None
59
+ else {}
60
+ ),
61
+ }
62
+ for node in workflow.nodes
63
+ ],
64
+ "inputs": [
65
+ {"name": p.name, "node_id": p.node_id, "node_port": p.node_port}
66
+ for p in workflow.inputs
67
+ ],
68
+ "outputs": [
69
+ {"name": p.name, "node_id": p.node_id, "node_port": p.node_port}
70
+ for p in workflow.outputs
71
+ ],
72
+ "edges": [
73
+ {
74
+ "source_node": edge.source_node,
75
+ "source_port": edge.source_port,
76
+ "target_node": edge.target_node,
77
+ "target_port": edge.target_port,
78
+ }
79
+ for edge in workflow.edges
80
+ ],
81
+ }
82
+ try:
83
+ json.dumps(payload)
84
+ except (TypeError, ValueError) as error:
85
+ raise WorkflowPersistenceError(
86
+ f"Workflow contains parameters that cannot be represented as JSON: {error}"
87
+ ) from error
88
+ return payload
89
+
90
+
91
+ def workflow_from_dict(payload: Mapping[str, Any], registry: NodeRegistry) -> WorkflowGraph:
92
+ """Validate and reconstruct an authoritative graph from persisted data."""
93
+ if not isinstance(payload, Mapping):
94
+ raise WorkflowPersistenceError("Workflow JSON root must be an object")
95
+ if payload.get("format") != WORKFLOW_FORMAT:
96
+ raise WorkflowPersistenceError(
97
+ f"Unsupported workflow format {payload.get('format')!r}; expected {WORKFLOW_FORMAT!r}"
98
+ )
99
+ version = payload.get("version")
100
+ if version not in {LEGACY_WORKFLOW_FORMAT_VERSION, WORKFLOW_FORMAT_VERSION}:
101
+ raise WorkflowPersistenceError(
102
+ f"Unsupported workflow version {payload.get('version')!r}; "
103
+ f"expected {LEGACY_WORKFLOW_FORMAT_VERSION} or {WORKFLOW_FORMAT_VERSION}"
104
+ )
105
+
106
+ nodes_payload = payload.get("nodes")
107
+ edges_payload = payload.get("edges")
108
+ if not isinstance(nodes_payload, list):
109
+ raise WorkflowPersistenceError("Workflow 'nodes' must be an array")
110
+ if not isinstance(edges_payload, list):
111
+ raise WorkflowPersistenceError("Workflow 'edges' must be an array")
112
+
113
+ payload = _resolve_testdata_paths(payload)
114
+ nodes_payload = payload["nodes"]
115
+ graph = WorkflowGraph()
116
+ for index, raw_node in enumerate(nodes_payload):
117
+ node = _parse_node(raw_node, index, registry, version=version)
118
+ try:
119
+ graph.add_node(node)
120
+ except ValueError as error:
121
+ raise WorkflowPersistenceError(str(error)) from error
122
+
123
+ for index, raw_edge in enumerate(edges_payload):
124
+ edge = _parse_edge(raw_edge, index)
125
+ try:
126
+ graph.add_edge(edge, registry)
127
+ except WorkflowValidationError as error:
128
+ raise WorkflowPersistenceError(f"Invalid edge at index {index}: {error}") from error
129
+ for index, raw_port in enumerate(payload.get("inputs", [])):
130
+ port = _parse_workflow_port(raw_port, index, graph, registry, "input")
131
+ try:
132
+ graph.export_input(port.name, port.node_id, port.node_port, registry, label=port.label)
133
+ except (ValueError, WorkflowValidationError) as error:
134
+ raise WorkflowPersistenceError(
135
+ f"Invalid workflow input at index {index}: {error}"
136
+ ) from error
137
+ for index, raw_port in enumerate(payload.get("outputs", [])):
138
+ port = _parse_workflow_port(raw_port, index, graph, registry, "output")
139
+ try:
140
+ graph.export_output(port.name, port.node_id, port.node_port, registry, label=port.label)
141
+ except (ValueError, WorkflowValidationError) as error:
142
+ raise WorkflowPersistenceError(
143
+ f"Invalid workflow output at index {index}: {error}"
144
+ ) from error
145
+ try:
146
+ graph.sync_boundary_nodes(registry)
147
+ except (ValueError, WorkflowValidationError) as error:
148
+ raise WorkflowPersistenceError(f"Invalid workflow boundary node: {error}") from error
149
+ return graph
150
+
151
+
152
+ def save_workflow(workflow: WorkflowGraph, path: str | Path) -> Path:
153
+ """Write a workflow to deterministic, human-readable JSON."""
154
+ destination = Path(path).expanduser()
155
+ if not destination.parent.is_dir():
156
+ raise WorkflowPersistenceError(
157
+ f"Workflow output directory does not exist: {destination.parent}"
158
+ )
159
+ payload = workflow_to_dict(workflow)
160
+ try:
161
+ destination.write_text(
162
+ json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
163
+ )
164
+ except OSError as error:
165
+ raise WorkflowPersistenceError(
166
+ f"Could not save workflow to '{destination}': {error}"
167
+ ) from error
168
+ return destination.resolve()
169
+
170
+
171
+ def load_workflow(path: str | Path, registry: NodeRegistry) -> WorkflowGraph:
172
+ """Load and validate a workflow JSON file against the available node registry."""
173
+ source = Path(path).expanduser()
174
+ try:
175
+ content = source.read_text(encoding="utf-8")
176
+ except OSError as error:
177
+ raise WorkflowPersistenceError(
178
+ f"Could not read workflow from '{source}': {error}"
179
+ ) from error
180
+ return workflow_from_json(content, registry, source_name=str(source))
181
+
182
+
183
+ def workflow_from_json(
184
+ content: str, registry: NodeRegistry, *, source_name: str = "workflow"
185
+ ) -> WorkflowGraph:
186
+ """Parse and validate workflow JSON received without a filesystem path."""
187
+ if not isinstance(content, str):
188
+ raise WorkflowPersistenceError("Workflow JSON content must be text")
189
+ try:
190
+ payload = json.loads(content)
191
+ except json.JSONDecodeError as error:
192
+ raise WorkflowPersistenceError(
193
+ f"Workflow file '{source_name}' is not valid JSON: {error}"
194
+ ) from error
195
+ return workflow_from_dict(payload, registry)
196
+
197
+
198
+ def execute_workflow_file(
199
+ path: str | Path,
200
+ parameter_overrides: Mapping[str, Mapping[str, Any]] | None = None,
201
+ *,
202
+ registry: NodeRegistry | None = None,
203
+ ) -> WorkflowRunResult:
204
+ """Load and execute a workflow, optionally overriding parameters by node ID."""
205
+ if registry is None:
206
+ from flowgraph.application.node_registry import create_node_registry
207
+
208
+ registry = create_node_registry()
209
+ workflow = load_workflow(path, registry)
210
+ _apply_parameter_overrides(workflow, parameter_overrides or {})
211
+ return WorkflowExecutor(registry).run(workflow)
212
+
213
+
214
+ def execute_workflow(
215
+ workflow: WorkflowGraph,
216
+ registry: NodeRegistry,
217
+ parameter_overrides: Mapping[str, Mapping[str, Any]] | None = None,
218
+ ) -> WorkflowRunResult:
219
+ """execute a workflow, optionally overriding parameters by node ID."""
220
+ _apply_parameter_overrides(workflow, parameter_overrides or {})
221
+ return WorkflowExecutor(registry).run(workflow)
222
+
223
+
224
+ def _parse_node(
225
+ raw_node: object,
226
+ index: int,
227
+ registry: NodeRegistry,
228
+ *,
229
+ version: object,
230
+ ) -> NodeInstance:
231
+ if not isinstance(raw_node, Mapping):
232
+ raise WorkflowPersistenceError(f"Node at index {index} must be an object")
233
+ node_id = _required_string(raw_node, "id", f"node at index {index}")
234
+ definition_id = _required_string(raw_node, "definition_id", f"node {node_id!r}")
235
+ if registry.get(definition_id) is None:
236
+ raise WorkflowPersistenceError(
237
+ f"Node {node_id!r} uses unknown definition {definition_id!r}"
238
+ )
239
+ parameters = raw_node.get("parameters", {})
240
+ if not isinstance(parameters, Mapping) or not all(isinstance(name, str) for name in parameters):
241
+ raise WorkflowPersistenceError(
242
+ f"Parameters for node {node_id!r} must be an object with string keys"
243
+ )
244
+ position = raw_node.get("position", {})
245
+ if not isinstance(position, Mapping):
246
+ raise WorkflowPersistenceError(f"Position for node {node_id!r} must be an object")
247
+ x = _coordinate(position.get("x", 0), node_id, "x")
248
+ y = _coordinate(position.get("y", 0), node_id, "y")
249
+ subworkflow_payload = raw_node.get("subworkflow")
250
+ subworkflow: WorkflowGraph | None = None
251
+ if subworkflow_payload is not None:
252
+ if not isinstance(subworkflow_payload, Mapping):
253
+ raise WorkflowPersistenceError(f"Subworkflow for node {node_id!r} must be an object")
254
+ subworkflow = workflow_from_dict(subworkflow_payload, registry)
255
+ elif definition_id == "run-workflow" and version == LEGACY_WORKFLOW_FORMAT_VERSION:
256
+ legacy_content = parameters.get("workflow")
257
+ if isinstance(legacy_content, str) and legacy_content.strip():
258
+ subworkflow = workflow_from_json(
259
+ legacy_content,
260
+ registry,
261
+ source_name=f"legacy nested workflow in node {node_id!r}",
262
+ )
263
+ parameters = {name: value for name, value in parameters.items() if name != "workflow"}
264
+ elif (definition := registry.get(definition_id)) is not None and definition.subworkflow_factory:
265
+ subworkflow = definition.subworkflow_factory()
266
+ return NodeInstance(node_id, definition_id, deepcopy(dict(parameters)), x, y, subworkflow)
267
+
268
+
269
+ def _resolve_testdata_paths(payload: Mapping[str, Any]) -> Mapping[str, Any]:
270
+ """Resolve the portable ``{testdata}`` token in persisted string values.
271
+
272
+ The token is intentionally expanded while loading rather than while saving:
273
+ workflow JSON remains portable and can still be edited or shared, while
274
+ packaged demo workflows refer to the test data installed with FlowGraph.
275
+ """
276
+ testdata_root = package_resource_path("testdata")
277
+ resolved = deepcopy(dict(payload))
278
+ nodes = resolved.get("nodes")
279
+ if not isinstance(nodes, list):
280
+ return resolved
281
+ for node in nodes:
282
+ if not isinstance(node, dict):
283
+ continue
284
+ parameters = node.get("parameters")
285
+ if isinstance(parameters, dict):
286
+ node["parameters"] = {
287
+ name: _resolve_testdata_value(value, testdata_root)
288
+ for name, value in parameters.items()
289
+ }
290
+ subworkflow = node.get("subworkflow")
291
+ if isinstance(subworkflow, Mapping):
292
+ node["subworkflow"] = _resolve_testdata_paths(subworkflow)
293
+ return resolved
294
+
295
+
296
+ def _resolve_testdata_value(value: Any, testdata_root: Path) -> Any:
297
+ """Recursively resolve ``{testdata}`` in JSON-compatible parameter values."""
298
+ if isinstance(value, str) and value.startswith("{testdata}"):
299
+ suffix = value[len("{testdata}") :].lstrip("/\\")
300
+ return str(testdata_root / Path(suffix))
301
+ if isinstance(value, list):
302
+ return [_resolve_testdata_value(item, testdata_root) for item in value]
303
+ if isinstance(value, dict):
304
+ return {key: _resolve_testdata_value(item, testdata_root) for key, item in value.items()}
305
+ return value
306
+
307
+
308
+ def _serialize_testdata_value(value: Any, testdata_root: Path) -> Any:
309
+ """Replace packaged testdata paths with the portable ``{testdata}`` token."""
310
+ if isinstance(value, str):
311
+ path = Path(value).expanduser()
312
+ try:
313
+ relative = path.resolve().relative_to(testdata_root)
314
+ except (OSError, ValueError):
315
+ return value
316
+ return "{testdata}/" + relative.as_posix()
317
+ if isinstance(value, list):
318
+ return [_serialize_testdata_value(item, testdata_root) for item in value]
319
+ if isinstance(value, dict):
320
+ return {key: _serialize_testdata_value(item, testdata_root) for key, item in value.items()}
321
+ return value
322
+
323
+
324
+ def _parse_workflow_port(
325
+ raw_port: object, index: int, graph: WorkflowGraph, registry: NodeRegistry, direction: str
326
+ ) -> WorkflowPort:
327
+ if not isinstance(raw_port, Mapping):
328
+ raise WorkflowPersistenceError(f"Workflow {direction} at index {index} must be an object")
329
+ name = _required_string(raw_port, "name", f"workflow {direction} at index {index}")
330
+ node_id = _required_string(raw_port, "node_id", f"workflow {direction} {name!r}")
331
+ node_port = _required_string(raw_port, "node_port", f"workflow {direction} {name!r}")
332
+ node = graph.require_node(node_id)
333
+ definition = registry.require(node.definition_id)
334
+ port = (
335
+ definition.input_for_instance(node_port, node)
336
+ if direction == "input"
337
+ else definition.output_for_instance(node_port, node)
338
+ )
339
+ if port is None:
340
+ raise WorkflowPersistenceError(
341
+ f"Workflow {direction} {name!r} references unknown port {node_id!r}.{node_port!r}"
342
+ )
343
+ workflow_direction = PortDirection.INPUT if direction == "input" else PortDirection.OUTPUT
344
+ return WorkflowPort(
345
+ name, node_id, node_port, workflow_direction, port.data_type, None, port.is_param
346
+ )
347
+
348
+
349
+ def _parse_edge(raw_edge: object, index: int) -> WorkflowEdge:
350
+ if not isinstance(raw_edge, Mapping):
351
+ raise WorkflowPersistenceError(f"Edge at index {index} must be an object")
352
+ context = f"edge at index {index}"
353
+ return WorkflowEdge(
354
+ _required_string(raw_edge, "source_node", context),
355
+ _required_string(raw_edge, "source_port", context),
356
+ _required_string(raw_edge, "target_node", context),
357
+ _required_string(raw_edge, "target_port", context),
358
+ )
359
+
360
+
361
+ def _required_string(payload: Mapping[str, Any], key: str, context: str) -> str:
362
+ value = payload.get(key)
363
+ if not isinstance(value, str) or not value:
364
+ raise WorkflowPersistenceError(f"{context.capitalize()} requires non-empty string {key!r}")
365
+ return value
366
+
367
+
368
+ def _coordinate(value: object, node_id: str, axis: str) -> float:
369
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
370
+ raise WorkflowPersistenceError(f"Position {axis!r} for node {node_id!r} must be a number")
371
+ return value
372
+
373
+
374
+ def _apply_parameter_overrides(
375
+ workflow: WorkflowGraph, overrides: Mapping[str, Mapping[str, Any]]
376
+ ) -> None:
377
+ if not isinstance(overrides, Mapping):
378
+ raise WorkflowPersistenceError("Parameter overrides must be a mapping by node ID")
379
+ for node_id, parameters in overrides.items():
380
+ if not isinstance(node_id, str):
381
+ raise WorkflowPersistenceError("Parameter override node IDs must be strings")
382
+ node = workflow.get_node(node_id)
383
+ if node is None:
384
+ raise WorkflowPersistenceError(
385
+ f"Parameter overrides reference unknown node {node_id!r}"
386
+ )
387
+ if not isinstance(parameters, Mapping) or not all(
388
+ isinstance(name, str) for name in parameters
389
+ ):
390
+ raise WorkflowPersistenceError(
391
+ f"Parameter overrides for node {node_id!r} must be a mapping with string keys"
392
+ )
393
+ node.parameters.update(deepcopy(dict(parameters)))
@@ -0,0 +1,170 @@
1
+ """Deterministic layout helpers for directed workflow graphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from flowgraph.application.workflow_core import WorkflowGraph
8
+
9
+ WORKFLOW_LAYER_SPACING = 360.0
10
+ """Horizontal distance between successive dependency layers in pixels."""
11
+
12
+ WORKFLOW_NODE_SPACING = 180.0
13
+ """Vertical distance between nodes in one dependency layer in pixels."""
14
+
15
+
16
+ def organize_workflow_nodes(
17
+ workflow: WorkflowGraph,
18
+ *,
19
+ layer_spacing: float = WORKFLOW_LAYER_SPACING,
20
+ node_spacing: float = WORKFLOW_NODE_SPACING,
21
+ ) -> dict[str, tuple[float, float]]:
22
+ """Return stable left-to-right positions for every node in a workflow.
23
+
24
+ Nodes occupy a layer one greater than the deepest upstream dependency. The
25
+ layout explicitly detects crossing edges and deterministically swaps pairs
26
+ of nodes in one layer when a permutation reduces the total crossing count.
27
+ Every layer starts at the same vertical origin, producing a regular grid.
28
+
29
+ Parameters
30
+ ----------
31
+ workflow:
32
+ Authoritative workflow graph to arrange.
33
+ layer_spacing:
34
+ Horizontal distance in pixels between dependency layers.
35
+ node_spacing:
36
+ Vertical distance in pixels between sibling nodes.
37
+
38
+ Returns
39
+ -------
40
+ dict[str, tuple[float, float]]
41
+ Positions keyed by node ID. An empty graph produces an empty mapping.
42
+ """
43
+ if layer_spacing <= 0 or node_spacing <= 0:
44
+ raise ValueError("Workflow layout spacing must be positive")
45
+
46
+ node_ids = sorted(node.id for node in workflow.nodes)
47
+ predecessors: dict[str, set[str]] = {node_id: set() for node_id in node_ids}
48
+ successors: dict[str, set[str]] = {node_id: set() for node_id in node_ids}
49
+ for edge in workflow.edges:
50
+ predecessors[edge.target_node].add(edge.source_node)
51
+ successors[edge.source_node].add(edge.target_node)
52
+
53
+ remaining_predecessors = {node_id: set(values) for node_id, values in predecessors.items()}
54
+ ready = sorted(node_id for node_id, values in remaining_predecessors.items() if not values)
55
+ layers: dict[str, int] = {node_id: 0 for node_id in ready}
56
+ ordered_node_ids: list[str] = []
57
+ while ready:
58
+ node_id = ready.pop(0)
59
+ ordered_node_ids.append(node_id)
60
+ for successor_id in sorted(successors[node_id]):
61
+ layers[successor_id] = max(layers.get(successor_id, 0), layers[node_id] + 1)
62
+ remaining_predecessors[successor_id].discard(node_id)
63
+ if not remaining_predecessors[successor_id]:
64
+ ready.append(successor_id)
65
+ ready.sort()
66
+
67
+ if len(ordered_node_ids) != len(node_ids):
68
+ raise ValueError("Workflow layout requires an acyclic graph")
69
+
70
+ nodes_by_layer: dict[int, list[str]] = defaultdict(list)
71
+ for node_id in ordered_node_ids:
72
+ nodes_by_layer[layers[node_id]].append(node_id)
73
+
74
+ for layer_node_ids in nodes_by_layer.values():
75
+ layer_node_ids.sort()
76
+ _reduce_edge_crossings(nodes_by_layer, workflow)
77
+
78
+ positions: dict[str, tuple[float, float]] = {}
79
+ for layer, layer_node_ids in nodes_by_layer.items():
80
+ for index, node_id in enumerate(layer_node_ids):
81
+ positions[node_id] = (layer * layer_spacing, index * node_spacing)
82
+ return positions
83
+
84
+
85
+ def _reduce_edge_crossings(
86
+ nodes_by_layer: dict[int, list[str]],
87
+ workflow: WorkflowGraph,
88
+ ) -> None:
89
+ """Swap layer-node pairs whenever the permutation reduces edge crossings."""
90
+ if len(nodes_by_layer) < 2:
91
+ return
92
+ crossing_count = _crossing_count(nodes_by_layer, workflow)
93
+ improved = True
94
+ while improved:
95
+ improved = False
96
+ for layer in sorted(nodes_by_layer):
97
+ node_ids = nodes_by_layer[layer]
98
+ for first_index in range(len(node_ids) - 1):
99
+ for second_index in range(first_index + 1, len(node_ids)):
100
+ node_ids[first_index], node_ids[second_index] = (
101
+ node_ids[second_index],
102
+ node_ids[first_index],
103
+ )
104
+ candidate_count = _crossing_count(nodes_by_layer, workflow)
105
+ if candidate_count < crossing_count:
106
+ crossing_count = candidate_count
107
+ improved = True
108
+ break
109
+ node_ids[first_index], node_ids[second_index] = (
110
+ node_ids[second_index],
111
+ node_ids[first_index],
112
+ )
113
+ if improved:
114
+ break
115
+ if improved:
116
+ break
117
+
118
+
119
+ def _crossing_count(nodes_by_layer: dict[int, list[str]], workflow: WorkflowGraph) -> int:
120
+ """Return the number of geometric crossings among the workflow's directed edges."""
121
+ positions = {
122
+ node_id: (float(layer), float(index))
123
+ for layer, node_ids in nodes_by_layer.items()
124
+ for index, node_id in enumerate(node_ids)
125
+ }
126
+ crossings = 0
127
+ for first_index, first_edge in enumerate(workflow.edges):
128
+ for second_edge in workflow.edges[first_index + 1 :]:
129
+ if {
130
+ first_edge.source_node,
131
+ first_edge.target_node,
132
+ }.intersection((second_edge.source_node, second_edge.target_node)):
133
+ continue
134
+ if _edges_cross(
135
+ first_edge.source_node,
136
+ first_edge.target_node,
137
+ second_edge.source_node,
138
+ second_edge.target_node,
139
+ positions,
140
+ ):
141
+ crossings += 1
142
+ return crossings
143
+
144
+
145
+ def _edges_cross(
146
+ first_source: str,
147
+ first_target: str,
148
+ second_source: str,
149
+ second_target: str,
150
+ positions: dict[str, tuple[float, float]],
151
+ ) -> bool:
152
+ """Return whether two directed edge segments strictly intersect on the canvas."""
153
+ first_start, first_end = positions[first_source], positions[first_target]
154
+ second_start, second_end = positions[second_source], positions[second_target]
155
+ overlap_start = max(first_start[0], second_start[0])
156
+ overlap_end = min(first_end[0], second_end[0])
157
+ if overlap_start >= overlap_end:
158
+ return False
159
+ first_difference_at_start = _edge_y(first_start, first_end, overlap_start) - _edge_y(
160
+ second_start, second_end, overlap_start
161
+ )
162
+ first_difference_at_end = _edge_y(first_start, first_end, overlap_end) - _edge_y(
163
+ second_start, second_end, overlap_end
164
+ )
165
+ return first_difference_at_start * first_difference_at_end < 0
166
+
167
+
168
+ def _edge_y(start: tuple[float, float], end: tuple[float, float], x: float) -> float:
169
+ """Return the vertical coordinate for a point on an edge at horizontal coordinate ``x``."""
170
+ return start[1] + (end[1] - start[1]) * (x - start[0]) / (end[0] - start[0])