Creating Python bindings for C / C ++ libraries using SIP. Part 1

Sometimes, while working on a project in Python, there is a desire to use a library that is not written in Python, but, for example, in C or C ++. The reasons for this may be different. First, Python is a wonderful language, but in some situations it is not fast enough. And if you see that performance is limited by the features of the Python language, then it makes sense to write part of the program in another language (in this article we will talk about C and C ++), arrange this part of the program as a library, make Python bindings (Python bindings) on top of it and use the module thus obtained as a normal Python library. Secondly, a situation often happens when you know that there is a library that solves the required problem, but, unfortunately, this library is not written in Python, but in the same C or C ++.In this case, we can also make a Python binding over the library and use it without thinking about the fact that the library was not originally written in Python.

There are various tools for creating Python bindings, ranging from lower-level ones like the Python / C API to higher-level ones like SWIG and SIP .

I did not have the goal of comparing different ways of creating Python bindings, but I would like to talk about the basics of using one tool, namely SIP . Initially, SIP was designed to create a binding around the Qt library - PyQt , and is also used to develop other large Python libraries, for example, wxPython .

In this article, gcc will be used as the compiler for C, and g ++ will be used as the C ++ compiler. All examples were tested under Arch Linux and Python 3.8. In order not to complicate the examples, the topic of compilation for different operating systems and using different compilers (for example, Visual Studio) is not included in the scope of this article.

You can download all the examples for this article from the repository on github .
The repository with SIP sources is located at https://www.riverbankcomputing.com/hg/sip/ . Mercurial is used as the version control system for SIP.

Making a binding over a library in C


Writing a library in C


This example is located in the pyfoo_c_01 folder in the source, but in this article we will assume that we are doing everything from scratch.

Let's start with a simple example. First, we’ll make a simple C library, which we will then run from a Python script. Let our library be the only function

int foo(char*);

which will take a string and return its length multiplied by 2. The

header file foo.h may look, for example, like this:

#ifndef FOO_LIB
#define FOO_LIB

int foo(char* str);

#endif

And the file with the implementation of foo.cpp :

#include <string.h>

#include "foo.h"

int foo(char* str) {
	return strlen(str) * 2;
}

To test the library's functionality, we will write a simple main.c program :

#include <stdio.h>

#include "foo.h"

int main(int argc, char* argv[]) {
	char* str = "0123456789";
	printf("%d\n", foo(str));
}

For accuracy, create a Makefile :

CC=gcc
CFLAGS=-c
DIR_OUT=bin

all: main

main: main.o libfoo.a
	$(CC) $(DIR_OUT)/main.o -L$(DIR_OUT) -lfoo -o $(DIR_OUT)/main

main.o: makedir main.c
	$(CC) $(CFLAGS) main.c -o $(DIR_OUT)/main.o

libfoo.a: makedir foo.c
	$(CC) $(CFLAGS) foo.c -o $(DIR_OUT)/foo.o
	ar rcs $(DIR_OUT)/libfoo.a $(DIR_OUT)/foo.o

makedir:
	mkdir -p $(DIR_OUT)

