4

Context

I have a Python project for which I wrap some C/C++ code (using the excellent PyBind library). I have a set of C and Python unit tests and I've configured Gitlab's CI to run them at each push. The C tests use a minimalist unit test framework called minunit and I use Python's unittest suite.

Before running the C tests, all the C code is compiled and then tested. I'd like to also compile the C/C++ wrapper for Python before running the Python tests, but have a hard time to do it.

Question in a few words

Is there a standard/good way to get Gitlab-CI to build a Python extension using setuptools before running unit-tests?

Question with more words / Description of what I tried

To compile the C/C++ wrapper locally, I use setuptools with a setup.py file including a build_ext command. I locally compile everything with python setup.py build_ext --inplace (the last arg --inplace will just copy the compiled file to the current directory). As far as I know, this is quite standard.

What I tried to do on Gitlab is to have a Python script (code below) that will run a few commands using os.system command (which appears to be bad practice...). The first command is to run a script building and running all C tests. This works but I'm happy to take recommendations (should I configure Gitlab CI to run C tests separately?).

Now, the problem comes when I try to build the C/C++ wrapper, with os.system("cd python/ \npython setup.py build_ext --inplace"). This generates the error

File "setup.py", line 1, in <module>
        from setuptools import setup, Extension
    ImportError: No module named setuptools

So I tried to modify my gitlab's CI configuration file to install python-dev. My .gitlab-ci.yml looks like

test:
  script:
  - apt-get install -y python-dev
  - python run_tests.py

But, not being sudo on the gitlab's server, I get the following error E: Could not open lock file /var/lib/dpkg/lock - open (13: Permission denied). Anyone knows a way around that, or a better way to tackle this problem?

Any help would be more than welcome!

run_tests.py file

    import unittest
    import os
    from shutil import copyfile
    import glob


    class AllTests(unittest.TestCase):
        def test_all(self):
            # this automatically loads all tests in current dir
            testsuite = unittest.TestLoader().discover('tests/Python_tests')
            # run tests
            result = unittest.TextTestRunner(verbosity=2).run(testsuite)
            # send/print results
            self.assertEqual(result.failures, [], 'Failure')


    if __name__ == "__main__":
        # run C tests
        print(' ------------------------------------------------------ C TESTS')
        os.system("cd tests/C_tests/ \nbash run_all.sh")

        # now python tests
        print(' ------------------------------------------------- PYTHON TESTS')
        # first build and copy shared library compiled from C++ in the python test directory
        # build lib 
        os.system("cd python/ \npython setup.py build_ext --inplace")
        # copy lib it to right place
        dest_dir = 'tests/Python_tests/'
        for file in glob.glob(r'python/*.so'):
            print('Copying file to test dir : ', file)
            copyfile(file, dest_dir+file.replace('python/', ''))
        # run Python tests
        unittest.main(verbosity=0)
Christian
  • 1,162
  • 12
  • 21

1 Answers1

3

My suggestion would be moving the entire test running logic into the setup script.

using test command

First of all, setuptools ships a test command, so you can run the tests via python setup.py test. Even better, the test calls build_ext command under the hood and places the built extensions so that they accessible in the tests, so no need for you to invoke python setup.py build_ext explicitly:

$ python setup.py test
running test
running egg_info
creating so.egg-info
writing so.egg-info/PKG-INFO
writing dependency_links to so.egg-info/dependency_links.txt
writing top-level names to so.egg-info/top_level.txt
writing manifest file 'so.egg-info/SOURCES.txt'
reading manifest file 'so.egg-info/SOURCES.txt'
writing manifest file 'so.egg-info/SOURCES.txt'
running build_ext
building 'wrap_fib' extension
creating build
creating build/temp.linux-aarch64-3.6
aarch64-unknown-linux-gnu-gcc -pthread -fPIC -I/data/gentoo64/usr/include/python3.6m -c wrap_fib.c -o build/temp.linux-aarch64-3.6/wrap_fib.o
aarch64-unknown-linux-gnu-gcc -pthread -fPIC -I/data/gentoo64/usr/include/python3.6m -c cfib.c -o build/temp.linux-aarch64-3.6/cfib.o
creating build/lib.linux-aarch64-3.6
aarch64-unknown-linux-gnu-gcc -pthread -shared -Wl,-O1 -Wl,--as-needed -L. build/temp.linux-aarch64-3.6/wrap_fib.o build/temp.linux-aarch64-3.6/cfib.o -L/data/gentoo64/usr/lib64 -lpython3.6m -o build/lib.linux-aarch64-3.6/wrap_fib.cpython-36m-aarch64-linux-gnu.so
copying build/lib.linux-aarch64-3.6/wrap_fib.cpython-36m-aarch64-linux-gnu.so ->
test_fib_0 (test_fib.FibonacciTests) ... ok
test_fib_1 (test_fib.FibonacciTests) ... ok
test_fib_10 (test_fib.FibonacciTests) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK

