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/__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
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()
|