Skip to content

Commit 718f4f2

Browse files
committed
Merge branch 'parsers'
2 parents 83ac98f + 96b4b3e commit 718f4f2

File tree

5 files changed

+118
-46
lines changed

5 files changed

+118
-46
lines changed

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ A simple way to run Python scripts from Node.js with basic but efficient inter-p
55
## Features
66

77
+ Reliably spawn Python scripts in a child process
8-
+ Text, JSON and binary modes
8+
+ Built-in text, JSON and binary modes
9+
+ Custom parsers and formatters
910
+ Simple and efficient data transfers through stdin and stdout streams
1011
+ Extended stack traces when an error is thrown
1112

@@ -78,6 +79,12 @@ pyshell.end(function (err) {
7879

7980
Use `.send(message)` to send a message to the Python script. Attach the `message` event to listen to messages emitted from the Python script.
8081

82+
Use `options.mode` to quickly setup how data is sent and received between your Node and Python applications.
83+
84+
* use `text` mode for exchanging lines of text
85+
* use `json` mode for exchanging JSON fragments
86+
* use `binary` mode for anything else (data is sent and received as-is)
87+
8188
For more details and examples including Python source code, take a look at the tests.
8289

8390
### Error Handling and extended stack traces
@@ -129,6 +136,9 @@ Creates an instance of `PythonShell` and starts the Python process
129136
* `text`: each line of data (ending with "\n") is emitted as a message (default)
130137
* `json`: each line of data (ending with "\n") is parsed as JSON and emitted as a message
131138
* `binary`: data is streamed as-is through `stdout` and `stdin`
139+
* `formatter`: each message to send is transformed using this method, then appended with "\n"
140+
* `parser`: each line of data (ending with "\n") is parsed with this function and its result is emitted as a message
141+
* `encoding`: the text encoding to apply on the child process streams (default: "utf8")
132142
* `pythonPath`: The path where to locate the "python" executable. Default: "python"
133143
* `pythonOptions`: Array of option switches to pass to "python"
134144
* `scriptPath`: The default path where to look for scripts. Default: "./python"
@@ -178,9 +188,7 @@ PythonShell.run('script.py', function (err, results) {
178188

179189
#### `.send(message)`
180190

181-
Sends a message to the Python script via stdin. The data is formatted according to the selected mode (text or JSON). This method can be overridden in order to format the data in some other way.
182-
183-
This method should not be used in binary mode.
191+
Sends a message to the Python script via stdin. The data is formatted according to the selected mode (text or JSON), or through a custom function when `formatter` is specified.
184192

185193
Example:
186194
```js
@@ -195,15 +203,15 @@ shell.send({ command: "do_stuff", args: [1, 2, 3] });
195203

196204
#### `.receive(data)`
197205

198-
Parses incoming data from the Python script written via stdout and emits `message` events. The data is parsed as JSON if mode has been set to "json". This method is called automatically as data is being received from stdout and can be overridden to parse the data differently.
206+
Parses incoming data from the Python script written via stdout and emits `message` events. This method is called automatically as data is being received from stdout.
199207

200208
#### `.end(callback)`
201209

202210
Closes the stdin stream, allowing the Python script to finish and exit. The optional callback is invoked when the process is terminated.
203211

204212
#### event: `message`
205213

206-
Fires when a chunk of data is parsed from the stdout stream via the `receive` method. This event is not emitted in binary mode.
214+
Fires when a chunk of data is parsed from the stdout stream via the `receive` method. If a `parser` method is specified, the result of this function will be the message value. This event is not emitted in binary mode.
207215

208216
Example:
209217
```js

index.js

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ function extend(obj) {
3030
* @constructor
3131
*/
3232
var PythonShell = function (script, options) {
33+
34+
function resolve(type, val) {
35+
if (typeof val === 'string') {
36+
// use a built-in function using its name
37+
return PythonShell[type][val];
38+
} else if (typeof val === 'function') {
39+
// use a custom function
40+
return val;
41+
}
42+
}
43+
3344
var self = this;
3445
var errorData = '';
3546
EventEmitter.call(this);
@@ -42,18 +53,20 @@ var PythonShell = function (script, options) {
4253
this.script = path.join(options.scriptPath || './python', script);
4354
this.command = pythonOptions.concat(this.script, scriptArgs);
4455
this.mode = options.mode || 'text';
56+
this.formatter = resolve('format', options.formatter || this.mode);
57+
this.parser = resolve('parse', options.parser || this.mode);
4558
this.terminated = false;
4659
this.childProcess = spawn(pythonPath, this.command, options);
4760

4861
['stdout', 'stdin', 'stderr'].forEach(function (name) {
4962
self[name] = self.childProcess[name];
50-
self.mode !== 'binary' && self[name].setEncoding('utf8');
63+
self.parser && self[name].setEncoding(options.encoding || 'utf8');
5164
});
5265

53-
// listen for incoming data on stdout
54-
this.stdout.on('data', function (data) {
55-
self.mode !== 'binary' && self.receive(data);
56-
});
66+
// parse incoming data on stdout
67+
if (this.parser) {
68+
this.stdout.on('data', PythonShell.prototype.receive.bind(this));
69+
}
5770

5871
// listen to stderr and emit errors for incoming data
5972
this.stderr.on('data', function (data) {
@@ -91,6 +104,28 @@ util.inherits(PythonShell, EventEmitter);
91104
// allow global overrides for options
92105
PythonShell.defaultOptions = {};
93106

107+
// built-in formatters
108+
PythonShell.format = {
109+
text: function toText(data) {
110+
if (!data) return '';
111+
else if (typeof data !== 'string') return data.toString();
112+
return data;
113+
},
114+
json: function toJson(data) {
115+
return JSON.stringify(data);
116+
}
117+
};
118+
119+
// built-in parsers
120+
PythonShell.parse = {
121+
text: function asText(data) {
122+
return data;
123+
},
124+
json: function asJson(data) {
125+
return JSON.parse(data);
126+
}
127+
};
128+
94129
/**
95130
* Runs a Python script and returns collected messages
96131
* @param {string} script The script to execute
@@ -143,22 +178,14 @@ PythonShell.prototype.parseError = function (data) {
143178

144179
/**
145180
* Sends a message to the Python shell through stdin
146-
* This method
147181
* Override this method to format data to be sent to the Python process
148182
* @param {string|Object} data The message to send
149183
* @returns {PythonShell} The same instance for chaining calls
150184
*/
151185
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-
}
186+
var data = this.formatter ? this.formatter(message) : message;
187+
if (this.mode !== 'binary') data += '\n';
188+
this.stdin.write(data);
162189
return this;
163190
};
164191

@@ -170,32 +197,28 @@ PythonShell.prototype.send = function (message) {
170197
*/
171198
PythonShell.prototype.receive = function (data) {
172199
var self = this;
173-
var lines = (''+data).split(/\n/g);
200+
var parts = (''+data).split(/\n/g);
174201

175-
if (lines.length === 1) {
202+
if (parts.length === 1) {
176203
// an incomplete record, keep buffering
177-
this._remaining = (this._remaining || '') + lines[0];
204+
this._remaining = (this._remaining || '') + parts[0];
178205
return this;
179206
}
180207

181-
var lastLine = lines.pop();
208+
var lastLine = parts.pop();
182209
// fix the first line with the remaining from the previous iteration of 'receive'
183-
lines[0] = (this._remaining || '') + lines[0];
210+
parts[0] = (this._remaining || '') + parts[0];
184211
// keep the remaining for the next iteration of 'receive'
185212
this._remaining = lastLine;
186213

187-
lines.forEach(function (line) {
188-
if (self.mode === 'json') {
189-
try {
190-
self.emit('message', JSON.parse(line));
191-
} catch (err) {
192-
self.emit('error', extend(
193-
new Error('invalid JSON message: ' + data + ' >> ' + err),
194-
{ inner: err, data: line}
195-
));
196-
}
197-
} else {
198-
self.emit('message', line);
214+
parts.forEach(function (part) {
215+
try {
216+
self.emit('message', self.parser(part));
217+
} catch(err) {
218+
self.emit('error', extend(
219+
new Error('invalid message: ' + data + ' >> ' + err),
220+
{ inner: err, data: part}
221+
));
199222
}
200223
});
201224

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
},
1111
"dependencies": {},
1212
"devDependencies": {
13-
"should": "^3.1.3",
14-
"mocha": "^1.17.1"
13+
"should": "^6.0.0",
14+
"mocha": "^2.2.5"
1515
},
1616
"repository": {
1717
"type": "git",

test/python/echo_binary.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import sys
2+
3+
# simple binary echo script
4+
sys.stdout.write(sys.stdin.read())

test/test-python-shell.js

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,35 @@ describe('PythonShell', function () {
9191
done();
9292
});
9393
});
94-
it('should throw when mode is "binary"', function (done) {
94+
it('should use a custom formatter', function (done) {
9595
var pyshell = new PythonShell('echo_text.py', {
96+
formatter: function (message) {
97+
return message.toUpperCase();
98+
}
99+
});
100+
var output = '';
101+
pyshell.stdout.on('data', function (data) {
102+
output += ''+data;
103+
});
104+
pyshell.send('hello').send('world').end(function (err) {
105+
if (err) return done(err);
106+
output.should.be.exactly('HELLO\nWORLD\n');
107+
done();
108+
});
109+
});
110+
it('should write as-is when mode is "binary"', function (done) {
111+
var pyshell = new PythonShell('echo_binary.py', {
96112
mode: 'binary'
97113
});
98-
(function () {
99-
pyshell.send('hello world!');
100-
}).should.throw();
101-
pyshell.end(done);
114+
var output = '';
115+
pyshell.stdout.on('data', function (data) {
116+
output += ''+data;
117+
});
118+
pyshell.send(new Buffer('i am not a string')).end(function (err) {
119+
if (err) return done(err);
120+
output.should.be.exactly('i am not a string');
121+
done();
122+
});
102123
});
103124
});
104125

@@ -150,6 +171,22 @@ describe('PythonShell', function () {
150171
};
151172
pyshell.end(done);
152173
});
174+
it('should use a custom parser function', function (done) {
175+
var pyshell = new PythonShell('echo_text.py', {
176+
mode: 'text',
177+
parser: function (message) {
178+
return message.toUpperCase();
179+
}
180+
});
181+
var count = 0;
182+
pyshell.on('message', function (message) {
183+
count === 0 && message.should.be.exactly('HELLO');
184+
count === 1 && message.should.be.exactly('WORLD!');
185+
count++;
186+
}).on('close', function () {
187+
count.should.be.exactly(2);
188+
}).send('hello').send('world!').end(done);
189+
});
153190
});
154191

155192
describe('.end(callback)', function () {

0 commit comments

Comments
 (0)