Node-REDのノードを作成してみる その1(python-venv)
はじめに
今回はNode-REDでPythonの仮想環境を利用できるノードを作成してみました。
これまでNode-RED MCU用のノードを作成したことはありますが、Node-RED用は2つ目です。
もう一つVOICEVOX CORE用のノードを先に作成していたのですが、環境構築のプロセスを検証するのが大変で後回しになっています。そちらでもPythonを使うので、今回のpython-venvノードの仕組みを適用しようと考えています。
▼最新のプログラムはGitHubをご覧ください。
https://github.com/404background/node-red-contrib-python-venv
▼npmにも公開しており、Node-REDのパレットの管理からインストールできます。
https://www.npmjs.com/package/@background404/node-red-contrib-python-venv
この記事の内容は、ver 0.0.2の内容になっています。
今後のアップデートで変更する可能性がありますので、ご注意ください。
現状ではWindows、Linux、Ubuntu環境で動作を確認しました。
▼以前の記事はこちら
OSによるvenvのディレクトリ構造の違い
ドキュメントに書かれているのですが、Windows環境かどうかでファイル構成が異なります。
▼こちらのページに書かれています。
https://docs.python.org/3/library/venv.html
▼引用すると、このように書かれています。
It also creates a bin (or Scripts on Windows) subdirectory containing a copy/symlink of the Python binary/binaries (as appropriate for the platform or arguments used at environment creation time).
It also creates an (initially empty) lib/pythonX.Y/site-packages subdirectory (on Windows, this is Lib\site-packages).
https://docs.python.org/3/library/venv.html
以下の2つのフォルダ名が異なります。
- bin(Windowsの場合はScripts)
- lib/pythonX.Y/site-packages(Windowsの場合はLib\site-packages)
bin/Scriptsにはpipやpython(Windowsだと.exeファイル)が入っています。また、site-packagesには仮想環境にインストールしたパッケージが入っています。
絶対パスでの実行
相対パスで実行するように一度作ったのですが、Node-REDを起動したときのディレクトリで実行しているような挙動だったりしました。
実行するディレクトリをはっきりさせるために、絶対パスで実行するようにしました。JavaScriptとPythonのそれぞれについて、公式リファレンスなどのページを参考にしました。
JavaScriptの場合
現在のディレクトリを取得する__dirname、path.dirnameを使います。
▼__dirnameについてはこちら
https://nodejs.org/docs/latest/api/modules.html#__dirname
▼path.dirname(path)についてはこちら
https://nodejs.org/docs/latest/api/path.html#pathdirnamepath
Pythonの場合
実行中のファイルのパスを取得する__file__と、絶対パスを取得するos.path.abspath(path)を使います。
▼__file__については、こちらの記事が分かりやすかったです。
https://note.nkmk.me/python-script-file-path/
▼os.path.abspath(path)についてはこちら
https://docs.python.org/ja/3/library/os.path.html
ノードを作成する
ノードの仕組み
ノードをインストールしたときに、setup.pyが実行されます。package.jsonのscriptsにある、preinstallでコマンドを実行するようになっています。
▼package.jsonはこちら
{
"name": "@background404/node-red-contrib-python-venv",
"version": "0.0.1",
"description": "Node for python virtual environment",
"main": "node/venv.js",
"scripts": {
"test": "mocha test/**/*_spec.js",
"preinstall": "python setup.py"
},
"engines": {
"node": ">=14.0.0"
},
"node-red": {
"version": ">=1.3.7",
"nodes": {
"venv": "node/venv.js",
"pip": "node/pip.js"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/404background/node-red-contrib-python-venv.git"
},
"keywords": [
"node-red",
"python"
],
"author": "background404",
"license": "MIT",
"bugs": {
"url": "https://github.com/404background/node-red-contrib-python-venv/issues"
},
"homepage": "https://github.com/404background/node-red-contrib-python-venv#readme"
}
setup.pyではPythonの仮想環境を作成し、pythonとpipのパスをpath.jsonファイルに保存するようになっています。
このとき、Windowsかどうかでディレクトリ名を変更しています。
▼setup.pyのプログラムはこちら。
import subprocess
import os
import json
absDir = os.path.dirname(os.path.abspath(__file__))
subprocess.run(['python', '-m', 'venv', 'pyenv'])
if os.name == 'nt':
subprocess.run([f'{absDir}/pyenv/Scripts/python.exe', '-m', 'pip', 'install', '--upgrade', 'pip'])
path = {
'NODE_PYENV_PYTHON': f'{absDir}/pyenv/Scripts/python.exe',
'NODE_PYENV_PIP': f'{absDir}/pyenv/Scripts/pip.exe'
}
else:
subprocess.run([f'{absDir}/pyenv/bin/python', '-m', 'pip', 'install', '--upgrade', 'pip'])
path = {
'NODE_PYENV_PYTHON': f'{absDir}/pyenv/bin/python',
'NODE_PYENV_PIP': f'{absDir}/pyenv/bin/pip'
}
with open(f'{absDir}/path.json', 'w') as f:
json.dump(path, f, indent=2)
path.jsonに書かれたパスを、ノード内部のJavaScriptのプログラムでも読み込むようにしています。
▼PythonとJavaScriptのデータのやり取りを、jsonファイルで行っています。
venvノードの構成
venvノードでは、child_processのexecSyncを使って、同期処理でPythonのプログラムを実行するコマンドを呼び出しています。
path.jsonに書かれた仮想環境のpythonファイルを呼び出すことで、システムのpythonとは処理を切り離しています。通常、仮想環境を利用するときはactivateしてから実行するのですが、今回はコマンドで実行するので直接呼び出しています。
また、config.codeが空白で、msg.codeにプログラムが送られてくると実行するようになっています。
▼venv.jsはこちら。
module.exports = function(RED) {
function Venv(config) {
RED.nodes.createNode(this,config)
let node = this
const fs = require('fs')
const path = require('path')
const filePath = path.join(path.dirname(__dirname), 'tmp', this.id + '.py')
const jsonPath = path.join(path.dirname(__dirname), 'path.json')
const json = fs.readFileSync(jsonPath)
const pythonPath = JSON.parse(json).NODE_PYENV_PYTHON
let code = ""
node.on('input', function(msg) {
const command = pythonPath + ' ' + filePath
if(config.code !== null && config.code !== "") {
code = config.code
} else {
code = msg.code
}
fs.writeFileSync(filePath, code)
let execSync = require('child_process').execSync
msg.payload = String(execSync(command))
node.send(msg)
})
}
RED.nodes.registerType("venv", Venv)
}
▼venv.htmlはこちら。
<script type="text/javascript">
RED.nodes.registerType('venv',{
category: 'python',
color: '#3e7cad',
defaults: {
name: {value:""},
code: {value:""}
},
inputs: 1,
outputs: 1,
icon: "function.svg",
label: function() {
return this.name||"venv";
}
});
</script>
<script type="text/html" data-template-name="venv">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name"><br>
<label for="node-input-code"><i class="fa fa-code"></i> Code:</label><br>
<textarea id="node-input-code" class="node-text-editor" style="height: 500px; min-height:300px; min-width: 100%;" placeholder="Your code here"></textarea><br>
</div>
</script>
<script type="text/html" data-help-name="venv">
</script>
▼編集画面はこんな感じです。
pipノードの構成
pipノードはvenvノードとそっくりで、こちらもexecSyncで実行しています。
pipと一緒によく使うコマンドは、プルダウンメニューで選択できるようにしています。
▼pip.jsはこちら。
module.exports = function(RED) {
function Pip(config) {
RED.nodes.createNode(this,config)
let node = this
let argument = ''
let action = ''
let command = ''
const path = require('path')
const fs = require('fs')
const jsonPath = path.join(path.dirname(__dirname), 'path.json')
const json = fs.readFileSync(jsonPath)
const pathPip = JSON.parse(json).NODE_PYENV_PIP
const execSync = require('child_process').execSync
node.on('input', function(msg) {
if(config.arg !== null && config.arg !== '') {
argument = config.arg
} else {
argument = msg.payload
}
switch(config.action) {
case 'install':
action = 'install'
option = ''
break
case 'uninstall':
action = 'uninstall'
option = '-y'
break
case 'list':
action = 'list'
option = ''
argument = ''
break
default:
action = ''
option = ''
break
}
command = pathPip + ' ' + action + ' ' + option + ' ' + argument
msg.payload = String(execSync(command))
node.send(msg)
})
}
RED.nodes.registerType('pip',Pip)
}
▼pip.htmlはこちら。
<script type="text/javascript">
RED.nodes.registerType('pip',{
category: 'python',
color: '#3e7cad',
defaults: {
name: {value:""},
arg: {value:""},
action: {value:""}
},
inputs: 1,
outputs: 1,
icon: "cog.svg",
label: function() {
return this.name||"pip";
}
});
</script>
<script type="text/html" data-template-name="pip">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name"><br>
<label for="node-input-action"><i class="fa fa-cog"></i> Command</label>
<select id="node-input-action" name="action">
<option value="None">None</option>
<option value="install">install</option>
<option value="uninstall">uninstall</option>
<option value="list">list</option>
</select><br>
<label for="node-input-arg"><i class="fa fa-tag"></i> arg</label>
<input type="text" id="node-input-arg" placeholder="argument">
</div>
</script>
<script type="text/html" data-help-name="pip">
</script>
▼編集画面はこんな感じです。
ノードを実行してみる
実際にノードをインストールして実行してみました。
既存の他のノードとも組み合わせて使えそうですね。http in/outノードと組み合わせて、リクエストを受け取ったらhttps://example.comの中身を送信するようにしてみました。
▼こんな感じで使えます。
▼サンプルフローはこちら。
[{"id":"c45c04db2987653c","type":"debug","z":"3a0260d17167ea2b","name":"debug 1","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":680,"y":460,"wires":[]},{"id":"a188371841023711","type":"venv","z":"3a0260d17167ea2b","name":"requests","code":"import requests\nurl = 'https://example.com/'\n \ntry:\n response = requests.get(url)\n\n if response.status_code == 200:\n print(response.text)\n else:\n print(f\"Failed to retrieve data. Status code: {response.status_code}\")\nexcept requests.RequestException as e:\n print(f\"An error occurred: {e}\")","_mcu":{"mcu":false},"x":440,"y":460,"wires":[["c45c04db2987653c","2e1f090ebb65b6fe"]]},{"id":"7ba222816f58207f","type":"pip","z":"3a0260d17167ea2b","name":"pip install requests","arg":"requests","action":"install","_mcu":{"mcu":false},"x":470,"y":240,"wires":[["49c3fb2e3c89c0c5"]]},{"id":"49c3fb2e3c89c0c5","type":"debug","z":"3a0260d17167ea2b","name":"debug 4","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":680,"y":240,"wires":[]},{"id":"e8f445ef70a07477","type":"inject","z":"3a0260d17167ea2b","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","_mcu":{"mcu":false},"x":260,"y":240,"wires":[["7ba222816f58207f"]]},{"id":"6804be6580cdea18","type":"inject","z":"3a0260d17167ea2b","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","_mcu":{"mcu":false},"x":260,"y":460,"wires":[["a188371841023711"]]},{"id":"28586b0a18f1874e","type":"pip","z":"3a0260d17167ea2b","name":"pip uninstall requests","arg":"requests","action":"uninstall","_mcu":{"mcu":false},"x":480,"y":280,"wires":[["349fd43a1ec03de1"]]},{"id":"349fd43a1ec03de1","type":"debug","z":"3a0260d17167ea2b","name":"debug 5","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":680,"y":280,"wires":[]},{"id":"1629786d40d7cf8a","type":"inject","z":"3a0260d17167ea2b","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","_mcu":{"mcu":false},"x":260,"y":280,"wires":[["28586b0a18f1874e"]]},{"id":"9d46cdda3e5f8f79","type":"pip","z":"3a0260d17167ea2b","name":"pip list","arg":"requests","action":"list","_mcu":{"mcu":false},"x":430,"y":580,"wires":[["800d8cecf096bea3"]]},{"id":"800d8cecf096bea3","type":"debug","z":"3a0260d17167ea2b","name":"debug 6","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":600,"y":580,"wires":[]},{"id":"39785fb1845c8265","type":"debug","z":"3a0260d17167ea2b","name":"debug 7","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":680,"y":360,"wires":[]},{"id":"18745a928deae57c","type":"venv","z":"3a0260d17167ea2b","name":"hello world","code":"print('hello, world!')","_mcu":{"mcu":false},"x":450,"y":360,"wires":[["39785fb1845c8265"]]},{"id":"bc8d4257965e746c","type":"inject","z":"3a0260d17167ea2b","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","_mcu":{"mcu":false},"x":260,"y":360,"wires":[["18745a928deae57c"]]},{"id":"24ce53db449abb87","type":"http in","z":"3a0260d17167ea2b","name":"","url":"/url","method":"get","upload":false,"swaggerDoc":"","_mcu":{"mcu":false},"x":270,"y":500,"wires":[["a188371841023711"]]},{"id":"2e1f090ebb65b6fe","type":"http response","z":"3a0260d17167ea2b","name":"","statusCode":"","headers":{},"_mcu":{"mcu":false},"x":670,"y":500,"wires":[]},{"id":"a171026a86c55d5c","type":"exec","z":"3a0260d17167ea2b","command":"pip list","addpay":"","append":"","useSpawn":"false","timer":"","winHide":false,"oldrc":false,"name":"","_mcu":{"mcu":false},"x":430,"y":660,"wires":[["2aa6fd50aad3dba9"],[],["94273bb3a668778f"]]},{"id":"dea9652985cd396c","type":"inject","z":"3a0260d17167ea2b","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","_mcu":{"mcu":false},"x":260,"y":660,"wires":[["a171026a86c55d5c"]]},{"id":"2aa6fd50aad3dba9","type":"debug","z":"3a0260d17167ea2b","name":"debug 8","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":600,"y":640,"wires":[]},{"id":"94273bb3a668778f","type":"debug","z":"3a0260d17167ea2b","name":"debug 9","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","_mcu":{"mcu":false},"x":600,"y":680,"wires":[]},{"id":"57fcebe823aa5c6f","type":"inject","z":"3a0260d17167ea2b","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","_mcu":{"mcu":false},"x":260,"y":580,"wires":[["9d46cdda3e5f8f79"]]}]
システムのpipと、pipノードのpipでパッケージを切り分けることもできました。
▼execで実行したpip listコマンドとは表示が異なっています。
最後に
Node-REDはJavaScript系ですが、その内部でPythonの仮想環境でプログラムを実行できるノードを作成してみました。
プログラムの流れをNode-REDのフローとして表現し、内部的にはPythonで実行するという使い方ができそうです。
個人的にはシンプルな形に落ち着いたように思っています。とはいえ、JavaScriptもPythonも特別に詳しいわけではないので、ご意見や修正点等があればissueやコメントに送っていただけると幸いです。
▼海外の方から、ノードへの入力ができるといいね!というご指摘を頂きました。jsonファイルで渡すことはできたので、それで実装してみようと思っています。
https://github.com/404background/node-red-contrib-python-venv/issues/4