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