Cython + GitLab

Ten artykuł przedstawia posługiwanie się Cythonem i budowanie obrazów za pomocą Cythona oraz poprawne praktyki CI/CD przy użyciu GitLaba i jego potoku CI/CD.

Ze względu na to, że nasz Python nagle musi zostać skompilowany, a skompilowany moduł wymagany jest do wszystkiego, począwszy od testów jednostkowych, poprzez dokumentację a na właściwym buildzie kończąc, uważam za stosowne rozbić joba na osobne etapy.

Załóżmy, że nasz projekt Cythonowy mamy przetestować jednostkowo, zrobić z niego dokumentację za pomocą Sphinxa (pamiętamy tylko że obiekty dokumentowane muszą być osiągalne przez Pythona, tak więc obiekty z cdef odpadają).

Jednocześnie mamy wymóg, że w projekcie znajduje się wiele plików pyx i każdy z nich chcemy mieć dostępny jako osobny moduł Pythona. Do tego posłuży nam snakehouse.

Załóżmy że pliki pyx są w katalogu example.

Caveaty podczas używania

Jeśli definiujesz funkcję C cdef, pamiętaj o przesyłaniu wyjątków klauzulą except. Inaczej Cython będzie zjadał te wyjątki, i jedyne co po nim zobaczysz to wynik na stdout czy tam stderr.

Budowanie

Zrobimy dwa osobne joby – w pierwszym skompilujemy nasz program do postaci pliku wheel, a w drugim zainstalujemy go na obrazie Dockerowym.

Kasowanie docstringów

Docstringi są fajną funkcją Pythona, ale nie w momencie gdy oddajesz klientowi projekt. Docstringi zagnieżdżone w Cythonie pojawią się również w pliku so/DLL.

Na szczęście jest dostępne narzędzie do automatycznego usuwania docstringów i komentarzy z Twojego kodu źródłowego!

W wyniku tego poniższe:

class Klasa:
    """Mój docstring"""

zostanie przerobione na:

class Klasa:

które poprawnym Pythonem nie jest. Aby uniknąć represji z tym związanych, musimy bloki zawierające docstringi lekko przeredagować:

class Klasa:
   """Mój docstring"""
   pass

Można to rozegrać w .gitlab-ci.yml następująco:

