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,132 @@
|
|
|
1
|
+
"""JSON execution boundary for the browser-hosted FlowGraph WASM runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from io import BytesIO
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from PIL.Image import Image
|
|
13
|
+
|
|
14
|
+
from flowgraph.application.node_registry import create_node_registry
|
|
15
|
+
from flowgraph.application.workflow_cli import format_workflow_inspection, workflow_outputs
|
|
16
|
+
from flowgraph.application.workflow_core import (
|
|
17
|
+
NodeExecutionError,
|
|
18
|
+
WorkflowError,
|
|
19
|
+
WorkflowExecutor,
|
|
20
|
+
WorkflowRunResult,
|
|
21
|
+
)
|
|
22
|
+
from flowgraph.application.workflow_io import workflow_from_json
|
|
23
|
+
|
|
24
|
+
_REGISTRY = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def execute_workflow_json(workflow_json: str) -> str:
|
|
28
|
+
"""Execute one workflow JSON document and return a serialized response.
|
|
29
|
+
|
|
30
|
+
The function is independent from JavaScript and is the stable Python-side
|
|
31
|
+
entry point used by the persistent browser WASM worker. Runtime libraries
|
|
32
|
+
may remain warm in the surrounding WASM process while workflow state is
|
|
33
|
+
reconstructed for each request.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
global _REGISTRY
|
|
37
|
+
if _REGISTRY is None:
|
|
38
|
+
_REGISTRY = create_node_registry()
|
|
39
|
+
registry = _REGISTRY
|
|
40
|
+
workflow = workflow_from_json(workflow_json, registry, source_name="WASM workflow")
|
|
41
|
+
result = WorkflowExecutor(registry).run(workflow)
|
|
42
|
+
response = {
|
|
43
|
+
"status": "ok",
|
|
44
|
+
"execution_order": list(result.execution_order),
|
|
45
|
+
"outputs": _json_safe(workflow_outputs(workflow, result)),
|
|
46
|
+
"nodes": _serialize_result(result),
|
|
47
|
+
"display_inputs": _display_result(result.node_inputs),
|
|
48
|
+
"display_images": _display_images(result.node_inputs),
|
|
49
|
+
"projection": format_workflow_inspection(workflow, registry),
|
|
50
|
+
}
|
|
51
|
+
except Exception as error: # noqa: BLE001 - the worker boundary serializes all failures
|
|
52
|
+
response = _error_response(error)
|
|
53
|
+
return json.dumps(response, ensure_ascii=False, separators=(",", ":"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _serialize_result(result: WorkflowRunResult) -> dict[str, Any]:
|
|
57
|
+
"""Return JSON-safe retained inputs and outputs for one execution result."""
|
|
58
|
+
return {
|
|
59
|
+
"inputs": _json_safe(result.node_inputs),
|
|
60
|
+
"outputs": _json_safe(result.node_outputs),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _display_result(values: Mapping[str, Mapping[str, Any]]) -> dict[str, dict[str, str]]:
|
|
65
|
+
"""Return Python string representations for values shown in the web editor."""
|
|
66
|
+
return {
|
|
67
|
+
str(node_id): {str(port_name): str(value) for port_name, value in node_values.items()}
|
|
68
|
+
for node_id, node_values in values.items()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _display_images(
|
|
73
|
+
values: Mapping[str, Mapping[str, Any]],
|
|
74
|
+
) -> dict[str, dict[str, dict[str, Any]]]:
|
|
75
|
+
"""Return browser-ready PNG previews for Pillow image inputs."""
|
|
76
|
+
previews: dict[str, dict[str, dict[str, Any]]] = {}
|
|
77
|
+
for node_id, node_values in values.items():
|
|
78
|
+
node_previews: dict[str, dict[str, Any]] = {}
|
|
79
|
+
for port_name, value in node_values.items():
|
|
80
|
+
if not isinstance(value, Image):
|
|
81
|
+
continue
|
|
82
|
+
buffer = BytesIO()
|
|
83
|
+
value.save(buffer, format="PNG")
|
|
84
|
+
node_previews[str(port_name)] = {
|
|
85
|
+
"mime_type": "image/png",
|
|
86
|
+
"data": b64encode(buffer.getvalue()).decode("ascii"),
|
|
87
|
+
"width": value.width,
|
|
88
|
+
"height": value.height,
|
|
89
|
+
"mode": value.mode,
|
|
90
|
+
}
|
|
91
|
+
if node_previews:
|
|
92
|
+
previews[str(node_id)] = node_previews
|
|
93
|
+
return previews
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _error_response(error: Exception) -> dict[str, Any]:
|
|
97
|
+
"""Convert an execution or validation failure into a stable response."""
|
|
98
|
+
response: dict[str, Any] = {
|
|
99
|
+
"status": "error",
|
|
100
|
+
"error": {"type": type(error).__name__, "message": str(error)},
|
|
101
|
+
}
|
|
102
|
+
if isinstance(error, NodeExecutionError) and error.partial_result is not None:
|
|
103
|
+
response["partial"] = _serialize_result(error.partial_result)
|
|
104
|
+
if isinstance(error, WorkflowError) and getattr(error, "issues", None):
|
|
105
|
+
response["error"]["issues"] = [
|
|
106
|
+
{
|
|
107
|
+
"code": issue.code.value,
|
|
108
|
+
"message": issue.message,
|
|
109
|
+
"node_id": issue.node_id,
|
|
110
|
+
"edge_id": issue.edge_id,
|
|
111
|
+
}
|
|
112
|
+
for issue in error.issues
|
|
113
|
+
]
|
|
114
|
+
return response
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _json_safe(value: Any) -> Any:
|
|
118
|
+
"""Convert runtime values into deterministic JSON-compatible data."""
|
|
119
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
120
|
+
return value
|
|
121
|
+
if isinstance(value, Path):
|
|
122
|
+
return str(value)
|
|
123
|
+
if isinstance(value, Mapping):
|
|
124
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
125
|
+
if isinstance(value, (list, tuple, set)):
|
|
126
|
+
return [_json_safe(item) for item in value]
|
|
127
|
+
if hasattr(value, "mode") and hasattr(value, "size"):
|
|
128
|
+
return {"type": type(value).__name__, "mode": value.mode, "size": list(value.size)}
|
|
129
|
+
return {"type": type(value).__name__, "description": str(value)}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["execute_workflow_json"]
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Headless command-line inspection and execution for FlowGraph workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from flowgraph.application.workflow_core import (
|
|
11
|
+
NodeRegistry,
|
|
12
|
+
PortDefinition,
|
|
13
|
+
PortDirection,
|
|
14
|
+
WorkflowEdge,
|
|
15
|
+
WorkflowExecutor,
|
|
16
|
+
WorkflowGraph,
|
|
17
|
+
WorkflowRunResult,
|
|
18
|
+
)
|
|
19
|
+
from flowgraph.application.workflow_io import load_workflow
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WorkflowCliError(ValueError):
|
|
23
|
+
"""Raised when command-line workflow arguments or output cannot be handled."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_assignment(value: str, *, option: str) -> tuple[str, Any]:
|
|
27
|
+
"""Parse a ``name=value`` command-line assignment.
|
|
28
|
+
|
|
29
|
+
Values are decoded as JSON when possible. Values that are not valid JSON are
|
|
30
|
+
retained as strings, which makes unquoted paths and ordinary text convenient.
|
|
31
|
+
"""
|
|
32
|
+
name, separator, raw_value = value.partition("=")
|
|
33
|
+
if not separator or not name:
|
|
34
|
+
raise WorkflowCliError(f"{option} expects NAME=VALUE, got {value!r}")
|
|
35
|
+
try:
|
|
36
|
+
parsed = json.loads(raw_value)
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
parsed = raw_value
|
|
39
|
+
return name, parsed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_inputs(assignments: Sequence[str]) -> dict[str, Any]:
|
|
43
|
+
"""Parse workflow input assignments and reject duplicate names."""
|
|
44
|
+
result: dict[str, Any] = {}
|
|
45
|
+
for assignment in assignments:
|
|
46
|
+
name, value = parse_assignment(assignment, option="--input")
|
|
47
|
+
if name in result:
|
|
48
|
+
raise WorkflowCliError(f"Workflow input {name!r} was provided more than once")
|
|
49
|
+
result[name] = value
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_overrides(assignments: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
54
|
+
"""Parse ``node-id.parameter=value`` assignments into executor overrides."""
|
|
55
|
+
result: dict[str, dict[str, Any]] = {}
|
|
56
|
+
for assignment in assignments:
|
|
57
|
+
address, value = parse_assignment(assignment, option="--override")
|
|
58
|
+
node_id, separator, parameter = address.partition(".")
|
|
59
|
+
if not separator or not node_id or not parameter:
|
|
60
|
+
raise WorkflowCliError(
|
|
61
|
+
f"--override expects NODE-ID.PARAMETER=VALUE, got {assignment!r}"
|
|
62
|
+
)
|
|
63
|
+
if parameter in result.setdefault(node_id, {}):
|
|
64
|
+
raise WorkflowCliError(
|
|
65
|
+
f"Parameter override {node_id}.{parameter} was provided more than once"
|
|
66
|
+
)
|
|
67
|
+
result[node_id][parameter] = value
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def inspect_workflow(path: str | Path) -> str:
|
|
72
|
+
"""Load a workflow and return its inputs, outputs, and directed ASCII graph."""
|
|
73
|
+
from flowgraph.application.node_registry import create_node_registry
|
|
74
|
+
|
|
75
|
+
registry = create_node_registry()
|
|
76
|
+
workflow = load_workflow(path, registry)
|
|
77
|
+
return format_workflow_inspection(workflow, registry)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_workflow_inspection(workflow: WorkflowGraph, registry: NodeRegistry) -> str:
|
|
81
|
+
"""Format one workflow as a deterministic vertical tree inspection report.
|
|
82
|
+
|
|
83
|
+
Nodes are visited in stable topological order. Edges are rendered below the
|
|
84
|
+
output port that transports them, and every connected input is shown in the
|
|
85
|
+
target node's input section before its parameters and outputs.
|
|
86
|
+
"""
|
|
87
|
+
lines = ["Inputs:"]
|
|
88
|
+
if workflow.inputs:
|
|
89
|
+
lines.extend(
|
|
90
|
+
f" {port.name}: {port.data_type.label} ({port.node_id}.{port.node_port})"
|
|
91
|
+
for port in workflow.inputs
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
lines.append(" (none)")
|
|
95
|
+
|
|
96
|
+
lines.append("Outputs:")
|
|
97
|
+
if workflow.outputs:
|
|
98
|
+
lines.extend(
|
|
99
|
+
f" {port.name}: {port.data_type.label} ({port.node_id}.{port.node_port})"
|
|
100
|
+
for port in workflow.outputs
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
lines.append(" (none)")
|
|
104
|
+
|
|
105
|
+
lines.append("Workflow:")
|
|
106
|
+
if not workflow.nodes:
|
|
107
|
+
lines.append(" (empty)")
|
|
108
|
+
else:
|
|
109
|
+
lines.extend(_format_graph(workflow, registry, indent=" "))
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _format_graph(workflow: WorkflowGraph, registry: NodeRegistry, *, indent: str) -> list[str]:
|
|
114
|
+
"""Return a vertical tree rendering of *workflow* at *indent*.
|
|
115
|
+
|
|
116
|
+
Disconnected nodes are listed first. Connected nodes are then rendered from
|
|
117
|
+
stable graph roots. Before a shared target is rendered, its unresolved input
|
|
118
|
+
sources are rendered first so all of the target's connected inputs appear
|
|
119
|
+
together before its outputs. Unicode box-drawing characters make the
|
|
120
|
+
continuation of each connection explicit in terminal output.
|
|
121
|
+
"""
|
|
122
|
+
order = workflow.topological_order()
|
|
123
|
+
order_index = {node_id: index for index, node_id in enumerate(order)}
|
|
124
|
+
incoming = {node_id: [] for node_id in order}
|
|
125
|
+
outgoing = {node_id: [] for node_id in order}
|
|
126
|
+
for edge in workflow.edges:
|
|
127
|
+
if edge.source_node in outgoing and edge.target_node in incoming:
|
|
128
|
+
outgoing[edge.source_node].append(edge)
|
|
129
|
+
incoming[edge.target_node].append(edge)
|
|
130
|
+
for edges in incoming.values():
|
|
131
|
+
edges.sort(
|
|
132
|
+
key=lambda edge: (
|
|
133
|
+
order_index.get(edge.source_node, len(order)),
|
|
134
|
+
edge.source_port,
|
|
135
|
+
edge.target_port,
|
|
136
|
+
edge.source_node,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
for edges in outgoing.values():
|
|
140
|
+
edges.sort(
|
|
141
|
+
key=lambda edge: (
|
|
142
|
+
order_index.get(edge.target_node, len(order)),
|
|
143
|
+
edge.source_port,
|
|
144
|
+
edge.target_port,
|
|
145
|
+
edge.target_node,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
rendered: set[str] = set()
|
|
150
|
+
lines: list[str] = []
|
|
151
|
+
|
|
152
|
+
def render_node(
|
|
153
|
+
node_id: str,
|
|
154
|
+
indent: list[str],
|
|
155
|
+
connected_edge: WorkflowEdge | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
node = workflow.require_node(node_id)
|
|
158
|
+
definition = registry.require(node.definition_id)
|
|
159
|
+
ports = definition.ports_for_instance(node)
|
|
160
|
+
rendered.add(node_id)
|
|
161
|
+
lines.append(f"{indent[0]}{_node_header(node.definition_id, node.id, definition.label)}")
|
|
162
|
+
|
|
163
|
+
connected_port = None
|
|
164
|
+
if connected_edge is not None:
|
|
165
|
+
connected_port = next(
|
|
166
|
+
(
|
|
167
|
+
port
|
|
168
|
+
for port in ports
|
|
169
|
+
if port.direction is PortDirection.INPUT
|
|
170
|
+
and port.name == connected_edge.target_port
|
|
171
|
+
),
|
|
172
|
+
None,
|
|
173
|
+
)
|
|
174
|
+
if connected_port is not None:
|
|
175
|
+
lines.append(
|
|
176
|
+
f"{indent[1]}{_port_text('P' if connected_port.is_param else 'I', connected_port)}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
connected_input_names = {
|
|
180
|
+
edge.target_port for edge in incoming[node_id] if edge.source_node in rendered
|
|
181
|
+
}
|
|
182
|
+
if connected_port is not None:
|
|
183
|
+
connected_input_names.add(connected_port.name)
|
|
184
|
+
has_additional_connected_input = False
|
|
185
|
+
for edge in incoming[node_id]:
|
|
186
|
+
if connected_port is not None and edge.target_port == connected_port.name:
|
|
187
|
+
continue
|
|
188
|
+
target_port = definition.input_for_instance(edge.target_port, node)
|
|
189
|
+
if target_port is not None:
|
|
190
|
+
lines.append(
|
|
191
|
+
f"{_additional_input_prefix(indent)}"
|
|
192
|
+
f"{_port_text('P' if target_port.is_param else 'I', target_port)}"
|
|
193
|
+
)
|
|
194
|
+
has_additional_connected_input = True
|
|
195
|
+
if has_additional_connected_input:
|
|
196
|
+
indent = _continued_indent(indent)
|
|
197
|
+
for port in ports:
|
|
198
|
+
if (
|
|
199
|
+
port.direction is PortDirection.INPUT
|
|
200
|
+
and not port.is_param
|
|
201
|
+
and port.name not in connected_input_names
|
|
202
|
+
):
|
|
203
|
+
lines.append(f"{indent[2]}{_port_text('I', port)}")
|
|
204
|
+
for parameter in definition.parameters:
|
|
205
|
+
if parameter.name in connected_input_names:
|
|
206
|
+
continue
|
|
207
|
+
value = node.parameters.get(parameter.name, parameter.default)
|
|
208
|
+
lines.append(
|
|
209
|
+
f"{indent[2]}(P) {parameter.name} - {parameter.kind.label}: {_display_value(value)}"
|
|
210
|
+
)
|
|
211
|
+
for port in ports:
|
|
212
|
+
if port.direction is not PortDirection.OUTPUT:
|
|
213
|
+
continue
|
|
214
|
+
lines.append(f"{indent[2]}{_port_text('O', port)}")
|
|
215
|
+
for edge in outgoing[node_id]:
|
|
216
|
+
if edge.source_port != port.name:
|
|
217
|
+
continue
|
|
218
|
+
target = workflow.get_node(edge.target_node)
|
|
219
|
+
if target is None:
|
|
220
|
+
continue
|
|
221
|
+
if edge.target_node in rendered:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
unresolved_sources = [
|
|
225
|
+
candidate
|
|
226
|
+
for candidate in incoming[edge.target_node]
|
|
227
|
+
if candidate.source_node not in rendered and candidate.source_node != node_id
|
|
228
|
+
]
|
|
229
|
+
for candidate in unresolved_sources:
|
|
230
|
+
source_node = workflow.get_node(candidate.source_node)
|
|
231
|
+
if source_node is None or source_node.id in rendered:
|
|
232
|
+
continue
|
|
233
|
+
lines.append(f"{indent[3]}│")
|
|
234
|
+
render_node(
|
|
235
|
+
source_node.id,
|
|
236
|
+
_carried_indent(indent[3]),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if edge.target_node in rendered:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
lines.append(f"{indent[3]}│")
|
|
243
|
+
render_node(
|
|
244
|
+
edge.target_node,
|
|
245
|
+
_child_indent(indent[3]),
|
|
246
|
+
connected_edge=edge,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if node.subworkflow is not None:
|
|
250
|
+
lines.append(f"{indent[0]}subworkflow:")
|
|
251
|
+
if node.subworkflow.nodes:
|
|
252
|
+
lines.extend(_format_graph(node.subworkflow, registry, indent=f"{indent[0]} "))
|
|
253
|
+
else:
|
|
254
|
+
lines.append(f"{indent[0]} (empty)")
|
|
255
|
+
|
|
256
|
+
disconnected = [node_id for node_id in order if not incoming[node_id] and not outgoing[node_id]]
|
|
257
|
+
roots = [node_id for node_id in order if not incoming[node_id] and node_id not in disconnected]
|
|
258
|
+
remaining = [
|
|
259
|
+
node_id for node_id in order if node_id not in disconnected and node_id not in roots
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
top_level_groups = [*disconnected, *roots, *remaining]
|
|
263
|
+
for node_id in top_level_groups:
|
|
264
|
+
if node_id in rendered:
|
|
265
|
+
continue
|
|
266
|
+
if lines:
|
|
267
|
+
lines.append("")
|
|
268
|
+
render_node(node_id, [indent, indent, indent, " "])
|
|
269
|
+
return lines
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _child_indent(edge_base: str) -> list[str]:
|
|
273
|
+
"""Build indentation slots for a node reached by a primary edge."""
|
|
274
|
+
return [
|
|
275
|
+
f"{edge_base}│ ",
|
|
276
|
+
f"{edge_base}└─",
|
|
277
|
+
f"{edge_base} ",
|
|
278
|
+
f"{edge_base} ",
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _carried_indent(edge_base: str) -> list[str]:
|
|
283
|
+
"""Build indentation slots for a node reached as an additional source."""
|
|
284
|
+
return [
|
|
285
|
+
f"{edge_base}│ ",
|
|
286
|
+
f"{edge_base}│ ",
|
|
287
|
+
f"{edge_base}│ ",
|
|
288
|
+
f"{edge_base}│ ",
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _additional_input_prefix(indent: list[str]) -> str:
|
|
293
|
+
"""Return the prefix for an additional connected input on the current node."""
|
|
294
|
+
return f"{indent[3].split('│', 1)[0]}└────"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _continued_indent(indent: list[str]) -> list[str]:
|
|
298
|
+
"""Return indentation after an additional input closes its branch."""
|
|
299
|
+
return [slot.replace("│", " ") for slot in indent]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _node_header(definition_id: str, node_id: str, label: str) -> str:
|
|
303
|
+
"""Format a node definition/instance header."""
|
|
304
|
+
return f"({definition_id})[{node_id}] {label}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _port_text(kind: str, port: PortDefinition) -> str:
|
|
308
|
+
"""Format one resolved node port."""
|
|
309
|
+
return f"({kind}) {port.name} - {port.data_type.label}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def execute_cli_workflow(
|
|
313
|
+
path: str | Path,
|
|
314
|
+
*,
|
|
315
|
+
inputs: Mapping[str, Any] | None = None,
|
|
316
|
+
overrides: Mapping[str, Mapping[str, Any]] | None = None,
|
|
317
|
+
) -> tuple[WorkflowGraph, WorkflowRunResult]:
|
|
318
|
+
"""Load and execute a workflow using published inputs and parameter overrides."""
|
|
319
|
+
from flowgraph.application.node_registry import create_node_registry
|
|
320
|
+
|
|
321
|
+
registry = create_node_registry()
|
|
322
|
+
workflow = load_workflow(path, registry)
|
|
323
|
+
_apply_overrides(workflow, overrides or {})
|
|
324
|
+
injected = _resolve_inputs(workflow, inputs or {})
|
|
325
|
+
return workflow, WorkflowExecutor(registry).run(workflow, initial_inputs=injected)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def workflow_outputs(workflow: WorkflowGraph, result: WorkflowRunResult) -> dict[str, Any]:
|
|
329
|
+
"""Extract public workflow outputs from an execution result."""
|
|
330
|
+
return {
|
|
331
|
+
port.name: result.node_outputs[port.node_id][port.node_port] for port in workflow.outputs
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def format_workflow_outputs(outputs: Mapping[str, Any]) -> str:
|
|
336
|
+
"""Format workflow outputs for terminal display."""
|
|
337
|
+
lines = ["Outputs:"]
|
|
338
|
+
if not outputs:
|
|
339
|
+
return "\n".join([*lines, " (none)"])
|
|
340
|
+
lines.extend(f" {name}: {_display_value(value)}" for name, value in outputs.items())
|
|
341
|
+
return "\n".join(lines)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def write_json_output(outputs: Mapping[str, Any], path: str | Path) -> Path:
|
|
345
|
+
"""Write JSON-safe workflow outputs to *path* and return its resolved path."""
|
|
346
|
+
destination = Path(path).expanduser()
|
|
347
|
+
if not destination.parent.is_dir():
|
|
348
|
+
raise WorkflowCliError(f"Output directory does not exist: {destination.parent}")
|
|
349
|
+
try:
|
|
350
|
+
payload = _json_safe(outputs)
|
|
351
|
+
destination.write_text(
|
|
352
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
353
|
+
)
|
|
354
|
+
except (OSError, TypeError, ValueError) as error:
|
|
355
|
+
raise WorkflowCliError(
|
|
356
|
+
f"Could not write JSON output to '{destination}': {error}"
|
|
357
|
+
) from error
|
|
358
|
+
return destination.resolve()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _resolve_inputs(
|
|
362
|
+
workflow: WorkflowGraph, values: Mapping[str, Any]
|
|
363
|
+
) -> dict[str, dict[str, Any]]:
|
|
364
|
+
unknown = set(values).difference(port.name for port in workflow.inputs)
|
|
365
|
+
if unknown:
|
|
366
|
+
raise WorkflowCliError(f"Unknown workflow inputs: {sorted(unknown)!r}")
|
|
367
|
+
missing = [port.name for port in workflow.inputs if port.name not in values]
|
|
368
|
+
if missing:
|
|
369
|
+
raise WorkflowCliError(f"Missing required workflow inputs: {', '.join(missing)}")
|
|
370
|
+
injected: dict[str, dict[str, Any]] = {}
|
|
371
|
+
for port in workflow.inputs:
|
|
372
|
+
value = values[port.name]
|
|
373
|
+
if not port.data_type.accepts(value):
|
|
374
|
+
raise WorkflowCliError(
|
|
375
|
+
f"Workflow input {port.name!r} expected {port.data_type.label}, "
|
|
376
|
+
f"got {type(value).__name__}"
|
|
377
|
+
)
|
|
378
|
+
injected.setdefault(port.node_id, {})[port.node_port] = value
|
|
379
|
+
return injected
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _apply_overrides(workflow: WorkflowGraph, overrides: Mapping[str, Mapping[str, Any]]) -> None:
|
|
383
|
+
from flowgraph.application.node_registry import create_node_registry
|
|
384
|
+
|
|
385
|
+
registry = create_node_registry()
|
|
386
|
+
for node_id, parameters in overrides.items():
|
|
387
|
+
node = workflow.get_node(node_id)
|
|
388
|
+
if node is None:
|
|
389
|
+
raise WorkflowCliError(f"Parameter override references unknown node {node_id!r}")
|
|
390
|
+
definition = registry.require(node.definition_id)
|
|
391
|
+
known = {parameter.name for parameter in definition.parameters}
|
|
392
|
+
unknown = set(parameters).difference(known)
|
|
393
|
+
if unknown:
|
|
394
|
+
raise WorkflowCliError(f"Unknown parameters for node {node_id!r}: {sorted(unknown)!r}")
|
|
395
|
+
node.parameters.update(parameters)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _display_value(value: Any) -> str:
|
|
399
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
400
|
+
return repr(value)
|
|
401
|
+
return f"<{type(value).__name__}: {value}>"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _json_safe(value: Any) -> Any:
|
|
405
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
406
|
+
return value
|
|
407
|
+
if isinstance(value, Path):
|
|
408
|
+
return str(value)
|
|
409
|
+
if isinstance(value, Mapping):
|
|
410
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
411
|
+
if isinstance(value, (list, tuple, set)):
|
|
412
|
+
return [_json_safe(item) for item in value]
|
|
413
|
+
if hasattr(value, "mode") and hasattr(value, "size"):
|
|
414
|
+
return {"type": type(value).__name__, "mode": value.mode, "size": list(value.size)}
|
|
415
|
+
if type(value).__name__ == "MeshDocument":
|
|
416
|
+
return {"type": type(value).__name__, "description": str(value)}
|
|
417
|
+
return repr(value)
|