Search code examples
cythonportabilitydistutilsdylib

Dealing with dylibs when distributing python packages


I need to run certain commands after building a Cython module with python setup.py bdist_wheel or an equivalent command. Is this possible?

Specifically here is the situation: I am working on a Cython program for osx. It links to certain .dylib files in my home directory and to get the program to build, you either have to run

export DYLD_LIBRARY_PATH=/path/to/parent/dir

(which is a hassle) or include a directive in setup.py to run

install_name_tool -change @executable_path/my.dylib /path/to/my.dylib

What this basically does is it tells the binary where to look for the dylib. Neither option is ideal, but the latter seems slightly more portable. Right now I just added these instructions to the bottom of setup.py, but this is still not portable: let's say I package and upload my package as follows:

python setup.py sdist  # I think this collects source files into a .tar
python setup.py bdist_wheel  # I think this builds a binary. This is where I assume the post-compilation stuff should happen.
twine upload dist/*   # This puts stuff up on PyPi.

Now, I have read the documentation and a few tutorials, and I am admittedly still not 100% sure what these commands do. But I know that if subsequently, in another project, I run

pip install mypackage

The dylib issue rears its ugly head:

ImportError: dlopen(/path/to/my/module.cpython-36m-darwin.so, 2): 
Library not loaded: @executable_path/my.dylib
  Referenced from: /path/to/my/module.cpython-36m-darwin.so
  Reason: image not found

The solution it seems is to add some kind of automatic post-compilation direction to setup.py to indicate that the install_name_tool operation needs to be performed. Assuming this is possible, how would one do it? Ideally I would like to use off-the-shelf Cython.Build.cythonize and setuptools.Extension, but maybe I need to do something more customized. Thanks for your suggestions!

Solution

Danny correctly pointed out that running install_name_tool is not a portable solution and instead the dylib should somehow be included in the package. delocate is the tool for the job, but the first challenge that I encountered was that delocate doesn't work with @executable_path:

❯ delocate-wheel -w fixed_wheels -v dist/*.whl
Fixing: dist/my-package-1.0.9-cp36-cp36m-macosx_10_12_x86_64.whl
/Users/ethan/virtualenvs/my-package/lib/python3.6/site-packages/delocate/delocating.py:71: UserWarning: Not processing required path @executable_path/my.dylib because it begins with @
  'begins with @'.format(required))

Using otool, I was able to verify that the id of my.dylib uses @executable_path:

❯ otool -L my.dylib
my.dylib:
    @executable_path/my.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 120.1.0)
    /System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL (compatibility version 1.0.0, current version 1.0.0)
    /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit (compatibility version 1.0.0, current version 275.0.0)
    /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1258.1.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1226.10.1)

To get rid of @executable_path I ran the following:

❯ install_name_tool -id my.dylib my.dylib

Now look at the output of otool -L:

❯ otool -L my.dylib
my.dylib:
    my.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 120.1.0)
    /System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL (compatibility version 1.0.0, current version 1.0.0)
    /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit (compatibility version 1.0.0, current version 275.0.0)
    /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1258.1.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1226.10.1)

In general, this is not a good id to use for a dylib, but it is enough for delocate-wheel to work with. I rebuilt my .whl:

python setup.py bdist_wheel

And now when I ran delocate_wheel:

❯ delocate-wheel -w fixed_wheels -v dist/*.whl
Fixing: dist/my.whl
Traceback (most recent call last):
...    
delocate.delocating.DelocationError: library "/Users/ethan/my-package/my.dylib" does not exist

This clearly tells us where delocate expects to find my.dylib. So I copied my.dylib to /Users/ethan/my-package/my.dylib and reran the command:

❯ delocate-wheel -w fixed_wheels -v dist/*.whl
Fixing: dist/my.whl
Copied to package .dylibs directory:
  /Users/ethan/my-package/my.dylib

Success! What is this .dylibs directory? I ran tar -xvf my.whl to unpack the wheel and examine its contents:

❯ tree -a fixed_wheels/my-package
fixed_wheels/my-package
├── .dylibs
│   └── my.dylib
├── __init__.py
└── package.cpython-36m-darwin.so

As you can see, the my.dylib has been copied into a .dylibs/ directory that gets packaged in the my.whl. After uploading my.whl to pypi, I was able to download and run the code just fine.


Solution

  • Have a look at delocate and the Linux equivalent auditwheel.

    Rather than having to change link loader paths at runtime, the delocate tool embeds third party libraries into a wheel and adjusts load time paths accordingly.

    The result is a distributable binary wheel with all dependent libraries correctly included.

    Setting runtime load paths is not portable and will not work across python versions or machine architectures.

    For example for the above, after a wheel is created by python setup.py bdist_wheel run:

    delocate-wheel -w fixed_wheels <wheel file>

    A fixed wheel file will be placed under fixed_wheels directory. Run with -v to see what libraries it has embedded.