clean:
	rm -rf $(DIR_OUT)/*

Let all the sources of the library foo are located in a subfolder of foo in the source folder:

foo_c_01 /
└── foo
    β”œβ”€β”€ foo.c
    β”œβ”€β”€ foo.h
    β”œβ”€β”€ main.c
    └── Makefile


We go into the foo folder and compile the sources using the command

make

During compilation, the text will be displayed.

mkdir -p bin
gcc -c main.c -o bin/main.o
gcc -c foo.c -o bin/foo.o
ar rcs bin/libfoo.a bin/foo.o
gcc bin/main.o -Lbin -lfoo -o bin/main

The compilation result will be placed in the bin folder inside the foo folder :

foo_c_01 /
└── foo
    β”œβ”€β”€ bin
    β”‚ β”œβ”€β”€ foo.o
    β”‚ β”œβ”€β”€ libfoo.a
    β”‚ β”œβ”€β”€ main
    β”‚ └── main.o
    β”œβ”€β”€ foo.c
    β”œβ”€β”€ foo.h
    β”œβ”€β”€ main.c
    └── Makefile


We compiled a library for static linking and a program that uses it under the name main . After compilation, you can verify that the main program starts.

Let's make a Python binding over the foo library.

SIP Basics


First you need to install SIP. This is done standardly, as with all other libraries using pip:

pip install --user sip

Of course, if you are working in a virtual environment, then the --user parameter, indicating that the SIP library needs to be installed in the user folder, and not globally in the system, should not be specified.

What do we need to do so that the foo library can be called from Python code? At a minimum, you need to create two files: one of them in TOML format and name it pyproject.toml , and the second is a file with the extension .sip. Let's deal with each of them sequentially.

We need to agree on the structure of the source. Inside the pyfoo_c folder is the foo folder , which contains the source for the library. After compilation , the bin folder is created inside the foo folder, which will contain all the compiled files. Later we will add the ability for the user to specify the paths to the header and object files of the library through the command line.

Files required for SIP will be located in the same folder as the foo folder .

pyproject.toml


The pyproject.toml file is not an invention of SIP developers, but the Python project description format described in PEP 517 β€œA build-system independent format for source trees” and in PEP 518 β€œSpecifying Minimum Build System Requirements for Python Projects” . This is a TOML file , which can be considered as a more advanced version of the ini format, in which the parameters are stored in the form of β€œkey = value”, and the parameters can be located not just in sections like [foo], which are called tables in TOML terms, but and in subsections of the form [foo.bar.spam]. Parameters can contain not only strings, but also lists, numbers, and Boolean values.

This file is supposed to describe everything that is needed to build a Python package, and not necessarily using SIP. However, as we will see a little later, in some cases this file will not be enough, and in addition it will need to create a small Python script. But let's talk about everything in order.

A full description of all the possible parameters of the pyproject.toml file that are related to SIP can be found on the SIP documentation page .

For our example, create the pyproject.toml file at the same level as the foo folder :

foo_c_01 /
β”œβ”€β”€ foo
β”‚ β”œβ”€β”€ bin
β”‚ β”‚ β”œβ”€β”€ foo.o
β”‚ β”‚ β”œβ”€β”€ libfoo.a
β”‚ β”‚ β”œβ”€β”€ main
β”‚ β”‚ └── main.o
β”‚ β”œβ”€β”€ foo.c
β”‚ β”œβ”€β”€ foo.h
β”‚ β”œβ”€β”€ main.c
β”‚ └── Makefile
└── pyproject.toml


The contents of pyproject.toml will be as follows:

[build-system]
requires = ["sip >=5, <6"]
build-backend = "sipbuild.api"

[tool.sip.metadata]
name = "pyfoo"
version = "0.1"
license = "MIT"

[tool.sip.bindings.pyfoo]
headers = ["foo.h"]
libraries = ["foo"]
include-dirs = ["foo"]
library-dirs = ["foo/bin"]

The [build-system] section (β€œtable” in TOML terms) is standard and is described in PEP 518 . It contains two parameters:


Other parameters are described in the [tool.sip. *] Sections .

The [tool.sip.metadata] section contains general information about the package: the name of the package to be built (our package will be called pyfoo , but do not confuse this name with the name of the module, which we will later import into Python), the version number of the package (in our case version number β€œ0.1”) and license (for example, β€œ MIT ”).

The most important from the point of view of the assembly is described in the [tool.sip.bindings. pyfoo ].

Note the package name in the section header. We have added two parameters to this section:

  • headers - a list of header files that are needed to use the foo library.
  • libraries - a list of object files compiled for static linking.
  • include-dirs is the path where to look for additional header files besides those that are attached to the C compiler. In this case, where to look for the foo.h file .
  • library-dirs is the path where to look for additional object files besides those that are attached to the C compiler. In this case, this is the folder in which the compiled library file foo is created .

So, we created the first necessary file for SIP. Now we move on to creating the next file that will describe the contents of the future Python module.

pyfoo.sip


Create the pyfoo.sip file in the same folder as the pyproject.toml file :

foo_c_01 /
β”œβ”€β”€ foo
β”‚ β”œβ”€β”€ bin
β”‚ β”‚ β”œβ”€β”€ foo.o
β”‚ β”‚ β”œβ”€β”€ libfoo.a
β”‚ β”‚ β”œβ”€β”€ main
β”‚ β”‚ └── main.o
β”‚ β”œβ”€β”€ foo.c
β”‚ β”œβ”€β”€ foo.h
β”‚ β”œβ”€β”€ main.c
β”‚ └── Makefile
β”œβ”€β”€ pyfoo.sip
└── pyproject.toml


A file with the extension .sip describes the interface of the source library, which will be converted into a module in Python. This file has its own format, which we will now consider, and resembles the C / C ++ header file with additional markup, which should help SIP create a Python module.

In our example, this file should be called pyfoo.sip , because before that, in the pyproject.toml file , we created the [tool.sip.bindings. pyfoo]. In the general case, there can be several such partitions and, accordingly, there must be several * .sip files. But if we have several sip files, then this is a special case from the point of view of SIP, and we will not consider it in this article. Please note that in the general case, the name of the .sip file (and, accordingly, the name of the section) may not coincide with the name of the package, which is specified in the name parameter in the [tool.sip.metadata] section .

Consider the pyfoo.sip file from our example:

%Module(name=foo, language="C")

int foo(char*);

Lines that begin with the character "%" are called directives. They should tell SIP how to properly assemble and style the Python module. A complete list of directives is described on this documentation page . Some directives have additional parameters. Parameters may not be required.

In this example, we use two directives; we will get to know some other directives in the following examples.

The pyfoo.sip file starts with the % Module directive (name = foo, language = β€œC”) . Please note that we specified the value of the first parameter ( name ) without quotes, and the value of the second parameter ( language) with quotes, like strings in C / C ++. This is a requirement of this directive as described in the documentation for the % Module directive .

In the % Module directive, only the name parameter is required , which sets the name of the Python module from which we will import the library function. In this case, the module is called foo , it will contain the function foo , so after assembly and installation we will import it using the code:

from foo import foo

We could make this module nested in another module by replacing this line, for example, with this:

%Module(name=foo.bar, language="C")
...

Then import the function foo would need to be as follows:

from foo.bar import foo

The language parameter of the % Module directive indicates the language in which the source library is written. The value of this parameter can be either β€œC” or β€œC ++”. If this parameter is not specified, then SIP will assume that the library is written in C ++.

Now look at the last line of the pyfoo.sip file :

int foo(char*);

This is a description of the interface of the function from the library that we want to put in the Python module. Based on this declaration, sip will create a Python function. I think that everything should be clear here.

We collect and check


Now everything is ready to build a Python package with a binding for a C library. First of all, you need to build the library itself. Go to the pyfoo_c_01 / foo / folder and start the build using the make command :

$ make

mkdir -p bin
gcc -c main.c -o bin/main.o
gcc -c foo.c -o bin/foo.o
ar rcs bin/libfoo.a bin/foo.o
gcc bin/main.o -Lbin -lfoo -o bin/main

If everything went well, then the bin folder will be created inside the foo folder , in which, among other files, there will be a compiled libfoo.a library . Let me remind you that here, in order not to be distracted from the main topic, we are only talking about building under Linux using gcc. Go back to the pyfoo_c_01 folder . Now it's time to get to know the SIP teams. After installing SIP, the following command line commands will become available ( documentation page ):



  • sip-build . Creates a Python extension object file.
  • sip-install . Creates a Python extension object file and installs it.
  • sip-sdist. .tar.gz, pip.
  • sip-wheel. wheel ( .whl).
  • sip-module. , , SIP. , , . , standalone project, , , , .
  • sip-distinfo. .dist-info, wheel.

These commands need to be run from the folder where the pyproject.toml file is located .

To get started, to better understand the operation of SIP, run the sip-build command , with the --verbose option for more detailed output to the console, and see what happens during the build process.

$ sip-build --verbose

These bindings will be built: pyfoo.
Generating the pyfoo bindings ...
Compiling the 'foo' module ...
building 'foo' extension
creating build
creating build / temp.linux-x86_64-3.8
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c sipfoocmodule.c -o build / temp.linux-x86_64-3.8 / sipfoocmodule.o
sipfoocmodule.c: In the func_foo function:
sipfoocmodule .c: 29: 22: warning: implicit function declaration β€œfoo” [-Wimplicit-function-declaration]
29 | sipRes = foo (a0);
| ^ ~~
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c array.c -o build / temp.linux-x86_64-3.8 / array.o
gcc -pthread -Wno-unused-result -Wsign -compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c bool.cpp -o build / temp.linux-x86_64-3.8 / bool.o
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c objmap.c -o build / temp.linux-x86_64-3.8 / objmap.o
gcc -pthread -Wno-unused-result -Wsign -compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c qtlib.c -o build / temp.linux-x86_64-3.8 / qtlib.o
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c int_convertors.c -o build / temp.linux-x86_64-3.8 / int_convertors.o
gcc -pthread -Wno-unused-result -Wsign -compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c voidptr.c -o build / temp.linux-x86_64-3.8 / voidptr.o
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c apiversions.c -o build / temp.linux-x86_64-3.8 / apiversions.o
gcc -pthread -Wno-unused-result -Wsign -compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c descriptors.c -o build / temp.linux-x86_64-3.8 / descriptors.o
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c threads.c -o build / temp.linux-x86_64-3.8 / threads.o
gcc -pthread -Wno-unused-result -Wsign -compare -DNDEBUG -g -fwrapv -O3 -Wall -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fno-semantic-interposition -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -march = x86-64 -mtune = generic -O3 -pipe -fno-plt -fPIC -DSIP_PROTECTED_IS_PUBLIC -Dprotected = public -I. -I ../../ foo -I / usr / include / python3.8 -c siplib.c -o build / temp.linux-x86_64-3.8 / siplib.o
siplib.c: In the function "slot_richcompare":
siplib.c: 9536: 16: warning: "st" may be used without initialization in this function [-Wmaybe-uninitialized]
9536 | slot = findSlotInClass (ctd, st);
| ^ ~~~~~~~~~~~~~~~~~~~~~~~~
siplib.c: 10671: 19: note: β€œst” was declared here
10671 | sipPySlotType st;
| ^ ~
siplib.c: In the function "parsePass2":
siplib.c: 5625: 32: warning: "owner" may be used without initialization in this function [-Wmaybe-uninitialized]
5625 | * owner = arg;
| ~~~~~~~ ^ ~
g ++ -pthread -shared -Wl, -O1, - sort-common, - as-needed, -z, relro, -z, now -fno-semantic-interposition -Wl, -O1, - sort-common, --as-needed, -z, relro, -z, now build / temp.linux-x86_64-3.8 / sipfoocmodule.o build / temp.linux-x86_64-3.8 / array.o build / temp.linux-x86_64-3.8 /bool.o build / temp.linux-x86_64-3.8 / objmap.o build / temp.linux-x86_64-3.8 / qtlib.o build / temp.linux-x86_64-3.8 / int_convertors.o build / temp.linux-x86_64 -3.8 / voidptr.o build / temp.linux-x86_64-3.8 / apiversions.o build / temp.linux-x86_64-3.8 / descriptors.o build / temp.linux-x86_64-3.8 / threads.o build / temp.linux -x86_64-3.8 / siplib.o -L ../../ foo / bin -L / usr / lib -lfoo -o / home / jenyay / projects / soft / sip-examples / pyfoo_c_01 / build / foo / foo. cpython-38-x86_64-linux-gnu.so
The project has been built.

We will not go deep into the work of SIP, but it can be seen from the output that some sources are compiling. These sources can be seen in the build / foo / folder created by this command :

pyfoo_c_01
β”œβ”€β”€ build
β”‚ └── foo
β”‚ β”œβ”€β”€ apiversions.c
β”‚ β”œβ”€β”€ array.c
β”‚ β”œβ”€β”€ array.h
β”‚ β”œβ”€β”€ bool.cpp
β”‚ β”œβ”€β”€ build
β”‚ β”‚ └── temp.linux-x86_64-3.8
β”‚ β”‚ β”œβ”€β”€ apiversions.o
β”‚ β”‚ β”œβ”€β”€ array.o
β”‚ β”‚ β”œβ”€β”€ bool.o
β”‚ β”‚ β”œβ”€β”€ descriptors.o
β”‚ β”‚ β”œβ”€β”€ int_convertors.o
β”‚ β”‚ β”œβ”€β”€ objmap.o
β”‚ β”‚ β”œβ”€β”€ qtlib.o
β”‚ β”‚ β”œβ”€β”€ sipfoocmodule.o
β”‚ β”‚ β”œβ”€β”€ siplib.o
β”‚ β”‚ β”œβ”€β”€ threads.o
β”‚ β”‚ └── voidptr.o
β”‚ β”œβ”€β”€ descriptors.c
β”‚ β”œβ”€β”€ foo.cpython-38-x86_64-linux-gnu.so
β”‚ β”œβ”€β”€ int_convertors.c
β”‚ β”œβ”€β”€ objmap.c
β”‚ β”œβ”€β”€ qtlib.c
β”‚ β”œβ”€β”€ sipAPIfoo.h
β”‚ β”œβ”€β”€ sipfoocmodule.c
β”‚ β”œβ”€β”€ sip.h
β”‚ β”œβ”€β”€ sipint.h
β”‚ β”œβ”€β”€ siplib.c
β”‚ β”œβ”€β”€ threads.c
β”‚ └── voidptr.c
β”œβ”€β”€ foo
β”‚ β”œβ”€β”€ bin
β”‚ β”‚ β”œβ”€β”€ foo.o
β”‚ β”‚ β”œβ”€β”€ libfoo.a
β”‚ β”‚ β”œβ”€β”€ main
β”‚ β”‚ └── main.o
β”‚ β”œβ”€β”€ foo.c
β”‚ β”œβ”€β”€ foo.h
β”‚ β”œβ”€β”€ main.c
β”‚ └── Makefile
β”œβ”€β”€ pyfoo.sip
└── pyproject.toml


Auxiliary sources appeared in the build / foo folder . Out of curiosity, let's look at the sipfoocmodule.c file , since it directly relates to the foo module that will be created:

/*
 * Module code.
 *
 * Generated by SIP 5.1.1
 */

