pynode-flow 0.2.0__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 (227) hide show
  1. pynode/__init__.py +37 -0
  2. pynode/__main__.py +9 -0
  3. pynode/_perf.py +65 -0
  4. pynode/_version.py +24 -0
  5. pynode/api/__init__.py +15 -0
  6. pynode/api/helpers.py +79 -0
  7. pynode/api/nodes.py +423 -0
  8. pynode/api/services.py +124 -0
  9. pynode/api/sse.py +58 -0
  10. pynode/api/uploads.py +59 -0
  11. pynode/api/workflows.py +466 -0
  12. pynode/config.py +93 -0
  13. pynode/install_nodes.py +143 -0
  14. pynode/main.py +118 -0
  15. pynode/node_registry.py +156 -0
  16. pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
  17. pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
  18. pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
  19. pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
  20. pynode/nodes/BatchNode/__init__.py +3 -0
  21. pynode/nodes/BatchNode/batch_node.py +86 -0
  22. pynode/nodes/BatchNode/requirements.txt +1 -0
  23. pynode/nodes/CameraNode/__init__.py +4 -0
  24. pynode/nodes/CameraNode/camera_node.py +204 -0
  25. pynode/nodes/CameraNode/requirements.txt +2 -0
  26. pynode/nodes/ChangeNode/__init__.py +1 -0
  27. pynode/nodes/ChangeNode/change_node.py +255 -0
  28. pynode/nodes/ChangeNode/requirements.txt +1 -0
  29. pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
  30. pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
  31. pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
  32. pynode/nodes/CounterNode/__init__.py +3 -0
  33. pynode/nodes/CounterNode/counter_node.py +191 -0
  34. pynode/nodes/CropNode/__init__.py +4 -0
  35. pynode/nodes/CropNode/crop_node.py +317 -0
  36. pynode/nodes/CropNode/requirements.txt +2 -0
  37. pynode/nodes/DebugNode/__init__.py +4 -0
  38. pynode/nodes/DebugNode/debug_node.py +169 -0
  39. pynode/nodes/DebugNode/requirements.txt +1 -0
  40. pynode/nodes/DelayNode/delay_node.py +218 -0
  41. pynode/nodes/DelayNode/requirements.txt +1 -0
  42. pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
  43. pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
  44. pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
  45. pynode/nodes/ErrorNode/error_node.py +94 -0
  46. pynode/nodes/ErrorNode/requirements.txt +1 -0
  47. pynode/nodes/FilterNode/__init__.py +3 -0
  48. pynode/nodes/FilterNode/filter_node.py +102 -0
  49. pynode/nodes/FilterNode/requirements.txt +1 -0
  50. pynode/nodes/FrameSourceNode/__init__.py +4 -0
  51. pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
  52. pynode/nodes/FrameSourceNode/requirements.txt +1 -0
  53. pynode/nodes/FunctionNode/__init__.py +3 -0
  54. pynode/nodes/FunctionNode/function_node.py +168 -0
  55. pynode/nodes/FunctionNode/requirements.txt +1 -0
  56. pynode/nodes/GateNode/gate_node.py +60 -0
  57. pynode/nodes/GateNode/requirements.txt +1 -0
  58. pynode/nodes/GetiUploadNode/__init__.py +3 -0
  59. pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
  60. pynode/nodes/GetiUploadNode/requirements.txt +1 -0
  61. pynode/nodes/ImageFormatNode/__init__.py +4 -0
  62. pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
  63. pynode/nodes/ImageFormatNode/requirements.txt +2 -0
  64. pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
  65. pynode/nodes/ImageViewerNode/__init__.py +4 -0
  66. pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
  67. pynode/nodes/ImageViewerNode/requirements.txt +2 -0
  68. pynode/nodes/ImageWriterNode/__init__.py +3 -0
  69. pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
  70. pynode/nodes/ImageWriterNode/requirements.txt +1 -0
  71. pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
  72. pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
  73. pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
  74. pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
  75. pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
  76. pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
  77. pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
  78. pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
  79. pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
  80. pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
  81. pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
  82. pynode/nodes/InferenceNode/inference_node.py +451 -0
  83. pynode/nodes/InferenceNode/requirements.txt +13 -0
  84. pynode/nodes/InjectNode/inject_node.py +245 -0
  85. pynode/nodes/InjectNode/requirements.txt +1 -0
  86. pynode/nodes/JoinNode/__init__.py +3 -0
  87. pynode/nodes/JoinNode/join_node.py +156 -0
  88. pynode/nodes/JoinNode/requirements.txt +1 -0
  89. pynode/nodes/LabelFilterNode/__init__.py +3 -0
  90. pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
  91. pynode/nodes/LabelFilterNode/requirements.txt +1 -0
  92. pynode/nodes/MQTTNode/__init__.py +8 -0
  93. pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
  94. pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
  95. pynode/nodes/MQTTNode/mqtt_service.py +367 -0
  96. pynode/nodes/MQTTNode/requirements.txt +1 -0
  97. pynode/nodes/MessageWriterNode/__init__.py +7 -0
  98. pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
  99. pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
  100. pynode/nodes/OmronCameraNode/__init__.py +3 -0
  101. pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
  102. pynode/nodes/OmronCameraNode/requirements.txt +4 -0
  103. pynode/nodes/OpenCV/__init__.py +1 -0
  104. pynode/nodes/OpenCV/arithmetic_node.py +376 -0
  105. pynode/nodes/OpenCV/bitwise_node.py +127 -0
  106. pynode/nodes/OpenCV/blend_node.py +127 -0
  107. pynode/nodes/OpenCV/blob_detector_node.py +233 -0
  108. pynode/nodes/OpenCV/blur_node.py +141 -0
  109. pynode/nodes/OpenCV/color_space_node.py +139 -0
  110. pynode/nodes/OpenCV/colormap_node.py +262 -0
  111. pynode/nodes/OpenCV/contours_node.py +258 -0
  112. pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
  113. pynode/nodes/OpenCV/draw_node.py +312 -0
  114. pynode/nodes/OpenCV/edge_detector_node.py +177 -0
  115. pynode/nodes/OpenCV/equirectangular_node.py +447 -0
  116. pynode/nodes/OpenCV/fft_node.py +102 -0
  117. pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
  118. pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
  119. pynode/nodes/OpenCV/histogram_node.py +160 -0
  120. pynode/nodes/OpenCV/hough_circles_node.py +224 -0
  121. pynode/nodes/OpenCV/hough_lines_node.py +243 -0
  122. pynode/nodes/OpenCV/in_range_node.py +186 -0
  123. pynode/nodes/OpenCV/morphology_node.py +136 -0
  124. pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
  125. pynode/nodes/OpenCV/paste_node.py +244 -0
  126. pynode/nodes/OpenCV/perspective_node.py +174 -0
  127. pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
  128. pynode/nodes/OpenCV/requirements.txt +2 -0
  129. pynode/nodes/OpenCV/resize_node.py +175 -0
  130. pynode/nodes/OpenCV/rotate_node.py +159 -0
  131. pynode/nodes/OpenCV/template_match_node.py +227 -0
  132. pynode/nodes/OpenCV/threshold_node.py +167 -0
  133. pynode/nodes/PointInShapeNode/__init__.py +3 -0
  134. pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
  135. pynode/nodes/PointInShapeNode/requirements.txt +2 -0
  136. pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
  137. pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
  138. pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
  139. pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
  140. pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
  141. pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
  142. pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
  143. pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
  144. pynode/nodes/RESTEndpointNode/__init__.py +3 -0
  145. pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
  146. pynode/nodes/RangeNode/__init__.py +3 -0
  147. pynode/nodes/RangeNode/range_node.py +115 -0
  148. pynode/nodes/RangeNode/requirements.txt +1 -0
  149. pynode/nodes/RateProbeNode/__init__.py +3 -0
  150. pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
  151. pynode/nodes/RateProbeNode/requirements.txt +1 -0
  152. pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
  153. pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
  154. pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
  155. pynode/nodes/SliceNode/README.md +149 -0
  156. pynode/nodes/SliceNode/__init__.py +5 -0
  157. pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
  158. pynode/nodes/SliceNode/requirements.txt +3 -0
  159. pynode/nodes/SliceNode/slice_collector_node.py +370 -0
  160. pynode/nodes/SliceNode/slice_image_node.py +380 -0
  161. pynode/nodes/SplitNode/__init__.py +3 -0
  162. pynode/nodes/SplitNode/requirements.txt +1 -0
  163. pynode/nodes/SplitNode/split_node.py +155 -0
  164. pynode/nodes/SwitchNode/requirements.txt +1 -0
  165. pynode/nodes/SwitchNode/switch_node.py +259 -0
  166. pynode/nodes/SyncNode/__init__.py +3 -0
  167. pynode/nodes/SyncNode/sync_node.py +287 -0
  168. pynode/nodes/TemplateNode/requirements.txt +1 -0
  169. pynode/nodes/TemplateNode/template_node.py +86 -0
  170. pynode/nodes/TrackerNode/__init__.py +2 -0
  171. pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
  172. pynode/nodes/TrackerNode/tracker_node.py +745 -0
  173. pynode/nodes/UltralyticsNode/__init__.py +4 -0
  174. pynode/nodes/UltralyticsNode/requirements.txt +2 -0
  175. pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
  176. pynode/nodes/UnknownNode/__init__.py +93 -0
  177. pynode/nodes/VLLMNode/__init__.py +3 -0
  178. pynode/nodes/VLLMNode/vllm_node.py +345 -0
  179. pynode/nodes/VideoReaderNode/__init__.py +3 -0
  180. pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
  181. pynode/nodes/VideoWriterNode/__init__.py +3 -0
  182. pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
  183. pynode/nodes/WebhookNode/__init__.py +3 -0
  184. pynode/nodes/WebhookNode/webhook_node.py +591 -0
  185. pynode/nodes/__init__.py +109 -0
  186. pynode/nodes/base_node.py +562 -0
  187. pynode/nodes/image_utils.py +291 -0
  188. pynode/nodes/info.py +109 -0
  189. pynode/nodes/mDNSNode/__init__.py +13 -0
  190. pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
  191. pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
  192. pynode/nodes/mDNSNode/mdns_manager.py +399 -0
  193. pynode/nodes/mDNSNode/requirements.txt +1 -0
  194. pynode/nodes/messages.py +81 -0
  195. pynode/server.py +187 -0
  196. pynode/static/images/favicon4.png +0 -0
  197. pynode/static/index.html +200 -0
  198. pynode/static/js/README.md +139 -0
  199. pynode/static/js/auth.js +89 -0
  200. pynode/static/js/clipboard.js +152 -0
  201. pynode/static/js/config.js +14 -0
  202. pynode/static/js/connections.js +844 -0
  203. pynode/static/js/debug.js +524 -0
  204. pynode/static/js/events.js +775 -0
  205. pynode/static/js/history.js +199 -0
  206. pynode/static/js/main.js +55 -0
  207. pynode/static/js/minimap.js +242 -0
  208. pynode/static/js/nodes.js +995 -0
  209. pynode/static/js/palette.js +255 -0
  210. pynode/static/js/properties.js +1270 -0
  211. pynode/static/js/selection.js +241 -0
  212. pynode/static/js/state.js +211 -0
  213. pynode/static/js/ui-utils.js +28 -0
  214. pynode/static/js/viewport.js +204 -0
  215. pynode/static/js/workflow.js +469 -0
  216. pynode/static/js/workflows.js +344 -0
  217. pynode/static/style.css +2751 -0
  218. pynode/workflow_engine.py +483 -0
  219. pynode/workflow_manager.py +440 -0
  220. pynode_flow-0.2.0.dist-info/METADATA +520 -0
  221. pynode_flow-0.2.0.dist-info/RECORD +227 -0
  222. pynode_flow-0.2.0.dist-info/WHEEL +5 -0
  223. pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
  224. pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
  225. pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
  226. pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
  227. pynode_flow-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,483 @@
