@inteli.city/node-red-contrib-exec-collection 1.0.4 → 1.0.5

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.
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ module.exports = function(RED) {
6
+
7
+ function PythonConfigNode(n) {
8
+ RED.nodes.createNode(this, n);
9
+
10
+ this.name = n.name;
11
+ this.pythonPath = n.pythonPath || '';
12
+
13
+ if (!this.pythonPath) {
14
+ this.error('python.config: pythonPath is required');
15
+ return;
16
+ }
17
+
18
+ if (!fs.existsSync(this.pythonPath)) {
19
+ this.warn(`python.config: path does not exist: ${this.pythonPath}`);
20
+ }
21
+ }
22
+
23
+ RED.nodes.registerType('python.config', PythonConfigNode);
24
+ };
@@ -0,0 +1,360 @@
1
+ <!--* header -->
2
+ <script type="text/markdown" data-help-name="python.queue">
3
+ ## python.queue
4
+
5
+ Sends code to a pool of persistent Python workers. Each message renders the Nunjucks template into Python, dispatches it to a free worker via stdin, and returns stdout as `msg.payload`.
6
+
7
+ **Mental model:** you are not running a script — you are sending code to a running Python engine. State persists between messages on the same worker.
8
+
9
+ ---
10
+
11
+ ## Python
12
+
13
+ Select a **python.config** node to choose which Python binary the workers use. If none is selected, falls back to `python3`.
14
+
15
+ The config node can point to a system Python or a virtual environment:
16
+
17
+ ```
18
+ /usr/bin/python3
19
+ /home/user/myenv/bin/python
20
+ ```
21
+
22
+ The environment must already exist with all required packages installed.
23
+
24
+ ---
25
+
26
+ ## Queue
27
+
28
+ Controls how many Python workers run concurrently. The status badge shows `waiting (executing/limit)`:
29
+
30
+ | Status | Meaning |
31
+ |---|---|
32
+ | Blue dot `0 (0/2)` | Workers running, all idle |
33
+ | Blue ring `0 (2/2)` | All workers executing |
34
+ | Blue ring `3 (2/2)` | 3 messages waiting, both workers busy |
35
+ | Grey dot `0 (0/2)` | No workers (idle timeout or not started) |
36
+
37
+ Workers start lazily on the first message. After 20 minutes idle, all workers are killed and restart on the next message.
38
+
39
+ The **⏹ button** in the node editor header kills all workers immediately — no redeploy needed. A confirmation dialog appears whenever workers are alive (executing or idle).
40
+
41
+ ---
42
+
43
+ ## Writing templates
44
+
45
+ Nunjucks renders `{{ }}` expressions before Python sees the code. All `msg` values are strings after rendering.
46
+
47
+ **Always wrap string variables in Python quotes:**
48
+
49
+ ```python
50
+ print("{{ payload }}") # correct
51
+ print({{ payload }}) # wrong — NameError
52
+ ```
53
+
54
+ Numbers and serialized objects can be injected directly:
55
+
56
+ ```python
57
+ threshold = {{ payload }} # safe when payload is a number
58
+ ```
59
+
60
+ **Warning:** Nunjucks evaluates `{{ }}` inside `#` comments too. Don't put expressions in comments unless you intend them to render.
61
+
62
+ ---
63
+
64
+ ## Persistent state
65
+
66
+ Each worker's namespace persists across all messages it handles:
67
+
68
+ ```python
69
+ if "model" not in dir():
70
+ import pickle
71
+ with open("/path/to/model.pkl", "rb") as f:
72
+ model = pickle.load(f)
73
+
74
+ import json
75
+ prediction = model.predict([json.loads("{{ payload }}")])[0]
76
+ print(json.dumps({"prediction": int(prediction)}))
77
+ ```
78
+
79
+ With Queue > 1, state is **per-worker** — no shared state between workers.
80
+
81
+ ---
82
+
83
+ ## Output
84
+
85
+ The bottom row controls how output is handled:
86
+
87
+ **Output format** — how each segment is parsed (plain text, JSON, YAML, XML).
88
+
89
+ **Stream** — how stdout is split into messages:
90
+ - **Delimited** (default): buffers stdout and splits on the delimiter (`\n` by default). Each `print()` produces one message.
91
+ - **Raw**: emits each stdout chunk immediately with no buffering. Chunks may not align with `print()` calls.
92
+
93
+ **Delimiter** — only visible in Delimited mode. Supports escape sequences: `\n`, `\t`, `\r`, or any literal string such as `|||`.
94
+
95
+ Use `print()` to produce output.
96
+
97
+ ---
98
+
99
+ ## stderr
100
+
101
+ ```python
102
+ import sys
103
+ print("debug info", file=sys.stderr) # node warning — not in payload
104
+ print('{"result": 42}') # becomes msg.payload
105
+ ```
106
+
107
+ ---
108
+
109
+ [Full documentation](https://www.npmjs.com/package/@inteli.city/node-red-contrib-exec-collection) · [Nunjucks templating](https://mozilla.github.io/nunjucks/templating.html)
110
+ </script>
111
+
112
+ <!--* styles -->
113
+ <style>
114
+ #node-pq-kill-btn:hover { color: #d9534f !important; }
115
+ #node-pq-kill-btn.is-executing { color: #d9534f !important; }
116
+ </style>
117
+
118
+ <!--* node-design -->
119
+ <script type="text/html" data-template-name="python.queue">
120
+ <div class="form-row" style="position:relative;">
121
+ <label for="node-input-name">
122
+ <i class="fa fa-tag"></i>
123
+ <span data-i18n="node-red:common.label.name"></span>
124
+ </label>
125
+ <div style="display: inline-block; width: calc(100% - 140px)">
126
+ <input type="text" id="node-input-name" style="width:100%;" data-i18n="[placeholder]node-red:common.label.name">
127
+ </div>
128
+ <button id="node-pq-kill-btn"
129
+ title="Kill running workers"
130
+ style="position:absolute; right:5px; top:50%; transform:translateY(-50%); z-index:5;
131
+ background:transparent; border:none; padding:6px 8px; margin-left:10px; cursor:pointer;
132
+ color:#888; font-size:13px; line-height:1;">
133
+ <i class="fa fa-stop"></i>
134
+ </button>
135
+ </div>
136
+
137
+ <div class="form-row">
138
+ <label>
139
+ <i class="fa fa-sign-out"></i>
140
+ <span>Queue</span>
141
+ </label>
142
+ <input style="width:60px;" type="number" id="node-input-queue" value="1">
143
+ <label style="margin-left:15px; width:auto;" for="node-input-pythonConfig">
144
+ <i class="fa fa-terminal"></i>
145
+ <span>Python</span>
146
+ </label>
147
+ <input type="text" id="node-input-pythonConfig" style="width:calc(100% - 260px); margin-left:5px;">
148
+ </div>
149
+
150
+ <div class="form-row" style="position: relative;">
151
+ <label>
152
+ <i class="fa fa-code"></i>
153
+ <span>Template</span>
154
+ </label>
155
+
156
+ <div style="position: absolute; right:0; display:inline-block; text-align: right; font-size: 0.8em;">
157
+ <input type="hidden" id="node-input-template" autofocus="autofocus">
158
+ <button id="node-template-expand-editor" class="red-ui-button red-ui-button-small"><i class="fa fa-expand"></i></button>
159
+ </div>
160
+ </div>
161
+ <div class="form-row node-text-editor-row">
162
+ <div style="height: 300px; min-height:150px;" class="node-text-editor" id="node-input-template-editor"></div>
163
+ </div>
164
+
165
+ <div class="form-row" style="margin-bottom:0px; white-space:nowrap;">
166
+ <label for="node-input-output"><i class="fa fa-long-arrow-right"></i> <span data-i18n="node-red:template.label.output"></span></label>
167
+ <select id="node-input-output" style="width:90px;">
168
+ <option value="str">Plain text</option>
169
+ <option value="parsedJSON">Parsed JSON</option>
170
+ <option value="parsedYAML">Parsed YAML</option>
171
+ <option value="parsedXML">Parsed XML</option>
172
+ </select>
173
+
174
+ <label style="margin-left:14px;" for="node-input-field"><i class="fa fa-ellipsis-h"></i> <span data-i18n="node-red:common.label.property"></span></label>
175
+ <input style="margin-left:-14px; width:118px;" type="text" id="node-input-field" placeholder="payload">
176
+ <input style="margin-left:-14px; width:118px;" type="hidden" id="node-input-fieldType">
177
+
178
+ <label style="margin-left:14px;">
179
+ <i class="fa fa-random"></i>
180
+ <span>Parsing</span>
181
+ </label>
182
+ <select id="node-input-streamMode" style="width:90px; margin-left:-20px;">
183
+ <option value="delimited">Delimited</option>
184
+ <option value="raw">Raw</option>
185
+ </select>
186
+ <input type="text" id="node-input-delimiter" style="margin-left:4px; width:46px;" placeholder="\n">
187
+ </div>
188
+ <div id="node-pq-raw-hint" style="display:none; text-align:right; margin-top:3px; font-size:0.75em; color:#aaa;">
189
+ Raw output may produce incomplete messages.
190
+ </div>
191
+ </script>
192
+
193
+ <!--* javascript -->
194
+ <script type="text/javascript">
195
+ RED.nodes.registerType('python.queue', {
196
+ color: "rgb(100, 140, 200)",
197
+ category: 'function',
198
+ defaults: {
199
+ name: { value: "" },
200
+ pythonConfig: { value: "", type: "python.config", required: false },
201
+ queue: { value: 1 },
202
+ streamMode: { value: "delimited" },
203
+ delimiter: { value: "\\n", validate: function(v) { return typeof v === 'string' && v.length > 0; } },
204
+ template: { value: "# All msg values render as strings. Wrap in quotes for Python string literals.\n# WARNING: Nunjucks evaluates {{ }} everywhere, including inside # comments.\n\ndata = \"{{ payload }}\"\nprint(data)" },
205
+ output: { value: "str" },
206
+ field: { value: "payload", validate: RED.validators.typedInput("fieldType") },
207
+ fieldType: { value: "msg" },
208
+ currentLine: { value: { row: 0, column: 0 } }
209
+ },
210
+ inputs: 1,
211
+ outputs: 1,
212
+ icon: "cog.png",
213
+ label: function() {
214
+ return this.name || "python.queue";
215
+ },
216
+ labelStyle: function() {
217
+ return this.name ? "node_label_italic" : "";
218
+ },
219
+ oneditprepare: function() {
220
+ var that = this;
221
+
222
+ if (!this.field) { this.field = 'payload'; }
223
+ if (!this.fieldType) { this.fieldType = 'msg'; }
224
+
225
+ $("#node-input-field").typedInput({
226
+ default: 'msg',
227
+ types: ['msg', 'flow', 'global'],
228
+ typeField: $("#node-input-fieldType")
229
+ });
230
+
231
+ function updateStreamUI() {
232
+ var mode = $("#node-input-streamMode").val();
233
+ var $sel = $("#node-input-streamMode");
234
+ var $rawOpt = $sel.find("option[value='raw']");
235
+ if (mode === 'raw') {
236
+ $("#node-input-delimiter").hide();
237
+ $("#node-pq-raw-hint").show();
238
+ $sel.css("color", "#b8860b");
239
+ $rawOpt.text("⚠ Raw");
240
+ $sel.attr("title", "Raw mode emits arbitrary stdout chunks. Messages may be incomplete or split unpredictably.");
241
+ } else {
242
+ $("#node-input-delimiter").show();
243
+ $("#node-pq-raw-hint").hide();
244
+ $sel.css("color", "");
245
+ $rawOpt.text("Raw");
246
+ $sel.removeAttr("title");
247
+ }
248
+ }
249
+ $("#node-input-streamMode").on("change", updateStreamUI);
250
+ updateStreamUI();
251
+
252
+ this.editor = RED.editor.createEditor({
253
+ id: 'node-input-template-editor',
254
+ mode: 'ace/mode/python',
255
+ value: $("#node-input-template").val()
256
+ });
257
+
258
+ if (this.currentLine === undefined) { this.currentLine = { row: 0, column: 0 }; }
259
+ this.editor.resize(true);
260
+ this.editor.scrollToLine(this.currentLine.row + 1, true, true, function() {});
261
+ this.editor.gotoLine(this.currentLine.row + 1, this.currentLine.column + 1, true);
262
+
263
+ RED.popover.tooltip(
264
+ $("#node-template-expand-editor"),
265
+ RED._("node-red:common.label.expand")
266
+ );
267
+
268
+ $("#node-template-expand-editor").on("click", function(e) {
269
+ e.preventDefault();
270
+ var value = that.editor.getValue();
271
+ RED.editor.editText({
272
+ mode: 'python',
273
+ value: value,
274
+ width: "Infinity",
275
+ cursor: that.editor.getCursorPosition(),
276
+ complete: function(v, cursor) {
277
+ that.editor.setValue(v, -1);
278
+ that.editor.gotoLine(cursor.row + 1, cursor.column, false);
279
+ setTimeout(function() { that.editor.focus(); }, 300);
280
+ }
281
+ });
282
+ });
283
+
284
+ setTimeout(function() {
285
+ var nodeId = that.id;
286
+
287
+ function refreshKillBtn() {
288
+ $.get("python-queue/" + nodeId + "/status", function(data) {
289
+ var executing = parseInt(data.executing, 10) || 0;
290
+ $("#node-pq-kill-btn").toggleClass("is-executing", executing > 0);
291
+ });
292
+ }
293
+ refreshKillBtn();
294
+ var killBtnInterval = setInterval(refreshKillBtn, 1000);
295
+
296
+ $("#node-pq-kill-btn").on("click", function(e) {
297
+ e.preventDefault();
298
+ e.stopPropagation();
299
+
300
+ function doKill() {
301
+ $.post("python-queue/" + nodeId + "/kill");
302
+ setTimeout(refreshKillBtn, 300);
303
+ }
304
+
305
+ $.get("python-queue/" + nodeId + "/status", function(data) {
306
+ var executing = parseInt(data.executing, 10) || 0;
307
+ var workers = parseInt(data.workers, 10) || 0;
308
+ console.log("[python.queue kill] click — nodeId:", nodeId, "executing:", executing, "workers:", workers);
309
+ if (workers > 0) {
310
+ var msg = executing > 0
311
+ ? "Kill <strong>" + executing + "</strong> running worker" + (executing > 1 ? "s" : "") + "?"
312
+ : "Kill idle workers?";
313
+ var $dlg = $("<div>")
314
+ .html(msg)
315
+ .dialog({
316
+ modal: true,
317
+ title: "Kill running workers",
318
+ closeOnEscape: true,
319
+ width: 320,
320
+ buttons: {
321
+ "Cancel": function() { $(this).dialog("close"); },
322
+ "Kill": function() { $(this).dialog("close"); doKill(); }
323
+ },
324
+ close: function() { $(this).dialog("destroy").remove(); }
325
+ });
326
+ $dlg.closest(".ui-dialog").css("z-index", 9999);
327
+ } else {
328
+ doKill();
329
+ }
330
+ }).fail(function() { doKill(); });
331
+ });
332
+
333
+ that._killBtnInterval = killBtnInterval;
334
+ }, 0);
335
+ },
336
+ oneditsave: function() {
337
+ this.currentLine = this.editor.getCursorPosition();
338
+ $("#node-input-template").val(this.editor.getValue());
339
+ this.editor.destroy();
340
+ delete this.editor;
341
+ if (this._killBtnInterval) { clearInterval(this._killBtnInterval); delete this._killBtnInterval; }
342
+ },
343
+ oneditcancel: function() {
344
+ this.editor.destroy();
345
+ delete this.editor;
346
+ if (this._killBtnInterval) { clearInterval(this._killBtnInterval); delete this._killBtnInterval; }
347
+ },
348
+ oneditresize: function(size) {
349
+ var rows = $("#dialog-form>div:not(.node-text-editor-row)");
350
+ var height = $("#dialog-form").height();
351
+ for (var i = 0; i < rows.length; i++) {
352
+ height -= $(rows[i]).outerHeight(true);
353
+ }
354
+ var editorRow = $("#dialog-form>div.node-text-editor-row");
355
+ height -= (parseInt(editorRow.css("marginTop")) + parseInt(editorRow.css("marginBottom")));
356
+ $(".node-text-editor").css("height", height + "px");
357
+ this.editor.resize();
358
+ }
359
+ });
360
+ </script>