#include "sipAPIfoo.h"

/* Define the strings used by this module. */
const char sipStrings_foo[] = {
    'f', 'o', 'o', 0,
};

PyDoc_STRVAR(doc_foo, "foo(str) -> int");

static PyObject *func_foo(PyObject *sipSelf,PyObject *sipArgs)
{
    PyObject *sipParseErr = SIP_NULLPTR;

    {
        char* a0;

        if (sipParseArgs(&sipParseErr, sipArgs, "s", &a0))
        {
            int sipRes;

            sipRes = foo(a0);

            return PyLong_FromLong(sipRes);
        }
    }

    /* Raise an exception if the arguments couldn't be parsed. */
    sipNoFunction(sipParseErr, sipName_foo, doc_foo);

    return SIP_NULLPTR;
}

/* This defines this module. */
sipExportedModuleDef sipModuleAPI_foo = {
    0,
    SIP_ABI_MINOR_VERSION,
    sipNameNr_foo,
    0,
    sipStrings_foo,
    SIP_NULLPTR,
    SIP_NULLPTR,
    0,
    SIP_NULLPTR,
    SIP_NULLPTR,
    0,
    SIP_NULLPTR,
    0,
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR,
    {SIP_NULLPTR, SIP_NULLPTR, SIP_NULLPTR, SIP_NULLPTR, SIP_NULLPTR,
            SIP_NULLPTR, SIP_NULLPTR, SIP_NULLPTR, SIP_NULLPTR, SIP_NULLPTR},
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR,
    SIP_NULLPTR
};