(I used the code from the Cython Book examples repository to play with, but the output should be pretty similar to what PyBind produces).

using the extra keywords

Another feature that may come handy are the extra keywords setuptools adds: test_suite, tests_require, test_loader (docs). Here's an example of embedding a custom test suite as you do in run_tests.py:

# setup.py

import unittest
from Cython.Build import cythonize
from setuptools import setup, Extension

exts = cythonize([Extension("wrap_fib", sources=["cfib.c", "wrap_fib.pyx"])])


def pysuite():
    return unittest.TestLoader().discover('tests/python_tests')


if __name__ == '__main__':
    setup(
        name='so',
        version='0.1',
        ext_modules=exts,
        test_suite='setup.pysuite'
    )

extending the test command

The last requirement is running C tests. We can embed them by overriding the test command and invoking some custom code from there. The advantage of that is that distutils offers a command API with many useful functions, like copying files or executing external commands:

# setup.py

import os
import unittest
from Cython.Build import cythonize
from setuptools import setup, Extension
from setuptools.command.test import test as test_orig


exts = cythonize([Extension("wrap_fib", sources=["cfib.c", "wrap_fib.pyx"])])


class test(test_orig):

    def run(self):
        # run python tests
        super().run()
        # run c tests
        self.announce('Running C tests ...')
        pwd = os.getcwd()
        os.chdir('tests/C_tests')
        self.spawn(['bash', 'run_all.sh'])
        os.chdir(pwd)

def pysuite():
    return unittest.TestLoader().discover('tests/python_tests')


if __name__ == '__main__':
    setup(
        name='so',
        version='0.1',
        ext_modules=exts,
        test_suite='setup.pysuite',
        cmdclass={'test': test}
    )

I extended the original test command, running some extra stuff after the python unit tests finish (notice calling of an external command via self.spawn). All that is left is replacing the default test command with the custom one via passing cmdclass in the setup function.

Now you have everything collected in the setup script and python setup.py test will do all the dirty job.

But, not being sudo on the gitlab's server, I get the following error

I don't have any experience with Gitlab CI, but I can't imagine there is no possibility to install packages on the build server. Maybe this question will be helpful: How to use sudo in build script for gitlab ci?

If there really is no other option, you can bootstrap a local copy of setuptools with ez_setup.py. Note, however, that although this method still works, it was deprecated recently.

Also, if you happen to use a recent version of Python (3.4 and newer), then you should have pip bundled with Python distribution, so it should be possible to install setuptools without root permissions with

$ python -m pip install --user setuptools
hoefling
  • 59,418
  • 12
  • 147
  • 194
  • Thanks a lot for your answer! However, my tests are not located in the same directory than the wrapper and the command `python setup.py test` doesn't find any, which could be solved quite easily I guess. The problem is that this solution still assumes the ability to load `setuptools`, which isn't the case with my problem... Thanks about the two other links, I'll check them out and add an answer if I find one that solves my issue. – Christian Jun 06 '18 at 19:51
  • You can adjust the test discovery in the setup script, there is an example of doing this in the answer. If you mean the C tests, I have updated the answer with descending into the test directory (via `os.chdir`). – hoefling Jun 07 '18 at 08:58
  • 1
    As for the permission issue, can you share more details? Is this a system where you are a non-root user without any chance of getting `sudo` permissions from your admin? Can you check whether `sudo apt install` works by chance? Also, what Python version is preinstalled/targeted (`python -V`)? Do you have `pip` available for the targeted Python distribution (`pip -V`)? There are still some other options available, like building a local installation of Python from source and installing any packages you need locally, or running a fresh docker container where you will have root access. – hoefling Jun 07 '18 at 09:02
  • Thanks, yes adjusting the tests discovery seems like the natural option, but there's still the issue that this requires `setuptools`. Yeah I did try with `sudo apt install` and get the same error. Unfortunately no, I cannot have sudo access. I don't know what's the version running on the server, it must be Python 3.5.4, but I'll double check! I do have `pip`, yes. – Christian Jun 07 '18 at 10:10
  • Yes, regarding docker I have never used it but I looked it up and this looks like a good solution to my problem. Have you ever runned tests with docker on gitlab ? – Christian Jun 07 '18 at 10:14
  • 1
    If you have `pip` available and even a recent Python, using docker just to install `setuptools` is an overkill - use `pip install --user setuptools` or `python -m pip install --user setuptools` and you're good to go. Check first whether `pip` and `python` match the targeted versions, replace with specific `pip3.5`/`python3.5`/etc. – hoefling Jun 07 '18 at 10:35
  • Thank you so much, adding `python -m pip install --user setuptools` did the trick! You can modify your comment as an answer and I'll mark it as the correct answer. Thanks a mil – Christian Jun 07 '18 at 20:55
  • Nice, glad I could help! I've mentioned the method in the answer, last paragraph. – hoefling Jun 08 '18 at 10:50