image: smokserwis/build:python3
.compile:
    stage: compile
    script:
        - python setup.py bdist_wheel
        - doctor-wheel dist/*.whl
        - mv dist/*.whl .
    artifacts:
        paths:
            - "*.whl"
 compile_master:
    extends: .compile
    before_script:
        - strip-docs .
    only:
        - master
 compile_else:
    extends: .compile
    except:
        - master

Kompilacja wheela

Zanim się za cokolwiek zabierzemy, musimy zainstalować snakehouse:

pip install satella cython
pip install snakehouse

Na wstępie pamiętać, że ograniczeniem snakehouse jest import modułów “na dzień dobry”. Musimy tak więc stworzyć boilerplate, który załaduje nasz Cythonowy projekt i dopiero potem go wywoła.
Tworzymy tak więc katalog start_example i umieszczamy w nim pliki __init__.py i __main__.py. W pliku __main__.py umieszczamy poniższy kod:

if __name__ == '__main__':
    from example.run import run
    run()

czy tam w jakikolwiek inny sposób uruchamiamy naszą aplikację. Chodzi tutaj tylko o to, żeby importy z snakehouse_ nie szły na górze pliku.

W Dockerfile-u czy czym tam uruchamiamy program ustawiamy:

CMD ["python", "-m", "start_example"]

czy tam ENTRYPOINT jak wolimy.

Następnie tworzymy plik setup.py:

from setuptools import find_packages
from distutils.core import setup
from snakehouse import build, Multibuild, monkey_patch_parallel_compilation, find_pyx_and_c

monkey_patch_parallel_compilation()

setup(name='example',
      description='An example example',
        
      packages=find_packages(include=['example', 'start_example']),
      ext_modules=build([Multibuild('example', find_pyx_and_c('example')), ],
                        compiler_directives={
                           'language_level': '3',
                        })
      )

monkey_patch_parallel_compilation nie jest tutaj niezbędne, ale znacząco przyśpieszy kompilację.

Jesteśmy już prawie na miejscu! Odpalamy basha czy co tam lubimy:

python setup.py bdist_wheel

Teraz w katalogu :code:dist będzie czekał na nas nowiutki wheel, który możemy zainstalować:

pip install dist/*.whl

Uwaga: Taka kompilacja wyrzuci nam adnotacje typowe. Adnotacje nie będą dostępne ani w dokumentacji, ani mechanizmem introspekcji Pythona. Jeśli chcemy zachować adnotacje typowe, (chociażby do celów dokumentacji ze Sphinxem), musimy to wywołać następująco:

setup(name='example',
      description='An example example',
      packages=find_packages(include=['example', 'start_example']),
      ext_modules=build([Multibuild('example', find_pyx_and_c('example')), ],
                        compiler_directives={
                            'language_level': '3',
                            'embedsignature': True
                        })
      )

Tak więc definiując adnotacje typowe jedynie dla buildów w których została zdefiniowana zmienna środowiskowa CI (większość środowisk buildowania ją definiuje, ale jak to bywa w tej branży skonsultuj się ze swoją dokumentacją) tak więc skrypt może wyglądać również tak:

import os
from setuptools import find_packages
from distutils.core import setup
from snakehouse import build, Multibuild, monkey_patch_parallel_compilation, find_pyx_and_c

monkey_patch_parallel_compilation()

comp_dirs = {'language_level': '3'}
if 'CI' in os.environ:
    comp_dirs['embedsignature'] = True

setup(name='example',
      description='An example example',
      packages=find_packages(include=['example', 'start_example']),
      ext_modules=build([Multibuild('example', find_pyx_and_c('example')), ],
                        compiler_directives=comp_dirs)

Można od razu zbudować i zainstalować projekt ze źródła:

python setup.py install

Ale nie zalecam tego, bo przynajmniej w Dockerfile’u zostanie ten kod źródłowy, a na usunięciu go przecież nam najbardziej zależy. Minus jest też taki że na docelowym systemie musi być zainstalowany Cython i snakehouse. Kopiujemy teraz naszego wheela i załączamy go do artefaktów, czyli plik .gitlab-ci.yml będzie wyglądał teraz tak:

stages:
    - compile
    - build

compile:
    stage: compile
    image: smokserwis/build:python3
    script:
        - python setup.py bdist_wheel
        - cp dist/*.whl .
    artifacts:
        paths:
            - "*.whl"

Uwaga: Obraz smokserwis/build:python3 wykorzystujemy ponieważ ma on wbudowanego Cythona, snakehouse, satellę oraz inne moduły wspomniane w tym artykule, w szczególności doctor-wheel oraz strip-docs.

Jeśli chciałbyś budować wersję paczki na procesor ARM, użyj poniższej sentencji:

compile:
    stage: compile
    image: smokserwis/build:arm-python3
    script:
        - python setup.py bdist_wheel
        - cp dist/*.whl .
    artifacts:
        paths:
           - "*.whl"
    tags:
        - armv7l

smokserwis/build:arm-python3 jest ARM-owym odpowiednikiem smokserwis/build:python3.

Postprocessing

Zauważamy że zrobiony plik wheel jest trochę duży. No cóż, zostało w nim masę symboli które zostawia Cython. Aby je usunąć wydajemy:

pip install doctor-wheel
doctor-wheel dist/*.whl

Albo za pomocą CI:

compile:
    stage: compile
    image: smokserwis/build:python3
    script:
        - python setup.py bdist_wheel
        - cp dist/*.whl .
        - doctor-wheel *.whl
    artifacts:
        paths:
            - "*.whl"

doctor-wheel to jeden z moich skryptów które rozpakowują zipa, którym jest wheel, wywołują polecenie strip na każdym pliku so który znajduje się w tym zipie, po czym spowrotem go pakują. Czynią to in-place, czyli nasz wheel w katalogu dist zostanie zmieniony lokalnie.

Budowanie obrazu

Teraz czas na instalację naszej paczki w docelowym środowisku. Jeśli nie korzystasz z Dockera to śmiało
pomiń ten rozdział.

Teraz tworzymy sobie joba z buildem:

build:
    stage: build
    dependencies:
        - compile
    script:
        - docker build -t tag_repa .
        - docker push tag_repa

Pamiętamy o tym żeby w Dockerfile’u zawrzeć poniższe:

ADD *.whl /tmp/
RUN pip install /tmp/*.whl && \
    rm -rf /tmp/*.whl

Pamiętamy jeszcze o ograniczeniach dotyczących uruchamiania rzeczy paczkowanych snakehouse’m:

CMD ["python", "-m", "start_example"]

I voila, obraz Dockera zbudowany! Obraz Dockera najlepiej zaczynać FROM python:3.8.

Do zainstalowania tej paczki nie jest potrzebny ani Cython ani snakehouse.
Plik wheel to gotowa do zainstalowania binarka. Oczywiście zależności wymienione w setup.py zostaną zainstalowane automatycznie. Właśnie taka jest przewaga kompilacji w osobnym kroku, że nie trzeba ze sobą nosić tych wszystkich śmieci.

Testy jednostkowe i dokumentacja

Aby odpalić unit testy posługujemy się przepisem z poprzedniego rozdziału instalując paczkę, a potem dalej normalnie jak dla unit testów/dokumentacji.

Pamiętaj tylko że skompilować wheel produkcyjny w osobnym stepie niż wheel do unit testów i dokumentacji, gdyż ten do unit testów i dokumentacji zawierać będzie adnotacje typowe, a nie chcesz przecież wrogowi zbytnio ułatwiać zadania, prawda?

Published

By Piotr Maślanka

Programmer, certified first aider, entrepreneur, biotechnologist, expert witness, mentor, former PhD student. Your favourite renaissance man.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.