/* The SIP API and the APIs of any imported modules. */
const sipAPIDef *sipAPI_foo;

/* The Python module initialisation function. */
#if defined(SIP_STATIC_MODULE)
PyObject *PyInit_foo(void)
#else
PyMODINIT_FUNC PyInit_foo(void)
#endif
{
    static PyMethodDef sip_methods[] = {
        {sipName_foo, func_foo, METH_VARARGS, doc_foo},
        {SIP_NULLPTR, SIP_NULLPTR, 0, SIP_NULLPTR}
    };

    static PyModuleDef sip_module_def = {
        PyModuleDef_HEAD_INIT,
        "foo",
        SIP_NULLPTR,
        -1,
        sip_methods,
        SIP_NULLPTR,
        SIP_NULLPTR,
        SIP_NULLPTR,
        SIP_NULLPTR
    };

    PyObject *sipModule, *sipModuleDict;
    /* Initialise the module and get it's dictionary. */
    if ((sipModule = PyModule_Create(&sip_module_def)) == SIP_NULLPTR)
        return SIP_NULLPTR;

    sipModuleDict = PyModule_GetDict(sipModule);

    if ((sipAPI_foo = sip_init_library(sipModuleDict)) == SIP_NULLPTR)
        return SIP_NULLPTR;

    /* Export the module and publish it's API. */
    if (sipExportModule(&sipModuleAPI_foo, SIP_ABI_MAJOR_VERSION, SIP_ABI_MINOR_VERSION, 0) < 0)
    {
        Py_DECREF(sipModule);
        return SIP_NULLPTR;
    }
    /* Initialise the module now all its dependencies have been set up. */
    if (sipInitModule(&sipModuleAPI_foo,sipModuleDict) < 0)
    {
        Py_DECREF(sipModule);
        return SIP_NULLPTR;
    }

    return sipModule;
}

