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
pynode/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ PyNode - A Node-RED-like Visual Workflow System with Python Backend
3
+ """
4
+
5
+ # Tune CPU thread pools before heavy libraries (torch/OpenMP) initialise, so
6
+ # the OMP_WAIT_POLICY change takes effect. See pynode._perf for rationale.
7
+ from pynode._perf import configure_inference_threads
8
+
9
+ configure_inference_threads()
10
+
11
+ from pynode.workflow_engine import WorkflowEngine
12
+
13
+
14
+ def _resolve_version() -> str:
15
+ """Best-effort package version.
16
+
17
+ Prefers the installed distribution metadata (the authoritative version for
18
+ a `pip install`), then the file setuptools_scm writes at build time, and
19
+ finally a placeholder for a bare source checkout that was never built.
20
+ """
21
+ try:
22
+ from importlib.metadata import PackageNotFoundError, version
23
+ try:
24
+ return version("pynode-flow")
25
+ except PackageNotFoundError:
26
+ pass
27
+ except Exception:
28
+ pass
29
+ try:
30
+ from pynode._version import __version__ as _scm_version # generated by setuptools_scm
31
+ return _scm_version
32
+ except Exception:
33
+ return "0.0.0+unknown"
34
+
35
+
36
+ __version__ = _resolve_version()
37
+ __all__ = ['WorkflowEngine', '__version__']
pynode/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ PyNode package entry point for module execution.
3
+ Enables running: python -m pynode
4
+ """
5
+
6
+ from pynode.main import main
7
+
8
+ if __name__ == '__main__':
9
+ main()
pynode/_perf.py ADDED
@@ -0,0 +1,65 @@
1
+ """CPU thread-pool tuning for GPU-backed inference.
2
+
3
+ The heavy model math runs on the GPU (CUDA / OpenVINO), but every frame still
4
+ triggers small CPU-side pre/post-processing ops around it: letterbox resize,
5
+ NMS, tensor -> numpy conversions, and result drawing. PyTorch and OpenCV each
6
+ default their thread pool to span *every* core, and OpenMP's default wait
7
+ policy is ACTIVE (busy-spin), so those pools keep spinning between the tiny
8
+ per-frame ops and peg the CPU even though the GPU does the real work.
9
+
10
+ These ops are latency-bound, not throughput-bound, so a wide pool buys nothing
11
+ and only adds spin overhead. We cap the pools and disable the busy-spin.
12
+
13
+ Call configure_inference_threads() as early as possible - before torch/OpenMP
14
+ initialise - so the OMP_WAIT_POLICY change takes effect.
15
+
16
+ Overridable via PYNODE_INFERENCE_THREADS (e.g. set it higher when you actually
17
+ run a plain .pt model on the CPU *device* and want torch to use more cores;
18
+ GPU / OpenVINO inference is unaffected by this cap, which only touches the
19
+ light pre/post-processing).
20
+ """
21
+
22
+ import logging
23
+ import os
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Pre/post-processing ops are small and latency-bound; a handful of threads is
28
+ # plenty and avoids the all-cores busy-spin that dominates CPU during GPU runs.
29
+ _DEFAULT_THREADS = 4
30
+
31
+
32
+ def _thread_cap() -> int:
33
+ override = os.environ.get("PYNODE_INFERENCE_THREADS")
34
+ if override:
35
+ try:
36
+ return max(1, int(override))
37
+ except ValueError:
38
+ logger.warning(
39
+ "Invalid PYNODE_INFERENCE_THREADS=%r; using default", override
40
+ )
41
+ cpu = os.cpu_count() or _DEFAULT_THREADS
42
+ return min(_DEFAULT_THREADS, cpu)
43
+
44
+
45
+ def configure_inference_threads() -> None:
46
+ """Cap torch/OpenCV CPU thread pools and disable OpenMP busy-spin."""
47
+ # Must be set before the OpenMP runtime initialises (i.e. before torch is
48
+ # imported) for the worker threads to stop busy-spinning between ops.
49
+ os.environ.setdefault("OMP_WAIT_POLICY", "PASSIVE")
50
+
51
+ cap = _thread_cap()
52
+
53
+ try:
54
+ import torch
55
+
56
+ torch.set_num_threads(cap)
57
+ except Exception as e: # torch may be optional / not importable yet
58
+ logger.debug("Could not cap torch threads: %s", e)
59
+
60
+ try:
61
+ import cv2
62
+
63
+ cv2.setNumThreads(cap)
64
+ except Exception as e:
65
+ logger.debug("Could not cap OpenCV threads: %s", e)
pynode/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = 'g92a665661'
pynode/api/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """API blueprints for the PyNode Flask server."""
2
+
3
+ from pynode.api.nodes import nodes_bp
4
+ from pynode.api.services import services_bp
5
+ from pynode.api.sse import sse_bp
6
+ from pynode.api.uploads import uploads_bp
7
+ from pynode.api.workflows import workflows_bp
8
+
9
+ ALL_BLUEPRINTS = (workflows_bp, nodes_bp, services_bp, uploads_bp, sse_bp)
10
+
11
+
12
+ def register_blueprints(app):
13
+ """Register every API blueprint on the given Flask app."""
14
+ for bp in ALL_BLUEPRINTS:
15
+ app.register_blueprint(bp)
pynode/api/helpers.py ADDED
@@ -0,0 +1,79 @@
1
+ """Shared helpers for the API blueprints.
2
+
3
+ Route handlers access the per-app WorkflowManager through ``_get_manager()``,
4
+ which reads it off ``current_app`` - so every blueprint works against
5
+ whichever app instance is handling the request (default instance or a fresh
6
+ ``create_app()`` app in tests).
7
+ """
8
+
9
+ from flask import current_app, jsonify, request
10
+
11
+ _INVALID_BODY_ERROR = 'Invalid or missing JSON body'
12
+
13
+
14
+ def _get_manager():
15
+ """Return the WorkflowManager of the app handling the current request."""
16
+ return current_app.extensions['workflow_manager']
17
+
18
+
19
+ def _json_error(message, status):
20
+ """JSON error envelope: {'success': False, 'error': str}."""
21
+ return jsonify({'success': False, 'error': message}), status
22
+
23
+
24
+ def _get_json_body(required=True):
25
+ """Parse the request body as a JSON object.
26
+
27
+ Returns a dict on success. Returns None when the body is invalid: not
28
+ parseable JSON, not a JSON object, or missing while ``required`` is True.
29
+ A missing/empty body with ``required=False`` yields ``{}``.
30
+ """
31
+ data = request.get_json(silent=True)
32
+ if data is None:
33
+ # get_json(silent=True) is None for both "no body" and "bad JSON";
34
+ # treat any non-empty unparseable body as invalid even when optional.
35
+ if required or request.get_data(cache=True):
36
+ return None
37
+ return {}
38
+ if not isinstance(data, dict):
39
+ return None
40
+ return data
41
+
42
+
43
+ def _get_workflow_id_from_request():
44
+ """Extract workflow_id from request query params, defaulting to active."""
45
+ manager = _get_manager()
46
+ with manager.state_lock:
47
+ return request.args.get('workflow', manager.active_workflow_id)
48
+
49
+
50
+ def _get_working_engine(workflow_id=None):
51
+ """Get working engine for a workflow, defaulting to active."""
52
+ return _get_manager().get_working_engine(workflow_id)
53
+
54
+
55
+ def _get_deployed_engine(workflow_id=None):
56
+ """Get deployed engine for a workflow, defaulting to active."""
57
+ return _get_manager().get_deployed_engine(workflow_id)
58
+
59
+
60
+ def _find_deployed_node(node_id):
61
+ """Find a node across all deployed engines. Returns (node, workflow_id) or (None, None)."""
62
+ return _get_manager().find_deployed_node(node_id)
63
+
64
+
65
+ def _resolve_node_handler(node_id, handler_name):
66
+ """Resolve a deployed node and one of its callable handler methods.
67
+
68
+ Returns (node, method, error_response). On success error_response is None;
69
+ on failure node/method are None and error_response is a (json, status) tuple.
70
+ """
71
+ node, _ = _find_deployed_node(node_id)
72
+ if not node:
73
+ return None, None, (jsonify({'success': False, 'error': 'Node not found'}), 404)
74
+
75
+ method = getattr(node, handler_name, None)
76
+ if method is None or not callable(method):
77
+ return None, None, (jsonify({'success': False, 'error': f'Node does not support {handler_name}'}), 400)
78
+
79
+ return node, method, None
pynode/api/nodes.py ADDED
@@ -0,0 +1,423 @@
1
+ """Node API: node-types listing, node CRUD, position, enabled state,
2
+ connections, declared button actions, and the dynamic per-node-type routes
3
+ that node classes declare via ``api_routes``.
4
+ """
5
+
6
+ import inspect
7
+ import os
8
+
9
+ from flask import Blueprint, Response, jsonify, request, stream_with_context
10
+
11
+ from pynode import node_registry
12
+ from pynode.api.helpers import (
13
+ _INVALID_BODY_ERROR,
14
+ _find_deployed_node,
15
+ _get_json_body,
16
+ _get_manager,
17
+ _get_working_engine,
18
+ _get_workflow_id_from_request,
19
+ _json_error,
20
+ _resolve_node_handler,
21
+ )
22
+
23
+ nodes_bp = Blueprint('nodes', __name__)
24
+
25
+
26
+ @nodes_bp.route('/api/node-types', methods=['GET'])
27
+ def get_node_types():
28
+ """Get all available node types (excluding system nodes like ErrorNode)."""
29
+ return jsonify(node_registry.get_node_types())
30
+
31
+
32
+ @nodes_bp.route('/api/nodes', methods=['GET'])
33
+ def get_nodes():
34
+ """Get all nodes in the working workflow."""
35
+ wid = _get_workflow_id_from_request()
36
+ engine = _get_working_engine(wid)
37
+ if not engine:
38
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
39
+ node_list = [node.to_dict() for node in engine.nodes.values()]
40
+ return jsonify(node_list)
41
+
42
+
43
+ @nodes_bp.route('/api/nodes', methods=['POST'])
44
+ def create_node():
45
+ """Create a new node in working workflow."""
46
+ data = _get_json_body()
47
+ if data is None:
48
+ return _json_error(_INVALID_BODY_ERROR, 400)
49
+ node_type = data.get('type')
50
+ node_id = data.get('id')
51
+ name = data.get('name', '')
52
+ config = data.get('config', {})
53
+ wid = _get_workflow_id_from_request()
54
+ engine = _get_working_engine(wid)
55
+ if not engine:
56
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
57
+
58
+ try:
59
+ node = engine.create_node(node_type, node_id, name, config)
60
+ # Set position if provided
61
+ if 'x' in data:
62
+ node.x = data.get('x', 0)
63
+ if 'y' in data:
64
+ node.y = data.get('y', 0)
65
+ return jsonify(node.to_dict()), 201
66
+ except Exception as e:
67
+ return jsonify({'success': False, 'error': str(e)}), 400
68
+
69
+
70
+ @nodes_bp.route('/api/nodes/<node_id>', methods=['GET'])
71
+ def get_node(node_id):
72
+ """Get a specific node from working workflow."""
73
+ wid = _get_workflow_id_from_request()
74
+ engine = _get_working_engine(wid)
75
+ if not engine:
76
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
77
+ node = engine.get_node(node_id)
78
+ if node:
79
+ return jsonify(node.to_dict())
80
+ return jsonify({'success': False, 'error': 'Node not found'}), 404
81
+
82
+
83
+ @nodes_bp.route('/api/nodes/<node_id>', methods=['PUT'])
84
+ def update_node(node_id):
85
+ """Update a node's configuration in working workflow."""
86
+ wid = _get_workflow_id_from_request()
87
+ engine = _get_working_engine(wid)
88
+ if not engine:
89
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
90
+ node = engine.get_node(node_id)
91
+ if not node:
92
+ return jsonify({'success': False, 'error': 'Node not found'}), 404
93
+
94
+ data = _get_json_body()
95
+ if data is None:
96
+ return _json_error(_INVALID_BODY_ERROR, 400)
97
+
98
+ if 'name' in data:
99
+ node.name = data['name']
100
+ if 'config' in data:
101
+ node.configure(data['config'])
102
+ if 'enabled' in data:
103
+ node.enabled = data['enabled']
104
+
105
+ return jsonify(node.to_dict())
106
+
107
+
108
+ @nodes_bp.route('/api/nodes/<node_id>', methods=['DELETE'])
109
+ def delete_node(node_id):
110
+ """Delete a node from working workflow."""
111
+ wid = _get_workflow_id_from_request()
112
+ engine = _get_working_engine(wid)
113
+ if not engine:
114
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
115
+ try:
116
+ engine.delete_node(node_id)
117
+ return '', 204
118
+ except Exception as e:
119
+ return jsonify({'success': False, 'error': str(e)}), 400
120
+
121
+
122
+ @nodes_bp.route('/api/nodes/<node_id>/position', methods=['PUT'])
123
+ def update_node_position(node_id):
124
+ """Update a node's position in working workflow."""
125
+ data = _get_json_body()
126
+ if data is None:
127
+ return _json_error(_INVALID_BODY_ERROR, 400)
128
+ wid = _get_workflow_id_from_request()
129
+ engine = _get_working_engine(wid)
130
+ if not engine:
131
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
132
+ try:
133
+ node = engine.nodes.get(node_id)
134
+ if not node:
135
+ return jsonify({'success': False, 'error': 'Node not found'}), 404
136
+
137
+ node.x = data.get('x', 0)
138
+ node.y = data.get('y', 0)
139
+ return jsonify({'success': True})
140
+ except Exception as e:
141
+ return jsonify({'success': False, 'error': str(e)}), 400
142
+
143
+
144
+ @nodes_bp.route('/api/nodes/<node_id>/enabled', methods=['POST'])
145
+ def set_node_enabled(node_id):
146
+ """Set node enabled state (doesn't require redeployment)."""
147
+ manager = _get_manager()
148
+ data = _get_json_body()
149
+ if data is None:
150
+ return _json_error(_INVALID_BODY_ERROR, 400)
151
+ enabled = data.get('enabled', True)
152
+
153
+ try:
154
+ # Update BOTH working and deployed engines so state is preserved.
155
+ # Snapshot engine references under the lock, then work on them outside.
156
+ with manager.state_lock:
157
+ engine_pairs = [
158
+ (manager.working_engines.get(wid), manager.deployed_engines.get(wid))
159
+ for wid in manager.workflows
160
+ ]
161
+
162
+ for working, deployed in engine_pairs:
163
+ working_node = working.nodes.get(node_id) if working else None
164
+ deployed_node = deployed.nodes.get(node_id) if deployed else None
165
+
166
+ if working_node:
167
+ working_node.enabled = enabled
168
+ if deployed_node:
169
+ deployed_node.enabled = enabled
170
+
171
+ # Save workflow to persist the state
172
+ manager.save_workflow_to_disk()
173
+
174
+ return jsonify({'success': True, 'enabled': enabled})
175
+ except Exception as e:
176
+ return jsonify({'success': False, 'error': str(e)}), 400
177
+
178
+
179
+ @nodes_bp.route('/api/nodes/<node_id>/enabled', methods=['GET'])
180
+ def get_node_enabled(node_id):
181
+ """Get node enabled state."""
182
+ manager = _get_manager()
183
+ try:
184
+ # Search all workflows for this node (snapshot refs under the lock)
185
+ with manager.state_lock:
186
+ engine_pairs = [
187
+ (manager.working_engines.get(wid), manager.deployed_engines.get(wid))
188
+ for wid in manager.workflows
189
+ ]
190
+
191
+ node = None
192
+ for working, deployed in engine_pairs:
193
+ node = ((deployed.nodes.get(node_id) if deployed else None)
194
+ or (working.nodes.get(node_id) if working else None))
195
+ if node:
196
+ break
197
+
198
+ if not node:
199
+ return jsonify({'success': False, 'error': 'Node not found'}), 404
200
+
201
+ return jsonify({'enabled': node.enabled})
202
+ except Exception as e:
203
+ return jsonify({'success': False, 'error': str(e)}), 400
204
+
205
+
206
+ @nodes_bp.route('/api/connections', methods=['POST'])
207
+ def create_connection():
208
+ """Create a connection between two nodes in working workflow."""
209
+ data = _get_json_body()
210
+ if data is None:
211
+ return _json_error(_INVALID_BODY_ERROR, 400)
212
+ source_id = data.get('source')
213
+ target_id = data.get('target')
214
+ source_output = data.get('sourceOutput', 0)
215
+ target_input = data.get('targetInput', 0)
216
+ wid = _get_workflow_id_from_request()
217
+ engine = _get_working_engine(wid)
218
+ if not engine:
219
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
220
+
221
+ try:
222
+ engine.connect_nodes(source_id, target_id, source_output, target_input)
223
+ return jsonify({
224
+ 'source': source_id,
225
+ 'target': target_id,
226
+ 'sourceOutput': source_output,
227
+ 'targetInput': target_input
228
+ }), 201
229
+ except Exception as e:
230
+ return jsonify({'success': False, 'error': str(e)}), 400
231
+
232
+
233
+ @nodes_bp.route('/api/connections', methods=['DELETE'])
234
+ def delete_connection():
235
+ """Delete a connection between two nodes in working workflow."""
236
+ data = _get_json_body()
237
+ if data is None:
238
+ return _json_error(_INVALID_BODY_ERROR, 400)
239
+ source_id = data.get('source')
240
+ target_id = data.get('target')
241
+ source_output = data.get('sourceOutput', 0)
242
+ wid = _get_workflow_id_from_request()
243
+ engine = _get_working_engine(wid)
244
+ if not engine:
245
+ return jsonify({'success': False, 'error': 'Workflow not found'}), 404
246
+
247
+ try:
248
+ engine.disconnect_nodes(source_id, target_id, source_output)
249
+ return '', 204
250
+ except Exception as e:
251
+ return jsonify({'success': False, 'error': str(e)}), 400
252
+
253
+
254
+ def _get_declared_actions(node):
255
+ """Collect the union of 'actions' declared across the node's class hierarchy."""
256
+ declared = set()
257
+ for klass in type(node).__mro__:
258
+ declared.update(getattr(klass, 'actions', None) or [])
259
+ return declared
260
+
261
+
262
+ def _action_accepts_arg(method):
263
+ """Return True if `method` accepts at least one positional argument."""
264
+ try:
265
+ sig = inspect.signature(method)
266
+ except (TypeError, ValueError):
267
+ return False
268
+ for param in sig.parameters.values():
269
+ if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD,
270
+ param.VAR_POSITIONAL):
271
+ return True
272
+ return False
273
+
274
+
275
+ @nodes_bp.route('/api/nodes/<node_id>/<action>', methods=['POST'])
276
+ def trigger_node_action(node_id, action):
277
+ """Trigger a button action on a node in deployed workflow.
278
+
279
+ Only actions explicitly declared in the node class's `actions` list may be
280
+ invoked; anything else (including private/underscore names) returns 404.
281
+
282
+ Actions may optionally accept a single value supplied via a JSON body
283
+ ``{"value": ...}`` (e.g. VideoReaderNode.seek(target)); zero-argument
284
+ actions are called with no arguments.
285
+ """
286
+ try:
287
+ node, _ = _find_deployed_node(node_id)
288
+ if not node:
289
+ return jsonify({'success': False, 'error': 'Node not found'}), 404
290
+
291
+ # Only allow explicitly declared, public action names
292
+ if action.startswith('_') or action not in _get_declared_actions(node):
293
+ return jsonify({'success': False, 'error': f'Action {action} not found on node'}), 404
294
+
295
+ method = getattr(node, action, None)
296
+ if not callable(method):
297
+ return jsonify({'success': False, 'error': f'{action} is not a callable method'}), 400
298
+
299
+ data = request.get_json(silent=True)
300
+ if (isinstance(data, dict) and 'value' in data
301
+ and _action_accepts_arg(method)):
302
+ method(data['value'])
303
+ else:
304
+ method()
305
+ return jsonify({'success': True, 'status': 'success', 'action': action})
306
+ except Exception as e:
307
+ return jsonify({'success': False, 'error': str(e)}), 500
308
+
309
+
310
+ # ==============================================================================
311
+ # Dynamic node endpoint registration (from node-class ``api_routes``)
312
+ # ==============================================================================
313
+
314
+ def _add_node_route(route, view_func, endpoint_name, methods):
315
+ """Register a URL rule under the standard node API prefix."""
316
+ nodes_bp.add_url_rule(
317
+ f'/api/nodes/<node_id>/{route}',
318
+ endpoint=endpoint_name,
319
+ view_func=view_func,
320
+ methods=methods
321
+ )
322
+
323
+
324
+ def _register_json_route(route, methods, handler_name, endpoint_name):
325
+ """Register a standard JSON API route."""
326
+ def handler(node_id):
327
+ _, method, error = _resolve_node_handler(node_id, handler_name)
328
+ if error:
329
+ return error
330
+ try:
331
+ result = method()
332
+ if result is None:
333
+ return '', 204
334
+ return jsonify(result)
335
+ except Exception as e:
336
+ return jsonify({'success': False, 'error': str(e)}), 500
337
+
338
+ _add_node_route(route, handler, endpoint_name, methods)
339
+
340
+
341
+ def _register_file_upload_route(route, methods, handler_name, endpoint_name, allowed_extensions):
342
+ """Register a file upload API route."""
343
+ def handler(node_id):
344
+ _, method, error = _resolve_node_handler(node_id, handler_name)
345
+ if error:
346
+ # Re-shape error payload to the success/error contract used by uploads
347
+ payload, status = error
348
+ return jsonify({'success': False, 'error': payload.get_json()['error']}), status
349
+
350
+ if 'file' not in request.files:
351
+ return jsonify({'success': False, 'error': 'No file provided'}), 400
352
+
353
+ file = request.files['file']
354
+ if not file.filename or file.filename == '':
355
+ return jsonify({'success': False, 'error': 'No file selected'}), 400
356
+
357
+ if allowed_extensions:
358
+ ext = os.path.splitext(file.filename)[1].lower()
359
+ if ext not in allowed_extensions:
360
+ return jsonify({'success': False, 'error': f'Unsupported file type: {ext}'}), 400
361
+
362
+ try:
363
+ file_bytes = file.read()
364
+ result = method(file_bytes, file.filename)
365
+ return jsonify(result)
366
+ except Exception as e:
367
+ return jsonify({'success': False, 'error': str(e)}), 500
368
+
369
+ _add_node_route(route, handler, endpoint_name, methods)
370
+
371
+
372
+ def _register_stream_route(route, handler_name, endpoint_name):
373
+ """Register an MJPEG stream route."""
374
+ def handler(node_id):
375
+ _, method, error = _resolve_node_handler(node_id, handler_name)
376
+ if error:
377
+ return error
378
+
379
+ def generate():
380
+ for img_data in method():
381
+ yield (b'--frame\r\n'
382
+ b'Content-Type: image/jpeg\r\n\r\n' + img_data + b'\r\n')
383
+
384
+ return Response(
385
+ stream_with_context(generate()),
386
+ mimetype='multipart/x-mixed-replace; boundary=frame'
387
+ )
388
+
389
+ _add_node_route(route, handler, endpoint_name, ['GET'])
390
+
391
+
392
+ def _register_dynamic_node_routes():
393
+ """Register routes for all node types that declare api_routes."""
394
+ registered = set()
395
+
396
+ for node_type_name, routes in node_registry.api_route_registry.items():
397
+ for route_def in routes:
398
+ route = route_def['route']
399
+ methods = route_def.get('methods', ['GET'])
400
+ handler_name = route_def['handler']
401
+ route_type = route_def.get('type', 'json')
402
+
403
+ # Create a unique endpoint name for Flask
404
+ # Combine methods to handle same route with different methods
405
+ methods_key = '_'.join(sorted(methods))
406
+ endpoint_name = f"node_{node_type_name}_{route}_{methods_key}"
407
+
408
+ if endpoint_name in registered:
409
+ continue
410
+ registered.add(endpoint_name)
411
+
412
+ if route_type == 'file_upload':
413
+ allowed_ext = route_def.get('allowed_extensions', set())
414
+ _register_file_upload_route(route, methods, handler_name, endpoint_name, allowed_ext)
415
+ elif route_type == 'stream':
416
+ _register_stream_route(route, handler_name, endpoint_name)
417
+ else:
418
+ _register_json_route(route, methods, handler_name, endpoint_name)
419
+
420
+
421
+ # Register all dynamic routes on the blueprint at import time (node classes
422
+ # are static); every app the blueprint is registered on gets them.
423
+ _register_dynamic_node_routes()