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,466 @@
1
+ """Workflow management API: CRUD, active switching, export/import,
2
+ full deploy (save), incremental deploy (deploy-changes), restart and stats.
3
+ """
4
+
5
+ import logging
6
+
7
+ from flask import Blueprint, jsonify
8
+
9
+ from pynode.api.helpers import (
10
+ _INVALID_BODY_ERROR,
11
+ _get_deployed_engine,
12
+ _get_json_body,
13
+ _get_manager,
14
+ _get_working_engine,
15
+ _get_workflow_id_from_request,
16
+ _json_error,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ workflows_bp = Blueprint('workflows', __name__)
22
+
23
+
24
+ @workflows_bp.route('/api/version', methods=['GET'])
25
+ def get_version():
26
+ """Return the running PyNode version (for display in the UI)."""
27
+ from pynode import __version__
28
+ return jsonify({'success': True, 'version': __version__})
29
+
30
+
31
+ @workflows_bp.route('/api/workflows', methods=['GET'])
32
+ def list_workflows():
33
+ """List all workflows with metadata."""
34
+ manager = _get_manager()
35
+ result = []
36
+ with manager.state_lock:
37
+ for wid, meta in manager.workflows.items():
38
+ engine = manager.working_engines.get(wid)
39
+ deployed = manager.deployed_engines.get(wid)
40
+ node_count = len(engine.nodes) if engine else 0
41
+ result.append({
42
+ 'id': wid,
43
+ 'name': meta['name'],
44
+ 'enabled': meta['enabled'],
45
+ 'nodeCount': node_count,
46
+ 'active': wid == manager.active_workflow_id,
47
+ 'running': bool(deployed.running) if deployed else False
48
+ })
49
+ return jsonify(result)
50
+
51
+
52
+ @workflows_bp.route('/api/workflows', methods=['POST'])
53
+ def create_workflow():
54
+ """Create a new workflow."""
55
+ manager = _get_manager()
56
+ data = _get_json_body(required=False)
57
+ if data is None:
58
+ return _json_error(_INVALID_BODY_ERROR, 400)
59
+ name = data.get('name', 'New Workflow')
60
+
61
+ with manager.state_lock:
62
+ wid = manager.create_new_workflow(name=name)
63
+ manager.deployed_engines[wid].start()
64
+ meta = dict(manager.workflows[wid])
65
+ manager.save_workflow_to_disk()
66
+
67
+ return jsonify({
68
+ 'id': wid,
69
+ 'name': meta['name'],
70
+ 'enabled': meta['enabled']
71
+ }), 201
72
+
73
+
74
+ @workflows_bp.route('/api/workflows/<workflow_id>', methods=['PUT'])
75
+ def update_workflow(workflow_id):
76
+ """Update workflow metadata (name, enabled)."""
77
+ manager = _get_manager()
78
+ data = _get_json_body()
79
+ if data is None:
80
+ return _json_error(_INVALID_BODY_ERROR, 400)
81
+ with manager.state_lock:
82
+ if workflow_id not in manager.workflows:
83
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
84
+
85
+ meta = manager.workflows[workflow_id]
86
+
87
+ if 'name' in data:
88
+ new_name = manager.unique_workflow_name(data['name'], exclude_id=workflow_id)
89
+ meta['name'] = new_name
90
+
91
+ if 'enabled' in data:
92
+ meta['enabled'] = data['enabled']
93
+ deployed = manager.deployed_engines.get(workflow_id)
94
+ if deployed:
95
+ if data['enabled'] and not deployed.running:
96
+ deployed.start()
97
+ elif not data['enabled'] and deployed.running:
98
+ deployed.stop()
99
+
100
+ result = {'id': workflow_id, 'name': meta['name'], 'enabled': meta['enabled']}
101
+
102
+ manager.save_workflow_to_disk()
103
+
104
+ return jsonify(result)
105
+
106
+
107
+ @workflows_bp.route('/api/workflows/<workflow_id>', methods=['DELETE'])
108
+ def delete_workflow(workflow_id):
109
+ """Delete a workflow."""
110
+ manager = _get_manager()
111
+ with manager.state_lock:
112
+ if workflow_id not in manager.workflows:
113
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
114
+
115
+ # Don't allow deleting the last workflow
116
+ if len(manager.workflows) <= 1:
117
+ return jsonify({'success': False, 'error': 'Cannot delete the last workflow'}), 400
118
+
119
+ # Stop and remove engines
120
+ deployed = manager.deployed_engines.get(workflow_id)
121
+ if deployed and deployed.running:
122
+ deployed.stop()
123
+
124
+ del manager.workflows[workflow_id]
125
+ manager.working_engines.pop(workflow_id, None)
126
+ manager.deployed_engines.pop(workflow_id, None)
127
+
128
+ # If active workflow was deleted, switch to first remaining
129
+ if manager.active_workflow_id == workflow_id:
130
+ manager.active_workflow_id = next(iter(manager.workflows))
131
+ active = manager.active_workflow_id
132
+
133
+ manager.save_workflow_to_disk()
134
+ return jsonify({'success': True, 'activeWorkflow': active})
135
+
136
+
137
+ @workflows_bp.route('/api/workflows/active', methods=['PUT'])
138
+ def set_active_workflow():
139
+ """Set the active workflow."""
140
+ manager = _get_manager()
141
+ data = _get_json_body()
142
+ if data is None:
143
+ return _json_error(_INVALID_BODY_ERROR, 400)
144
+ wid = data.get('workflowId')
145
+
146
+ with manager.state_lock:
147
+ if wid not in manager.workflows:
148
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
149
+
150
+ manager.active_workflow_id = wid
151
+ return jsonify({'success': True, 'activeWorkflow': wid})
152
+
153
+
154
+ @workflows_bp.route('/api/workflow', methods=['GET'])
155
+ def get_workflow():
156
+ """Export the working workflow for the active/requested workflow."""
157
+ manager = _get_manager()
158
+ wid = _get_workflow_id_from_request()
159
+ with manager.state_lock:
160
+ engine = _get_working_engine(wid)
161
+ meta = dict(manager.workflows[wid]) if wid in manager.workflows else None
162
+ if not engine or meta is None:
163
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
164
+ result = engine.export_workflow()
165
+ result['id'] = wid
166
+ result['name'] = meta['name']
167
+ result['enabled'] = meta['enabled']
168
+ return jsonify(result)
169
+
170
+
171
+ @workflows_bp.route('/api/workflow/deployed', methods=['GET'])
172
+ def get_deployed_workflow():
173
+ """Export the deployed workflow."""
174
+ wid = _get_workflow_id_from_request()
175
+ engine = _get_deployed_engine(wid)
176
+ if not engine:
177
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
178
+ return jsonify(engine.export_workflow())
179
+
180
+
181
+ @workflows_bp.route('/api/workflow', methods=['POST'])
182
+ def import_workflow():
183
+ """Import a workflow into both working and deployed engines (full deploy for one workflow)."""
184
+ manager = _get_manager()
185
+ data = _get_json_body()
186
+ if data is None:
187
+ return _json_error(_INVALID_BODY_ERROR, 400)
188
+ wid = _get_workflow_id_from_request()
189
+ with manager.state_lock:
190
+ working = _get_working_engine(wid)
191
+ deployed = _get_deployed_engine(wid)
192
+ enabled = manager.workflows[wid]['enabled'] if wid in manager.workflows else False
193
+ if not working or not deployed:
194
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
195
+ try:
196
+ deployed.stop()
197
+ working.import_workflow(data)
198
+ deployed.import_workflow(data)
199
+ manager.save_workflow_to_disk()
200
+ if enabled:
201
+ deployed.start()
202
+ result = working.export_workflow()
203
+ result['id'] = wid
204
+ return jsonify(result), 201
205
+ except Exception as e:
206
+ return jsonify({'success': False, 'error': str(e)}), 400
207
+
208
+
209
+ @workflows_bp.route('/api/workflow/save', methods=['POST'])
210
+ def save_workflow():
211
+ """Deploy all workflows - full deploy across all workflows."""
212
+ manager = _get_manager()
213
+ try:
214
+ # Snapshot state under the lock; do slow engine work outside it.
215
+ # Engines have their own internal RLock, so holding a reference is safe
216
+ # even if the workflow is concurrently deleted.
217
+ with manager.state_lock:
218
+ snapshot = [
219
+ (wid, manager.working_engines.get(wid), manager.deployed_engines.get(wid),
220
+ meta['enabled'])
221
+ for wid, meta in manager.workflows.items()
222
+ ]
223
+
224
+ for wid, working, deployed, enabled in snapshot:
225
+ if not working or not deployed:
226
+ continue
227
+
228
+ # Export from working engine
229
+ workflow_data = working.export_workflow()
230
+
231
+ # Preserve runtime state from deployed engine
232
+ for node_data in workflow_data.get('nodes', []):
233
+ node_id = node_data['id']
234
+ deployed_node = deployed.nodes.get(node_id)
235
+ if deployed_node:
236
+ node_data['enabled'] = deployed_node.enabled
237
+
238
+ # Stop deployed engine and import new workflow
239
+ deployed.stop()
240
+ deployed.import_workflow(workflow_data)
241
+
242
+ # Also update working engine with preserved states
243
+ working.import_workflow(workflow_data)
244
+
245
+ # Start if workflow is enabled
246
+ if enabled:
247
+ deployed.start()
248
+
249
+ manager.save_workflow_to_disk()
250
+ return jsonify({'success': True}), 200
251
+ except Exception as e:
252
+ return jsonify({'success': False, 'error': str(e)}), 400
253
+
254
+
255
+ @workflows_bp.route('/api/workflow/deploy-changes', methods=['POST'])
256
+ def deploy_changes():
257
+ """Incrementally deploy only changed nodes for a specific workflow."""
258
+ manager = _get_manager()
259
+ try:
260
+ data = _get_json_body()
261
+ if data is None:
262
+ return _json_error(_INVALID_BODY_ERROR, 400)
263
+ with manager.state_lock:
264
+ wid = data.get('workflowId', manager.active_workflow_id)
265
+ working = manager.working_engines.get(wid)
266
+ deployed = manager.deployed_engines.get(wid)
267
+ if not working or not deployed:
268
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
269
+
270
+ modified_nodes = data.get('modifiedNodes', [])
271
+ added_nodes = data.get('addedNodes', [])
272
+ deleted_nodes = data.get('deletedNodes', [])
273
+ added_connections = data.get('addedConnections', [])
274
+ deleted_connections = data.get('deletedConnections', [])
275
+
276
+ nodes_restarted = 0
277
+
278
+ # 1. Delete removed connections first
279
+ for conn in deleted_connections:
280
+ try:
281
+ deployed.disconnect_nodes(
282
+ conn['source'],
283
+ conn['target'],
284
+ conn.get('sourceOutput', 0)
285
+ )
286
+ working.disconnect_nodes(
287
+ conn['source'],
288
+ conn['target'],
289
+ conn.get('sourceOutput', 0)
290
+ )
291
+ except Exception as e:
292
+ logger.error(f"Error deleting connection: {e}")
293
+
294
+ # 2. Delete removed nodes
295
+ for node_id in deleted_nodes:
296
+ try:
297
+ node = deployed.get_node(node_id)
298
+ if node:
299
+ node.on_stop()
300
+ deployed.delete_node(node_id)
301
+ working.delete_node(node_id)
302
+ nodes_restarted += 1
303
+ except Exception as e:
304
+ logger.error(f"Error deleting node {node_id}: {e}")
305
+
306
+ # 3. Add new nodes
307
+ for node_data in added_nodes:
308
+ try:
309
+ node = deployed.create_node(
310
+ node_type=node_data['type'],
311
+ node_id=node_data['id'],
312
+ name=node_data.get('name', ''),
313
+ config=node_data.get('config', {})
314
+ )
315
+ node.enabled = node_data.get('enabled', True)
316
+ node.x = node_data.get('x', 0)
317
+ node.y = node_data.get('y', 0)
318
+
319
+ if deployed.running:
320
+ node.on_start()
321
+
322
+ w_node = working.create_node(
323
+ node_type=node_data['type'],
324
+ node_id=node_data['id'],
325
+ name=node_data.get('name', ''),
326
+ config=node_data.get('config', {})
327
+ )
328
+ w_node.enabled = node_data.get('enabled', True)
329
+ w_node.x = node_data.get('x', 0)
330
+ w_node.y = node_data.get('y', 0)
331
+
332
+ nodes_restarted += 1
333
+ except Exception as e:
334
+ logger.error(f"Error adding node: {e}")
335
+
336
+ # 4. Update modified nodes (stop, reconfigure, restart)
337
+ for node_data in modified_nodes:
338
+ node_id = node_data['id']
339
+ try:
340
+ deployed_node = deployed.get_node(node_id)
341
+ working_node = working.get_node(node_id)
342
+
343
+ if deployed_node:
344
+ deployed_node.on_stop()
345
+ deployed_node.name = node_data.get('name', deployed_node.name)
346
+ deployed_node.configure(node_data.get('config', {}))
347
+ deployed_node.enabled = node_data.get('enabled', True)
348
+ deployed_node.x = node_data.get('x', 0)
349
+ deployed_node.y = node_data.get('y', 0)
350
+ if deployed.running:
351
+ deployed_node.on_start()
352
+ nodes_restarted += 1
353
+
354
+ if working_node:
355
+ working_node.name = node_data.get('name', working_node.name)
356
+ working_node.configure(node_data.get('config', {}))
357
+ working_node.enabled = node_data.get('enabled', True)
358
+ working_node.x = node_data.get('x', 0)
359
+ working_node.y = node_data.get('y', 0)
360
+
361
+ except Exception as e:
362
+ logger.error(f"Error updating node {node_id}: {e}")
363
+
364
+ # 5. Add new connections
365
+ for conn in added_connections:
366
+ try:
367
+ deployed.connect_nodes(
368
+ conn['source'],
369
+ conn['target'],
370
+ conn.get('sourceOutput', 0),
371
+ conn.get('targetInput', 0)
372
+ )
373
+ working.connect_nodes(
374
+ conn['source'],
375
+ conn['target'],
376
+ conn.get('sourceOutput', 0),
377
+ conn.get('targetInput', 0)
378
+ )
379
+ except Exception as e:
380
+ logger.error(f"Error adding connection: {e}")
381
+
382
+ # 6. If the workflow is enabled but its deployed engine is not
383
+ # running (e.g. after a transient /api/workflow/stop), start it so
384
+ # hitting Deploy resumes processing. engine.start() calls on_start()
385
+ # on every node, including any added/modified above that were not
386
+ # started individually because the engine was stopped. When the
387
+ # engine is already running (the normal case) this is a no-op.
388
+ with manager.state_lock:
389
+ enabled = manager.workflows.get(wid, {}).get('enabled', False)
390
+ if enabled and not deployed.running:
391
+ deployed.start()
392
+
393
+ manager.save_workflow_to_disk()
394
+
395
+ return jsonify({
396
+ 'success': True,
397
+ 'nodesRestarted': nodes_restarted
398
+ }), 200
399
+
400
+ except Exception as e:
401
+ logger.error(f"Error in incremental deploy: {e}")
402
+ return jsonify({'success': False, 'error': str(e)}), 400
403
+
404
+
405
+ @workflows_bp.route('/api/workflow/restart', methods=['POST'])
406
+ def restart_workflow():
407
+ """Restart all deployed workflows."""
408
+ manager = _get_manager()
409
+ try:
410
+ # Snapshot under the lock; engine stop/start happens outside it.
411
+ with manager.state_lock:
412
+ snapshot = [
413
+ (wid, manager.deployed_engines.get(wid), meta['enabled'])
414
+ for wid, meta in manager.workflows.items()
415
+ ]
416
+
417
+ for wid, deployed, enabled in snapshot:
418
+ if not deployed:
419
+ continue
420
+ workflow_data = deployed.export_workflow()
421
+ deployed.stop()
422
+ deployed.import_workflow(workflow_data)
423
+ if enabled:
424
+ deployed.start()
425
+
426
+ return jsonify({'success': True}), 200
427
+ except Exception as e:
428
+ logger.error(f"Error restarting workflow: {e}")
429
+ return jsonify({'success': False, 'error': str(e)}), 400
430
+
431
+
432
+ @workflows_bp.route('/api/workflow/stop', methods=['POST'])
433
+ def stop_workflows():
434
+ """Transiently stop ALL deployed workflow engines.
435
+
436
+ Unlike disabling a workflow, this does NOT change any workflow's
437
+ persisted 'enabled' flag and does NOT write to disk - the next Deploy
438
+ (full save or incremental deploy-changes) starts enabled workflows again.
439
+ """
440
+ manager = _get_manager()
441
+ try:
442
+ # Snapshot under the lock; engine stop happens outside it.
443
+ with manager.state_lock:
444
+ snapshot = [manager.deployed_engines.get(wid)
445
+ for wid in manager.workflows]
446
+
447
+ stopped = 0
448
+ for deployed in snapshot:
449
+ if deployed and deployed.running:
450
+ deployed.stop()
451
+ stopped += 1
452
+
453
+ return jsonify({'success': True, 'stopped': stopped}), 200
454
+ except Exception as e:
455
+ logger.error(f"Error stopping workflows: {e}")
456
+ return jsonify({'success': False, 'error': str(e)}), 400
457
+
458
+
459
+ @workflows_bp.route('/api/workflow/stats', methods=['GET'])
460
+ def get_workflow_stats():
461
+ """Get deployed workflow statistics."""
462
+ wid = _get_workflow_id_from_request()
463
+ engine = _get_deployed_engine(wid)
464
+ if not engine:
465
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
466
+ return jsonify(engine.get_workflow_stats())
pynode/config.py ADDED
@@ -0,0 +1,93 @@
1
+ """Central configuration for PyNode.
2
+
3
+ Consolidates the environment variables and path-resolution conventions that
4
+ were previously scattered across ``main.py``, ``server.py`` and
5
+ ``workflow_manager.py``.
6
+
7
+ Environment variables
8
+ ---------------------
9
+
10
+ - ``PYNODE_DATA_DIR``: directory that holds PyNode's mutable data. Workflows
11
+ are persisted under ``<data dir>/workflows/`` (``workflow.json`` plus the
12
+ ``_backups/`` folder). Overridden by the ``--data-dir`` CLI flag. See
13
+ :func:`resolve_data_dir` for the full precedence.
14
+ - ``PYNODE_API_KEY``: API key required on all ``/api/`` requests
15
+ (``X-API-Key`` header or ``api_key`` query parameter). Empty/unset = auth
16
+ disabled. Overridden by the ``--api-key`` CLI flag.
17
+ - ``PYNODE_CORS_ORIGINS``: comma-separated list of allowed CORS origins.
18
+ Empty/unset = ``*`` (all origins). Overridden by the ``--cors-origins``
19
+ CLI flag.
20
+ """
21
+
22
+ import os
23
+
24
+ # Environment variable names (single source of truth).
25
+ ENV_DATA_DIR = 'PYNODE_DATA_DIR'
26
+ ENV_API_KEY = 'PYNODE_API_KEY'
27
+ ENV_CORS_ORIGINS = 'PYNODE_CORS_ORIGINS'
28
+
29
+ # Package directory (static files, package-relative uploads).
30
+ PKG_DIR = os.path.dirname(os.path.abspath(__file__))
31
+
32
+ # Directory the ``pynode`` package sits in. For a source checkout this is the
33
+ # repository root (which contains pyproject.toml and workflows/); for a pip
34
+ # install it is site-packages, which must NOT be used as a data directory.
35
+ CHECKOUT_DIR = os.path.dirname(PKG_DIR)
36
+
37
+ # Name of the workflows subdirectory inside the data dir.
38
+ WORKFLOWS_SUBDIR = 'workflows'
39
+
40
+
41
+ def _is_source_checkout(base_dir):
42
+ """True if ``base_dir`` looks like a writable PyNode source checkout.
43
+
44
+ Heuristic: ``pyproject.toml`` sits next to the ``pynode`` package (true
45
+ for a git clone / editable install, false for site-packages) and the
46
+ directory is writable.
47
+ """
48
+ try:
49
+ return (os.path.isfile(os.path.join(base_dir, 'pyproject.toml'))
50
+ and os.path.isdir(base_dir)
51
+ and os.access(base_dir, os.W_OK))
52
+ except OSError:
53
+ return False
54
+
55
+
56
+ def resolve_data_dir(cli_data_dir=None, environ=None, checkout_dir=None):
57
+ """Resolve the PyNode data directory (parent of ``workflows/``).
58
+
59
+ Precedence:
60
+
61
+ 1. ``cli_data_dir`` (the ``--data-dir`` CLI flag),
62
+ 2. the ``PYNODE_DATA_DIR`` environment variable,
63
+ 3. the source-checkout root (directory containing the ``pynode``
64
+ package) IF it looks like a writable source checkout — i.e.
65
+ ``pyproject.toml`` is present next to the package,
66
+ 4. otherwise ``~/.pynode`` (the pip-installed case, where the package
67
+ parent would be site-packages).
68
+
69
+ The directory is only resolved here, never created — creation happens in
70
+ ``WorkflowManager`` when the app is built.
71
+ """
72
+ environ = os.environ if environ is None else environ
73
+ checkout_dir = CHECKOUT_DIR if checkout_dir is None else checkout_dir
74
+
75
+ if cli_data_dir:
76
+ return os.path.abspath(os.path.expanduser(cli_data_dir))
77
+
78
+ env_dir = environ.get(ENV_DATA_DIR)
79
+ if env_dir:
80
+ return os.path.abspath(os.path.expanduser(env_dir))
81
+
82
+ if _is_source_checkout(checkout_dir):
83
+ return checkout_dir
84
+
85
+ return os.path.join(os.path.expanduser('~'), '.pynode')
86
+
87
+
88
+ def resolve_workflows_dir(cli_data_dir=None, environ=None, checkout_dir=None):
89
+ """The workflows directory inside the resolved data dir."""
90
+ return os.path.join(
91
+ resolve_data_dir(cli_data_dir=cli_data_dir, environ=environ,
92
+ checkout_dir=checkout_dir),
93
+ WORKFLOWS_SUBDIR)