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.
- pynode/__init__.py +37 -0
- pynode/__main__.py +9 -0
- pynode/_perf.py +65 -0
- pynode/_version.py +24 -0
- pynode/api/__init__.py +15 -0
- pynode/api/helpers.py +79 -0
- pynode/api/nodes.py +423 -0
- pynode/api/services.py +124 -0
- pynode/api/sse.py +58 -0
- pynode/api/uploads.py +59 -0
- pynode/api/workflows.py +466 -0
- pynode/config.py +93 -0
- pynode/install_nodes.py +143 -0
- pynode/main.py +118 -0
- pynode/node_registry.py +156 -0
- pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
- pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
- pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
- pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
- pynode/nodes/BatchNode/__init__.py +3 -0
- pynode/nodes/BatchNode/batch_node.py +86 -0
- pynode/nodes/BatchNode/requirements.txt +1 -0
- pynode/nodes/CameraNode/__init__.py +4 -0
- pynode/nodes/CameraNode/camera_node.py +204 -0
- pynode/nodes/CameraNode/requirements.txt +2 -0
- pynode/nodes/ChangeNode/__init__.py +1 -0
- pynode/nodes/ChangeNode/change_node.py +255 -0
- pynode/nodes/ChangeNode/requirements.txt +1 -0
- pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
- pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
- pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
- pynode/nodes/CounterNode/__init__.py +3 -0
- pynode/nodes/CounterNode/counter_node.py +191 -0
- pynode/nodes/CropNode/__init__.py +4 -0
- pynode/nodes/CropNode/crop_node.py +317 -0
- pynode/nodes/CropNode/requirements.txt +2 -0
- pynode/nodes/DebugNode/__init__.py +4 -0
- pynode/nodes/DebugNode/debug_node.py +169 -0
- pynode/nodes/DebugNode/requirements.txt +1 -0
- pynode/nodes/DelayNode/delay_node.py +218 -0
- pynode/nodes/DelayNode/requirements.txt +1 -0
- pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
- pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
- pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
- pynode/nodes/ErrorNode/error_node.py +94 -0
- pynode/nodes/ErrorNode/requirements.txt +1 -0
- pynode/nodes/FilterNode/__init__.py +3 -0
- pynode/nodes/FilterNode/filter_node.py +102 -0
- pynode/nodes/FilterNode/requirements.txt +1 -0
- pynode/nodes/FrameSourceNode/__init__.py +4 -0
- pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
- pynode/nodes/FrameSourceNode/requirements.txt +1 -0
- pynode/nodes/FunctionNode/__init__.py +3 -0
- pynode/nodes/FunctionNode/function_node.py +168 -0
- pynode/nodes/FunctionNode/requirements.txt +1 -0
- pynode/nodes/GateNode/gate_node.py +60 -0
- pynode/nodes/GateNode/requirements.txt +1 -0
- pynode/nodes/GetiUploadNode/__init__.py +3 -0
- pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
- pynode/nodes/GetiUploadNode/requirements.txt +1 -0
- pynode/nodes/ImageFormatNode/__init__.py +4 -0
- pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
- pynode/nodes/ImageFormatNode/requirements.txt +2 -0
- pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
- pynode/nodes/ImageViewerNode/__init__.py +4 -0
- pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
- pynode/nodes/ImageViewerNode/requirements.txt +2 -0
- pynode/nodes/ImageWriterNode/__init__.py +3 -0
- pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
- pynode/nodes/ImageWriterNode/requirements.txt +1 -0
- pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
- pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
- pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
- pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
- pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
- pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
- pynode/nodes/InferenceNode/inference_node.py +451 -0
- pynode/nodes/InferenceNode/requirements.txt +13 -0
- pynode/nodes/InjectNode/inject_node.py +245 -0
- pynode/nodes/InjectNode/requirements.txt +1 -0
- pynode/nodes/JoinNode/__init__.py +3 -0
- pynode/nodes/JoinNode/join_node.py +156 -0
- pynode/nodes/JoinNode/requirements.txt +1 -0
- pynode/nodes/LabelFilterNode/__init__.py +3 -0
- pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
- pynode/nodes/LabelFilterNode/requirements.txt +1 -0
- pynode/nodes/MQTTNode/__init__.py +8 -0
- pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
- pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
- pynode/nodes/MQTTNode/mqtt_service.py +367 -0
- pynode/nodes/MQTTNode/requirements.txt +1 -0
- pynode/nodes/MessageWriterNode/__init__.py +7 -0
- pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
- pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
- pynode/nodes/OmronCameraNode/__init__.py +3 -0
- pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
- pynode/nodes/OmronCameraNode/requirements.txt +4 -0
- pynode/nodes/OpenCV/__init__.py +1 -0
- pynode/nodes/OpenCV/arithmetic_node.py +376 -0
- pynode/nodes/OpenCV/bitwise_node.py +127 -0
- pynode/nodes/OpenCV/blend_node.py +127 -0
- pynode/nodes/OpenCV/blob_detector_node.py +233 -0
- pynode/nodes/OpenCV/blur_node.py +141 -0
- pynode/nodes/OpenCV/color_space_node.py +139 -0
- pynode/nodes/OpenCV/colormap_node.py +262 -0
- pynode/nodes/OpenCV/contours_node.py +258 -0
- pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
- pynode/nodes/OpenCV/draw_node.py +312 -0
- pynode/nodes/OpenCV/edge_detector_node.py +177 -0
- pynode/nodes/OpenCV/equirectangular_node.py +447 -0
- pynode/nodes/OpenCV/fft_node.py +102 -0
- pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
- pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
- pynode/nodes/OpenCV/histogram_node.py +160 -0
- pynode/nodes/OpenCV/hough_circles_node.py +224 -0
- pynode/nodes/OpenCV/hough_lines_node.py +243 -0
- pynode/nodes/OpenCV/in_range_node.py +186 -0
- pynode/nodes/OpenCV/morphology_node.py +136 -0
- pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
- pynode/nodes/OpenCV/paste_node.py +244 -0
- pynode/nodes/OpenCV/perspective_node.py +174 -0
- pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
- pynode/nodes/OpenCV/requirements.txt +2 -0
- pynode/nodes/OpenCV/resize_node.py +175 -0
- pynode/nodes/OpenCV/rotate_node.py +159 -0
- pynode/nodes/OpenCV/template_match_node.py +227 -0
- pynode/nodes/OpenCV/threshold_node.py +167 -0
- pynode/nodes/PointInShapeNode/__init__.py +3 -0
- pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
- pynode/nodes/PointInShapeNode/requirements.txt +2 -0
- pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
- pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
- pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
- pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
- pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
- pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
- pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
- pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
- pynode/nodes/RESTEndpointNode/__init__.py +3 -0
- pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
- pynode/nodes/RangeNode/__init__.py +3 -0
- pynode/nodes/RangeNode/range_node.py +115 -0
- pynode/nodes/RangeNode/requirements.txt +1 -0
- pynode/nodes/RateProbeNode/__init__.py +3 -0
- pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
- pynode/nodes/RateProbeNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
- pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
- pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
- pynode/nodes/SliceNode/README.md +149 -0
- pynode/nodes/SliceNode/__init__.py +5 -0
- pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
- pynode/nodes/SliceNode/requirements.txt +3 -0
- pynode/nodes/SliceNode/slice_collector_node.py +370 -0
- pynode/nodes/SliceNode/slice_image_node.py +380 -0
- pynode/nodes/SplitNode/__init__.py +3 -0
- pynode/nodes/SplitNode/requirements.txt +1 -0
- pynode/nodes/SplitNode/split_node.py +155 -0
- pynode/nodes/SwitchNode/requirements.txt +1 -0
- pynode/nodes/SwitchNode/switch_node.py +259 -0
- pynode/nodes/SyncNode/__init__.py +3 -0
- pynode/nodes/SyncNode/sync_node.py +287 -0
- pynode/nodes/TemplateNode/requirements.txt +1 -0
- pynode/nodes/TemplateNode/template_node.py +86 -0
- pynode/nodes/TrackerNode/__init__.py +2 -0
- pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
- pynode/nodes/TrackerNode/tracker_node.py +745 -0
- pynode/nodes/UltralyticsNode/__init__.py +4 -0
- pynode/nodes/UltralyticsNode/requirements.txt +2 -0
- pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
- pynode/nodes/UnknownNode/__init__.py +93 -0
- pynode/nodes/VLLMNode/__init__.py +3 -0
- pynode/nodes/VLLMNode/vllm_node.py +345 -0
- pynode/nodes/VideoReaderNode/__init__.py +3 -0
- pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
- pynode/nodes/VideoWriterNode/__init__.py +3 -0
- pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
- pynode/nodes/WebhookNode/__init__.py +3 -0
- pynode/nodes/WebhookNode/webhook_node.py +591 -0
- pynode/nodes/__init__.py +109 -0
- pynode/nodes/base_node.py +562 -0
- pynode/nodes/image_utils.py +291 -0
- pynode/nodes/info.py +109 -0
- pynode/nodes/mDNSNode/__init__.py +13 -0
- pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
- pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
- pynode/nodes/mDNSNode/mdns_manager.py +399 -0
- pynode/nodes/mDNSNode/requirements.txt +1 -0
- pynode/nodes/messages.py +81 -0
- pynode/server.py +187 -0
- pynode/static/images/favicon4.png +0 -0
- pynode/static/index.html +200 -0
- pynode/static/js/README.md +139 -0
- pynode/static/js/auth.js +89 -0
- pynode/static/js/clipboard.js +152 -0
- pynode/static/js/config.js +14 -0
- pynode/static/js/connections.js +844 -0
- pynode/static/js/debug.js +524 -0
- pynode/static/js/events.js +775 -0
- pynode/static/js/history.js +199 -0
- pynode/static/js/main.js +55 -0
- pynode/static/js/minimap.js +242 -0
- pynode/static/js/nodes.js +995 -0
- pynode/static/js/palette.js +255 -0
- pynode/static/js/properties.js +1270 -0
- pynode/static/js/selection.js +241 -0
- pynode/static/js/state.js +211 -0
- pynode/static/js/ui-utils.js +28 -0
- pynode/static/js/viewport.js +204 -0
- pynode/static/js/workflow.js +469 -0
- pynode/static/js/workflows.js +344 -0
- pynode/static/style.css +2751 -0
- pynode/workflow_engine.py +483 -0
- pynode/workflow_manager.py +440 -0
- pynode_flow-0.2.0.dist-info/METADATA +520 -0
- pynode_flow-0.2.0.dist-info/RECORD +227 -0
- pynode_flow-0.2.0.dist-info/WHEEL +5 -0
- pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
- pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
- pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
- pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
- pynode_flow-0.2.0.dist-info/top_level.txt +1 -0
pynode/api/workflows.py
ADDED
|
@@ -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)
|