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環境で動作を確認しました。

▼以前の記事はこちら

Node-RED MCU用のノードを作成してみる その3(Serialノード)

はじめに  今回はNode-RED MCU用のSerialノードを作成してみました。デバッガツールとは元々シリアル通信を行っているのですが、他のポートでのシリアル通信を行うことが…

Node-REDのノードを公開してみる

はじめに  以前の記事でNode-RED MCU用のノードを作成しました。今回はそのノードを公開してみました。  1月に「Node-REDのノードをつくるハンズオン」というイベントが…

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

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です