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,196 @@
1
+ """Workflow node for executing user-provided Python code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import inspect
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from flowgraph.application.workflow_core import (
11
+ NodeDefinition,
12
+ NodeInstance,
13
+ ParameterDefinition,
14
+ PortDefinition,
15
+ PortDirection,
16
+ )
17
+
18
+ from .data_types import ANY, BOOLEAN, FLOAT, INTEGER, LIST_FLOAT, LIST_INT, LIST_STR, STRING
19
+
20
+ DEFAULT_CODE = """def Execute(input1, input2, input3) -> tuple[Any, Any, Any]:
21
+ return (input1, input2, input3)
22
+ """
23
+
24
+ _ANNOTATION_TYPES = {
25
+ "Any": ANY,
26
+ "bool": BOOLEAN,
27
+ "float": FLOAT,
28
+ "int": INTEGER,
29
+ "list[float]": LIST_FLOAT,
30
+ "list[int]": LIST_INT,
31
+ "list[str]": LIST_STR,
32
+ "str": STRING,
33
+ }
34
+
35
+
36
+ def _annotation_name(annotation: ast.expr | None) -> str:
37
+ """Return the supported source spelling for one function annotation."""
38
+ if annotation is None:
39
+ return "Any"
40
+ if isinstance(annotation, ast.Name):
41
+ return annotation.id
42
+ if (
43
+ isinstance(annotation, ast.Subscript)
44
+ and isinstance(annotation.value, ast.Name)
45
+ and annotation.value.id == "list"
46
+ and isinstance(annotation.slice, ast.Name)
47
+ ):
48
+ return f"list[{annotation.slice.id}]"
49
+ return ast.unparse(annotation)
50
+
51
+
52
+ def _execute_function(source: str) -> ast.FunctionDef:
53
+ """Parse *source* and return its top-level ``Execute`` function declaration."""
54
+ if not isinstance(source, str) or not source.strip():
55
+ raise ValueError("The user function code must be non-empty Python source")
56
+ try:
57
+ module = ast.parse(source, filename="<flowgraph user-function>", mode="exec")
58
+ except SyntaxError as error:
59
+ raise ValueError(f"The user function code is invalid Python: {error.msg}") from error
60
+ functions = [
61
+ statement
62
+ for statement in module.body
63
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
64
+ and statement.name == "Execute"
65
+ ]
66
+ if len(functions) != 1:
67
+ raise ValueError(
68
+ "The user function code must define exactly one top-level Execute function"
69
+ )
70
+ function = functions[0]
71
+ if isinstance(function, ast.AsyncFunctionDef):
72
+ raise TypeError("Execute must be a regular function, not an async function")
73
+ return function
74
+
75
+
76
+ def _input_ports(source: str) -> tuple[PortDefinition, ...]:
77
+ """Extract validated typed input ports from a user ``Execute`` declaration."""
78
+ function = _execute_function(source)
79
+ arguments = function.args
80
+ if arguments.vararg is not None or arguments.kwarg is not None or arguments.kwonlyargs:
81
+ raise ValueError(
82
+ "Execute may use only positional parameters; *args and keyword-only parameters are unsupported"
83
+ )
84
+ inputs = [*arguments.posonlyargs, *arguments.args]
85
+ ports: list[PortDefinition] = []
86
+ for argument in inputs:
87
+ annotation_name = _annotation_name(argument.annotation)
88
+ data_type = _ANNOTATION_TYPES.get(annotation_name)
89
+ if data_type is None:
90
+ supported = ", ".join(_ANNOTATION_TYPES)
91
+ raise ValueError(
92
+ f"Unsupported annotation for Execute parameter {argument.arg!r}: "
93
+ f"{annotation_name!r}. Supported annotations are {supported}."
94
+ )
95
+ ports.append(
96
+ PortDefinition(
97
+ argument.arg, PortDirection.INPUT, data_type, argument.arg, required=False
98
+ )
99
+ )
100
+ return tuple(ports)
101
+
102
+
103
+ def _output_ports(source: str) -> tuple[PortDefinition, ...]:
104
+ """Extract validated typed output ports from a user ``Execute`` return annotation."""
105
+ annotation = _execute_function(source).returns
106
+ if not (
107
+ isinstance(annotation, ast.Subscript)
108
+ and isinstance(annotation.value, ast.Name)
109
+ and annotation.value.id == "tuple"
110
+ ):
111
+ raise ValueError("Execute must declare a fixed-length tuple return annotation")
112
+ elements = (
113
+ list(annotation.slice.elts)
114
+ if isinstance(annotation.slice, ast.Tuple)
115
+ else [annotation.slice]
116
+ )
117
+ ports: list[PortDefinition] = []
118
+ for index, element in enumerate(elements, start=1):
119
+ annotation_name = _annotation_name(element)
120
+ data_type = _ANNOTATION_TYPES.get(annotation_name)
121
+ if data_type is None:
122
+ supported = ", ".join(_ANNOTATION_TYPES)
123
+ raise ValueError(
124
+ f"Unsupported annotation for Execute output {index}: {annotation_name!r}. "
125
+ f"Supported annotations are {supported}."
126
+ )
127
+ ports.append(
128
+ PortDefinition(f"output{index}", PortDirection.OUTPUT, data_type, f"Output {index}")
129
+ )
130
+ return tuple(ports)
131
+
132
+
133
+ def _ports_for_instance(instance: NodeInstance) -> tuple[PortDefinition, ...]:
134
+ """Resolve User Function inputs and outputs from an instance's source code."""
135
+ source = instance.parameters.get("code", DEFAULT_CODE)
136
+ return _input_ports(source) + _output_ports(source)
137
+
138
+
139
+ def _execute_user_code(
140
+ inputs: Mapping[str, Any], parameters: Mapping[str, Any]
141
+ ) -> Mapping[str, Any]:
142
+ """Compile and run the user's ``Execute`` function in a trusted local namespace."""
143
+ code = parameters.get("code", DEFAULT_CODE)
144
+ input_ports = _input_ports(code)
145
+ output_ports = _output_ports(code)
146
+
147
+ namespace: dict[str, Any] = {"__builtins__": __builtins__, "Any": Any}
148
+ compiled = compile(code, "<flowgraph user-function>", "exec")
149
+ exec(compiled, namespace) # noqa: S102 - this node intentionally runs trusted user code
150
+ function = namespace.get("Execute")
151
+ if not callable(function):
152
+ raise TypeError("The user function code must define callable Execute")
153
+
154
+ signature = inspect.signature(function)
155
+ function_parameters = list(signature.parameters.values())
156
+ if [parameter.name for parameter in function_parameters] != [
157
+ port.name for port in input_ports
158
+ ] or any(
159
+ parameter.kind
160
+ not in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
161
+ for parameter in function_parameters
162
+ ):
163
+ raise ValueError("Execute must not be reassigned after its validated function declaration")
164
+
165
+ result = function(*(inputs.get(port.name) for port in input_ports))
166
+ if not isinstance(result, tuple) or len(result) != len(output_ports):
167
+ raise TypeError(
168
+ f"Execute must return a tuple containing exactly {len(output_ports)} outputs"
169
+ )
170
+ return {port.name: value for port, value in zip(output_ports, result, strict=True)}
171
+
172
+
173
+ USER_FUNCTION = NodeDefinition(
174
+ id="user-function",
175
+ icon="mdi-code-braces",
176
+ label="User Function",
177
+ description="Runs trusted local Python code defining a typed Execute function.",
178
+ ports=(),
179
+ executor=_execute_user_code,
180
+ instance_port_resolver=_ports_for_instance,
181
+ parameters=(
182
+ ParameterDefinition(
183
+ "code",
184
+ STRING,
185
+ "Execute code",
186
+ DEFAULT_CODE,
187
+ "def Execute(value: float): ...",
188
+ port=False,
189
+ ),
190
+ ),
191
+ presentation="code",
192
+ )
193
+
194
+ AVAILABLE_NODES = (USER_FUNCTION,)
195
+
196
+ __all__ = ["AVAILABLE_NODES", "DEFAULT_CODE", "USER_FUNCTION"]
@@ -0,0 +1,176 @@
1
+ """Composite node that executes an editable nested workflow graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from flowgraph.application.workflow_core import (
9
+ NodeDefinition,
10
+ NodeInstance,
11
+ NodeRegistry,
12
+ PortDefinition,
13
+ PortDirection,
14
+ WorkflowExecutor,
15
+ WorkflowGraph,
16
+ WorkflowRunResult,
17
+ )
18
+
19
+ from .data_types import ANY, FLOAT, INTEGER, LIST_ANY, LIST_FLOAT, LIST_INT, LIST_STR, STRING
20
+
21
+ RUN_WORKFLOW_PORTS = (PortDefinition("result", PortDirection.OUTPUT, ANY, "Result"),)
22
+ BATCH_WORKFLOW_PORTS = (PortDefinition("result", PortDirection.OUTPUT, LIST_ANY, "Results"),)
23
+
24
+ _BATCH_LIST_TYPES = {
25
+ STRING.id: LIST_STR,
26
+ INTEGER.id: LIST_INT,
27
+ FLOAT.id: LIST_FLOAT,
28
+ }
29
+
30
+
31
+ def _nested_ports(instance: NodeInstance) -> tuple[PortDefinition, ...]:
32
+ """Return ports exported by a composite node's child workflow."""
33
+ graph = instance.subworkflow
34
+ if graph is None:
35
+ return RUN_WORKFLOW_PORTS
36
+ exported = tuple(
37
+ PortDefinition(port.name, port.direction, port.data_type, port.display_label)
38
+ for port in (*graph.inputs, *graph.outputs)
39
+ )
40
+ names = {(port.direction, port.name) for port in RUN_WORKFLOW_PORTS}
41
+ return RUN_WORKFLOW_PORTS + tuple(
42
+ port for port in exported if (port.direction, port.name) not in names
43
+ )
44
+
45
+
46
+ def _execute_workflow(
47
+ instance: NodeInstance,
48
+ inputs: Mapping[str, Any],
49
+ _parameters: Mapping[str, Any],
50
+ registry: NodeRegistry,
51
+ ) -> Mapping[str, Any]:
52
+ """Execute the child graph with values supplied to its public inputs."""
53
+ graph = instance.subworkflow
54
+ if graph is None:
55
+ raise ValueError("The nested workflow is not initialized")
56
+ graph.sync_boundary_nodes(registry)
57
+ injected = {port.node_id: {port.node_port: inputs[port.name]} for port in graph.inputs}
58
+ nested_result: WorkflowRunResult = WorkflowExecutor(registry).run(
59
+ graph, initial_inputs=injected
60
+ )
61
+ outputs = {
62
+ port.name: nested_result.node_outputs[port.node_id][port.node_port]
63
+ for port in graph.outputs
64
+ }
65
+ return {"result": nested_result, **outputs}
66
+
67
+
68
+ def _batch_ports(instance: NodeInstance) -> tuple[PortDefinition, ...]:
69
+ """Return broadcasting inputs and aggregated outputs of a child workflow."""
70
+ graph = instance.subworkflow
71
+ if graph is None:
72
+ return BATCH_WORKFLOW_PORTS
73
+ inputs = tuple(
74
+ PortDefinition(
75
+ port.name,
76
+ PortDirection.INPUT,
77
+ port.data_type,
78
+ port.display_label,
79
+ accepted_data_types=(list_type,)
80
+ if (list_type := _BATCH_LIST_TYPES.get(port.data_type.id))
81
+ else (),
82
+ )
83
+ for port in graph.inputs
84
+ )
85
+ outputs = tuple(
86
+ PortDefinition(
87
+ port.name,
88
+ PortDirection.OUTPUT,
89
+ _BATCH_LIST_TYPES.get(port.data_type.id, LIST_ANY),
90
+ port.display_label,
91
+ )
92
+ for port in graph.outputs
93
+ )
94
+ names = {(port.direction, port.name) for port in BATCH_WORKFLOW_PORTS}
95
+ return BATCH_WORKFLOW_PORTS + tuple(
96
+ port for port in (*inputs, *outputs) if (port.direction, port.name) not in names
97
+ )
98
+
99
+
100
+ def _execute_batch_workflow(
101
+ instance: NodeInstance,
102
+ inputs: Mapping[str, Any],
103
+ _parameters: Mapping[str, Any],
104
+ registry: NodeRegistry,
105
+ ) -> Mapping[str, Any]:
106
+ """Execute a child graph for each list entry and aggregate its public outputs.
107
+
108
+ Lists define the batch dimension and must all have the same length. Scalar
109
+ inputs broadcast to every child execution.
110
+ """
111
+ graph = instance.subworkflow
112
+ if graph is None:
113
+ raise ValueError("The nested workflow is not initialized")
114
+ graph.sync_boundary_nodes(registry)
115
+ list_inputs = {name: value for name, value in inputs.items() if isinstance(value, list)}
116
+ if not list_inputs:
117
+ raise ValueError("Batch Workflow requires at least one list input")
118
+ lengths = {len(value) for value in list_inputs.values()}
119
+ if len(lengths) != 1:
120
+ details = ", ".join(f"{name}={len(value)}" for name, value in list_inputs.items())
121
+ raise ValueError(f"Batch Workflow list inputs must have equal lengths: {details}")
122
+
123
+ batch_size = lengths.pop()
124
+ results: list[WorkflowRunResult] = []
125
+ outputs = {port.name: [] for port in graph.outputs}
126
+ executor = WorkflowExecutor(registry)
127
+ for index in range(batch_size):
128
+ injected = {
129
+ port.node_id: {
130
+ port.node_port: inputs[port.name][index]
131
+ if isinstance(inputs[port.name], list)
132
+ else inputs[port.name]
133
+ }
134
+ for port in graph.inputs
135
+ }
136
+ result = executor.run(graph, initial_inputs=injected)
137
+ results.append(result)
138
+ for port in graph.outputs:
139
+ outputs[port.name].append(result.node_outputs[port.node_id][port.node_port])
140
+ return {"result": results, **outputs}
141
+
142
+
143
+ def _empty_workflow() -> WorkflowGraph:
144
+ """Create an independent empty graph for one composite node instance."""
145
+ return WorkflowGraph()
146
+
147
+
148
+ RUN_WORKFLOW = NodeDefinition(
149
+ id="run-workflow",
150
+ icon="mdi-play-network-outline",
151
+ label="Run Full Workflow",
152
+ description="Executes and exports the inputs and outputs of an editable nested workflow.",
153
+ ports=(RUN_WORKFLOW_PORTS),
154
+ executor=lambda _inputs, _parameters: {},
155
+ instance_port_resolver=_nested_ports,
156
+ instance_executor=_execute_workflow,
157
+ subworkflow_factory=_empty_workflow,
158
+ presentation="workflow",
159
+ )
160
+
161
+ BATCH_WORKFLOW = NodeDefinition(
162
+ id="batch-workflow",
163
+ icon="mdi-format-list-numbered",
164
+ label="Batch Workflow",
165
+ description="Executes an editable nested workflow once for each list entry.",
166
+ ports=(BATCH_WORKFLOW_PORTS),
167
+ executor=lambda _inputs, _parameters: {},
168
+ instance_port_resolver=_batch_ports,
169
+ instance_executor=_execute_batch_workflow,
170
+ subworkflow_factory=_empty_workflow,
171
+ presentation="workflow",
172
+ )
173
+
174
+ AVAILABLE_NODES = (RUN_WORKFLOW, BATCH_WORKFLOW)
175
+
176
+ __all__ = ["AVAILABLE_NODES", "BATCH_WORKFLOW", "RUN_WORKFLOW"]
@@ -0,0 +1,78 @@
1
+ """Workflow boundary nodes that define public inputs and outputs."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from flowgraph.application.workflow_core import (
7
+ NodeDefinition,
8
+ ParameterDefinition,
9
+ PortDefinition,
10
+ PortDirection,
11
+ )
12
+
13
+ from .data_types import ANY, ParameterKind
14
+
15
+
16
+ def _workflow_input(inputs: Mapping[str, Any], _parameters: Mapping[str, Any]) -> Mapping[str, Any]:
17
+ """Pass a supplied workflow input through to downstream nodes."""
18
+ return {"value": inputs["value"]}
19
+
20
+
21
+ WORKFLOW_INPUT = NodeDefinition(
22
+ id="workflow-input",
23
+ icon="mdi-import",
24
+ label="Workflow Input",
25
+ description="Defines a named public input for the workflow.",
26
+ ports=(
27
+ PortDefinition("value", PortDirection.INPUT, ANY, "Value"),
28
+ PortDefinition("value", PortDirection.OUTPUT, ANY, "Value"),
29
+ ),
30
+ executor=_workflow_input,
31
+ parameters=(
32
+ ParameterDefinition(
33
+ "name",
34
+ ParameterKind.TEXT,
35
+ "Input name",
36
+ "input",
37
+ "Public workflow input name",
38
+ port=False,
39
+ ),
40
+ ),
41
+ color="orange",
42
+ )
43
+
44
+
45
+ def _workflow_output(
46
+ _inputs: Mapping[str, Any], _parameters: Mapping[str, Any]
47
+ ) -> Mapping[str, Any]:
48
+ """Pass a value through to the public workflow output."""
49
+ return {"value": _inputs["value"]}
50
+
51
+
52
+ WORKFLOW_OUTPUT = NodeDefinition(
53
+ id="workflow-output",
54
+ icon="mdi-export",
55
+ label="Workflow Output",
56
+ description="Defines a named public output for the workflow.",
57
+ ports=(
58
+ PortDefinition("value", PortDirection.INPUT, ANY, "Value"),
59
+ PortDefinition("value", PortDirection.OUTPUT, ANY, "Value"),
60
+ ),
61
+ executor=_workflow_output,
62
+ parameters=(
63
+ ParameterDefinition(
64
+ "name",
65
+ ParameterKind.TEXT,
66
+ "Output name",
67
+ "output",
68
+ "Public workflow output name",
69
+ port=False,
70
+ ),
71
+ ),
72
+ color="orange",
73
+ )
74
+
75
+
76
+ AVAILABLE_NODES = (WORKFLOW_INPUT, WORKFLOW_OUTPUT)
77
+
78
+ __all__ = ["AVAILABLE_NODES", "WORKFLOW_INPUT", "WORKFLOW_OUTPUT"]
@@ -0,0 +1,159 @@
1
+ """Workflow nodes that write FlowGraph domain objects to external files."""
2
+
3
+ import logging
4
+ from collections.abc import Mapping
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from flowgraph.adapters.readers import MESH_DOCUMENT
9
+ from flowgraph.application.workflow_core import (
10
+ NodeDefinition,
11
+ ParameterDefinition,
12
+ PortDefinition,
13
+ PortDirection,
14
+ )
15
+ from flowgraph.domain.mesh_document import MeshDocument
16
+
17
+ from .data_types import ParameterKind
18
+
19
+ LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+ class MeshWriteError(RuntimeError):
23
+ """Raised when a mesh document cannot be written by a writer adapter."""
24
+
25
+
26
+ def _validate_destination(
27
+ inputs: Mapping[str, Any], parameters: Mapping[str, Any], writer_name: str
28
+ ) -> Path:
29
+ """Return a usable destination or raise a writer-specific error."""
30
+ configured_path = inputs.get("path", parameters.get("path", ""))
31
+ destination = Path(configured_path).expanduser()
32
+
33
+ if not destination.name:
34
+ raise MeshWriteError("Mesh filename must not be empty")
35
+ if not destination.suffix:
36
+ raise MeshWriteError(
37
+ f"Mesh filename has no extension; {writer_name} cannot select a writer: {destination}"
38
+ )
39
+ if not destination.parent.is_dir():
40
+ raise MeshWriteError(
41
+ f"Mesh output directory does not exist or is not a directory: {destination.parent}"
42
+ )
43
+ return destination
44
+
45
+
46
+ def _write_muscat(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
47
+ """Write a mesh document using Muscat's extension-aware universal writer."""
48
+
49
+ destination = _validate_destination(inputs, parameters, "Muscat")
50
+ document: MeshDocument = inputs["mesh"]
51
+
52
+ try:
53
+ from Muscat.IO.UniversalWriter import InitAllWriters, WriteMesh
54
+
55
+ LOGGER.debug("Initialising Muscat writers for %s", destination)
56
+ InitAllWriters()
57
+ LOGGER.info("Writing mesh document with Muscat: %s", destination)
58
+ WriteMesh(destination, document.mesh)
59
+ except Exception as error:
60
+ LOGGER.exception("Muscat failed while writing %s", destination)
61
+ raise MeshWriteError(f"Muscat could not write '{destination}': {error}") from error
62
+
63
+ LOGGER.info("Muscat wrote mesh document to %s", destination.resolve())
64
+ return {}
65
+
66
+
67
+ def _write_meshio(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
68
+ """Convert a Muscat mesh through its meshio bridge and write it with meshio."""
69
+ destination = _validate_destination(inputs, parameters, "meshio")
70
+ document: MeshDocument = inputs["mesh"]
71
+
72
+ try:
73
+ import meshio
74
+ from Muscat.Bridges.MeshIOBridge import MeshToMeshIO
75
+
76
+ LOGGER.info("Writing mesh document with meshio: %s", destination)
77
+ meshio.write(destination, MeshToMeshIO(document.mesh))
78
+ except Exception as error:
79
+ LOGGER.exception("meshio failed while writing %s", destination)
80
+ raise MeshWriteError(f"meshio could not write '{destination}': {error}") from error
81
+
82
+ LOGGER.info("meshio wrote mesh document to %s", destination.resolve())
83
+ return {}
84
+
85
+
86
+ def _write_meshlane(inputs: Mapping[str, Any], parameters: Mapping[str, Any]) -> Mapping[str, Any]:
87
+ """Convert a Muscat mesh and write it with the optional MeshLane adapter."""
88
+ destination = _validate_destination(inputs, parameters, "MeshLane")
89
+ document: MeshDocument = inputs["mesh"]
90
+
91
+ try:
92
+ import meshlane
93
+ except ImportError as error:
94
+ raise MeshWriteError(
95
+ "MeshLane is not installed; install FlowGraph with the 'meshlane' extra"
96
+ ) from error
97
+
98
+ try:
99
+ from Muscat.Bridges.MeshlaneBridge import MeshToMeshlane
100
+ except ImportError as error:
101
+ raise MeshWriteError(
102
+ "This Muscat installation has no MeshLane bridge; install FlowGraph with "
103
+ "the 'meshlane' extra"
104
+ ) from error
105
+
106
+ try:
107
+ LOGGER.info("Writing mesh document with MeshLane: %s", destination)
108
+ meshlane.write(destination, MeshToMeshlane(document.mesh))
109
+ except Exception as error:
110
+ LOGGER.exception("MeshLane failed while writing %s", destination)
111
+ raise MeshWriteError(f"MeshLane could not write '{destination}': {error}") from error
112
+
113
+ LOGGER.info("MeshLane wrote mesh document to %s", destination.resolve())
114
+ return {}
115
+
116
+
117
+ WRITE_MUSCAT = NodeDefinition(
118
+ id="write-muscat",
119
+ icon="mdi-file-export-outline",
120
+ label="Write Mesh (Muscat)",
121
+ description="Writes the canonical mesh document to a filename selected by extension.",
122
+ ports=(PortDefinition("mesh", PortDirection.INPUT, MESH_DOCUMENT, "Mesh"),),
123
+ executor=_write_muscat,
124
+ parameters=(
125
+ ParameterDefinition(
126
+ "path", ParameterKind.FILE, "File path", "", "/path/to/mesh", port=True
127
+ ),
128
+ ),
129
+ )
130
+
131
+
132
+ WRITE_MESHIO = NodeDefinition(
133
+ id="write-meshio",
134
+ icon="mdi-file-swap-outline",
135
+ label="Write Mesh (MeshIO)",
136
+ description="Writes the canonical mesh document with meshio through Muscat's bridge.",
137
+ ports=(PortDefinition("mesh", PortDirection.INPUT, MESH_DOCUMENT, "Mesh"),),
138
+ executor=_write_meshio,
139
+ parameters=(
140
+ ParameterDefinition(
141
+ "path", ParameterKind.FILE, "File path", "", "/path/to/mesh", port=True
142
+ ),
143
+ ),
144
+ )
145
+
146
+
147
+ WRITE_MESHLANE = NodeDefinition(
148
+ id="write-meshlane",
149
+ icon="mdi-file-swap-outline",
150
+ label="Write Mesh (MeshLane)",
151
+ description="Writes the canonical mesh document with the optional MeshLane adapter.",
152
+ ports=(PortDefinition("mesh", PortDirection.INPUT, MESH_DOCUMENT, "Mesh"),),
153
+ executor=_write_meshlane,
154
+ parameters=(
155
+ ParameterDefinition(
156
+ "path", ParameterKind.FILE, "File path", "", "/path/to/mesh", port=True
157
+ ),
158
+ ),
159
+ )
@@ -0,0 +1 @@
1
+ """Application commands, history, validation, and workflow services."""
@@ -0,0 +1,14 @@
1
+ """Headless built-in workflow node catalog and starter graph."""
2
+
3
+ from flowgraph.adapters import ADAPTERS
4
+ from flowgraph.application.workflow_core import NodeRegistry
5
+
6
+ __all__ = ["create_node_registry"]
7
+
8
+
9
+ def create_node_registry() -> NodeRegistry:
10
+ """Return all built-in nodes whose executors are safe for headless use."""
11
+ registry = NodeRegistry()
12
+ for definition in ADAPTERS.node_definitions:
13
+ registry.register(definition)
14
+ return registry
@@ -0,0 +1,63 @@
1
+ """Explicit automatic conversions permitted between workflow port types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from flowgraph.application.workflow_core import DataType
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PortConversion:
14
+ """A directed value conversion between two stable workflow type IDs."""
15
+
16
+ source_type_id: str
17
+ target_type_id: str
18
+ label: str
19
+ convert: Callable[[Any], Any]
20
+
21
+
22
+ INTEGER_TO_FLOAT = PortConversion("integer", "float", "Integer → Float", float)
23
+ INTEGER_TO_STRING = PortConversion("integer", "string", "Integer → string", str)
24
+ INTEGER_TO_LIST_INT = PortConversion(
25
+ "integer", "list[int]", "Integer → list[int]", lambda value: [value]
26
+ )
27
+
28
+ FLOAT_TO_INTEGER = PortConversion("float", "integer", "Float -> Integer", round)
29
+ FLOAT_TO_STRING = PortConversion("float", "string", "Float → Text", str)
30
+ FLOAT_TO_LIST_FLOAT = PortConversion(
31
+ "float", "list[float]", "Float → list[float]", lambda value: [value]
32
+ )
33
+
34
+ PATH_TO_STR = PortConversion("filename", "string", "filename → Text", str)
35
+ STR_TO_PATH = PortConversion("string", "filename", "Text → filename", str)
36
+
37
+ VEC3D_TO_LIST = PortConversion(
38
+ "VEC3D", "list[float]", "Tex3D Vector → list[float]", lambda x: list(x)
39
+ )
40
+
41
+
42
+ PORT_CONVERSIONS = (
43
+ INTEGER_TO_FLOAT,
44
+ INTEGER_TO_STRING,
45
+ INTEGER_TO_LIST_INT,
46
+ FLOAT_TO_INTEGER,
47
+ FLOAT_TO_STRING,
48
+ FLOAT_TO_LIST_FLOAT,
49
+ PATH_TO_STR,
50
+ STR_TO_PATH,
51
+ VEC3D_TO_LIST,
52
+ )
53
+
54
+
55
+ _CONVERSIONS_BY_TYPE_IDS = {
56
+ (conversion.source_type_id, conversion.target_type_id): conversion
57
+ for conversion in PORT_CONVERSIONS
58
+ }
59
+
60
+
61
+ def resolve_port_conversion(source: DataType[Any], target: DataType[Any]) -> PortConversion | None:
62
+ """Return the explicitly registered conversion from *source* to *target*, if any."""
63
+ return _CONVERSIONS_BY_TYPE_IDS.get((source.id, target.id))