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,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])
|