If you worked with the Python / C API, you will see familiar functions. Pay particular attention to the func_foo function starting at line 18.

As a result of compilation of these sources, the file build / foo / foo.cpython-38-x86_64-linux-gnu.so will be created , and it contains the Python extension, which still needs to be installed correctly.

In order to compile the extension and install it right away, you can use the sip-install command , but we won’t use it, because by default it tries to install the created Python extension globally into the system. This command has a parameter --target-dir, with which you can specify the path where you want to install the extension, but we better use other tools that create packages, which can then be installed using pip.

First, use the sip-sdist command . Using it is very simple:

$ sip-sdist

The sdist has been built.

After that, the pyfoo-0.1.tar.gz file will be created , which can be installed using the command:

pip install --user pyfoo-0.1.tar.gz

As a result, the following information will be shown and the package will be installed:

Processing ./pyfoo-0.1.tar.gz
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
    Preparing wheel metadata ... done
Building wheels for collected packages: pyfoo
  Building wheel for pyfoo (PEP 517) ... done
  Created wheel for pyfoo: filename=pyfoo-0.1-cp38-cp38-manylinux1_x86_64.whl size=337289 sha256=762fc578...
  Stored in directory: /home/jenyay/.cache/pip/wheels/54/dc/d8/cc534fff...
Successfully built pyfoo
Installing collected packages: pyfoo
  Attempting uninstall: pyfoo
    Found existing installation: pyfoo 0.1
    Uninstalling pyfoo-0.1:
      Successfully uninstalled pyfoo-0.1
Successfully installed pyfoo-0.1

Let's make sure that we managed to make a Python binding. We start Python and try to call the function. Let me remind you that according to our settings, the pyfoo package contains the foo module , which has the foo function .

>>> from foo import foo
>>> foo(b'123456')
12

Please note that as a parameter to the function, we pass not just a string, but a byte string b'123456 '- a direct analogue of char * to C. A little later, we will add the conversion of char * to str and vice versa. The result was expected. Let me remind you that the function foo returns the double size of an array of type char * , passed to it as a parameter.

Let's try passing a regular Python string to the foo function instead of a list of bytes.

>>> from foo import foo
>>> foo('123456')

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo(str): argument 1 has unexpected type 'str'

The created binding was unable to convert the string to char * ; we will discuss how to teach it how to do this in the next section.

Congratulations, we made the first binding on a library written in C.

Exit the Python interpreter and assemble the assembly in wheel format. As you most likely know, wheel is a relatively new package format that has been used universally recently. The format is described in PEP 427, β€œThe Wheel Binary Package Format 1.0,” but a description of the features of the wheel format is a topic worthy of a separate large article. It is important for us that the user can easily install the package in wheel format using pip.

A package in wheel format is no more complicated than a package in sdist format. To do this, in the folder with the filepyproject.toml need to execute the command

sip-wheel

After running this command, the build process will be shown and there may be warnings from the compiler:

$ sip-wheel

These bindings will be built: pyfoo.
Generating the pyfoo bindings ...
Compiling the 'foo' module ...
sipfoocmodule.c: In the func_foo function:
sipfoocmodule.c: 29:22: warning: implicit declaration of the foo function [-Wimplicit-function-declaration]
29 | sipRes = foo (a0);
| ^ ~~
siplib.c: In the function "slot_richcompare":
siplib.c: 9536: 16: warning: "st" may be used without initialization in this function [-Wmaybe-uninitialized]
9536 | slot = findSlotInClass (ctd, st);
| ^ ~~~~~~~~~~~~~~~~~~~~~~~~
siplib.c: 10671: 19: remark: β€œst” was declared here
10671 | sipPySlotType st;
| ^ ~
siplib.c: In the function "parsePass2":
siplib.c: 5625: 32: warning: "owner" may be used without initialization in this function [-Wmaybe-uninitialized]
5625 | * owner = arg;
| ~~~~~~~ ^ ~ ~
The wheel has been built.

When the assembly is completed (our small project compiles quickly), a file with the name pyfoo-0.1-cp38-cp38-manylinux1_x86_64.whl or similar will appear in the project folder . The name of the generated file may differ depending on your operating system and version of Python.

Now we can install this package using pip:

pip install --user --upgrade pyfoo-0.1-cp38-cp38-manylinux1_x86_64.whl

The --upgrade option is used here so that pip replaces the pyfoo module installed earlier.

Further, the foo module and the pyfoo package can be used, as shown above.

Add conversion rules to char *


In the previous section, we encountered a problem that the foo function can only accept a set of bytes, but not strings. Now we will fix this shortcoming. To do this, we will use another SIP tool - annotations . Annotations are used inside .sip files and are applied to some code elements: functions, classes, function arguments, exceptions, variables, etc. Annotations are written between forward slashes: / annotation / .

An annotation can work as a flag, which can be in the state set or not set, for example: / ReleaseGIL / , or some annotations need to be assigned some values, for example: / Encoding = "UTF-8" /. If several annotations need to be applied to some object, then they are separated by commas inside slashes: / annotation_1, annotation_2 /.

