[3] Integrando Python com C/C++ ~ Divulgando seu pacote

[3] Integrando Python com C/C++ ~ Divulgando seu pacote

Após seguir as duas postagens anteriores da seção "Integrando Python com C/C++", você já deve ter virado o lorde da integração de Python com C/C++, e agora deseja divulgar para os mortais o seu pacote revolucionário. Para divulgar pacotes em Python, o modo mais comum é criar o script setup.py e divulga-lo no PyPi. Para essa postagem, estarei supondo que você já leu as duas postagens anteriores a respeito da integração de Python com C/C++ e também que você já saiba como divulgar pacotes no PyPi. Caso precise, recomendo que clique nos links.

Além disso, escute essa música enquanto ler essa postagem.

Introdução

Não é recomendável divulgar a sua shared library já compilada para terceiros, pois o computador dele certamente será diferente. Pode ser que seja necessário que a vinculação das chamadas de funções precise ser um pouco diferente, ou então o sistema operacional seja outro. O processo de compilação pode antecipar erros futuros, tais como carência de biblioteca exigida. Esses erros normalmente resultam em undefined symbol, algo em que tive problemas.

Desse modo, precisamos divulgar apenas o código fonte e as instruções de como deve ser feita a compilação. Para isso, pessoalmente, gosto de usar o CMake, por ser versátil e servir para a maior parte dos casos.

Um modo que não descreverei aqui e que também pode ser usado para compilar seu código em C/C++ durante a instalação de um pacote Python é usando o Extension, porém, desse modo, o arquivo .so terá um nome previamente desconhecido (o nome será algo como "nome do arquivo c"-"versão do python".so) e não saberemos ao certo o diretório em que ele se localizará (normalmente, na pasta raiz do pacote ao ser instalado); pelo o que pesquisei, esse meio é mais usado caso você esteja integrando o C/C++ no Python através do Python.h, e não com ctypes. Como desejo evitar esses problemas, decidi me manter em usar o CMake, e não usar o Extension.

Executar o CMakeFile no setup.py*

Considere a seguinte estrutura dos arquivos:

/setup.py
/src/__init__.py
/src/cppcode/CMakeList.txt
/src/cppcode/main.cpp

O nosso CMakeFile.txt pode ser bem complexo com trocentas linhas, ou tão simples quanto:

add_library(pyslibtesseract SHARED main.cpp)

Para compilarmos com o CMake, é simples: uma chamada no terminal com cmake ., para assim gerar o arquivo Makefile. Em seguida, executarmos no terminal make. Desse modo, geramos o libpyslibtesseract.so. cmake e make são processos, e podermos executar processos no Python através do Popen da biblioteca padrão subprocess. Caso não saiba como usa-lo, veja essa minha postagem.

É bom que após cada processo verificarmos se obtivemos os arquivos necessários, e para isso usamos a função os.path.exists

Desse modo, o nosso código final é

from subprocess import Popen, PIPE
import os

my_path = os.path.dirname(os.path.realpath(__file__))

print('Runing cmake at', my_path + '/src/cppcode/')
p = Popen(['cmake', '.'], stdout=PIPE, cwd=my_path + '/src/cppcode/')
print(p.stdout.read())
assert(p.wait() == 0)

if not os.path.exists(my_path + '/src/cppcode/Makefile'):
    raise RuntimeError('Makefile was not generated!')

print('Runing make', my_path + '/src/cppcode/')
p = Popen(['make'], stdout=PIPE, cwd=my_path + '/src/cppcode/')
print(p.stdout.read())
assert(p.wait() == 0)

if not os.path.exists(my_path + '/src/cppcode/libpyslibtesseract.so'):
    raise RuntimeError('pyslibtesseract.so was not generated!')

Agora precisamos saber como chama-lo no setup.py quando for necessário. Após muitas pesquisas, testes e chocolates, encontrei uma solução: adicionar comandos que serão executados antes do install. Para descobrir isso, me baseei no setup.py do matplotlib em que estava estudando para me inspirar, e também nessa resposta no stackoverflow. Perceba o seguinte código no matplotlib:

from setuptools.command.test import test as TestCommand

...

class NoopTestCommand(TestCommand):
    def run(self):
        print("Matplotlib does not support running tests with "
              "'python setup.py test'. Please run 'python tests.py'")

...

cmdclass = versioneer.get_cmdclass()
cmdclass['test'] = NoopTestCommand

...

distrib = setup(
    name="matplotlib",
    version=__version__,
    description="Python plotting package",
    ...
    cmdclass=cmdclass,
)

Está claro que aí sobrescreve o que acontece caso o usuário tente usar python3 setup.py test para exibir aquela mensagem. No Stackoverflow, o cara postou o seguinte:

from distutils.command import build as build_module

class build(build_module.build):
    def run(self):
        RunYourOtherScript()
        build_module.build.run(self)

setup(
    ...
    cmdclass = {
        'build': build,
    },
)

Ou seja, adicionando aquela sexta linha, poderemos executar alguns códigos antes do build normal que é usado no python3 setup.py build. Me inspirando tanto no matplotlib como nesse código acima, testei se basta usar o valor 'install' no lugar de 'build' ou 'debug', e deu incrivelmente certo!

from setuptools.command.install import install as InstallCommand
from setuptools import setup

class MyInstall(InstallCommand):
    def run(self):
        # Compile C code at src/cppcode
        from subprocess import Popen, PIPE
        import os

        my_path = os.path.dirname(os.path.realpath(__file__))

        print('Runing cmake at', my_path + '/src/cppcode/')
        p = Popen(['cmake', '.'], stdout=PIPE, cwd=my_path + '/src/cppcode/')
        print(p.stdout.read())
        assert(p.wait() == 0)

        if not os.path.exists(my_path + '/src/cppcode/Makefile'):
            raise RuntimeError('Makefile was not generated!')

        print('Runing make', my_path + '/src/cppcode/')
        p = Popen(['make'], stdout=PIPE, cwd=my_path + '/src/cppcode/')
        print(p.stdout.read())
        assert(p.wait() == 0)

        if not os.path.exists(my_path + '/src/cppcode/libpyslibtesseract.so'):
            raise RuntimeError('pyslibtesseract.so was not generated!')

        # Run install default
        return InstallCommand.run(self)

# Python setup
setup(
    name='pyslibtesseract',
    ...
    cmdclass={
        'install': MyInstall
    },
    package_data={'pyslibtesseract': ['cppcode/main.cpp', 'cppcode/CMakeLists.txt', 'cppcode/libpyslibtesseract.so']},
)

Lembre-se que o nosso package_data precisa ter tantos os códigos em C/C++, o CMakeList e o .so que será gerado na compilação!

Note o seguinte: após escrever esse script e for executar sudo python3 setup.py install, você verá os prints. Porém, se você colocar a biblioteca no PyPi e for instalar através do pip3 não verá os prints. A razão disso? Não sei, porém, funcionou.

Você pode ver o seu pacote no diretório /usr/local/lib/python3.4/dist-packages e ir para a pasta dele. Então, verá que o arquivo .so foi compilado automaticamente e ele está lá - de brinde, graças ao que especificamos no package_data, os demais arquivos gerados durante a compilação e agora desnecessários não estão presentes aí.

Edição em 21/03/2017

Esse código proposto nessa postagem precisa de mais refinamento. Eu o usei para o meu pacote pyslibtesseract, porém, ao tentar instalar esse pacote no OS X, recebo erros. Se alguém quiser contribuir, agradeço =P