Introduction to Boa

Boa is the Python Bridge Layer in Pipcook, it lets you call Python functions seamlessly in Node.js, it delivers any Python module for Node.js developer in lower-cost to learn or use.

Quick Start

Let’s have a glance on how to call to Python’s function:

  1. const boa = require('@pipcook/boa');
  2. const os = boa.import('os');
  3. console.log(os.getpid()); // prints the pid from python.
  4. // using keyword arguments namely `kwargs`
  5. os.makedirs('..', boa.kwargs({
  6. mode: 0x777,
  7. exist_ok: false,
  8. }));
  9. // using bult-in functions
  10. const { range, len } = boa.builtins();
  11. const list = range(0, 10); // create a range array
  12. console.log(len(list)); // 10
  13. console.log(list[2]); // 2

Install Python Package

By default, Boa will install a conda virtual environment under the path of the Boa package. To make it easier to install python libraries, you can run:

  1. $ ./node_modules/.bin/bip install <package-name>

bip is an alias of pip that points to the correct Python environment.

API References

A Connection between 2 languages(ecosystems) has huge works to be done, even though this package is working only on the unilateral from Python to JavaScript. The most difficult part is that for developers, they need to understand the correspondence between the two languages and ecosystems. Therefore, a good design principle will make developers reduce learning costs.

boa

require('@pipcook/boa') returns the root object, which will be your entry point to all Python functions, and it provides these methods:

.builtins()

Gets the Python’s built-in functions, for example:

  1. const { len, range } = boa.builtins();
  2. len([1, 2, 3]); // 3
  3. len(range(0, 10)); // 10

.import(mod)

Imports a Python module in your current environment, the module includes:

  • system modules like os, string and re.
  • third-party modules like numpy and request via pip.

To call the function, you should pass a mod argument for the module that you want to import.

  1. const os = boa.import('os');
  2. const str = boa.import('string');
  3. const numpy = boa.import('numpy');

This returns an instance of PythonObjectWrapper or a JavaScript primitive value for some special cases.

.kwargs(map)

Creates a Python’s keyword arguments, Python provides a way to map arguments with names:

  1. fs.open('./a-file-to-open', mode=0e777)

Correspondingly, this function is used to represent a keyword arguments, and the specific usage is very easy to understand:

  1. const fs = boa.import('fs');
  2. fs.open('./a-file-to-open', boa.kwargs({ mode: 0e777 }));

.with(ctx, fn)

It’s equivalent to the with-statement in Python, this would be called with an object ctx that supports the context management protocol (that is, has __enter__() and __exit__() methods). And 2nd fn is corresponding to the execution block, A simple example is as follows:

  1. boa.with(localcontext(), (ctx) => {
  2. // execution
  3. // the ctx is localcontext().__enter().
  4. });

.eval(str)

Execute Python expression in the context specified, here is a simple call:

  1. boa.eval('len([10, 20])');
  2. // 2

Alternatively, developers can use tagged template literal to pass variables that have been defined in JavaScript:

  1. const elem = np.array([[1, 2, 3], [4, 5, 6]], np.int32);
  2. boa.eval`${elem} + 100`; // do matrix + operation
  3. boa.eval`len(${elem})`; // equivalent to `len(elem)`

For multi-line code, Python 3 does not provide a mechanism to return a value, so the eval function can only handle single-line Python expressions.

.bytes(str)

A shortcut to create a Python’s bytes literal from JavaScript string, it’s equivalent to b'foobar' in Python.

  1. const { bytes } = boa;
  2. bytes('foobar'); // "b'foobar'"

The bytes(str) function simply creates a plain object that is used to pass a string to a Python function as a bytes literal, but does not correspond to any Python object itself. Alternatively, you could use Python’s builtin class bytes for creating a real object:

  1. const { bytes } = boa.builtins();
  2. const foobar = Buffer.from('foobar');
  3. bytes.fromhex(foobar.toString('hex'));
  4. // "b'foobar'"

Class PythonObjectWrapper

This class represents a wrapper for the corresponding object in Python runtime, it must be returned only from boa methods like boa.builtins and boa.import.

creation of instance

In order for developers to use Python objects seamlessly, creating a PythonObjectWrapper requires some necessary steps.

First, check the type of the Python object under instance. If it is one of the following types, it will be converted to the corresponding primitive type.

python typeprimitive
int,floatnumber
int64bigint
float64bigdecimal
boolboolean
strstring
NoneTypenull

If the type of the object that needs to be wrapped is not in the above primitive, a temporary object will be created, and methods and properties will be defined through Object.defineProperties.

On an instance of PythonObjectWrapper, developers can directly obtain values through the property way, just like using those in Python. This is because we use ES6 Proxy, so the last step, we created a Proxy Object, configured with 3 trap handlers, get, set, apply, and finally returns this proxy object.

property accessor

