├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── usage.rst ├── index.rst ├── Makefile ├── make.bat ├── installation.rst └── conf.py ├── pynodered ├── imported_modules │ └── __init__.py ├── __init__.py ├── templates │ ├── httprequest.html.in │ └── httprequest.js.in ├── server.py ├── ttldict.py └── core.py ├── images └── lower-case-flow.png ├── HISTORY.rst ├── requirements_dev.txt ├── MANIFEST.in ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── examples └── simple.py ├── AUTHORS.rst ├── setup.cfg ├── tox.ini ├── tests └── test_pynodered.py ├── .travis.yml ├── LICENSE ├── .gitignore ├── setup.py ├── Makefile ├── README.rst └── CONTRIBUTING.rst /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /pynodered/imported_modules/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # a 'mailbox' for modules dynamically loaded -------------------------------------------------------------------------------- /images/lower-case-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ghislainp/pynodered/HEAD/images/lower-case-flow.png -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use pynodered in a project:: 6 | 7 | import pynodered 8 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.0 (2019-01-08) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==25.2 2 | bumpversion==0.5.3 3 | wheel==0.38.1 4 | watchdog==0.9.0 5 | flake8==3.5.0 6 | tox==3.5.2 7 | coverage==4.5.1 8 | Sphinx==1.8.1 9 | twine==1.12.1 10 | 11 | pytest==3.8.2 12 | pytest-runner==4.2 13 | 14 | -------------------------------------------------------------------------------- /pynodered/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for pynodered.""" 4 | 5 | __author__ = """Ghislain Picard""" 6 | __email__ = 'ghipicard@gmail.com' 7 | __version__ = '0.1.0' 8 | 9 | 10 | from pynodered.core import node_red, NodeProperty -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.md 2 | include CONTRIBUTING.md 3 | include HISTORY.md 4 | include LICENSE 5 | include README.md 6 | include pynodered/templates/*in 7 | 8 | recursive-include tests * 9 | recursive-exclude * __pycache__ 10 | recursive-exclude * *.py[co] 11 | 12 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * pynodered version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pynodered's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from pynodered import node_red, NodeProperty 4 | 5 | 6 | @node_red(category="pyfuncs") 7 | def lower_case(node, msg): 8 | 9 | msg['payload'] = msg['payload'].lower() 10 | return msg 11 | 12 | 13 | @node_red(category="pyfuncs", 14 | properties=dict(number = NodeProperty("Number", value="1"))) 15 | def repeat(node, msg): 16 | 17 | msg['payload'] = msg['payload'] * int(node.number.value) 18 | return msg 19 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Inspiration 6 | ----------- 7 | 8 | This package is based on the idea and part of the code written by Tomaž Šolc 9 | Copyright (C) 2017 SensorLab, Jožef Stefan Institute http://sensorlab.ijs.si licensed under GPL version 3 10 | 11 | available from here: https://github.com/sensorlab/sigfox-toolbox/tree/master/node-red-python 12 | 13 | Developer 14 | --------- 15 | 16 | * Ghislain Picard 17 | 18 | Contributors 19 | ------------ 20 | 21 | None yet. Why not be the first? 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:pynodered/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | # Define setup.py command aliases here 22 | test = pytest 23 | 24 | [tool:pytest] 25 | collect_ignore = ['setup.py'] 26 | 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, py35, py36, flake8 3 | 4 | [travis] 5 | python = 6 | 3.6: py36 7 | 3.5: py35 8 | 3.4: py34 9 | 2.7: py27 10 | 11 | [testenv:flake8] 12 | basepython = python 13 | deps = flake8 14 | commands = flake8 pynodered 15 | 16 | [testenv] 17 | setenv = 18 | PYTHONPATH = {toxinidir} 19 | deps = 20 | -r{toxinidir}/requirements_dev.txt 21 | ; If you want to make tox run the tests with the same versions, create a 22 | ; requirements.txt with the pinned versions and uncomment the following line: 23 | ; -r{toxinidir}/requirements.txt 24 | commands = 25 | pip install -U pip 26 | py.test --basetemp={envtmpdir} 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/test_pynodered.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `pynodered` package.""" 5 | 6 | import pytest 7 | 8 | 9 | import pynodered 10 | 11 | 12 | @pytest.fixture 13 | def response(): 14 | """Sample pytest fixture. 15 | 16 | See more at: http://doc.pytest.org/en/latest/fixture.html 17 | """ 18 | # import requests 19 | # return requests.get('https://github.com/audreyr/cookiecutter-pypackage') 20 | 21 | 22 | def test_content(response): 23 | """Sample pytest test function with the pytest fixture as an argument.""" 24 | # from bs4 import BeautifulSoup 25 | # assert 'GitHub' in BeautifulSoup(response.content).title.string 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = pynodered 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | python: 5 | - 3.6 6 | - 3.5 7 | 8 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 9 | install: pip install -U tox-travis 10 | 11 | # Command to run tests, e.g. python setup.py test 12 | script: tox 13 | 14 | # Assuming you have installed the travis-ci CLI tool, after you 15 | # create the Github repo and add it to Travis, run the 16 | # following command to finish PyPI deployment setup: 17 | # $ travis encrypt --add deploy.password 18 | deploy: 19 | provider: pypi 20 | distributions: sdist bdist_wheel 21 | user: ghislainp 22 | password: 23 | secure: PLEASE_REPLACE_ME 24 | on: 25 | tags: true 26 | repo: ghislainp/pynodered 27 | python: 3.6 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=pynodered 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install pynodered, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install pynodered 16 | 17 | This is the preferred method to install pynodered, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for pynodered can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/ghislainp/pynodered 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/ghislainp/pynodered/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/ghislainp/pynodered 51 | .. _tarball: https://github.com/ghislainp/pynodered/tarball/master 52 | -------------------------------------------------------------------------------- /pynodered/templates/httprequest.html.in: -------------------------------------------------------------------------------- 1 | 18 | 19 | 22 | 23 | 26 | 27 | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | make python function easily accessible from Node-RED 5 | Copyright (C) 2019 Ghislain Picard 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | 20 | Also add information on how to contact you by electronic and paper mail. 21 | 22 | You should also get your employer (if you work as a programmer) or school, 23 | if any, to sign a "copyright disclaimer" for the program, if necessary. 24 | For more information on this, and how to apply and follow the GNU GPL, see 25 | . 26 | 27 | The GNU General Public License does not permit incorporating your program 28 | into proprietary programs. If your program is a subroutine library, you 29 | may consider it more useful to permit linking proprietary applications with 30 | the library. If this is what you want to do, use the GNU Lesser General 31 | Public License instead of this License. But first, please read 32 | . 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | desktop.ini 105 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open('README.rst', encoding = 'UTF-8') as readme_file: 9 | readme = readme_file.read() 10 | 11 | # commented to avoid a problem with tox testing. Need a proper fix. 12 | #with open('HISTORY.rst') as history_file: 13 | # history = history_file.read() 14 | 15 | requirements = [ 'flask', 'json-rpc'] 16 | 17 | setup_requirements = ['pytest-runner', ] 18 | 19 | test_requirements = ['pytest', ] 20 | 21 | setup( 22 | author="Ghislain Picard", 23 | author_email='ghislain.picard@univ-grenoble-alpes.fr', 24 | classifiers=[ 25 | 'Development Status :: 2 - Pre-Alpha', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 28 | 'Natural Language :: English', 29 | 'Programming Language :: Python :: 3', 30 | 'Programming Language :: Python :: 3.5', 31 | 'Programming Language :: Python :: 3.6', 32 | 'Programming Language :: Python :: 3.7', 33 | ], 34 | description="make python function easily accessible from Node-RED ", 35 | install_requires=requirements, 36 | license="GNU General Public License v3", 37 | long_description=readme, #+ '\n\n' + history, 38 | include_package_data=True, 39 | keywords='pynodered', 40 | name='pynodered', 41 | packages=find_packages(include=['pynodered', 'pynodered.imported_modules']), 42 | setup_requires=setup_requirements, 43 | test_suite='tests', 44 | tests_require=test_requirements, 45 | url='https://github.com/ghislainp/pynodered', 46 | version='0.1.0', 47 | zip_safe=False, 48 | 49 | entry_points={ 50 | 'console_scripts': [ 51 | 'pynodered=pynodered.server:main', 52 | ], 53 | }, 54 | ) 55 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 pynodered tests 55 | 56 | test: ## run tests quickly with the default Python 57 | py.test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source pynodered -m pytest 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | rm -f docs/pynodered.rst 70 | rm -f docs/modules.rst 71 | sphinx-apidoc -o docs/ pynodered 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | pynodered 3 | ========= 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/pynodered.svg 7 | :target: https://pypi.python.org/pypi/pynodered 8 | 9 | .. image:: https://img.shields.io/travis/ghislainp/pynodered.svg 10 | :target: https://travis-ci.org/ghislainp/pynodered 11 | 12 | .. image:: https://readthedocs.org/projects/pynodered/badge/?version=latest 13 | :target: https://pynodered.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | 16 | About 17 | -------- 18 | 19 | Make your python functions available in Node-RED using a simple decorator. 20 | 21 | pynodered is a server communicating with Node-RED to make available your python functions as blocks in Node-RED. pynodered reads modules and python files containing functions decorated with 'node_red', register them as blocks in Node-RED. pynodred then handles communications between Node-RED (in javascript) to run your function whenever the block receives a message. 22 | 23 | Quick Start 24 | ------------ 25 | 26 | Requires python>=3.5 27 | 28 | .. code-block:: console 29 | 30 | $ pip install pynodered 31 | # Install node dependencies 32 | $ npm install follow-redirects url querystring 33 | 34 | Examples 35 | ------------ 36 | 37 | A simple example to lowercase the payload of the messages: 38 | 39 | .. code-block:: python 40 | 41 | from pynodered import node_red 42 | 43 | @node_red(category="pyfuncs") 44 | def lower_case(node, msg): 45 | 46 | msg['payload'] = str(msg['payload']).lower() 47 | return msg 48 | 49 | Put this snippet is in a file "example.py", run the pynodered server with: 50 | 51 | .. code-block:: console 52 | 53 | $ pynodered example.py 54 | 55 | and in another console: 56 | 57 | .. code-block:: console 58 | 59 | $ node-red 60 | 61 | In Node-RED, you now have a new category "pyfuncs" with a function lower_case. It can be used in a flow as any other blocks: 62 | 63 | .. image:: images/lower-case-flow.png 64 | 65 | 66 | It is possible to add block properties (constant arguments provided by the user in Node-RED) by defining a property in the decorator: 67 | 68 | .. code-block:: python 69 | 70 | from pynodered import node_red, NodeProperty 71 | 72 | @node_red(category="pyfuncs", 73 | properties=dict(number=NodeProperty("Number", value="1"))) 74 | 75 | def repeat(node, msg): 76 | 77 | msg['payload'] = msg['payload'] * int(node.number.value) 78 | return msg 79 | 80 | Don't forget to restart the pynodered server everytime your python files change. Node-RED also needs to be restarted but only when the function name or properties change or a new function is added. Refreshing the browser is then necessary. 81 | 82 | By default pynodered exports the functions in the Node-RED package 'pynodered' and the category 'default'. The category name can be changed with the decorator optional argument. For the package name and information, the python module containing the functions can declare a 'package' dictonary like this: 83 | 84 | .. code-block:: python 85 | 86 | package = { 87 | "name" : "FFT filters", 88 | "version" : "0.01", 89 | "description" : "Nodes written in Python for signal processing", 90 | } 91 | 92 | Warning 93 | ---------- 94 | 95 | pynodered is not ready for production use yet. On a multi-user computer, be aware that pynodered server will respond to anyone request and will execute code under the account that launched the server. NODE-Red has a similar issue (https://nodered.org/docs/security) 96 | 97 | 98 | License 99 | ---------- 100 | 101 | Copyright (C) 2019 Ghislain Picard 102 | 103 | Free software: GNU General Public License v3 104 | 105 | 106 | This package is a rewrite and extension of the code intially written by Tomaž Šolc for sigfox-toolbox 107 | https://github.com/sensorlab/sigfox-toolbox/tree/master/node-red-python. 108 | Copyright (2017) SensorLab, Jožef Stefan Institute http://sensorlab.ijs.si and licensed under GPL version 3 109 | 110 | 111 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/ghislainp/pynodered/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | pynodered could always use more documentation, whether as part of the 42 | official pynodered docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/ghislainp/pynodered/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `pynodered` for local development. 61 | 62 | 1. Fork the `pynodered` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/pynodered.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv pynodered 70 | $ cd pynodered/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 pynodered tests 83 | $ python setup.py test or py.test 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5, 3.6, 3.7. Check 106 | https://travis-ci.org/ghislainp/pynodered/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ py.test tests.test_pynodered 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bumpversion patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /pynodered/server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | import glob 4 | from pathlib import Path 5 | import importlib 6 | import inspect 7 | import pprint 8 | import json 9 | import copy 10 | 11 | from flask import Flask 12 | from flask import Blueprint, jsonify 13 | 14 | from jsonrpc.backend.flask import api 15 | # https://media.readthedocs.org/pdf/json-rpc/latest/json-rpc.pdf 16 | 17 | from pynodered.core import silent_node_waiting 18 | 19 | app = Flask(__name__) 20 | app.register_blueprint(api.as_blueprint()) 21 | 22 | 23 | def node_directory(package_name): 24 | return Path.home() / ".node-red" / "node_modules" / package_name # assume this also work on MacOS and Windows... 25 | 26 | 27 | def main(): 28 | parser = argparse.ArgumentParser(prog='pynodered') 29 | parser.add_argument('--noinstall', action="store_true", 30 | help="do not install javascript files to save startup time. It is only necessary to install the files once or whenever a python function change") 31 | parser.add_argument('--port', 32 | help="port to use by Flask to run the Python server handling the request from Node-RED", 33 | default=5051) 34 | parser.add_argument('filenames', help='list of python file names or module names', nargs='+') 35 | args = parser.parse_args(sys.argv[1:]) 36 | 37 | # register files: 38 | packages = dict() 39 | 40 | package_tpl = { 41 | "name": "pynodered", 42 | "version": "0.01", 43 | "description": "Nodes written in Python", 44 | "dependencies": {"follow-redirects": "1.5.10"}, 45 | "keywords": ["node-red"], 46 | "node-red": { 47 | "nodes": {} 48 | } 49 | } 50 | 51 | registered = 0 52 | 53 | for path in args.filenames: 54 | 55 | print("Path: ", path) 56 | 57 | # import the module by file or by name 58 | if path.endswith(".py"): 59 | path = Path(path) 60 | if path.stem.startswith("_"): 61 | continue 62 | # import a file 63 | module_name = "pynodered.imported_modules." + path.stem 64 | spec = importlib.util.spec_from_file_location(module_name, path) 65 | module = importlib.util.module_from_spec(spec) 66 | spec.loader.exec_module(module) 67 | sys.modules[module_name] = module 68 | else: 69 | # import a module 70 | module = importlib.import_module(path) 71 | 72 | # prepare the package json file 73 | if hasattr(module, "package"): 74 | if not isinstance(module.package, dict) or 'name' not in module.package: 75 | raise Exception( 76 | "the 'package' attribute in the module must be a dict defining at least the 'name' of the module in Node-RED") 77 | package_name = module.package['name'] 78 | if package_name not in packages: 79 | packages[package_name] = copy.deepcopy(package_tpl) # load default values 80 | packages[package_name].update(module.package) # update them with module.package 81 | else: 82 | package_name = 'pynodered' # default name 83 | if package_name not in packages: 84 | packages[package_name] = copy.deepcopy(package_tpl) # load default values 85 | 86 | node_dir = node_directory(package_name) 87 | 88 | # now look for the functions and classes 89 | 90 | for name, obj in inspect.getmembers(module, inspect.isclass): 91 | if hasattr(obj, "install") and hasattr(obj, "work") and hasattr(obj, "run") and hasattr(obj, "name"): 92 | print(f"From {name} register {obj.name}") 93 | if not args.noinstall: 94 | obj.install(node_dir, args.port) 95 | print("Install %s" % name) 96 | packages[package_name]["node-red"]["nodes"][obj.name] = obj.name + '.js' 97 | 98 | inst = obj() 99 | api.dispatcher.add_method(silent_node_waiting(inst.run), obj.name) 100 | registered += 1 101 | 102 | # obj can run an http_server if it has one 103 | if hasattr(obj, "http_server"): 104 | obj.http_server(app) 105 | 106 | if registered == 0: 107 | raise Exception("Zero function or class to register to Node-RED has been found. Check your python files") 108 | 109 | if not args.noinstall: 110 | for package_name in packages: 111 | with open(node_directory(package_name) / "package.json", "w") as f: 112 | json.dump(packages[package_name], f) 113 | 114 | # print('ROUTES') 115 | # for rule in app.url_map.iter_rules(): 116 | # # Filter out rules we can't navigate to in a browser 117 | # # and rules that require parameters 118 | # print(rule.methods,rule.endpoint) 119 | 120 | app.run(host='127.0.0.1', port=args.port) # , debug=True) 121 | 122 | 123 | if __name__ == '__main__': 124 | main() 125 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # pynodered documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | import pynodered 26 | 27 | # -- General configuration --------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'pynodered' 51 | copyright = u"2019, Ghislain Picard" 52 | author = u"Ghislain Picard" 53 | 54 | # The version info for the project you're documenting, acts as replacement 55 | # for |version| and |release|, also used in various other places throughout 56 | # the built documents. 57 | # 58 | # The short X.Y version. 59 | version = pynodered.__version__ 60 | # The full version, including alpha/beta/rc tags. 61 | release = pynodered.__version__ 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'alabaster' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a 90 | # theme further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ['_static'] 99 | 100 | 101 | # -- Options for HTMLHelp output --------------------------------------- 102 | 103 | # Output file base name for HTML help builder. 104 | htmlhelp_basename = 'pynodereddoc' 105 | 106 | 107 | # -- Options for LaTeX output ------------------------------------------ 108 | 109 | latex_elements = { 110 | # The paper size ('letterpaper' or 'a4paper'). 111 | # 112 | # 'papersize': 'letterpaper', 113 | 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | 118 | # Additional stuff for the LaTeX preamble. 119 | # 120 | # 'preamble': '', 121 | 122 | # Latex figure (float) alignment 123 | # 124 | # 'figure_align': 'htbp', 125 | } 126 | 127 | # Grouping the document tree into LaTeX files. List of tuples 128 | # (source start file, target name, title, author, documentclass 129 | # [howto, manual, or own class]). 130 | latex_documents = [ 131 | (master_doc, 'pynodered.tex', 132 | u'pynodered Documentation', 133 | u'Ghislain Picard', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'pynodered', 143 | u'pynodered Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'pynodered', 155 | u'pynodered Documentation', 156 | author, 157 | 'pynodered', 158 | 'One line description of project.', 159 | 'Miscellaneous'), 160 | ] 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /pynodered/ttldict.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013, Jyrki Muukkonen 2 | 3 | # Permission to use, copy, modify, and/or distribute this software for any 4 | # purpose with or without fee is hereby granted, provided that the above 5 | # copyright notice and this permission notice appear in all copies. 6 | 7 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | # Copyright (c) 2017, Oz Tiram 16 | 17 | # Permission to use, copy, modify, and/or distribute this software for any 18 | # purpose with or without fee is hereby granted, provided that the above 19 | # copyright notice and this permission notice appear in all copies. 20 | 21 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 22 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 23 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 24 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 25 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 26 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 27 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 28 | 29 | # Modified by Ghislain to remove the orderdict 30 | 31 | from threading import RLock 32 | import time 33 | 34 | __all__ = ['TTLDict'] 35 | 36 | 37 | class TTLDict(dict): 38 | """ 39 | Dict with TTL 40 | Extra args and kwargs are passed to initial .update() call 41 | """ 42 | def __init__(self, default_ttl, *args, **kwargs): 43 | """ 44 | Be warned, if you use this with Python versions earlier than 3.6 45 | when passing **kwargs order is not preseverd. 46 | """ 47 | assert isinstance(default_ttl, int) 48 | self._default_ttl = default_ttl 49 | self._lock = RLock() 50 | super().__init__() 51 | self.update(*args, **kwargs) 52 | 53 | def __repr__(self): 54 | return '' % ( 55 | id(self), self._default_ttl, self.items()) 56 | 57 | def __len__(self): 58 | with self._lock: 59 | self._purge() 60 | return super().__len__() 61 | 62 | def set_ttl(self, key, ttl, now=None): 63 | """Set TTL for the given key""" 64 | if now is None: 65 | now = time.time() 66 | with self._lock: 67 | value = self[key] 68 | super().__setitem__(key, (now + ttl, value)) 69 | 70 | def get_ttl(self, key, now=None): 71 | """Return remaining TTL for a key""" 72 | if now is None: 73 | now = time.time() 74 | with self._lock: 75 | expire, _value = super().__getitem__(key) 76 | return expire - now 77 | 78 | def expire_at(self, key, timestamp): 79 | """Set the key expire timestamp""" 80 | with self._lock: 81 | value = self.__getitem__(key) 82 | super().__setitem__(key, (timestamp, value)) 83 | 84 | def is_expired(self, key, now=None): 85 | """ Check if key has expired, and return it if so""" 86 | with self._lock: 87 | if now is None: 88 | now = time.time() 89 | 90 | expire, _value = super().__getitem__(key) 91 | 92 | if expire: 93 | if expire < now: 94 | return key 95 | 96 | def _purge(self): 97 | _keys = list(super().__iter__()) 98 | _remove = [key for key in _keys if self.is_expired(key)] # noqa 99 | [self.__delitem__(key) for key in _remove] 100 | 101 | def __iter__(self): 102 | """ 103 | Yield only non expired keys, without purging the expired ones 104 | """ 105 | with self._lock: 106 | for key in super().__iter__(): 107 | if not self.is_expired(key): 108 | yield key 109 | 110 | def __setitem__(self, key, value): 111 | with self._lock: 112 | if self._default_ttl is None: 113 | expire = None 114 | else: 115 | expire = time.time() + self._default_ttl 116 | super().__setitem__(key, (expire, value)) 117 | 118 | def __delitem__(self, key): 119 | with self._lock: 120 | super().__delitem__(key) 121 | 122 | def __getitem__(self, key): 123 | with self._lock: 124 | if self.is_expired(key): 125 | self.__delitem__(key) 126 | raise KeyError 127 | item = super().__getitem__(key)[1] 128 | return item 129 | 130 | def keys(self): 131 | with self._lock: 132 | self._purge() 133 | return super().keys() 134 | 135 | def items(self): 136 | with self._lock: 137 | self._purge() 138 | _items = list(super(Dict, self).items()) 139 | return [(k, v[1]) for (k, v) in _items] 140 | 141 | def values(self): 142 | with self._lock: 143 | self._purge() 144 | _values = list(super(Dict, self).values()) 145 | return [v[1] for v in _values] 146 | 147 | def get(self, key, default=None): 148 | try: 149 | return self[key] 150 | except KeyError: 151 | return default 152 | -------------------------------------------------------------------------------- /pynodered/templates/httprequest.js.in: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on Node-RED code, modified by Tomaz Solc. 3 | * 4 | * Copyright 2013, 2016 IBM Corp. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | **/ 18 | 19 | module.exports = function(RED) { 20 | "use strict"; 21 | var http = require("follow-redirects").http; 22 | var urllib = require("url"); 23 | var querystring = require("querystring"); 24 | 25 | function HTTPRequest(n) { 26 | RED.nodes.createNode(this, n); 27 | var node = this; 28 | var nodeUrl = "http://localhost:%(port)s/"; // %(name)s"; 29 | if (RED.settings.httpRequestTimeout) { this.reqTimeout = parseInt(RED.settings.httpRequestTimeout) || 120000; } 30 | else { this.reqTimeout = 120000; } 31 | 32 | this.on("input",function(msg) { 33 | var preRequestTimestamp = process.hrtime(); 34 | node.status({fill:"blue",shape:"dot",text:"httpin.status.requesting"}); 35 | var url = nodeUrl; 36 | 37 | var method = "POST"; 38 | var opts = urllib.parse(url); 39 | opts.method = method; 40 | opts.headers = {}; 41 | if (msg.headers) { 42 | for (var v in msg.headers) { 43 | if (msg.headers.hasOwnProperty(v)) { 44 | var name = v.toLowerCase(); 45 | if (name !== "content-type" && name !== "content-length") { 46 | // only normalise the known headers used later in this 47 | // function. Otherwise leave them alone. 48 | name = v; 49 | } 50 | opts.headers[name] = msg.headers[v]; 51 | } 52 | } 53 | } 54 | if (this.credentials && this.credentials.user) { 55 | opts.auth = this.credentials.user+":"+(this.credentials.password||""); 56 | } 57 | var payload = null; 58 | 59 | if (msg.payload && (method == "POST" || method == "PUT" || method == "PATCH" ) ) { 60 | 61 | payload = { 62 | "jsonrpc": "2.0", 63 | "method": "%(name)s", 64 | "params": { "msg": msg, "config": n}, 65 | "id": "1"} 66 | payload = JSON.stringify(payload); 67 | if (opts.headers['content-type'] == null) { 68 | opts.headers['content-type'] = "application/json"; 69 | } 70 | 71 | if (opts.headers['content-length'] == null) { 72 | if (Buffer.isBuffer(payload)) { 73 | opts.headers['content-length'] = payload.length; 74 | } else { 75 | opts.headers['content-length'] = Buffer.byteLength(payload); 76 | } 77 | } 78 | } 79 | var urltotest = url; 80 | var req = http.request(opts,function(res) { 81 | res.setEncoding('utf8'); 82 | msg.statusCode = res.statusCode; 83 | msg.headers = res.headers; 84 | msg.payload = ""; 85 | // msg.url = url; // revert when warning above finally removed 86 | res.on('data',function(chunk) { 87 | msg.payload += chunk; 88 | }); 89 | res.on('end',function() { 90 | if (node.metric()) { 91 | // Calculate request time 92 | var diff = process.hrtime(preRequestTimestamp); 93 | var ms = diff[0] * 1e3 + diff[1] * 1e-6; 94 | var metricRequestDurationMillis = ms.toFixed(3); 95 | node.metric("duration.millis", msg, metricRequestDurationMillis); 96 | if (res.client && res.client.bytesRead) { 97 | node.metric("size.bytes", msg, res.client.bytesRead); 98 | } 99 | } 100 | try { msg.payload = JSON.parse(msg.payload); } 101 | catch(e) { node.warn(RED._("httpin.errors.json-error")); } 102 | try { 103 | const output = msg.payload["result"]["selected_output"]; 104 | const msgs = []; 105 | delete msg.payload["result"]["selected_output"]; 106 | msgs[output] = msg.payload["result"]; 107 | node.send(msgs); 108 | } catch(e) { 109 | node.send(msg.payload["result"]); 110 | } 111 | 112 | node.status({}); 113 | }); 114 | }); 115 | req.setTimeout(node.reqTimeout, function() { 116 | node.error(RED._("common.notification.errors.no-response"),msg); 117 | setTimeout(function() { 118 | node.status({fill:"red",shape:"ring",text:"common.notification.errors.no-response"}); 119 | },10); 120 | req.abort(); 121 | }); 122 | req.on('error',function(err) { 123 | node.error(err,msg); 124 | msg.payload = err.toString() + " : " + url; 125 | msg.statusCode = err.code; 126 | node.send(msg); 127 | node.status({fill:"red",shape:"ring",text:err.code}); 128 | }); 129 | if (payload) { 130 | req.write(payload); 131 | } 132 | req.end(); 133 | }); 134 | } 135 | 136 | RED.nodes.registerType("%(name)s",HTTPRequest); 137 | } 138 | -------------------------------------------------------------------------------- /pynodered/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import collections 3 | import json 4 | from pathlib import Path 5 | 6 | 7 | class NodeProperty(object): 8 | """a Node property. This is usually use to decalre field in a class deriving from RNBaseNode. 9 | """ 10 | 11 | def __init__(self, title=None, type="str", value="", required=False, input_type="text", values=None): 12 | 13 | self.type = type 14 | self.value = value # default value 15 | self.values = values # values for a select to pick from 16 | 17 | self.title = title 18 | self.required = required 19 | self.input_type = input_type 20 | 21 | def as_dict(self, *args): 22 | self.title = self.title or self.name 23 | if len(args) == 0: 24 | args = {"name", "title", "type", "value", "title", "required", "input_type"} 25 | 26 | return {a: getattr(self, a) for a in args} 27 | 28 | 29 | class FormMetaClass(type): 30 | def __new__(cls, name, base, attrs): 31 | new_class = super(FormMetaClass, cls).__new__(cls, name, base, attrs) 32 | 33 | properties = list() 34 | for name, attr in attrs.items(): 35 | if isinstance(attr, NodeProperty): 36 | attr.name = name 37 | properties.append(attr) 38 | # sorting manually corresponds to the definision order of Fields. 39 | new_class.properties = properties 40 | return new_class 41 | 42 | 43 | class RNBaseNode(metaclass=FormMetaClass): 44 | """Base class for Red-Node nodes. All user-defined nodes should derived from it. 45 | The child classes must implement the work(self, msg=None) method. 46 | """ 47 | 48 | rednode_template = "httprequest" 49 | 50 | # based on SFNR code (GPL v3) 51 | @classmethod 52 | def install(cls, node_dir, port): 53 | 54 | try: 55 | os.mkdir(node_dir) 56 | except OSError: 57 | pass 58 | 59 | for ext in ['js', 'html']: 60 | in_path = Path(__file__).parent / "templates" / ("%s.%s.in" % (cls.rednode_template, ext)) 61 | out_path = node_dir / ("%s.%s" % (cls.name, ext)) 62 | 63 | cls._install_template(in_path, out_path, node_dir, port) 64 | 65 | # based on SFNR code (GPL) 66 | @classmethod 67 | def _install_template(cls, in_path, out_path, node_dir, port): 68 | 69 | defaults = {} 70 | form = "" 71 | 72 | for property in cls.properties: 73 | defaults[property.name] = property.as_dict('value', 'required', 'type') 74 | 75 | if property.input_type == "text": 76 | form += """ 77 |
78 | 79 | 80 |
""" % property.as_dict() 81 | elif property.input_type == "password": 82 | form += """ 83 |
84 | 85 | 86 |
""" % property.as_dict() 87 | elif property.input_type == "checkbox": 88 | form += """ 89 |
90 | 91 | 92 |
""" % property.as_dict() 93 | elif property.input_type == "select": 94 | form += """ 95 |
96 | 97 | 103 |
""" 104 | else: 105 | raise Exception("Unknown input type") 106 | 107 | label_text = "" 108 | if len(cls.output_labels) > 1: 109 | count = 0 110 | for a_label in cls.output_labels: 111 | label_text += "if (index === {}) return \"{}\";\n".format(count, a_label) 112 | count += 1 113 | label_text += "else return \"\";" 114 | 115 | t = open(in_path).read() 116 | 117 | t = t % {'port': port, 118 | 'name': cls.name, 119 | 'title': cls.title, 120 | 'icon': cls.icon, 121 | 'color': cls.color, 122 | 'outputs': cls.outputs, 123 | 'category': cls.category, 124 | 'description': cls.description, 125 | 'labels_text': label_text, 126 | 'defaults': json.dumps(defaults), 127 | 'form': form 128 | } 129 | 130 | print("writing %s" % (str(out_path),)) 131 | 132 | open(out_path, 'w').write(t) 133 | 134 | def run(self, msg, config): 135 | 136 | for p in self.properties: 137 | p.value = config.get(p.name) 138 | 139 | return self.work(msg) 140 | 141 | 142 | class NodeWaiting(Exception): 143 | pass 144 | 145 | 146 | def silent_node_waiting(f): 147 | def applicator(*args, **kwargs): 148 | try: 149 | return f(*args, **kwargs) 150 | except NodeWaiting: 151 | # print('silent_node_waiting') 152 | return None # silent_node_waiting 153 | 154 | return applicator 155 | 156 | 157 | class Join(object): 158 | """implement a join properties for class deriving from RNBaseNode. This class handles waiting until a sufficient number of messages 159 | with the excepted_topics arrive. While waiting the Join instance raise NodeWaiting exception which is understood by the server which then silently inform node-red 160 | to continue without error. Once all the message with the expected topics are arrived, the instance return the messages list in the order of expected_topics. 161 | 162 | """ 163 | 164 | def __init__(self, expected_topics): 165 | self.mem = collections.defaultdict(dict) 166 | self.expected_topics = expected_topics 167 | 168 | def __call__(self, msg): 169 | self.push(msg) 170 | if not self.ready(msg): 171 | raise NodeWaiting 172 | return self.pop(msg) 173 | 174 | def push(self, msg): 175 | self.mem[msg['_msgid']][msg['topic']] = msg['payload'] 176 | 177 | def ready(self, msg): 178 | for topic in self.expected_topics: 179 | if topic not in self.mem[msg['_msgid']]: 180 | return False 181 | return True 182 | 183 | def get_messages(self, msg): 184 | return [self.mem[msg['_msgid']][topic] for topic in self.expected_topics] 185 | 186 | def pop(self, msg): 187 | msgs = self.mem.pop(msg['_msgid']) 188 | return [msgs[topic] for topic in self.expected_topics] 189 | 190 | def clean(self, msg): 191 | del self._cache[msg['_msgid']] 192 | 193 | 194 | def node_red(name=None, title=None, category="default", description=None, 195 | join=None, baseclass=RNBaseNode, properties=None, icon=None, color=None, outputs=1, output_labels=None): 196 | """decorator to make a python function available in node-red. The function must take two arguments, node and msg. 197 | msg is a dictionary with all the pairs of keys and value sent by node-red. Most interesting keys are 'payload', 'topic' and 'msgid_'. 198 | The node argument is an instance of the underlying class created by this decorator. It can be useful when you have a defined a common subclass 199 | of RNBaseNode that provided specific features for your application (usually database connection and similar). """ 200 | 201 | def wrapper(func): 202 | attrs = dict() 203 | attrs['name'] = name if name is not None else func.__name__ 204 | attrs['title'] = title if title is not None else attrs['name'] 205 | attrs['description'] = description if description is not None else func.__doc__ 206 | attrs['category'] = getattr(baseclass, "category", category) # take in the baseclass if possible 207 | attrs['icon'] = icon if icon is not None else 'function' 208 | 209 | try: 210 | if isinstance(color, str): 211 | attrs['color'] = color 212 | else: 213 | attrs['color'] = "rgb({},{},{})".format(color[0], color[1], color[2]) if color is not None else "rgb(231,231,174)" 214 | except (IndexError, TypeError): 215 | attrs['color'] = color 216 | 217 | if join is not None: 218 | if isinstance(join, Join): 219 | attrs['join'] = join 220 | elif isinstance(join, collections.Sequence): 221 | attrs['join'] = Join(join) 222 | else: 223 | raise Exception("join must be a Join object or a sequence of topic (str)") 224 | 225 | if properties is not None: 226 | if not isinstance(properties, dict): 227 | raise Exception("properties must be a dictionary with key the variable name and value a NodeProperty") 228 | for k in properties: 229 | attrs[k] = properties[k] 230 | 231 | attrs['work'] = func 232 | cls = FormMetaClass(attrs['name'], (baseclass,), attrs) 233 | 234 | return cls 235 | 236 | return wrapper 237 | 238 | # @node_red(name="myname", title="mytitle") 239 | # def mynode(msg=None): 240 | # """madoc""" 241 | # print(msg) 242 | 243 | # print(mynode) 244 | --------------------------------------------------------------------------------