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/install_nodes.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Install per-node dependencies for an installed PyNode package.
|
|
2
|
+
|
|
3
|
+
Each node under ``pynode/nodes/<NodeName>/`` may ship a ``requirements.txt``
|
|
4
|
+
listing packages it needs. Those files are *not* installed by ``pip install
|
|
5
|
+
pynode-flow`` -- pip only reads the dependencies/extras in ``pyproject.toml``.
|
|
6
|
+
|
|
7
|
+
Most node dependencies are covered by the ``pip install "pynode-flow[full]"``
|
|
8
|
+
extra. This command is the escape hatch for the rest: it walks the node folders
|
|
9
|
+
of the *installed* package (so it works from a plain ``pip install``, not only a
|
|
10
|
+
source checkout) and runs ``pip install -r`` for each node, which is the only
|
|
11
|
+
way to pull dependencies that cannot live in ``pyproject.toml`` -- e.g. vendor
|
|
12
|
+
SDKs like the Omron/Sentech ``stapipy`` (OmronCameraNode).
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
pynode-install-nodes # install deps for every node
|
|
17
|
+
pynode-install-nodes --node MQTTNode # only the named node(s)
|
|
18
|
+
pynode-install-nodes --list # show nodes with a requirements.txt
|
|
19
|
+
pynode-install-nodes --dry-run # print what would be installed
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import logging
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _nodes_dir() -> Path:
|
|
34
|
+
"""Return the ``nodes`` directory that ships inside this package."""
|
|
35
|
+
return Path(__file__).resolve().parent / "nodes"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_real_requirements(requirements_file: Path) -> bool:
|
|
39
|
+
"""True if the file lists at least one non-comment, non-blank requirement."""
|
|
40
|
+
try:
|
|
41
|
+
for raw_line in requirements_file.read_text(encoding="utf-8").splitlines():
|
|
42
|
+
line = raw_line.strip()
|
|
43
|
+
if line and not line.startswith("#"):
|
|
44
|
+
return True
|
|
45
|
+
except OSError:
|
|
46
|
+
return False
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_node_requirements(nodes_dir: Path | None = None) -> list[tuple[str, Path]]:
|
|
51
|
+
"""Return ``(node_name, requirements_path)`` for nodes with real requirements."""
|
|
52
|
+
nodes_dir = nodes_dir or _nodes_dir()
|
|
53
|
+
found: list[tuple[str, Path]] = []
|
|
54
|
+
if not nodes_dir.is_dir():
|
|
55
|
+
return found
|
|
56
|
+
for node_dir in sorted(p for p in nodes_dir.iterdir() if p.is_dir()):
|
|
57
|
+
if node_dir.name.startswith("__"):
|
|
58
|
+
continue
|
|
59
|
+
requirements_file = node_dir / "requirements.txt"
|
|
60
|
+
if requirements_file.is_file() and _has_real_requirements(requirements_file):
|
|
61
|
+
found.append((node_dir.name, requirements_file))
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def install_node_requirements(
|
|
66
|
+
only: list[str] | None = None,
|
|
67
|
+
dry_run: bool = False,
|
|
68
|
+
nodes_dir: Path | None = None,
|
|
69
|
+
) -> int:
|
|
70
|
+
"""Install per-node requirements. Returns the number of nodes that failed."""
|
|
71
|
+
requirements = find_node_requirements(nodes_dir)
|
|
72
|
+
if only:
|
|
73
|
+
wanted = {name.lower() for name in only}
|
|
74
|
+
requirements = [item for item in requirements if item[0].lower() in wanted]
|
|
75
|
+
missing = wanted - {name.lower() for name, _ in requirements}
|
|
76
|
+
for name in sorted(missing):
|
|
77
|
+
logger.warning("No node named %r with a requirements.txt was found", name)
|
|
78
|
+
|
|
79
|
+
if not requirements:
|
|
80
|
+
logger.info("No node dependencies to install.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
failures = 0
|
|
84
|
+
for node_name, requirements_file in requirements:
|
|
85
|
+
logger.info("Installing requirements for %s...", node_name)
|
|
86
|
+
cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
|
87
|
+
if dry_run:
|
|
88
|
+
logger.info(" [dry-run] %s", " ".join(cmd))
|
|
89
|
+
continue
|
|
90
|
+
result = subprocess.run(cmd, check=False)
|
|
91
|
+
if result.returncode == 0:
|
|
92
|
+
logger.info(" ✓ %s dependencies installed", node_name)
|
|
93
|
+
else:
|
|
94
|
+
failures += 1
|
|
95
|
+
logger.error(" ✗ Failed to install dependencies for %s", node_name)
|
|
96
|
+
|
|
97
|
+
installed = len(requirements) - failures
|
|
98
|
+
logger.info(
|
|
99
|
+
"Done. Processed %d node(s); %d succeeded, %d failed.",
|
|
100
|
+
len(requirements),
|
|
101
|
+
installed if not dry_run else 0,
|
|
102
|
+
failures,
|
|
103
|
+
)
|
|
104
|
+
return failures
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
prog="pynode-install-nodes",
|
|
110
|
+
description="Install the per-node Python dependencies bundled with PyNode.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--node",
|
|
114
|
+
action="append",
|
|
115
|
+
dest="nodes",
|
|
116
|
+
metavar="NodeName",
|
|
117
|
+
help="Only install this node's requirements (repeatable).",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--list",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="List nodes that ship a requirements.txt and exit.",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--dry-run",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="Show what would be installed without running pip.",
|
|
128
|
+
)
|
|
129
|
+
args = parser.parse_args(argv)
|
|
130
|
+
|
|
131
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
132
|
+
|
|
133
|
+
if args.list:
|
|
134
|
+
for node_name, requirements_file in find_node_requirements():
|
|
135
|
+
print(f"{node_name}: {requirements_file}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
failures = install_node_requirements(only=args.nodes, dry_run=args.dry_run)
|
|
139
|
+
return 1 if failures else 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
raise SystemExit(main())
|
pynode/main.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyNode - A Node-RED-like Visual Workflow System with Python Backend
|
|
3
|
+
|
|
4
|
+
This is a complete system for creating visual workflows with Python nodes.
|
|
5
|
+
Each node represents a function/operation and can be connected to create data pipelines.
|
|
6
|
+
|
|
7
|
+
Quick Start:
|
|
8
|
+
1. Install: pip install -e .
|
|
9
|
+
2. Run: pynode
|
|
10
|
+
3. Open browser: http://localhost:5000
|
|
11
|
+
|
|
12
|
+
Usage Example:
|
|
13
|
+
from pynode.workflow_engine import WorkflowEngine
|
|
14
|
+
from pynode.nodes import InjectNode, DebugNode
|
|
15
|
+
|
|
16
|
+
engine = WorkflowEngine()
|
|
17
|
+
engine.register_node_type(InjectNode)
|
|
18
|
+
engine.register_node_type(DebugNode)
|
|
19
|
+
|
|
20
|
+
inject = engine.create_node('InjectNode', name='my_inject')
|
|
21
|
+
debug = engine.create_node('DebugNode', name='my_debug')
|
|
22
|
+
engine.connect_nodes(inject.id, debug.id)
|
|
23
|
+
engine.start()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main():
|
|
34
|
+
"""Main entry point for PyNode application."""
|
|
35
|
+
# Parse command line arguments
|
|
36
|
+
parser = argparse.ArgumentParser(description='PyNode Visual Workflow System')
|
|
37
|
+
parser.add_argument('--production', action='store_true',
|
|
38
|
+
help='Run in production mode using Waitress server')
|
|
39
|
+
parser.add_argument('--host', default='0.0.0.0',
|
|
40
|
+
help='Host address to bind to (default: 0.0.0.0)')
|
|
41
|
+
parser.add_argument('--port', type=int, default=5000,
|
|
42
|
+
help='Port to bind to (default: 5000)')
|
|
43
|
+
parser.add_argument('--log-level', default='INFO',
|
|
44
|
+
help='Logging level (DEBUG, INFO, WARNING, ERROR). Default: INFO')
|
|
45
|
+
parser.add_argument('--cors-origins', default=None,
|
|
46
|
+
help='Comma-separated list of allowed CORS origins '
|
|
47
|
+
'(default: * i.e. all origins). '
|
|
48
|
+
'Also settable via the PYNODE_CORS_ORIGINS env var.')
|
|
49
|
+
parser.add_argument('--api-key', default=None,
|
|
50
|
+
help='Require this API key on all /api/ requests '
|
|
51
|
+
'(X-API-Key header or api_key query parameter). '
|
|
52
|
+
'Also settable via the PYNODE_API_KEY env var. '
|
|
53
|
+
'Default: no authentication.')
|
|
54
|
+
parser.add_argument('--data-dir', default=None,
|
|
55
|
+
help='Directory for PyNode data; workflows are '
|
|
56
|
+
'persisted under <data-dir>/workflows/. '
|
|
57
|
+
'Also settable via the PYNODE_DATA_DIR env var. '
|
|
58
|
+
'Default: the source checkout root when running '
|
|
59
|
+
'from a checkout, otherwise ~/.pynode.')
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
|
|
62
|
+
# Configure application-wide logging
|
|
63
|
+
logging.basicConfig(
|
|
64
|
+
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
|
65
|
+
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# CORS and the data directory are configured when pynode.server is first
|
|
69
|
+
# imported (it builds the module-level default app), so the CLI flags
|
|
70
|
+
# must be exported to the environment BEFORE the import below.
|
|
71
|
+
# (This is why the server import lives inside main() rather than at
|
|
72
|
+
# module top.) The env var names live in pynode.config.
|
|
73
|
+
if args.cors_origins is not None:
|
|
74
|
+
os.environ['PYNODE_CORS_ORIGINS'] = args.cors_origins
|
|
75
|
+
if args.data_dir is not None:
|
|
76
|
+
os.environ['PYNODE_DATA_DIR'] = args.data_dir
|
|
77
|
+
|
|
78
|
+
from pynode.server import app, load_workflow_from_disk
|
|
79
|
+
|
|
80
|
+
# The API key is read dynamically from app.config on every request, so
|
|
81
|
+
# it can simply be set after import. CLI flag wins over the env var
|
|
82
|
+
# (which server.py already applied as the app.config default).
|
|
83
|
+
if args.api_key is not None:
|
|
84
|
+
app.config['PYNODE_API_KEY'] = args.api_key
|
|
85
|
+
|
|
86
|
+
if app.config.get('PYNODE_API_KEY'):
|
|
87
|
+
logger.info("API key authentication ENABLED: /api/ requests require "
|
|
88
|
+
"the X-API-Key header or api_key query parameter")
|
|
89
|
+
if args.cors_origins is not None:
|
|
90
|
+
logger.info(f"CORS allowed origins: {args.cors_origins}")
|
|
91
|
+
|
|
92
|
+
# Load workflow from disk on startup
|
|
93
|
+
logger.info("Loading workflow from disk...")
|
|
94
|
+
load_workflow_from_disk()
|
|
95
|
+
|
|
96
|
+
if args.production:
|
|
97
|
+
# Production mode with Waitress
|
|
98
|
+
try:
|
|
99
|
+
from waitress import serve
|
|
100
|
+
logger.info("Starting PyNode server in PRODUCTION mode...")
|
|
101
|
+
logger.info(f"Server running at: http://{args.host}:{args.port}")
|
|
102
|
+
logger.info("Supports unlimited concurrent connections")
|
|
103
|
+
serve(app, host=args.host, port=args.port, threads=10,
|
|
104
|
+
channel_timeout=120, backlog=1024, connection_limit=1000)
|
|
105
|
+
except ImportError:
|
|
106
|
+
logger.error("waitress not installed. Install with: pip install waitress")
|
|
107
|
+
logger.warning("Falling back to development server...")
|
|
108
|
+
app.run(debug=False, host=args.host, port=args.port, threaded=True)
|
|
109
|
+
else:
|
|
110
|
+
# Development mode with Flask built-in server
|
|
111
|
+
logger.info("Starting PyNode server in DEVELOPMENT mode...")
|
|
112
|
+
logger.info(f"Server running at: http://{args.host}:{args.port}")
|
|
113
|
+
logger.info("For production use: pynode --production")
|
|
114
|
+
app.run(debug=False, host=args.host, port=args.port, threaded=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == '__main__':
|
|
118
|
+
main()
|
pynode/node_registry.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Static node-type registries built once at import time.
|
|
2
|
+
|
|
3
|
+
Node classes are static for the lifetime of the process, so everything here
|
|
4
|
+
(the reference engine used for introspection, the node-types cache served by
|
|
5
|
+
/api/node-types, and the api_routes / sse_handlers registries used for
|
|
6
|
+
dynamic route registration and SSE broadcasting) is safely shared by every
|
|
7
|
+
Flask app / WorkflowManager instance.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from pynode.workflow_engine import WorkflowEngine
|
|
13
|
+
from pynode import nodes
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_workflow_engine():
|
|
19
|
+
"""Create a new WorkflowEngine with all node types registered."""
|
|
20
|
+
engine = WorkflowEngine()
|
|
21
|
+
for node_class in nodes.get_all_node_types():
|
|
22
|
+
engine.register_node_type(node_class)
|
|
23
|
+
return engine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Reference engine for node type introspection only
|
|
27
|
+
reference_engine = create_workflow_engine()
|
|
28
|
+
|
|
29
|
+
# Cache for node types (built once at startup or on first request)
|
|
30
|
+
_node_types_cache = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_node_types_cache():
|
|
34
|
+
"""Build the node types cache from registered node types."""
|
|
35
|
+
global _node_types_cache
|
|
36
|
+
|
|
37
|
+
from pynode.nodes.base_node import BaseNode
|
|
38
|
+
base_properties = getattr(BaseNode, 'properties', [])
|
|
39
|
+
|
|
40
|
+
# Define category ordering (compared case-insensitively; display casing is
|
|
41
|
+
# applied by the frontend, e.g. 'opencv' renders as 'OpenCV').
|
|
42
|
+
category_order = [
|
|
43
|
+
'common',
|
|
44
|
+
'input',
|
|
45
|
+
'output',
|
|
46
|
+
'function',
|
|
47
|
+
'logic',
|
|
48
|
+
'network',
|
|
49
|
+
'vision',
|
|
50
|
+
'analysis',
|
|
51
|
+
'node probes',
|
|
52
|
+
'opencv'
|
|
53
|
+
]
|
|
54
|
+
category_rank = {c.lower(): i for i, c in enumerate(category_order)}
|
|
55
|
+
|
|
56
|
+
node_types = []
|
|
57
|
+
for name, node_class in reference_engine.node_types.items():
|
|
58
|
+
# Skip ErrorNode - it's a system node that shouldn't be manually added
|
|
59
|
+
if node_class.hidden:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
display_name = getattr(node_class, 'display_name', name)
|
|
63
|
+
icon = getattr(node_class, 'icon', '◆')
|
|
64
|
+
category = getattr(node_class, 'category', 'custom')
|
|
65
|
+
color = getattr(node_class, 'color', '#2d2d30')
|
|
66
|
+
border_color = getattr(node_class, 'border_color', '#555')
|
|
67
|
+
text_color = getattr(node_class, 'text_color', '#d4d4d4')
|
|
68
|
+
input_count = getattr(node_class, 'input_count', 1)
|
|
69
|
+
output_count = getattr(node_class, 'output_count', 1)
|
|
70
|
+
|
|
71
|
+
# Handle callable properties (get_properties classmethod)
|
|
72
|
+
if hasattr(node_class, 'get_properties') and callable(node_class.get_properties):
|
|
73
|
+
node_properties = node_class.get_properties()
|
|
74
|
+
else:
|
|
75
|
+
node_properties = getattr(node_class, 'properties', [])
|
|
76
|
+
|
|
77
|
+
# Ensure node_properties is iterable (list or tuple)
|
|
78
|
+
if not isinstance(node_properties, (list, tuple)):
|
|
79
|
+
node_properties = []
|
|
80
|
+
|
|
81
|
+
# Get property names from node-specific properties to avoid duplicates
|
|
82
|
+
node_prop_names = {prop.get('name') for prop in node_properties if isinstance(prop, dict)}
|
|
83
|
+
|
|
84
|
+
# Add base properties that aren't overridden by node-specific properties
|
|
85
|
+
merged_properties = [prop for prop in base_properties if prop.get('name') not in node_prop_names]
|
|
86
|
+
# Add all node-specific properties
|
|
87
|
+
merged_properties.extend(node_properties)
|
|
88
|
+
|
|
89
|
+
ui_component = getattr(node_class, 'ui_component', None)
|
|
90
|
+
ui_component_config = getattr(node_class, 'ui_component_config', {})
|
|
91
|
+
info = getattr(node_class, 'info', '')
|
|
92
|
+
|
|
93
|
+
node_types.append({
|
|
94
|
+
'type': name,
|
|
95
|
+
'name': display_name,
|
|
96
|
+
'icon': icon,
|
|
97
|
+
'category': category,
|
|
98
|
+
'color': color,
|
|
99
|
+
'borderColor': border_color,
|
|
100
|
+
'textColor': text_color,
|
|
101
|
+
'inputCount': input_count,
|
|
102
|
+
'outputCount': output_count,
|
|
103
|
+
'properties': merged_properties,
|
|
104
|
+
'uiComponent': ui_component,
|
|
105
|
+
'uiComponentConfig': ui_component_config,
|
|
106
|
+
'info': info
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
# Sort node types by category order, then alphabetically by name within
|
|
110
|
+
# each category. Category comparison is case-insensitive so e.g. nodes
|
|
111
|
+
# declaring 'opencv' or 'OpenCV' land in the same bucket.
|
|
112
|
+
def get_category_sort_key(node_type):
|
|
113
|
+
category = str(node_type['category']).lower()
|
|
114
|
+
# Categories not in the list go to the end (custom/system/third party)
|
|
115
|
+
order_index = category_rank.get(category, len(category_order))
|
|
116
|
+
return (order_index, node_type['name'].lower(), node_type['name'])
|
|
117
|
+
|
|
118
|
+
node_types.sort(key=get_category_sort_key)
|
|
119
|
+
|
|
120
|
+
_node_types_cache = node_types
|
|
121
|
+
return _node_types_cache
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_node_types():
|
|
125
|
+
"""Return the node-types cache, building it on first use if needed."""
|
|
126
|
+
if _node_types_cache is None:
|
|
127
|
+
build_node_types_cache()
|
|
128
|
+
return _node_types_cache
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Build a registry of node types that have SSE handlers
|
|
132
|
+
# Maps node_type_name -> list of sse_handler defs
|
|
133
|
+
sse_handler_registry = {}
|
|
134
|
+
|
|
135
|
+
# Build a registry of node types that have api_routes
|
|
136
|
+
# Maps node_type_name -> list of route defs
|
|
137
|
+
api_route_registry = {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_node_registries():
|
|
141
|
+
"""Scan all node classes and build registries for API routes and SSE handlers."""
|
|
142
|
+
for node_class in nodes.get_all_node_types():
|
|
143
|
+
node_type_name = node_class.__name__
|
|
144
|
+
|
|
145
|
+
api_routes = getattr(node_class, 'api_routes', [])
|
|
146
|
+
if api_routes:
|
|
147
|
+
api_route_registry[node_type_name] = api_routes
|
|
148
|
+
|
|
149
|
+
sse_handlers = getattr(node_class, 'sse_handlers', [])
|
|
150
|
+
if sse_handlers:
|
|
151
|
+
sse_handler_registry[node_type_name] = sse_handlers
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Build caches/registries at import
|
|
155
|
+
build_node_types_cache()
|
|
156
|
+
_build_node_registries()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto Sync Node - joins messages from multiple inputs and outputs them together.
|
|
3
|
+
Simply buffers the latest message from each input and outputs all when a new message arrives.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
from pynode.nodes.base_node import BaseNode, Info, MessageKeys
|
|
8
|
+
|
|
9
|
+
_info = Info()
|
|
10
|
+
_info.add_text("Joins messages from multiple inputs by buffering the latest message from each input and outputting them together when any new message arrives.")
|
|
11
|
+
_info.add_header("Inputs")
|
|
12
|
+
_info.add_bullets(
|
|
13
|
+
("Input 0-N:", "Messages to synchronize. The node buffers the latest message from each input."),
|
|
14
|
+
)
|
|
15
|
+
_info.add_header("Output")
|
|
16
|
+
_info.add_bullets(
|
|
17
|
+
("payload.messages:", "Array of payloads from all buffered messages."),
|
|
18
|
+
("payload.sync_values:", "Array of sync property values from each message."),
|
|
19
|
+
("payload.delta:", "Difference between max and min sync values."),
|
|
20
|
+
)
|
|
21
|
+
_info.add_header("Properties")
|
|
22
|
+
_info.add_bullets(
|
|
23
|
+
("Number of Inputs:", "How many input ports to synchronize (2-10)."),
|
|
24
|
+
("Sync Property Path:", "Property path to extract for delta calculation (e.g., 'frame_count')."),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AutoSyncNode(BaseNode):
|
|
29
|
+
"""
|
|
30
|
+
Auto Sync node - joins messages from multiple inputs.
|
|
31
|
+
Buffers the latest message from each input and outputs them together
|
|
32
|
+
along with the delta between sync property values.
|
|
33
|
+
"""
|
|
34
|
+
info = str(_info)
|
|
35
|
+
display_name = 'Auto Sync'
|
|
36
|
+
icon = '⚡'
|
|
37
|
+
category = 'logic'
|
|
38
|
+
color = '#FFB347'
|
|
39
|
+
border_color = '#CC8F39'
|
|
40
|
+
text_color = '#000000'
|
|
41
|
+
input_count = 2 # Default, can be changed via properties
|
|
42
|
+
output_count = 1
|
|
43
|
+
|
|
44
|
+
DEFAULT_CONFIG = {
|
|
45
|
+
'input_count': 2,
|
|
46
|
+
'sync_property': 'frame_count'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
properties = [
|
|
50
|
+
{
|
|
51
|
+
'name': 'input_count',
|
|
52
|
+
'label': 'Number of Inputs',
|
|
53
|
+
'type': 'number',
|
|
54
|
+
'default': DEFAULT_CONFIG['input_count'],
|
|
55
|
+
'min': 2,
|
|
56
|
+
'max': 10,
|
|
57
|
+
'help': 'Number of input ports to join'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
'name': 'sync_property',
|
|
61
|
+
'label': 'Sync Property Path',
|
|
62
|
+
'type': 'text',
|
|
63
|
+
'default': DEFAULT_CONFIG['sync_property'],
|
|
64
|
+
'help': 'Property path to compare for delta (e.g., "frame_count", "timestamp")'
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def __init__(self, node_id=None, name="auto sync"):
|
|
69
|
+
super().__init__(node_id, name)
|
|
70
|
+
self._buffers: Dict[int, Optional[Dict[str, Any]]] = {}
|
|
71
|
+
|
|
72
|
+
def configure(self, config: Dict[str, Any]):
|
|
73
|
+
"""Configure node and update input count."""
|
|
74
|
+
super().configure(config)
|
|
75
|
+
|
|
76
|
+
new_input_count = self.get_config_int('input_count', 2)
|
|
77
|
+
if new_input_count != self.input_count:
|
|
78
|
+
self.input_count = new_input_count
|
|
79
|
+
self._buffers = {i: None for i in range(self.input_count)}
|
|
80
|
+
|
|
81
|
+
def on_start(self):
|
|
82
|
+
"""Initialize buffers on start."""
|
|
83
|
+
super().on_start()
|
|
84
|
+
self._buffers = {i: None for i in range(self.input_count)}
|
|
85
|
+
|
|
86
|
+
def _get_sync_value(self, msg: Dict[str, Any]) -> Optional[float]:
|
|
87
|
+
"""Extract the sync property value from a message."""
|
|
88
|
+
sync_property = self.config.get('sync_property', 'frame_count')
|
|
89
|
+
|
|
90
|
+
# Try direct message property first
|
|
91
|
+
value = msg.get(sync_property)
|
|
92
|
+
|
|
93
|
+
# If not found, try nested path
|
|
94
|
+
if value is None:
|
|
95
|
+
value = self._get_nested_value(msg, sync_property)
|
|
96
|
+
|
|
97
|
+
# If still not found, try in payload
|
|
98
|
+
if value is None and MessageKeys.PAYLOAD in msg:
|
|
99
|
+
value = self._get_nested_value(msg[MessageKeys.PAYLOAD], sync_property)
|
|
100
|
+
|
|
101
|
+
# Convert to float if possible
|
|
102
|
+
if value is not None:
|
|
103
|
+
try:
|
|
104
|
+
return float(value)
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def on_input(self, msg: Dict[str, Any], input_index: int = 0):
|
|
111
|
+
"""Process incoming messages."""
|
|
112
|
+
if input_index >= self.input_count:
|
|
113
|
+
self.report_error(f"Received message on invalid input {input_index}")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Buffer the message
|
|
117
|
+
self._buffers[input_index] = msg
|
|
118
|
+
|
|
119
|
+
# Check if all inputs have messages
|
|
120
|
+
messages = []
|
|
121
|
+
sync_values = []
|
|
122
|
+
|
|
123
|
+
for i in range(self.input_count):
|
|
124
|
+
buffered = self._buffers.get(i)
|
|
125
|
+
if buffered is None:
|
|
126
|
+
return # Not all inputs have messages yet
|
|
127
|
+
messages.append(buffered)
|
|
128
|
+
sync_values.append(self._get_sync_value(buffered))
|
|
129
|
+
|
|
130
|
+
# All inputs have messages - compute delta and output
|
|
131
|
+
valid_values = [v for v in sync_values if v is not None]
|
|
132
|
+
if valid_values:
|
|
133
|
+
delta = max(valid_values) - min(valid_values)
|
|
134
|
+
else:
|
|
135
|
+
delta = None
|
|
136
|
+
|
|
137
|
+
# Build output
|
|
138
|
+
out_msg = {
|
|
139
|
+
MessageKeys.PAYLOAD: {
|
|
140
|
+
'messages': [m.get(MessageKeys.PAYLOAD) for m in messages],
|
|
141
|
+
'sync_values': sync_values,
|
|
142
|
+
'delta': delta
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Copy other properties from first message
|
|
147
|
+
for key in messages[0]:
|
|
148
|
+
if key not in (MessageKeys.PAYLOAD, MessageKeys.MSG_ID):
|
|
149
|
+
out_msg[key] = messages[0][key]
|
|
150
|
+
|
|
151
|
+
self.send(out_msg)
|