At Python language, an object has attr and item accessors, and they use different expressions:

  • x.y is for attr accessor
  • m[n] is for item accessor

Unfortunately, ES6 Proxy does not distinguish the above things. Therefore, it’s needed to define an algorithm to confirm their priority in a time of operation.

  • given a name variable which is passed by ES6 Proxy‘s get handler.
  • check the name property is owned by the JavaScript object via .hasOwnProperty().
    • return the property if it’s truthy.
  • check the name property is owned by the object’s class via .constructor.prototype.hasOwnProperty().
    • return the property if it’s truthy.
  • check if name is a numeric representation.
    • if it’s truthy, call the internal method .__getitem__(i) for item accessor.
    • otherwise
      • try to access the attr via the internal method .__getattr__().
      • try to access the item via the internal method .__getitem__().
      • otherwise, return undefined.

To better understand the algorithm above, let’s look at some examples:

  1. const boa = require('@pipcook/boa');
  2. const { abs, tuple } = boa.builtins();
  3. {
  4. console.log(abs(-100)); // 100
  5. console.log(abs(100)); // 100
  6. }
  7. {
  8. const re = boa.import('re');
  9. const m = re.search('(?<=abc)def', 'abcdef');
  10. console.log(m.group(0)); // 'def'
  11. }
  12. {
  13. // make sure the `numpy` is in your current python env.
  14. const np = boa.import('numpy');
  15. const x0 = np.array([[1, 2, 3], [4, 5, 6]], np.int32);
  16. const x1 = np.arange(15).reshape(3, 5);
  17. const x2 = np.zeros(tuple([3, 4]));
  18. }

As mentioned above, in addition to dynamically obtaining objects from the Python runtime, the class PythonObjectWrapper also defines the following public methods built into JavaScript.

.prototype.toString()

Returns a string for representing the object, internally it calls the CPython’s PyObject_Str.

  1. console.log(boa.import('os').toString());
  2. // "<module 'os' from '/usr/local/opt/python/Frameworks/Python.framework/Versions/3.7/lib/python3.7/os.py'>"

.prototype.slice(start, stop, step)

Returns a new wrapped slice object, it’s equivalent to s[start:stop:step]. For example:

  1. const { range } = boa.builtins();
  2. const mlist = range(0, 10); // [0...10]
  3. const s = mlist.slice(2, 10, 1); // [2...10]

Note: a new tc39 proposal slice notation attempts to add this kind of syntax, it’ll be merged when it’s land on v8 engine. Or try with eval in Python’s syntax:

  1. boa.eval`${mlist}[0...10]`
  2. boa.eval`${mlist}[1:10:2]`

.prototype.__hash__()

Returns the hash value of this object, internally it calls the CPython’s PyObject_Hash.

Magic methods there are some others like __getitem__, __setitem__, __getattr__, __setattr__ which are used internally in this library, it’s not recommended to actively use them at user-land.

.prototype[Symbol.toPrimitive](hint)

Returns a corresponding primitive value for this object, see Symbol.toPrimitive on MDN for more details.

Working with ECMAScript Modules

Requires Node.js >= v12.11.1

Use Node.js custom loader for better import-statement.

  1. // app.mjs
  2. import { range, len } from 'py:builtins';
  3. import os from 'py:os';
  4. import {
  5. array as NumpyArray,
  6. int32 as NumpyInt32,
  7. } from 'py:numpy';
  8. console.log(os.getpid()); // prints the pid from python.
  9. const list = range(0, 10); // create a range array
  10. console.log(len(list)); // 10
  11. console.log(list[2]); // 2
  12. const arr = NumpyArray([1, 2, 3], NumpyInt32); // Create an array of int32 using ndarry constructor
  13. console.log(arr[0]); // 1

In Node.js v14.x you can specify only --experimental-loader to launch your application:

  1. $ node --experimental-loader @pipcook/boa/esm/loader.mjs app.mjs

In Node.js version < v14.x, you also need to add the --experimental-modules option:

  1. $ node --experimental-modules --experimental-loader @pipcook/boa/esm/loader.mjs app.mjs

Build from source

  1. # clone this project firstly.
  2. $ npm install
  3. $ npm run build

Verify if the generated library is linked to correct Python version

When buidling finished, use objdump -macho -dylibs-used ./build/Release/boa.node to check if your linked libs are correct as:

  1. /build/Release/boa.node:
  2. /usr/local/opt/python/Frameworks/Python.framework/Versions/3.7/Python (compatibility version 3.7.0, current version 3.7.0)
  3. /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 400.9.4)
  4. /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1252.250.1)

See ./tests for more testing details.

Build Tests

To run the full tests:

  1. $ npm test

Virtual Environment for Python

If you are using virtualenv or conda, you can just set up your system environment PYTHONPATH to point to your site-packages folder. For instance:

  1. $ export PYTHONPATH = /Users/venv/lib/python3.7/site-packages