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