Skip to content

Commit 1c10fb3

Browse files
committed
initial commit
0 parents  commit 1c10fb3

File tree

13 files changed

+628
-0
lines changed

13 files changed

+628
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

.jshintrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"predef": [],
3+
"browser": false,
4+
"node": true,
5+
"curly": false,
6+
"strict": false,
7+
"expr": true,
8+
"unused": "vars"
9+
}

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## 0.0.1
2+
* initial version
3+
* independent module moved from [extrabacon/pyspreadsheet](https://github.com/extrabacon/pyspreadsheet)

README.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# python-shell
2+
3+
A simple way to run Python scripts from Node.js with basic but efficient inter-process communication through stdio.
4+
5+
## Features
6+
7+
+ Reliably spawn Python scripts in a child process
8+
+ Text, JSON and binary modes
9+
+ Simple and efficient data transfers through stdin and stdout streams
10+
+ Extended stack traces in case an exception is thrown
11+
12+
## Installation
13+
14+
```bash
15+
npm install python-shell
16+
```
17+
18+
To run the tests:
19+
```bash
20+
npm test
21+
```
22+
23+
## Documentation
24+
25+
### Running a Python script:
26+
27+
```js
28+
PythonShell.run('my_script.py', function (err) {
29+
if (err) throw err;
30+
console.log('finished');
31+
});
32+
```
33+
34+
If the script writes to stderr or exits with a non-zero code, an error will be thrown.
35+
36+
### Running a Python script with arguments and options:
37+
38+
```js
39+
var options = {
40+
mode: 'text',
41+
pythonPath: 'path/to/python',
42+
pythonOptions: ['-u'],
43+
scriptPath: 'path/to/my/scripts',
44+
args: ['value1', 'value2', 'value3']
45+
};
46+
47+
PythonShell.run('my_script.py', options, function (err) {
48+
if (err) throw err;
49+
});
50+
```
51+
52+
The options are:
53+
54+
* `mode`: Configures how data is exchanged between the child process and its parent. The possible values are:
55+
* `text`: each line of data (ending with "\n") is emitted as a message (default)
56+
* `json`: each line of data (ending with "\n") is parsed as JSON and emitted as a message
57+
* `binary`: data is streamed as-is through `stdout` nd `stdin`
58+
* `pythonPath`: The path where to locate the "python" executable. Default: "python"
59+
* `pythonOptions`: Array of option switches to pass to "python"
60+
* `scriptPath`: The default path where to look for scripts. Default: "./python"
61+
* `args`: Array of arguments to pass to the script
62+
63+
Other options are forwarded to `child_process.spawn`.
64+
65+
### Exchanging data between Node and Python:
66+
67+
```js
68+
var pyshell = new PythonShell('my_script.py');
69+
70+
// send a message to the Python script via stdin
71+
pyshell.send('hello');
72+
73+
pyshell.on('message', function (message) {
74+
// received a message emitted from the script via stdout
75+
console.log(message);
76+
});
77+
78+
// end the input stream and allow the process to exit
79+
pyshell.end(function (err) {
80+
if (err) throw err;
81+
console.log('finished');
82+
});
83+
```
84+
85+
Use `.send(message)` to send a message to the Python script. Attach the `message` event to listen to messages emitted from the Python script.
86+
87+
For more details and examples, take a look at the unit tests.
88+
89+
### Error Handling and extended stack traces
90+
91+
An error will be thrown if the process exits with a non-zero exit code or if data has been written to stderr. Additionally, if "stderr" contains a standard Python traceback, the error is augmented with Python exception details including a concatenated stack trace.
92+
93+
Example error with traceback (from test/python/error.py):
94+
```
95+
Traceback (most recent call last):
96+
File "test/python/error.py", line 6, in <module>
97+
divide_by_zero()
98+
File "test/python/error.py", line 4, in divide_by_zero
99+
print 1/0
100+
ZeroDivisionError: integer division or modulo by zero
101+
```
102+
would result into:
103+
```js
104+
{ [Error: ZeroDivisionError: integer division or modulo by zero]
105+
traceback: 'Traceback (most recent call last):\n File "test/python/error.py", line 6, in <module>\n divide_by_zero()\n File "test/python/error.py", line 4, in divide_by_zero\n print 1/0\nZeroDivisionError: integer division or modulo by zero\n',
106+
executable: 'python',
107+
options: null,
108+
script: 'test/python/error.py',
109+
args: null,
110+
exitCode: 1 }
111+
```
112+
and `err.stack` would look like this:
113+
```
114+
Error: ZeroDivisionError: integer division or modulo by zero
115+
at PythonShell.parseError (python-shell/index.js:131:17)
116+
at ChildProcess.<anonymous> (python-shell/index.js:67:28)
117+
at ChildProcess.EventEmitter.emit (events.js:98:17)
118+
at Process.ChildProcess._handle.onexit (child_process.js:797:12)
119+
----- Python Traceback -----
120+
File "test/python/error.py", line 6, in <module>
121+
divide_by_zero()
122+
File "test/python/error.py", line 4, in divide_by_zero
123+
print 1/0
124+
```
125+
126+
## License
127+
128+
The MIT License (MIT)
129+
130+
Copyright (c) 2014 Nicolas Mercier
131+
132+
Permission is hereby granted, free of charge, to any person obtaining a copy
133+
of this software and associated documentation files (the "Software"), to deal
134+
in the Software without restriction, including without limitation the rights
135+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
136+
copies of the Software, and to permit persons to whom the Software is
137+
furnished to do so, subject to the following conditions:
138+
139+
The above copyright notice and this permission notice shall be included in
140+
all copies or substantial portions of the Software.
141+
142+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
143+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
144+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
145+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
146+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
147+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
148+
THE SOFTWARE.

index.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
var EventEmitter = require('events').EventEmitter;
2+
var path = require('path');
3+
var util = require('util');
4+
var spawn = require('child_process').spawn;
5+
6+
function toArray(source) {
7+
if (typeof source === 'undefined' || source === null) {
8+
return [];
9+
} else if (!Array.isArray(source)) {
10+
return [source];
11+
}
12+
return source;
13+
}
14+
15+
function extend(obj) {
16+
Array.prototype.slice.call(arguments, 1).forEach(function (source) {
17+
if (source) {
18+
for (var key in source) {
19+
obj[key] = source[key];
20+
}
21+
}
22+
});
23+
return obj;
24+
}
25+
26+
/**
27+
* An interactive Python shell exchanging data through stdio
28+
* @param {string} script The python script to execute
29+
* @param {object} [options] The launch options (also passed to child_process.spawn)
30+
* @constructor
31+
*/
32+
var PythonShell = function (script, options) {
33+
var self = this;
34+
var errorData = '';
35+
EventEmitter.call(this);
36+
37+
options = extend({}, PythonShell.defaultOptions, options);
38+
var pythonPath = options.pythonPath || 'python';
39+
var pythonOptions = toArray(options.pythonOptions);
40+
var scriptArgs = toArray(options.args);
41+
42+
this.script = path.join(options.scriptPath || './python', script);
43+
this.command = pythonOptions.concat(this.script, scriptArgs);
44+
this.mode = options.mode || 'text';
45+
this.terminated = false;
46+
this.childProcess = spawn(pythonPath, this.command, options);
47+
48+
['stdout', 'stdin', 'stderr'].forEach(function (name) {
49+
self[name] = self.childProcess[name];
50+
self.mode !== 'binary' && self[name].setEncoding('utf8');
51+
});
52+
53+
// listen for incoming data on stdout
54+
this.stdout.on('data', function (data) {
55+
self.mode !== 'binary' && self.receive(data);
56+
});
57+
58+
// listen to stderr and emit errors for incoming data
59+
this.stderr.on('data', function (data) {
60+
errorData += ''+data;
61+
});
62+
63+
this.childProcess.on('exit', function (code) {
64+
var err;
65+
if (errorData || code !== 0) {
66+
if (errorData) {
67+
err = self.parseError(errorData);
68+
} else {
69+
err = new Error('process exited with code ' + code);
70+
}
71+
err = extend(err, {
72+
executable: pythonPath,
73+
options: pythonOptions.length ? pythonOptions : null,
74+
script: self.script,
75+
args: scriptArgs.length ? scriptArgs : null,
76+
exitCode: code
77+
});
78+
// do not emit error if only a callback is used
79+
if (self.listeners('ererrror').length || !self._endCallback) {
80+
self.emit('error', err);
81+
}
82+
}
83+
self.exitCode = code;
84+
self.terminated = true;
85+
self.emit('close');
86+
self._endCallback && self._endCallback(err);
87+
});
88+
};
89+
util.inherits(PythonShell, EventEmitter);
90+
91+
// allow global overrides for options
92+
PythonShell.defaultOptions = {};
93+
94+
/**
95+
* Runs a Python script and returns collected messages
96+
* @param {string} script The script to execute
97+
* @param {Object} options The execution options
98+
* @param {Function} callback The callback function to invoke with the script results
99+
* @return {PythonShell} The PythonShell instance
100+
*/
101+
PythonShell.run = function (script, options, callback) {
102+
if (typeof options === 'function') {
103+
callback = options;
104+
options = null;
105+
}
106+
107+
var pyshell = new PythonShell(script, options);
108+
var output = [];
109+
110+
return pyshell.on('message', function (message) {
111+
output.push(message);
112+
}).end(function (err) {
113+
if (err) return callback(err);
114+
return callback(null, output.length ? output : null);
115+
});
116+
};
117+
118+
/**
119+
* Parses an error thrown from the Python process through stderr
120+
* @param {string|Buffer} data The stderr contents to parse
121+
* @return {Error} The parsed error with extended stack trace when traceback is available
122+
*/
123+
PythonShell.prototype.parseError = function (data) {
124+
var text = ''+data;
125+
var error;
126+
127+
if (/^Traceback/.test(text)) {
128+
// traceback data is available
129+
var lines = (''+data).trim().split(/\n/g);
130+
var exception = lines.pop();
131+
error = new Error(exception);
132+
error.traceback = data;
133+
// extend stack trace
134+
error.stack += '\n ----- Python Traceback -----\n ';
135+
error.stack += lines.slice(1).join('\n ');
136+
} else {
137+
// otherwise, create a simpler error with stderr contents
138+
error = new Error(text);
139+
}
140+
141+
return error;
142+
};
143+
144+
/**
145+
* Sends a message to the Python shell through stdin
146+
* This method
147+
* Override this method to format data to be sent to the Python process
148+
* @param {string|Object} data The message to send
149+
* @returns {PythonShell} The same instance for chaining calls
150+
*/
151+
PythonShell.prototype.send = function (message) {
152+
if (this.mode === 'binary') {
153+
throw new Error('cannot send a message in binary mode, use stdin directly instead');
154+
} else if (this.mode === 'json') {
155+
// write a JSON formatted message
156+
this.stdin.write(JSON.stringify(message) + '\n');
157+
} else {
158+
// write text-based message (default)
159+
if (typeof message !== 'string') message = message.toString();
160+
this.stdin.write(message + '\n');
161+
}
162+
return this;
163+
};
164+
165+
/**
166+
* Parses data received from the Python shell stdout stream and emits "message" events
167+
* This method is not used in binary mode
168+
* Override this method to parse incoming data from the Python process into messages
169+
* @param {string|Buffer} data The data to parse into messages
170+
*/
171+
PythonShell.prototype.receive = function (data) {
172+
var self = this;
173+
var lines = (''+data).split(/\n/g);
174+
var lastLine = lines.pop();
175+
176+
// fix the first line with the remaining from the previous iteration of 'receive'
177+
lines[0] = (this._remaining || '') + lines[0];
178+
// keep the remaining for the next iteration of 'receive'
179+
this._remaining = lastLine;
180+
181+
lines.forEach(function (line) {
182+
if (self.mode === 'json') {
183+
try {
184+
self.emit('message', JSON.parse(line));
185+
} catch (err) {
186+
self.emit('error', extend(
187+
new Error('invalid JSON message: ' + data),
188+
{ inner: err, data: data}
189+
));
190+
}
191+
} else {
192+
self.emit('message', line);
193+
}
194+
});
195+
};
196+
197+
/**
198+
* Closes the stdin stream, which should cause the process to finish its work and close
199+
* @returns {PythonShell} The same instance for chaining calls
200+
*/
201+
PythonShell.prototype.end = function (callback) {
202+
this.childProcess.stdin.end();
203+
this._endCallback = callback;
204+
return this;
205+
};
206+
207+
module.exports = PythonShell;

0 commit comments

Comments
 (0)