1
+ """
2
+ Workflow Engine for managing nodes and executing workflows.
3
+ """
4
+
5
+ import logging
6
+ import threading
7
+ from typing import Dict, List, Any, Type, Optional
8
+
9
+ from pynode.nodes.base_node import BaseNode
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class WorkflowEngine:
15
+ """
16
+ Manages a collection of nodes and their connections.
17
+ Handles workflow execution and lifecycle.
18
+ """
19
+
20
+ def __init__(self):
21
+ self.nodes: Dict[str, BaseNode] = {}
22
+ self.node_types: Dict[str, Type[BaseNode]] = {}
23
+ self.running = False
24
+ self._lock = threading.RLock() # Use RLock (reentrant lock) to allow nested locking
25
+ self._system_error_node = None # System error node that's always present
26
+
27
+ def register_node_type(self, node_class: Type[BaseNode]):
28
+ """
29
+ Register a node type that can be instantiated.
30
+
31
+ Args:
32
+ node_class: A class that inherits from BaseNode
33
+ """
34
+ self.node_types[node_class.__name__] = node_class
35
+
36
+ def create_node(self, node_type: str, node_id: Optional[str] = None,
37
+ name: str = "", config: Optional[Dict[str, Any]] = None) -> BaseNode:
38
+ """
39
+ Create a new node instance and add it to the workflow.
40
+
41
+ Args:
42
+ node_type: The type of node to create (must be registered)
43
+ node_id: Optional unique ID for the node
44
+ name: Optional name for the node
45
+ config: Optional configuration dictionary
46
+
47
+ Returns:
48
+ The created node instance
49
+ """
50
+ if node_type not in self.node_types:
51
+ raise ValueError(f"Unknown node type: {node_type}")
52
+
53
+ with self._lock:
54
+ node_class = self.node_types[node_type]
55
+ node = node_class(node_id=node_id, name=name)
56
+
57
+ # Set workflow engine reference for error reporting
58
+ node.set_workflow_engine(self)
59
+
60
+ if config:
61
+ node.configure(config)
62
+
63
+ self.nodes[node.id] = node
64
+ return node
65
+
66
+ def get_node(self, node_id: str) -> Optional[BaseNode]:
67
+ """
68
+ Get a node by its ID.
69
+
70
+ Args:
71
+ node_id: The unique identifier of the node
72
+
73
+ Returns:
74
+ The node instance or None if not found
75
+ """
76
+ return self.nodes.get(node_id)
77
+
78
+ def delete_node(self, node_id: str):
79
+ """
80
+ Remove a node from the workflow.
81
+
82
+ Args:
83
+ node_id: The unique identifier of the node to remove
84
+ """
85
+ with self._lock:
86
+ if node_id in self.nodes:
87
+ node = self.nodes[node_id]
88
+
89
+ # Disconnect all outputs
90
+ node.outputs.clear()
91
+
92
+ # Disconnect all inputs (remove references from other nodes)
93
+ for other_node in self.nodes.values():
94
+ for output_idx in list(other_node.outputs.keys()):
95
+ other_node.disconnect(node, output_idx)
96
+
97
+ # Remove the node
98
+ del self.nodes[node_id]
99
+
100
+ def connect_nodes(self, source_id: str, target_id: str,
101
+ output_index: int = 0, input_index: int = 0):
102
+ """
103
+ Connect two nodes together.
104
+
105
+ Args:
106
+ source_id: ID of the source node
107
+ target_id: ID of the target node
108
+ output_index: Output port index on source node
109
+ input_index: Input port index on target node
110
+ """
111
+ source = self.get_node(source_id)
112
+ target = self.get_node(target_id)
113
+
114
+ if not source or not target:
115
+ raise ValueError("Source or target node not found")
116
+
117
+ with self._lock:
118
+ source.connect(target, output_index, input_index)
119
+
120
+ def disconnect_nodes(self, source_id: str, target_id: str, output_index: int = 0):
121
+ """
122
+ Disconnect two nodes.
123
+
124
+ Args:
125
+ source_id: ID of the source node
126
+ target_id: ID of the target node
127
+ output_index: Output port index on source node
128
+ """
129
+ source = self.get_node(source_id)
130
+ target = self.get_node(target_id)
131
+
132
+ if not source or not target:
133
+ raise ValueError("Source or target node not found")
134
+
135
+ with self._lock:
136
+ source.disconnect(target, output_index)
137
+
138
+ def start(self):
139
+ """
140
+ Start the workflow - calls on_start() for all nodes.
141
+ """
142
+ with self._lock:
143
+ if self.running:
144
+ return
145
+
146
+ self.running = True
147
+
148
+ # Create system error node if it doesn't exist
149
+ self._ensure_system_error_node()
150
+
151
+ for node in self.nodes.values():
152
+ try:
153
+ node.on_start()
154
+ except Exception as e:
155
+ logger.error(f"Error starting node {node.id}: {e}")
156
+
157
+ def stop(self):
158
+ """
159
+ Stop the workflow - calls on_stop() for all nodes.
160
+ """
161
+ with self._lock:
162
+ if not self.running:
163
+ return
164
+
165
+ self.running = False
166
+
167
+ for node in self.nodes.values():
168
+ try:
169
+ node.on_stop()
170
+ except Exception as e:
171
+ logger.error(f"Error stopping node {node.id}: {e}")
172
+
173
+ def get_debug_messages(self, node_id: str) -> List[Dict[str, Any]]:
174
+ """
175
+ Get debug messages from a debug node.
176
+
177
+ Args:
178
+ node_id: ID of the debug node
179
+
180
+ Returns:
181
+ List of debug messages
182
+ """
183
+ node = self.get_node(node_id)
184
+ if node and hasattr(node, 'messages'):
185
+ return node.messages
186
+ return []
187
+
188
+ def clear_debug_messages(self, node_id: str):
189
+ """
190
+ Clear debug messages from a debug node.
191
+
192
+ Args:
193
+ node_id: ID of the debug node
194
+ """
195
+ node = self.get_node(node_id)
196
+ if node and hasattr(node, 'messages'):
197
+ node.messages.clear()
198
+
199
+ def broadcast_error(self, source_node_id: str, source_node_name: str, error_msg: str):
200
+ """
201
+ Broadcast an error to all ErrorNodes in the workflow, including the system error node.
202
+
203
+ Args:
204
+ source_node_id: ID of the node that generated the error
205
+ source_node_name: Name of the node that generated the error
206
+ error_msg: The error message
207
+ """
208
+ with self._lock:
209
+ # Ensure system error node exists
210
+ self._ensure_system_error_node()
211
+
212
+ # Broadcast to all error nodes including system node
213
+ for node in self.nodes.values():
214
+ if node.type == 'ErrorNode' and hasattr(node, 'handle_error'):
215
+ try:
216
+ node.handle_error(source_node_id, source_node_name, error_msg)
217
+ except Exception as e:
218
+ logger.error(f"Error broadcasting to ErrorNode {node.id}: {e}")
219
+
220
+ def _ensure_system_error_node(self):
221
+ """
222
+ Ensure the system error node exists. Creates it if not present.
223
+ This node is always available and doesn't appear on the canvas.
224
+ """
225
+ if self._system_error_node is None or '__system_error__' not in self.nodes:
226
+ # Check if ErrorNode type is registered
227
+ if 'ErrorNode' in self.node_types:
228
+ try:
229
+ self._system_error_node = self.create_node(
230
+ 'ErrorNode',
231
+ node_id='__system_error__',
232
+ name='System Errors',
233
+ config={}
234
+ )
235
+ # Mark as system node so it won't be exported/displayed
236
+ if hasattr(self._system_error_node, 'is_system_node'):
237
+ self._system_error_node.is_system_node = True
238
+ except Exception as e:
239
+ logger.error(f"Failed to create system error node: {e}")
240
+
241
+ def get_system_errors(self) -> List[Dict[str, Any]]:
242
+ """
243
+ Get errors from the system error node.
244
+
245
+ Returns:
246
+ List of error messages from system error node
247
+ """
248
+ self._ensure_system_error_node()
249
+ if self._system_error_node and hasattr(self._system_error_node, 'get_errors'):
250
+ return self._system_error_node.get_errors()
251
+ return []
252
+
253
+ def clear_system_errors(self):
254
+ """
255
+ Clear errors from the system error node.
256
+ """
257
+ self._ensure_system_error_node()
258
+ if self._system_error_node and hasattr(self._system_error_node, 'clear_errors'):
259
+ self._system_error_node.clear_errors()
260
+
261
+ def get_error_messages(self, node_id: str) -> List[Dict[str, Any]]:
262
+ """
263
+ Get error messages from an error node.
264
+
265
+ Args:
266
+ node_id: ID of the error node
267
+
268
+ Returns:
269
+ List of error messages
270
+ """
271
+ node = self.get_node(node_id)
272
+ if node and hasattr(node, 'get_errors'):
273
+ return node.get_errors()
274
+ return []
275
+
276
+ def clear_error_messages(self, node_id: str):
277
+ """
278
+ Clear error messages from an error node.
279
+
280
+ Args:
281
+ node_id: ID of the error node
282
+ """
283
+ node = self.get_node(node_id)
284
+ if node and hasattr(node, 'clear_errors'):
285
+ node.clear_errors()
286
+
287
+ def export_workflow(self) -> Dict[str, Any]:
288
+ """
289
+ Export the entire workflow to a dictionary.
290
+ Excludes system nodes (like the system error node).
291
+
292
+ Returns:
293
+ Dictionary representation of the workflow
294
+ """
295
+ nodes_data = []
296
+ connections_data = []
297
+
298
+ for node in self.nodes.values():
299
+ # Skip system nodes
300
+ if node.id == '__system_error__' or getattr(node, 'is_system_node', False):
301
+ continue
302
+
303
+ # Handle unknown/placeholder nodes - export with original type
304
+ if getattr(node, '_is_unknown_node', False):
305
+ original_type = node.config.get('original_type', node.type)
306
+ original_config = node.config.get('original_config', {})
307
+ # Get the original name without the "(missing)" suffix
308
+ original_name = node.name
309
+ if original_name.endswith(' (missing)'):
310
+ original_name = original_name[:-10] # Remove ' (missing)'
311
+
312
+ nodes_data.append({
313
+ 'id': node.id,
314
+ 'type': original_type,
315
+ 'name': original_name,
316
+ 'config': original_config,
317
+ 'enabled': False, # Unknown nodes are always disabled
318
+ 'x': getattr(node, 'x', 0),
319
+ 'y': getattr(node, 'y', 0),
320
+ 'inputCount': node.input_count,
321
+ 'outputCount': node.output_count
322
+ })
323
+ else:
324
+ nodes_data.append({
325
+ 'id': node.id,
326
+ 'type': node.type,
327
+ 'name': node.name,
328
+ 'config': node.config,
329
+ 'enabled': node.enabled,
330
+ 'x': getattr(node, 'x', 0),
331
+ 'y': getattr(node, 'y', 0)
332
+ })
333
+
334
+ for output_idx, targets in node.outputs.items():
335
+ for target_node, input_idx in targets:
336
+ # Skip connections to system nodes
337
+ if target_node.id == '__system_error__' or getattr(target_node, 'is_system_node', False):
338
+ continue
339
+ connections_data.append({
340
+ 'source': node.id,
341
+ 'target': target_node.id,
342
+ 'sourceOutput': output_idx,
343
+ 'targetInput': input_idx
344
+ })
345
+
346
+ return {
347
+ 'nodes': nodes_data,
348
+ 'connections': connections_data
349
+ }
350
+
351
+ def import_workflow(self, workflow_data: Dict[str, Any]):
352
+ """
353
+ Import a workflow from a dictionary.
354
+
355
+ Args:
356
+ workflow_data: Dictionary with 'nodes' and 'connections'
357
+ """
358
+ with self._lock:
359
+ # Clear existing workflow (but preserve system error node)
360
+ system_error_node = self._system_error_node
361
+ self.nodes.clear()
362
+ self._system_error_node = None
363
+
364
+ # Create nodes
365
+ for node_data in workflow_data.get('nodes', []):
366
+ node_type = node_data['type']
367
+
368
+ if node_type in self.node_types:
369
+ # Known node type - create normally
370
+ node = self.create_node(
371
+ node_type=node_type,
372
+ node_id=node_data['id'],
373
+ name=node_data.get('name', ''),
374
+ config=node_data.get('config', {})
375
+ )
376
+ node.enabled = node_data.get('enabled', True)
377
+ else:
378
+ # Unknown node type - create placeholder
379
+ node = self._create_unknown_node(node_data)
380
+ logger.warning(f"Unknown node type '{node_type}' - created placeholder for node '{node_data['id']}'")
381
+
382
+ # Store position if provided
383
+ node.x = node_data.get('x', 0)
384
+ node.y = node_data.get('y', 0)
385
+
386
+ # Create connections
387
+ for conn_data in workflow_data.get('connections', []):
388
+ try:
389
+ self.connect_nodes(
390
+ source_id=conn_data['source'],
391
+ target_id=conn_data['target'],
392
+ output_index=conn_data.get('sourceOutput', 0),
393
+ input_index=conn_data.get('targetInput', 0)
394
+ )
395
+ except ValueError as e:
396
+ # Skip connections if source or target doesn't exist
397
+ logger.warning(f"Could not create connection: {e}")
398
+
399
+ # Recreate system error node if the workflow is running
400
+ if self.running:
401
+ self._ensure_system_error_node()
402
+
403
+ def _create_unknown_node(self, node_data: Dict[str, Any]) -> BaseNode:
404
+ """
405
+ Create a placeholder node for an unknown node type.
406
+
407
+ Args:
408
+ node_data: The original node data from the workflow
409
+
410
+ Returns:
411
+ An UnknownNode instance that preserves the original data
412
+ """
413
+ from pynode.nodes.UnknownNode import UnknownNode
414
+
415
+ original_type = node_data['type']
416
+ original_config = node_data.get('config', {})
417
+
418
+ # Try to preserve input/output counts from connections in the workflow
419
+ # Default to 1 each if we can't determine
420
+ input_count = node_data.get('inputCount', 1)
421
+ output_count = node_data.get('outputCount', 1)
422
+
423
+ # Preserve the user-given node name in the placeholder's display name;
424
+ # fall back to the original type when no name was given. Export strips
425
+ # the ' (missing)' suffix again, so the original name round-trips.
426
+ original_name = node_data.get('name') or original_type
427
+
428
+ # Create the unknown node with original type info
429
+ node = UnknownNode(
430
+ node_id=node_data['id'],
431
+ name=f"{original_name} (missing)",
432
+ original_type=original_type,
433
+ original_config=original_config,
434
+ input_count=input_count,
435
+ output_count=output_count
436
+ )
437
+
438
+ # Set workflow engine reference
439
+ node.set_workflow_engine(self)
440
+
441
+ # Store in nodes dict
442
+ self.nodes[node.id] = node
443
+
444
+ return node
445
+
446
+ def get_workflow_stats(self) -> Dict[str, Any]:
447
+ """
448
+ Get statistics about the current workflow.
449
+
450
+ Returns:
451
+ Dictionary with workflow statistics
452
+ """
453
+ def _is_system(node):
454
+ return (node.id == '__system_error__'
455
+ or getattr(node, 'is_system_node', False))
456
+
457
+ node_types_count = {}
458
+ total_nodes = 0
459
+ total_connections = 0
460
+
461
+ # System nodes (e.g. the hidden __system_error__ ErrorNode) are an
462
+ # implementation detail: exclude them and any connections to them,
463
+ # mirroring export_workflow().
464
+ for node in self.nodes.values():
465
+ if _is_system(node):
466
+ continue
467
+ total_nodes += 1
468
+ node_type = node.type
469
+ node_types_count[node_type] = node_types_count.get(node_type, 0) + 1
470
+
471
+ for connections in node.outputs.values():
472
+ total_connections += sum(
473
+ 1 for target, _ in connections if not _is_system(target))
474
+
475
+ return {
476
+ 'total_nodes': total_nodes,
477
+ 'total_connections': total_connections,
478
+ 'node_types': node_types_count,
479
+ 'running': self.running
480
+ }
481
+
482
+ def __repr__(self):
483
+ return f"<WorkflowEngine(nodes={len(self.nodes)}, running={self.running})>"