├── .editorconfig ├── .github └── workflows │ └── build_and_release.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTOR_LICENSE_AGREEMENT.md ├── LICENSE.md ├── README.md ├── __tests__ └── dummy.test.ts ├── credentials └── PythonEnvVars.credentials.ts ├── demo ├── package.json └── run_demo.sh ├── gulpfile.js ├── nodes └── PythonFunction │ ├── PythonFunction.node.ts │ └── script.template.py ├── package.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.yml] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/workflows/build_and_release.yml: -------------------------------------------------------------------------------- 1 | # ENV VARS: 2 | # - NPM_TOKEN 3 | # + GITHUB_TOKEN (by default) 4 | 5 | name: Build and Publish Package to npmjs 6 | 7 | on: 8 | release: 9 | types: [ published ] 10 | # types: [ created ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v2 # Setup .npmrc file to publish to npm 19 | with: 20 | node-version: '14.x' 21 | registry-url: 'https://registry.npmjs.org' 22 | - run: yarn install 23 | - run: yarn build 24 | - run: yarn publish --access public 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .tmp 4 | tmp 5 | dist 6 | npm-debug.log* 7 | package-lock.json 8 | yarn.lock 9 | 10 | *.tmp.* 11 | *.tmp 12 | 13 | .idea/ 14 | 15 | coverage/ 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | - fix optional credentials issue in recent n8n versions. 4 | -------------------------------------------------------------------------------- /CONTRIBUTOR_LICENSE_AGREEMENT.md: -------------------------------------------------------------------------------- 1 | # n8n Contributor License Agreement 2 | 3 | I give n8n permission to license my contributions on any terms they like. I am giving them this license in order to make it possible for them to accept my contributions into their project. 4 | 5 | ***As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | “Commons Clause” License Condition v1.0 2 | 3 | The Software is provided to you by the Licensor under the 4 | License, as defined below, subject to the following condition. 5 | 6 | Without limiting other conditions in the License, the grant 7 | of rights under the License will not include, and the License 8 | does not grant to you, the right to Sell the Software. 9 | 10 | For purposes of the foregoing, “Sell” means practicing any or 11 | all of the rights granted to you under the License to provide 12 | to third parties, for a fee or other consideration (including 13 | without limitation fees for hosting or consulting/ support 14 | services related to the Software), a product or service whose 15 | value derives, entirely or substantially, from the functionality 16 | of the Software. Any license notice or attribution required by 17 | the License must also include this Commons Clause License 18 | Condition notice. 19 | 20 | Software: n8n 21 | 22 | License: Apache 2.0 23 | 24 | Licensor: Jan Oberhauser 25 | 26 | 27 | --------------------------------------------------------------------- 28 | 29 | 30 | Apache License 31 | Version 2.0, January 2004 32 | http://www.apache.org/licenses/ 33 | 34 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 35 | 36 | 1. Definitions. 37 | 38 | "License" shall mean the terms and conditions for use, reproduction, 39 | and distribution as defined by Sections 1 through 9 of this document. 40 | 41 | "Licensor" shall mean the copyright owner or entity authorized by 42 | the copyright owner that is granting the License. 43 | 44 | "Legal Entity" shall mean the union of the acting entity and all 45 | other entities that control, are controlled by, or are under common 46 | control with that entity. For the purposes of this definition, 47 | "control" means (i) the power, direct or indirect, to cause the 48 | direction or management of such entity, whether by contract or 49 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 50 | outstanding shares, or (iii) beneficial ownership of such entity. 51 | 52 | "You" (or "Your") shall mean an individual or Legal Entity 53 | exercising permissions granted by this License. 54 | 55 | "Source" form shall mean the preferred form for making modifications, 56 | including but not limited to software source code, documentation 57 | source, and configuration files. 58 | 59 | "Object" form shall mean any form resulting from mechanical 60 | transformation or translation of a Source form, including but 61 | not limited to compiled object code, generated documentation, 62 | and conversions to other media types. 63 | 64 | "Work" shall mean the work of authorship, whether in Source or 65 | Object form, made available under the License, as indicated by a 66 | copyright notice that is included in or attached to the work 67 | (an example is provided in the Appendix below). 68 | 69 | "Derivative Works" shall mean any work, whether in Source or Object 70 | form, that is based on (or derived from) the Work and for which the 71 | editorial revisions, annotations, elaborations, or other modifications 72 | represent, as a whole, an original work of authorship. For the purposes 73 | of this License, Derivative Works shall not include works that remain 74 | separable from, or merely link (or bind by name) to the interfaces of, 75 | the Work and Derivative Works thereof. 76 | 77 | "Contribution" shall mean any work of authorship, including 78 | the original version of the Work and any modifications or additions 79 | to that Work or Derivative Works thereof, that is intentionally 80 | submitted to Licensor for inclusion in the Work by the copyright owner 81 | or by an individual or Legal Entity authorized to submit on behalf of 82 | the copyright owner. For the purposes of this definition, "submitted" 83 | means any form of electronic, verbal, or written communication sent 84 | to the Licensor or its representatives, including but not limited to 85 | communication on electronic mailing lists, source code control systems, 86 | and issue tracking systems that are managed by, or on behalf of, the 87 | Licensor for the purpose of discussing and improving the Work, but 88 | excluding communication that is conspicuously marked or otherwise 89 | designated in writing by the copyright owner as "Not a Contribution." 90 | 91 | "Contributor" shall mean Licensor and any individual or Legal Entity 92 | on behalf of whom a Contribution has been received by Licensor and 93 | subsequently incorporated within the Work. 94 | 95 | 2. Grant of Copyright License. Subject to the terms and conditions of 96 | this License, each Contributor hereby grants to You a perpetual, 97 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 98 | copyright license to reproduce, prepare Derivative Works of, 99 | publicly display, publicly perform, sublicense, and distribute the 100 | Work and such Derivative Works in Source or Object form. 101 | 102 | 3. Grant of Patent License. Subject to the terms and conditions of 103 | this License, each Contributor hereby grants to You a perpetual, 104 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 105 | (except as stated in this section) patent license to make, have made, 106 | use, offer to sell, sell, import, and otherwise transfer the Work, 107 | where such license applies only to those patent claims licensable 108 | by such Contributor that are necessarily infringed by their 109 | Contribution(s) alone or by combination of their Contribution(s) 110 | with the Work to which such Contribution(s) was submitted. If You 111 | institute patent litigation against any entity (including a 112 | cross-claim or counterclaim in a lawsuit) alleging that the Work 113 | or a Contribution incorporated within the Work constitutes direct 114 | or contributory patent infringement, then any patent licenses 115 | granted to You under this License for that Work shall terminate 116 | as of the date such litigation is filed. 117 | 118 | 4. Redistribution. You may reproduce and distribute copies of the 119 | Work or Derivative Works thereof in any medium, with or without 120 | modifications, and in Source or Object form, provided that You 121 | meet the following conditions: 122 | 123 | (a) You must give any other recipients of the Work or 124 | Derivative Works a copy of this License; and 125 | 126 | (b) You must cause any modified files to carry prominent notices 127 | stating that You changed the files; and 128 | 129 | (c) You must retain, in the Source form of any Derivative Works 130 | that You distribute, all copyright, patent, trademark, and 131 | attribution notices from the Source form of the Work, 132 | excluding those notices that do not pertain to any part of 133 | the Derivative Works; and 134 | 135 | (d) If the Work includes a "NOTICE" text file as part of its 136 | distribution, then any Derivative Works that You distribute must 137 | include a readable copy of the attribution notices contained 138 | within such NOTICE file, excluding those notices that do not 139 | pertain to any part of the Derivative Works, in at least one 140 | of the following places: within a NOTICE text file distributed 141 | as part of the Derivative Works; within the Source form or 142 | documentation, if provided along with the Derivative Works; or, 143 | within a display generated by the Derivative Works, if and 144 | wherever such third-party notices normally appear. The contents 145 | of the NOTICE file are for informational purposes only and 146 | do not modify the License. You may add Your own attribution 147 | notices within Derivative Works that You distribute, alongside 148 | or as an addendum to the NOTICE text from the Work, provided 149 | that such additional attribution notices cannot be construed 150 | as modifying the License. 151 | 152 | You may add Your own copyright statement to Your modifications and 153 | may provide additional or different license terms and conditions 154 | for use, reproduction, or distribution of Your modifications, or 155 | for any such Derivative Works as a whole, provided Your use, 156 | reproduction, and distribution of the Work otherwise complies with 157 | the conditions stated in this License. 158 | 159 | 5. Submission of Contributions. Unless You explicitly state otherwise, 160 | any Contribution intentionally submitted for inclusion in the Work 161 | by You to the Licensor shall be under the terms and conditions of 162 | this License, without any additional terms or conditions. 163 | Notwithstanding the above, nothing herein shall supersede or modify 164 | the terms of any separate license agreement you may have executed 165 | with Licensor regarding such Contributions. 166 | 167 | 6. Trademarks. This License does not grant permission to use the trade 168 | names, trademarks, service marks, or product names of the Licensor, 169 | except as required for reasonable and customary use in describing the 170 | origin of the Work and reproducing the content of the NOTICE file. 171 | 172 | 7. Disclaimer of Warranty. Unless required by applicable law or 173 | agreed to in writing, Licensor provides the Work (and each 174 | Contributor provides its Contributions) on an "AS IS" BASIS, 175 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 176 | implied, including, without limitation, any warranties or conditions 177 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 178 | PARTICULAR PURPOSE. You are solely responsible for determining the 179 | appropriateness of using or redistributing the Work and assume any 180 | risks associated with Your exercise of permissions under this License. 181 | 182 | 8. Limitation of Liability. In no event and under no legal theory, 183 | whether in tort (including negligence), contract, or otherwise, 184 | unless required by applicable law (such as deliberate and grossly 185 | negligent acts) or agreed to in writing, shall any Contributor be 186 | liable to You for damages, including any direct, indirect, special, 187 | incidental, or consequential damages of any character arising as a 188 | result of this License or out of the use or inability to use the 189 | Work (including but not limited to damages for loss of goodwill, 190 | work stoppage, computer failure or malfunction, or any and all 191 | other commercial damages or losses), even if such Contributor 192 | has been advised of the possibility of such damages. 193 | 194 | 9. Accepting Warranty or Additional Liability. While redistributing 195 | the Work or Derivative Works thereof, You may choose to offer, 196 | and charge a fee for, acceptance of support, warranty, indemnity, 197 | or other liability obligations and/or rights consistent with this 198 | License. However, in accepting such obligations, You may act only 199 | on Your own behalf and on Your sole responsibility, not on behalf 200 | of any other Contributor, and only if You agree to indemnify, 201 | defend, and hold each Contributor harmless for any liability 202 | incurred by, or claims asserted against, such Contributor by reason 203 | of your accepting any such warranty or additional liability. 204 | 205 | END OF TERMS AND CONDITIONS 206 | 207 | APPENDIX: How to apply the Apache License to your work. 208 | 209 | To apply the Apache License to your work, attach the following 210 | boilerplate notice, with the fields enclosed by brackets "[]" 211 | replaced with your own identifying information. (Don't include 212 | the brackets!) The text should be enclosed in the appropriate 213 | comment syntax for the file format. We also recommend that a 214 | file or class name and description of purpose be included on the 215 | same "printed page" as the copyright notice for easier 216 | identification within third-party archives. 217 | 218 | Copyright [yyyy] [name of copyright owner] 219 | 220 | Licensed under the Apache License, Version 2.0 (the "License"); 221 | you may not use this file except in compliance with the License. 222 | You may obtain a copy of the License at 223 | 224 | http://www.apache.org/licenses/LICENSE-2.0 225 | 226 | Unless required by applicable law or agreed to in writing, software 227 | distributed under the License is distributed on an "AS IS" BASIS, 228 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 229 | See the License for the specific language governing permissions and 230 | limitations under the License. 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # n8n-nodes-python 2 | 3 | ![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) 4 | 5 | PythonFunction module - custom node for running python code on n8n. 6 | 7 | > run python code on n8n 8 | 9 | # Python Function 10 | 11 | PythonFunction node is used to run custom Python snippets to transform data or to implement some custom functionality 12 | that n8n does not support yet. 13 | 14 | # Installation 15 | 16 | ## Using n8n-python Docker Image (Recommended) 17 | 18 | This node is pre-installed in 19 | the [n8n-python](https://github.com/naskio/docker-n8n-python) [docker image](https://hub.docker.com/r/naskio/n8n-python) 20 | . 21 | 22 | - Use `n8n-python:latest-debian` if you are planning to install heavy python packages such as `numpy` or `pandas`. 23 | - Use `n8n-python:latest` for a more lightweight image. 24 | 25 | > Example using [docker-compose.yml](https://github.com/naskio/docker-n8n-python/blob/main/demo/docker-compose-local.yml) 26 | 27 | ## Adding external packages 28 | 29 | You can mount a `requirements.txt` file to the container to install additional packages. 30 | 31 | You can use the [ExecuteCommand](https://n8n.io/integrations/n8n-nodes-base.executeCommand) node to 32 | run `pip install -r requirements.txt` 33 | and the [n8nTrigger](https://n8n.io/integrations/n8n-nodes-base.n8nTrigger) node to trigger it after each **restart**. 34 | 35 | ## Install Locally 36 | 37 | ### 1- Install Requirements 38 | 39 | This node requires the following dependencies to be installed in your environment: 40 | 41 | - Python 3.6 or higher 42 | ```shell 43 | python3 --version # check output version 44 | ``` 45 | 46 | - [python-fire](https://www.github.com/google/python-fire) 47 | ```shell 48 | # install fire 49 | pip install fire 50 | ``` 51 | 52 | ### 2- Add n8n-nodes-python to your n8n instance 53 | 54 | If you’re running either by installing it globally or via PM2, make sure that you install `n8n-nodes-python` inside n8n. 55 | n8n will find the module and load it automatically. 56 | 57 | If using docker, add the following line to your `Dockerfile`: 58 | 59 | ```shell 60 | # Install n8n-nodes-python module 61 | RUN cd /usr/local/lib/node_modules/n8n && npm install n8n-nodes-python 62 | ``` 63 | 64 | Read more about the installation process in 65 | the [n8n documentation - Use the n8n-nodes-module in production](https://docs.n8n.io/nodes/creating-nodes/create-n8n-nodes-module.html#use-the-n8n-nodes-module-in-production) 66 | . 67 | 68 | # Usage 69 | 70 | This node receives `ìtems` and should return a list of `items`. 71 | 72 | Example: 73 | 74 | ```python3 75 | new_items = [] 76 | for item in items: 77 | item['newField'] = 'newValue' 78 | new_items.append(item) 79 | return new_items # should return a list 80 | ``` 81 | 82 | > The JSON attribute of each item is added and removed automatically. 83 | > You can access the values directly without the `json` attribute. 84 | > You don't need to put the item in a `json` attribute. it will be done automatically. 85 | 86 | ## Variable: items 87 | 88 | the `items` variable is a list of items that are passed to the function. They are directly accessible in the function. 89 | 90 | Example: 91 | 92 | ```python3 93 | print(items) 94 | # > list 95 | return items 96 | ``` 97 | 98 | ## Credentials: env_vars (optional) 99 | 100 | You can specify environment variables to be used in the python code. The environment variables are accessible throw 101 | the `env_vars` dict. 102 | 103 | Example: 104 | 105 | ```python3 106 | print(env_vars) 107 | print(env_vars.get('MY_VAR','default_value')) 108 | # > dict 109 | ``` 110 | 111 | ## Logging to the browser console 112 | 113 | it is possible to write to the browser console by writing to `stdout` 114 | 115 | Example: 116 | 117 | ``` 118 | print('Hello World') 119 | # or 120 | import sys 121 | sys.stdout.write('Hello World') 122 | ``` 123 | 124 | # Notes 125 | 126 | - `stderr`is used for passing data between nodes. 127 | 128 | - if exit code is 0, the node will be executed successfully and `stderr` represents the JSON representation of the 129 | output of the node 130 | - if exit code is not 0, the node fails and `stderr` represents the error message 131 | 132 | 133 | - The `json` attribute of each item is added and removed automatically. (you can access and return the items directly 134 | without the `json` attribute) 135 | 136 | # Contribute 137 | 138 | Pull requests are welcome! For any bug reports, please create an issue. 139 | 140 | # License 141 | 142 | [Apache 2.0 with Commons Clause](LICENSE.md) 143 | -------------------------------------------------------------------------------- /__tests__/dummy.test.ts: -------------------------------------------------------------------------------- 1 | test('Hello', async () => { 2 | expect('Hello').toStrictEqual('Hello'); 3 | }); 4 | -------------------------------------------------------------------------------- /credentials/PythonEnvVars.credentials.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ICredentialType, 3 | INodeProperties, 4 | } from 'n8n-workflow'; 5 | 6 | 7 | export class PythonEnvVars implements ICredentialType { 8 | displayName = 'Python Environment Variables'; 9 | name = 'pythonEnvVars'; 10 | properties: INodeProperties[] = [ 11 | { 12 | displayName: '.env File Content', 13 | name: 'envFileContent', 14 | typeOptions: { 15 | rows: 10, 16 | }, 17 | type: 'string', 18 | default: '', 19 | placeholder: 'HELLO=World!\n', 20 | description: 'The content of the .env file.', 21 | }, 22 | // { 23 | // displayName: 'Environment Variables', 24 | // name: 'envVars', 25 | // type: 'fixedCollection', 26 | // // type: 'collection', 27 | // // type: 'string', 28 | // default: {}, 29 | // placeholder: 'Add environment variable', 30 | // typeOptions: { 31 | // multipleValues: true, 32 | // sortable: true, 33 | // }, 34 | // description: 'Environment variables.', 35 | // options: [ 36 | // { 37 | // name: 'envVar', 38 | // displayName: 'Environment Variable', 39 | // values: [ 40 | // { 41 | // displayName: 'Name', 42 | // name: 'name', 43 | // type: 'string', 44 | // default: '', 45 | // description: 'Name of the environment variable.', 46 | // }, 47 | // { 48 | // displayName: 'Value', 49 | // name: 'value', 50 | // type: 'string', 51 | // default: '', 52 | // description: 'Value of the environment variable.', 53 | // typeOptions: { 54 | // password: true, 55 | // }, 56 | // }, 57 | // ], 58 | // }, 59 | // ], 60 | // }, 61 | ]; 62 | } 63 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "n8n-demo", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "./node_modules/n8n/bin/n8n start" 7 | }, 8 | "dependencies": { 9 | "n8n": "^0.162.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /demo/run_demo.sh: -------------------------------------------------------------------------------- 1 | #### install package locally 2 | #!/bin/bash 3 | 4 | DEMO_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) 5 | 6 | echo DEMO_DIR: $DEMO_DIR 7 | 8 | quick=false # set to false to install all dependencies 9 | 10 | if [ $quick = true ]; then 11 | cd ${DEMO_DIR}/ 12 | cd ./../ 13 | yarn run build 14 | cd ${DEMO_DIR}/ 15 | yarn run start 16 | else 17 | # go to package root 18 | cd ${DEMO_DIR}/ 19 | cd ./../ 20 | # Install package dependencies 21 | yarn install 22 | # Build the package locally 23 | yarn run build 24 | # "Publish" the package locally 25 | yarn link 26 | # or 27 | # yarn install && yarn build && yarn link 28 | #### -------------------------------------------------- 29 | # run n8n locally 30 | cd ${DEMO_DIR}/ 31 | yarn install 32 | yarn unlink "n8n-nodes-python" 33 | yarn link "n8n-nodes-python" 34 | # yarn add n8n-nodes-python 35 | yarn run start 36 | fi 37 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const {src, dest} = require('gulp'); 2 | 3 | function copyAssets() { 4 | src('nodes/**/*.{png,svg,template.py}') 5 | .pipe(dest('dist/nodes')) 6 | 7 | return src('credentials/**/*.{png,svg}') 8 | .pipe(dest('dist/credentials')); 9 | } 10 | 11 | exports.default = copyAssets; 12 | -------------------------------------------------------------------------------- /nodes/PythonFunction/PythonFunction.node.ts: -------------------------------------------------------------------------------- 1 | import {IExecuteFunctions} from 'n8n-core'; 2 | import { 3 | IDataObject, 4 | INodeExecutionData, 5 | INodeType, 6 | INodeTypeDescription, 7 | NodeOperationError, 8 | } from 'n8n-workflow'; 9 | import {spawn} from 'child_process'; 10 | import * as path from 'path'; 11 | import * as fs from 'fs'; 12 | import * as tempy from 'tempy'; 13 | 14 | 15 | export interface IExecReturnData { 16 | exitCode: number; 17 | error?: Error; 18 | stderr: string; 19 | stdout: string; 20 | items?: IDataObject[]; 21 | } 22 | 23 | 24 | export class PythonFunction implements INodeType { 25 | description: INodeTypeDescription = { 26 | displayName: 'Python Function', 27 | name: 'pythonFunction', 28 | icon: 'fa:code', 29 | group: ['transform'], 30 | version: 1, 31 | description: 'Run custom Python 3.10 code which gets executed once and allows you to add, remove, change and replace items', 32 | defaults: { 33 | name: 'PythonFunction', 34 | color: '#4B8BBE', 35 | }, 36 | inputs: ['main'], 37 | outputs: ['main'], 38 | credentials: [ 39 | { 40 | name: 'pythonEnvVars', 41 | required: false, 42 | }, 43 | ], 44 | properties: [ 45 | { 46 | displayName: 'Python Code', 47 | name: 'functionCode', 48 | typeOptions: { 49 | alwaysOpenEditWindow: true, 50 | rows: 10, 51 | // codeAutocomplete: 'function', // requires changes in UI 52 | // editor: 'code', // LATER: need to set language='python' in monaco-editor but code => language='javascript' 53 | }, 54 | type: 'string', 55 | default: ` 56 | # Code here will run only once, no matter how many input items there are. 57 | # More info and help: https://github.com/naskio/n8n-nodes-python 58 | return items 59 | `, 60 | description: 'The Python code to execute.', 61 | noDataExpression: true, 62 | }, 63 | ], 64 | }; 65 | 66 | async execute(this: IExecuteFunctions): Promise { 67 | 68 | let items = this.getInputData(); 69 | // Copy the items as they may get changed in the functions 70 | items = JSON.parse(JSON.stringify(items)); 71 | 72 | 73 | // Get the python code snippet 74 | const functionCode = this.getNodeParameter('functionCode', 0) as string; 75 | // Get the environment variables 76 | let pythonEnvVars: Record = {}; 77 | try { 78 | pythonEnvVars = parseEnvFile(String((await this.getCredentials('pythonEnvVars'))?.envFileContent || '')); 79 | } catch (_) { 80 | } 81 | let scriptPath = ''; 82 | let jsonPath = ''; 83 | try { 84 | scriptPath = await getTemporaryScriptPath(functionCode); 85 | jsonPath = await getTemporaryJsonFilePath(unwrapJsonField(items)); 86 | } catch (error) { 87 | throw new NodeOperationError(this.getNode(), `Could not generate temporary files: ${error.message}`); 88 | } 89 | 90 | 91 | try { 92 | 93 | // Execute the function code 94 | const execResults = await execPythonSpawn(scriptPath, jsonPath, pythonEnvVars, this.sendMessageToUI); 95 | const { 96 | error: returnedError, 97 | exitCode, 98 | // stdout, 99 | // stderr, 100 | items: returnedItems, 101 | } = execResults; 102 | items = wrapJsonField(returnedItems) as INodeExecutionData[]; 103 | 104 | 105 | // check errors 106 | if (returnedError !== undefined) { 107 | throw new NodeOperationError(this.getNode(), `exitCode: ${exitCode} ${returnedError?.message || ''}`); 108 | } 109 | // Do very basic validation of the data 110 | if (items === undefined) { 111 | throw new NodeOperationError(this.getNode(), 'No data got returned. Always return an Array of items!'); 112 | } 113 | if (!Array.isArray(items)) { 114 | throw new NodeOperationError(this.getNode(), 'Always an Array of items has to be returned!'); 115 | } 116 | for (const item of items) { 117 | if (item.json === undefined) { 118 | throw new NodeOperationError(this.getNode(), 'All returned items have to contain a property named "json"!'); 119 | } 120 | if (typeof item.json !== 'object') { 121 | throw new NodeOperationError(this.getNode(), 'The json-property has to be an object!'); 122 | } 123 | if (item.binary !== undefined) { 124 | if (Array.isArray(item.binary) || typeof item.binary !== 'object') { 125 | throw new NodeOperationError(this.getNode(), 'The binary-property has to be an object!'); 126 | } 127 | } 128 | } 129 | } catch (error) { 130 | 131 | 132 | if (this.continueOnFail()) { 133 | items = [{json: {error: error.message}}]; 134 | } else { 135 | 136 | 137 | // Try to find the line number which contains the error and attach to error message 138 | const stackLines = error.stack.split('\n'); 139 | if (stackLines.length > 0) { 140 | const lineParts = stackLines[1].split(':'); 141 | if (lineParts.length > 2) { 142 | const lineNumber = lineParts.splice(-2, 1); 143 | if (!isNaN(lineNumber)) { 144 | error.message = `${error.message} [Line ${lineNumber}]`; 145 | } 146 | } 147 | } 148 | throw error; 149 | } 150 | } 151 | 152 | 153 | return this.prepareOutputData(items); 154 | } 155 | } 156 | 157 | 158 | function parseShellOutput(outputStr: string): [] { 159 | return JSON.parse(outputStr); 160 | } 161 | 162 | 163 | function execPythonSpawn(scriptPath: string, jsonPath: string, envVars: object, stdoutListener?: CallableFunction): Promise { 164 | const returnData: IExecReturnData = { 165 | error: undefined, 166 | exitCode: 0, 167 | stderr: '', 168 | stdout: '', 169 | }; 170 | return new Promise((resolve, reject) => { 171 | const child = spawn('python3', [scriptPath, '--json_path', jsonPath, '--env_vars', JSON.stringify(envVars)], { 172 | cwd: process.cwd(), 173 | // shell: true, 174 | }); 175 | 176 | child.stdout.on('data', data => { 177 | returnData.stdout += data.toString(); 178 | if (stdoutListener) { 179 | stdoutListener(data.toString()); 180 | } 181 | }); 182 | 183 | child.stderr.on('data', data => { 184 | returnData.stderr += data.toString(); 185 | }); 186 | 187 | child.on('error', (error) => { 188 | returnData.error = error; 189 | resolve(returnData); 190 | }); 191 | 192 | child.on('close', code => { 193 | returnData.exitCode = code || 0; 194 | if (!code) { 195 | returnData.items = parseShellOutput(returnData.stderr); 196 | } else { 197 | returnData.error = new Error(returnData.stderr); 198 | } 199 | resolve(returnData); 200 | }); 201 | }); 202 | } 203 | 204 | 205 | function parseEnvFile(envFileContent: string): Record { 206 | if (!envFileContent || envFileContent === '') { 207 | return {}; 208 | } 209 | const envLines = envFileContent.split('\n'); 210 | const envVars: Record = {}; 211 | for (const line of envLines) { 212 | const parts = line.split('='); 213 | if (parts.length === 2) { 214 | envVars[parts[0]] = parts[1]; 215 | } 216 | } 217 | return envVars; 218 | } 219 | 220 | 221 | function formatCodeSnippet(code: string): string { 222 | // add tab at the beginning of each line 223 | return code 224 | .replace(/\n/g, '\n\t') 225 | .replace(/\r/g, '\n\t') 226 | .replace(/\r\n\t/g, '\n\t') 227 | .replace(/\r\n/g, '\n\t'); 228 | } 229 | 230 | 231 | function getScriptCode(codeSnippet: string): string { 232 | const css = fs.readFileSync(path.resolve(__dirname, 'script.template.py'), 'utf8') || ''; 233 | return css.replace('pass', formatCodeSnippet(codeSnippet)); 234 | } 235 | 236 | 237 | async function getTemporaryScriptPath(codeSnippet: string): Promise { 238 | const tmpPath = tempy.file({extension: 'py'}); 239 | const codeStr = getScriptCode(codeSnippet); 240 | // write code to file 241 | fs.writeFileSync(tmpPath, codeStr); 242 | return tmpPath; 243 | } 244 | 245 | 246 | async function getTemporaryJsonFilePath(data: object): Promise { 247 | const tmpPath = tempy.file({extension: 'json'}); 248 | const jsonStr = JSON.stringify(data); 249 | // write code to file 250 | fs.writeFileSync(tmpPath, jsonStr); 251 | return tmpPath; 252 | } 253 | 254 | 255 | function unwrapJsonField(list: IDataObject[] = []): IDataObject[] { 256 | return list.reduce((acc, item) => { 257 | if ('json' in item) { 258 | acc.push(item.json as never); 259 | } else { 260 | acc.push(item as never); 261 | } 262 | return acc; 263 | }, []); 264 | } 265 | 266 | 267 | function wrapJsonField(list: IDataObject[] = []): IDataObject[] { 268 | return list.reduce((acc, item) => { 269 | const newItem: IDataObject = {...item}; 270 | newItem.json = item; 271 | acc.push(newItem as never); 272 | return acc; 273 | }, []); 274 | } 275 | -------------------------------------------------------------------------------- /nodes/PythonFunction/script.template.py: -------------------------------------------------------------------------------- 1 | import fire 2 | import json 3 | import sys 4 | from pathlib import Path 5 | 6 | 7 | def snippet_runner(items: list, env_vars: dict) -> list: 8 | # ...code snippet 9 | # should return items 10 | # <%- codeSnippet %> 11 | pass 12 | 13 | def main(json_path: str, env_vars: dict = {}) -> None: 14 | with open(Path(json_path), 'r') as json_file: 15 | items: list = json.load(json_file) 16 | new_items = snippet_runner(items, env_vars) 17 | assert type(new_items) is list or isinstance(new_items, list), "code snippet should return a list" 18 | # print('```', json.dumps(new_items), '```') 19 | # sys.stdout.write(json.dumps(new_items)) 20 | sys.stderr.write(json.dumps(new_items)) 21 | exit(0) 22 | 23 | 24 | if __name__ == "__main__": 25 | fire.Fire(main) 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "n8n-nodes-python", 3 | "version": "0.1.4", 4 | "description": "Run Python on n8n.", 5 | "license": "Apache 2.0 with Commons Clause", 6 | "homepage": "https://github.com/naskio/n8n-nodes-python", 7 | "author": { 8 | "name": "Mehdi Nassim KHODJA", 9 | "email": "contact@nask.io", 10 | "url": "https://nask.io" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/naskio/n8n-nodes-python.git" 15 | }, 16 | "keywords": [ 17 | "n8n", 18 | "nodemation", 19 | "nodes", 20 | "custom", 21 | "module", 22 | "development", 23 | "python", 24 | "python3", 25 | "python3.10", 26 | "python function", 27 | "function", 28 | "code", 29 | "custom code", 30 | "script" 31 | ], 32 | "scripts": { 33 | "dev": "npm run watch", 34 | "build": "tsc && gulp", 35 | "lint": "tslint -p tsconfig.json -c tslint.json", 36 | "lintfix": "tslint --fix -p tsconfig.json -c tslint.json", 37 | "nodelinter": "nodelinter", 38 | "watch": "tsc --watch", 39 | "test": "jest", 40 | "coverage": "jest --coverage", 41 | "demo": "./demo/run_demo.sh" 42 | }, 43 | "files": [ 44 | "dist" 45 | ], 46 | "n8n": { 47 | "credentials": [ 48 | "dist/credentials/PythonEnvVars.credentials.js" 49 | ], 50 | "nodes": [ 51 | "dist/nodes/PythonFunction/PythonFunction.node.js" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "@types/express": "^4.17.6", 56 | "@types/jest": "^26.0.13", 57 | "@types/node": "^14.17.27", 58 | "@types/request-promise-native": "~1.0.15", 59 | "gulp": "^4.0.0", 60 | "jest": "^26.4.2", 61 | "n8n-workflow": "~0.83.0", 62 | "nodelinter": "^0.1.9", 63 | "ts-jest": "^26.3.0", 64 | "tslint": "^6.1.2", 65 | "typescript": "~4.3.5" 66 | }, 67 | "dependencies": { 68 | "n8n-core": "~0.101.0", 69 | "tempy": "1.0.1" 70 | }, 71 | "jest": { 72 | "transform": { 73 | "^.+\\.tsx?$": "ts-jest" 74 | }, 75 | "testURL": "http://localhost/", 76 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 77 | "testPathIgnorePatterns": [ 78 | "/dist/", 79 | "/node_modules/" 80 | ], 81 | "moduleFileExtensions": [ 82 | "ts", 83 | "tsx", 84 | "js", 85 | "json" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es2017", 5 | "es2019.array" 6 | ], 7 | "types": [ 8 | "node", 9 | "jest" 10 | ], 11 | "module": "commonjs", 12 | "noImplicitAny": true, 13 | "removeComments": true, 14 | "strictNullChecks": true, 15 | "strict": true, 16 | "preserveConstEnums": true, 17 | "resolveJsonModule": true, 18 | "declaration": true, 19 | "outDir": "./dist/", 20 | "target": "es2017", 21 | "sourceMap": true 22 | }, 23 | "include": [ 24 | "credentials/**/*", 25 | "src/**/*", 26 | "nodes/**/*", 27 | "nodes/**/*.json", 28 | "test/**/*" 29 | ], 30 | "exclude": [ 31 | "**/*.spec.ts" 32 | ] 33 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "linterOptions": { 3 | "exclude": [ 4 | "node_modules/**/*" 5 | ] 6 | }, 7 | "defaultSeverity": "error", 8 | "jsRules": {}, 9 | "rules": { 10 | "array-type": [ 11 | true, 12 | "array-simple" 13 | ], 14 | "arrow-return-shorthand": true, 15 | "ban": [ 16 | true, 17 | { 18 | "name": "Array", 19 | "message": "tsstyle#array-constructor" 20 | } 21 | ], 22 | "ban-types": [ 23 | true, 24 | [ 25 | "Object", 26 | "Use {} instead." 27 | ], 28 | [ 29 | "String", 30 | "Use 'string' instead." 31 | ], 32 | [ 33 | "Number", 34 | "Use 'number' instead." 35 | ], 36 | [ 37 | "Boolean", 38 | "Use 'boolean' instead." 39 | ] 40 | ], 41 | "class-name": true, 42 | "curly": [ 43 | true, 44 | "ignore-same-line" 45 | ], 46 | "forin": true, 47 | "jsdoc-format": true, 48 | "label-position": true, 49 | "indent": [true, "tabs", 2], 50 | "member-access": [ 51 | true, 52 | "no-public" 53 | ], 54 | "new-parens": true, 55 | "no-angle-bracket-type-assertion": true, 56 | "no-any": true, 57 | "no-arg": true, 58 | "no-conditional-assignment": true, 59 | "no-construct": true, 60 | "no-debugger": true, 61 | "no-default-export": true, 62 | "no-duplicate-variable": true, 63 | "no-inferrable-types": true, 64 | "ordered-imports": [true, { 65 | "import-sources-order": "any", 66 | "named-imports-order": "case-insensitive" 67 | }], 68 | "no-namespace": [ 69 | true, 70 | "allow-declarations" 71 | ], 72 | "no-reference": true, 73 | "no-string-throw": true, 74 | "no-unused-expression": true, 75 | "no-var-keyword": true, 76 | "object-literal-shorthand": true, 77 | "only-arrow-functions": [ 78 | true, 79 | "allow-declarations", 80 | "allow-named-functions" 81 | ], 82 | "prefer-const": true, 83 | "radix": true, 84 | "semicolon": [ 85 | true, 86 | "always", 87 | "ignore-bound-class-methods" 88 | ], 89 | "switch-default": true, 90 | "trailing-comma": [ 91 | true, 92 | { 93 | "multiline": { 94 | "objects": "always", 95 | "arrays": "always", 96 | "functions": "always", 97 | "typeLiterals": "ignore" 98 | }, 99 | "esSpecCompliant": true 100 | } 101 | ], 102 | "triple-equals": [ 103 | true, 104 | "allow-null-check" 105 | ], 106 | "use-isnan": true, 107 | "quotemark": [ 108 | true, 109 | "single" 110 | ], 111 | "quotes": [ 112 | "error", 113 | "single" 114 | ], 115 | "variable-name": [ 116 | true, 117 | "check-format", 118 | "ban-keywords", 119 | "allow-leading-underscore", 120 | "allow-trailing-underscore" 121 | ] 122 | }, 123 | "rulesDirectory": [] 124 | } --------------------------------------------------------------------------------