In the following example, which is located in the folder pyfoo_c_02 , add a file pyfoo.sip annotation parameter function foo :

%Module(name=foo, language="C")

int foo(char* /Encoding="UTF-8"/);

The Encoding annotation indicates in which encoding the string to be passed to the function should be encoded. The values ​​for this annotation may be: ASCII, Latin-1, UTF-8, or None. If the Encoding annotation is not specified or equal to None , then the parameter for such a function is not subjected to any encoding and is passed to the function as is, but in this case the parameter in Python code must be of type bytes , i.e. an array of bytes, as we saw in the previous example. If the encoding is specified, then this parameter can be a string (type str in Python). Encoding annotation can only be applied to parameters of type char , const char ,char * or const char * .

Let's check how the foo function from the foo module now works . To do this, as before, you must first compile the foo library by invoking the make command inside the foo folder , and then call the command, for example, sip-wheel, from the pyfoo_c_02 example folder . The file pyfoo-0.2-cp38-cp38-manylinux1_x86_64.whl or with a similar name will be created , which can be set using the command:

pip install --user --upgrade pyfoo-0.2-cp38-cp38-manylinux1_x86_64.whl

If everything went well, start the Python interpreter and try to call the foo function with a string argument:

>>> from foo import foo

>>> foo(b'qwerty')
12

>>> foo('qwerty')
12

>>> foo('')
24

First, we make sure that using bytes is still possible. After that, we make sure that now we can pass string arguments to the foo function as well. Note that the foo function for a string argument with Russian letters returned a value twice as large as for a string containing only Latin letters. This happened because the function foo does not count the string length in characters (and doubles it), but the length of the char * array , and since in UTF-8 encoding Russian letters occupy 2 bytes, then the size of the char * array after conversion from Python strings turned out to be twice as long.

Fine! We solved the problem with the function argumentfoo , but what if we have dozens or hundreds of such functions in our library, will you have to specify the parameter encoding for each of them? Often the encoding used in a program is the same, and there is no purpose for different functions to indicate different encodings. In this case, SIP has the ability to specify the default encoding, and if for some function the encoding needs some other, then it can be redefined using the Encoding annotation .

To set the encoding of function parameters by default, the % DefaultEncoding directive is used . Its use is shown in the example located in the pyfoo_c_03 folder .

In order to take advantage of directive % DefaultEncoding , change the file pyfoo.sip, now its contents are as follows:

%Module(name=foo, language="C")
%DefaultEncoding "UTF-8"

int foo(char*);

Now, if the argument is a function of type char , char * , etc. If there is no Encoding annotation , then the encoding is taken from the % DefaultEncoding directive , and if it is not, then the conversion is not performed, and for all parameters char * , etc. it is necessary to transfer not lines, but bytes .

An example from the pyfoo_c_03 folder is collected and verified in the same way as an example from the pyfoo_c_02 folder .

Briefly about project.py. Automate assembly


So far, we have used two utility files to create a Python binding - pyproject.toml and pyfoo.sip . Now we will get acquainted with another such file, which should be called project.py . With this script, we can influence the build process of our package. Let's do the build automation. In order to collect the examples pyfoo_c_01 - pyfoo_c_03 from the previous sections, you had to first go to the foo / folder , compile there using the make command , return to the folder where the pyproject.toml file is located, and only then start building the package using one of sip- * commands .

Now our goal is to make sure that when executing the sip-build , sip-sdist and sip-wheel commands , the assembly of the foo C-library is started first , and then the command itself is already launched.

An example created in this section is located in the pyfoo_c_04 source folder .

To change the build process, we can declare a class in the project.py file (the file name should be just that) that is derived from the sipbuild.Project class . This class has methods that we can override on our own. Currently we are interested in the following methods:

  • build . Called during the call to the sip-build command .
  • build_sdist . Called when the sip-sdist command is called .
  • build_wheel . Called when the sip-wheel command is called .
  • install . Called when the sip-install command is invoked .

That is, we can redefine the behavior of these commands. Strictly speaking, the listed methods are declared in the abstract class sipbuild.AbstractProject , from which the derived class sipbuild.Project is created .

Create a project.py file with the following contents:

import os
import subprocess

from sipbuild import Project

class FooProject(Project):
    def _build_foo(self):
        cwd = os.path.abspath('foo')
        subprocess.run(['make'], cwd=cwd, capture_output=True, check=True)

    def build(self):
        self._build_foo()
        super().build()

    def build_sdist(self, sdist_directory):
        self._build_foo()
        return super().build_sdist(sdist_directory)

    def build_wheel(self, wheel_directory):
        self._build_foo()
        return super().build_wheel(wheel_directory)

    def install(self):
        self._build_foo()
        super().install()

We declared the FooProject class , derived from the sipbuild.Project class , and defined the methods build , build_sdist , build_wheel, and install in it . In all these methods, we call the methods of the same name from the base class, before calling the _build_foo method , which starts the execution of the make command in the foo folder .

Note that the build_sdist and build_wheel methods should return the name of the file they created. This is not written in the documentation, but is indicated in the SIP sources.

Now we do not need to run the make commandmanually to build the foo library , this will be done automatically.

If you now run the sip-wheel command in the pyfoo_c_04 folder , a file is created with the name pyfoo-0.4-cp38-cp38-manylinux1_x86_64.whl or similar depending on your operating system and version of Python. This package can be installed using the command:



pip install --user --upgrade pyfoo-0.4-cp38-cp38-manylinux1_x86_64.whl

After that, you can make sure that the foo function from the foo module still works.

Add command line options to build


The following example is in the pyfoo_c_05 folder , and the package has a version number of 0.5 (see the settings in the pyproject.toml file ). This example is based on an example from the documentation with some corrections. In this example, we will redo our project.py file and add new command line options for the build.

In our examples, we are building a very simple library foo, and in real projects, the library can be quite large and then it will not make sense to include it in the source code of the Python binding project. Let me remind you that SIP was originally created to create a binding for such a huge library as Qt. You can, of course, argue that submodules from git can help organize the source, but that’s not the point. Suppose that the library may not be in the folder with the binding source. In this case, the question arises, where should the SIP collector look for the library header and object files? In this case, different users may have their own ways of placing the library.

To solve this problem, we will add two new command-line options to the build system, with which you can specify the path to the file foo.h (parameter --foo-include-dir) and to the library object file (parameter --foo-library-dir ). In addition, we will assume that if these parameters are not specified, then the foo library is still located along with the binding sources.

We need to create the project.py file again , and in it declare a class derived from sipbuild.Project . Let's look at the new version of the project.py file first , and then see how it works.

import os

from sipbuild import Option, Project

class FooProject(Project):
    """        
       foo.
    """

    def get_options(self):
        """     . """

        tools = ['build', 'install', 'sdist', 'wheel']

        #   .
        options = super().get_options()

        #   
        inc_dir_option = Option('foo_include_dir',
                                help="the directory containing foo.h",
                                metavar="DIR",
                                default=os.path.abspath('foo'),
                                tools=tools)
        options.append(inc_dir_option)

        lib_dir_option = Option('foo_library_dir',
                                help="the directory containing the foo library",
                                metavar="DIR",
                                default=os.path.abspath('foo/bin'),
                                tools=tools)

        options.append(lib_dir_option)

        return options

    def apply_user_defaults(self, tool):
        """    . """

        #     
        super().apply_user_defaults(tool)

        #  ,         
        self.foo_include_dir = os.path.abspath(self.foo_include_dir)
        self.foo_library_dir = os.path.abspath(self.foo_library_dir)

    def update(self, tool):
        """   . """

        #   pyfoo
        # (  pyproject.toml  [tool.sip.bindings.pyfoo])
        foo_bindings = self.bindings['pyfoo']

        #   include_dirs  
        if self.foo_include_dir is not None:
            foo_bindings.include_dirs = [self.foo_include_dir]

        #   library_dirs  
        if self.foo_library_dir is not None:
            foo_bindings.library_dirs = [self.foo_library_dir]

        super().update(tool)

We again created the FooProject class , derived from sipbuild.Project . In this example, the automatic assembly of the foo library is disabled , because now it is assumed that it can be in some other place, and by the time the binding is created, the header and object files should be ready. Three methods are redefined

in the FooProject class : get_options , apply_user_defaults, and update . Consider them more carefully.

Let's start with the get_options method . This method should return a list of instances of the sipbuild.Option class. Each list item is a command line option. Inside the overridden method, we get the list of default options (the options variable ) by calling the base class method of the same name, then create two new options ( --foo_include_dir and --foo_library_dir ) and add them to the list, and then return this list from the function.

The constructor of the Option class accepts one required parameter (option name) and a sufficiently large number of optional ones that describe the type of value for this parameter, default value, parameter description, and some others. This example uses the following options for the Option constructor :

  • help , , sip-wheel -h
  • metavar β€” , , . metavar Β«DIRΒ», , β€” .
  • default β€” . , , foo , ( ).
  • tools β€” , . sip-build, sip-install, sip-sdist sip-wheel, tools = ['build', 'install', 'sdist', 'wheel'].

The following overloaded apply_user_defaults method is designed to set parameter values ​​that the user can pass through the command line. The apply_user_defaults method from the base class creates a variable (class member) for each command line parameter created in the get_options method , so it is important to call the base class method of the same name before using the created variables so that all variables created using the command line parameters are created and initialized with default values . After that, in our example, the variables self.foo_include_dir and self.foo_library_dir will be created. If the user has not specified the corresponding command line parameters, then they will take default values ​​according to the parameters of the constructor of the Option class ( default parameter ). If the default parameter is not set, then depending on the type of the expected parameter value, it will be initialized either None, or an empty list, or 0.

Inside the apply_user_defaults method, we make sure that the paths in the variables self.foo_include_dir and self.foo_library_dir are always absolute. This is necessary so that it does not depend on what the working folder will be at the time the assembly starts.

The last overloaded method in this class is update.. This method is called when it is necessary to apply to the project the changes made before this. For example, change or add the parameters specified in the pyproject.toml file . In the previous examples, we set the paths to the header and object files using the include-dirs and library-dirs parameters, respectively, inside the [tool.sip.bindings.pyfoo] section . Now we will set these parameters from the project.py script , so in the pyproject.toml file we will remove these parameters:

[build-system]
requires = ["sip >=5, <6"]
build-backend = "sipbuild.api"

[tool.sip.metadata]
name = "pyfoo"
version = "0.3"
license = "MIT"

[tool.sip.bindings.pyfoo]
headers = ["foo.h"]
libraries = ["foo"]

