Python modules with sbldpy
Warning
sbldpy is new and should be considered experimental. Details might still change in future releases.
sbldpy provides a simple way to make C++ functions and classes available as
Python modules. Unlike the pycpp_<modulename> subdirectories (which use
pybind11), the C++
code is not compiled with Python headers or linked with the Python library.
Instead, the module is compiled into a shared library providing a small and
fixed set of plain C functions, which is loaded with the ctypes module of the Python standard
library. This means that the compiled code does not depend on the version of
Python, and it is in general much faster to compile.
sbldpy is deliberately ultra lightweight, so modules compile quickly. It supports only a small set of features and types, which are always handled in the same simple manner. In particular, data is always copied (with the exception of arrays, which can be passed as views), and C++ functions can not return references or pointers. More advanced functionality (e.g. default values of arguments, properties, or alternative ways to create objects) can be implemented in Python code wrapping the module.
Modules are defined in subdirectories named pymod_<modulename> of a
package. The pymod_<modulename> and pycpp_<modulename> subdirectories
can be used in the same package, but a module can not be provided by more
than one of pymod_<modulename>/, pycpp_<modulename>/ and
python/<modulename>.py.
Module names can start with a single underscore, which is useful for compiled
modules which are only used by Python modules wrapping them (for instance
pymod__impl/ provides the module _impl, which a file
python/mymod.py could import with from ._impl import somefunction).
Two names are special: pymod__init/ provides the __init__.py file of
the package, and pymod__main/ provides its __main__.py file (these
files can then not be provided in other ways). The module in pymod__main/
must define a function named main without parameters, which is called
when the package is run with python3 -m <packagename> (its return value,
if any, is used as exit code). Names starting with _sbld
or with more than one underscore are not allowed.
Example
Placing the following in the file pymod_mymod/mod.cc of a package
MyPkg:
#include "sbldpy/sbldpy.hh"
#include "MyPkg/Stuff.hh" // Code in the package library (optional)
namespace {
double add( double a, double b ) { return a + b; }
std::string greet( const std::string& name ) { return "Hello " + name; }
class Counter {
public:
Counter( std::int64_t start ) : m_value(start) {}
std::int64_t increment( std::int64_t n ) { return m_value += n; }
std::int64_t value() const { return m_value; }
private:
std::int64_t m_value;
};
}
SBLDPY_MODULE( mymod )
{
m.doc( "My module" );
m.def( "add", &add, "Add two numbers", { "a", "b" } );
m.def( "greet", &greet, { "name" } );
m.add_class<Counter>( "Counter", "A counter" )
.def_init<std::int64_t>( { "start" } )
.def( "increment", &Counter::increment, { "n" } )
.def( "value", &Counter::value );
}
results in a Python module which can be used as:
import MyPkg.mymod
print( MyPkg.mymod.add( 1, 2 ) ) # 3.0
print( MyPkg.mymod.greet( name = 'you' ) ) # Hello you
c = MyPkg.mymod.Counter( 10 )
c.increment( n = 1 )
print( c.value() ) # 11
The name given to SBLDPY_MODULE must match the name of the subdirectory,
and SBLDPY_MODULE must be used exactly once per module (the code of a
module can be spread over several files in the pymod_<modulename>
directory). The functions, classes, etc. get proper signatures and
documentation in Python (try for instance help(MyPkg.mymod)). Argument
names are optional, but recommended: they make it possible to pass arguments by
name in Python, which helps avoiding mistakes. Without them, arguments can only
be given positionally.
Supported types
Since Python integers have no width, and to avoid issues with integer widths on different platforms, values passed from Python to C++ must use specific types:
Python |
C++ parameter type |
|---|---|
|
|
|
|
|
|
|
|
|
|
Numpy array (see below) |
|
Callable |
|
Object of class defined in the module |
|
A TypeError is raised for values of the wrong type (for instance, a
float is not accepted for an integer, and a bool is not accepted for
an integer or a float).
Arrays are passed to C++ as views of numpy arrays, and are never converted or
copied. The element type T can be any fixed width integer type, float
or double, and the numpy array must be one-dimensional, C-contiguous and
have exactly that element type (e.g. numpy.int32 for std::int32_t).
Anything else, including Python lists and bytes objects, results in a
TypeError with a hint about how the calling code can convert the argument
(e.g. with numpy.asarray(x, dtype=numpy.float64)). With a non-const T,
the C++ code can write into the array, which must then be writable. The data
is guaranteed to be valid during the call. The C++ code can keep the pointer
and use it after the call only if the Python code keeps the array alive and
does not resize it in the meantime. For instance, a Python wrapper can
allocate an array once, hand it to a C++ object, and let the C++ object fill
it in subsequent calls.
Return values (C++ to Python) can be:
C++ return type |
Python |
|---|---|
Any integer type up to 64 bits (except |
|
|
|
|
|
|
|
|
|
|
New one-dimensional numpy array (copy of the data) |
|
One-dimensional numpy array referring to the data (read-only for
|
Object of class defined in the module (by value) |
New object owned by Python |
|
|
References and pointers can not be returned, and a std::vector must be
returned as a sbldpy::NumpyCopy or sbldpy::NumpyView. The element type
of numpy arrays can be any fixed width integer type, float or double
(multi-dimensional arrays can be reshaped in Python).
A numpy array returned as a sbldpy::NumpyView from a method keeps the
Python object alive. The data must be owned by the C++ object, and the array
becomes invalid if the C++ object reallocates or destroys the data (e.g. if a
std::vector is resized). Call .copy() on the array to be safe. Using
unsupported types results in a compilation error with a descriptive message.
Classes
Classes are added with m.add_class<T>( name, doc ), and support:
.def_init<Args...>( ... ): constructor callingT(Args...). At most one constructor can be defined. Without a constructor, objects can only be created by functions returning them..def( name, method, ... ): method, given as a member function pointer ofTitself (not of a base class), or as a function or lambda takingT&orconst T&as first parameter. Methods can also have special names like__repr__..def_static( name, function, ... ): static method.
Objects are owned by Python, and deleted when the Python object is deleted. Objects can only be passed to functions of the same module, and the classes can not be subclassed in Python. Objects can not be copied or pickled in Python.
Callbacks
Python callables can be passed to C++ functions taking a
sbldpy::Callback<R(Args...)> parameter. The callback object can be copied
and stored, and the Python callable is kept alive until the last copy is
destroyed. Only plain data is passed (and copied): The parameters can be
integers (of any type up to 64 bits), float, double, bool,
std::string, const char*, tuples of those, and sbldpy::NumpyCopy objects. The return type
can be void, std::int64_t, double, bool, std::string,
tuples of those, or std::vector<T>. For the latter, the Python callable
must return a numpy array with the same requirements as for array parameters
(the data is copied into the std::vector).
double integrate( sbldpy::Callback<double(double)> f, std::int64_t n )
{
double s = 0.0;
for ( std::int64_t i = 0; i < n; ++i )
s += f( ( i + 0.5 ) / n );
return s / n;
}
If the Python callable raises an exception, a sbldpy::CallbackError is
thrown in C++. Unless it is caught by the C++ code, the original Python
exception is then raised again (with its traceback) when the C++ function
returns. Callbacks can call functions of the module again.
Exceptions
Exceptions thrown in C++ code are converted to Python exceptions:
std::invalid_argument, std::domain_error and std::length_error
become ValueError, std::out_of_range becomes IndexError,
std::overflow_error becomes OverflowError, sbldpy::TypeError
becomes TypeError, std::bad_alloc becomes MemoryError, and all
other exceptions become RuntimeError.
Threads
Python’s global interpreter lock (GIL) is released during calls to C++ functions, so C++ functions can run in parallel when called from several Python threads (and for instance a graphical user interface can stay responsive while heavy work is done in C++ in another thread). Callbacks can be invoked from any thread, and the GIL is acquired automatically when they are invoked.
Code which uses the Python C API in some other way while running inside a call
to a C++ function, either directly or through other bindings like pybind11 or
nanobind, must therefore acquire the GIL itself around that usage, e.g. with
PyGILState_Ensure() and PyGILState_Release(),
pybind11::gil_scoped_acquire or nanobind::gil_scoped_acquire.
Implementation and versions
sbldpy consists of the header-only C++17 library sbldpy/sbldpy.hh and the
pure-Python runtime module sbldpy. Each module library (named
_sbldpy_<modulename>.so and installed next to a small generated
<modulename>.py file) is used by the runtime only via the four plain C
functions sbldpy_abi_version, sbldpy_module_info, sbldpy_call and
sbldpy_last_error. C++ exceptions never cross these functions, and Python
exceptions never cross the callbacks. The binary interface between the
modules and the runtime has a version, which is checked when modules are
loaded.
simplebuild compiles module libraries with the usual (default) symbol
visibility, like other libraries, since hidden visibility breaks C++ features
like dynamic_cast and typeid across libraries on some platforms (e.g.
macOS) for types whose type information is generated in the module.
The version of sbldpy is available in C++ as SBLDPY_VERSION (an integer
which is major*1000000+minor*1000+patch, e.g. 1002003 for version
1.2.3) and SBLDPY_VERSION_STR, and in Python as sbldpy.version_int and
sbldpy.__version__. Information about a loaded module (including the
version of sbldpy it was built with) is available with
sbldpy.module_info(module).
Python module names starting with _sbld are reserved, and can not be used for
any Python modules in packages (and a package can not be named sbldpy).