Inside the method update us from the dictionary self.bindings keyed pyfoo gat instance sipbuild.Bindings . The key name corresponds to the [tool.sip.bindings. pyfoo ] from the pyproject.toml file , and the class instance thus obtained describes the settings described in this section. Then, the members of this class include_dirs and library_dirs (the names of the members correspond to the parameters include-dirs and library-dirs with a hyphen replaced by underscores) are assigned lists containing the paths stored in the members self.foo_include_dir and self.foo_library_dir. In this example, for accuracy, we check that the values self.foo_include_dir and self.foo_library_dir are not equal to None , but in this example this condition is always fulfilled because the command line parameters we created have default values.

Thus, we prepared the configuration files so that during the assembly it was possible to indicate the paths to the header and object files. Let's check what happened.

First, make sure that the default values ​​work. To do this, go to the pyfoo_c_05 / foo folder and build the library using the make command , since we disabled the automatic library build in this example.

After that, go to the folderpyfoo_c_05 and run the sip-wheel command . As a result of this command, the pyfoo-0.5-cp38-cp38-manylinux1_x86_64.whl file or with a similar name will be created .

Now move the foo folder somewhere outside the pyfoo_c_05 folder and run the sip-wheel command again . As a result, we get the expected error informing that we do not have an object library file:

usr/bin/ld:   -lfoo
collect2: :  ld     1
sip-wheel: Unable to compile the 'foo' module: command 'g++' failed with exit status 1

After that, run sip-wheel using the new command-line option:

sip-wheel --foo-include-dir ".../foo" --foo-library-dir ".../foo/bin"

Instead of the ellipsis, you need to specify the path to the folder where you moved the foo folder with the assembled library. As a result, the assembly should succeed in creating the .whl file. The created module can be installed and tested in the same way as in the previous sections.

Check the order of calling methods from project.py


The next example, which we will consider, will be very simple; it will demonstrate the order of calling the methods of the Project class , which we overloaded in the previous sections. This can be useful in order to understand when variables can be initialized. This example is located in the pyfoo_c_06 folder in the source repository.

The essence of this example is to overload all the methods that we used before in the FooProject class , which is located in the project.py file , and add calls to the print function to them , which would display the name of the method in which it is located:

from sipbuild import Project

class FooProject(Project):
    def get_options(self):
        print('get_options()')
        options = super().get_options()
        return options

    def apply_user_defaults(self, tool):
        print('apply_user_defaults()')
        super().apply_user_defaults(tool)

    def apply_nonuser_defaults(self, tool):
        print('apply_nonuser_defaults()')
        super().apply_nonuser_defaults(tool)

    def update(self, tool):
        print('update()')
        super().update(tool)

    def build(self):
        print('build()')
        super().build()

    def build_sdist(self, sdist_directory):
        print('build_sdist()')
        return super().build_sdist(sdist_directory)

    def build_wheel(self, wheel_directory):
        print('build_wheel()')
        return super().build_wheel(wheel_directory)

    def install(self):
        print('install()')
        super().install()

Attentive readers should note that in addition to the previously used methods, the apply_nonuser_defaults () method , which we have not talked about before , is overloaded in this example . This method recommends setting default values ​​for all variables that cannot be changed through command line parameters.

In the pyproject.toml file , return the explicit path to the library:

[build-system]
requires = ["sip >=5, <6"]
build-backend = "sipbuild.api"

[tool.sip.metadata]
name = "pyfoo"
version = "0.4"
license = "MIT"

[tool.sip.bindings.pyfoo]
headers = ["foo.h"]
libraries = ["foo"]
include-dirs = ["foo"]
library-dirs = ["foo/bin"]

For the project to build successfully, you need to go into the foo folder and build the library there using the make command . After that, return to the pyfoo_c_06 folder and run, for example, the sip-wheel command . As a result, if you discard the compiler warnings, the following text will be displayed:

get_options ()
apply_nonuser_defaults ()
get_options ()
get_options ()
apply_user_defaults ()
get_options ()
update ()
These bindings will be built: pyfoo.
build_wheel ()
Generating the pyfoo bindings ...
Compiling the 'foo' module ...
The wheel has been built.

Bold lines are displayed that are output from our project.py file . Thus, we see that the get_options method is called several times, and this must be taken into account if you are going to initialize any member variable in the class that is derived from Project . The get_options method is not the best place for this.

It is also useful to remember that the apply_nonuser_defaults method is called before the apply_user_defaults method , i.e. in the apply_user_defaults method it is already possible to use variables whose values ​​are set in the apply_nonuser_defaults method .

After that, the update method is called, and at the very end, the method directly responsible for the assembly, in our case, build_wheel .

Conclusion to the first part


In this article, we began to study the SIP tool designed to create Python bindings for libraries written in C or C ++. In this first part of the article, we examined the basics of using SIP using the example of creating a Python binding for a very simple library written in C.

We figured out the files that you need to create to work with SIP. The pyproject.toml file contains information about the package (name, version number, license, and paths to header and object files). Using the project.py file, you can influence the build process of the Python package, for example, start building the C-library or let the user specify the location of the header and object files of the library.

In the * .sip filedescribes the interface of the Python module listing the functions and classes that will be contained in the module. Directives and annotations are used to describe the interface in the * .sip file .

In the second part of the article, we will create a binding over an object-oriented library written in C ++, and by its example we will study techniques that will be useful in describing the interface of C ++ classes, and at the same time we will deal with new directives and annotations for us.

To be continued.

References



All Articles