How to add platform-specific package data in setup.py?

Viewed 120

I would need to deploy platform-specific package data in the wheels built with setup.py

For a bit of background, I am trying to create Python bindings for a certain library using pre-built binaries and ctypesgen. I have a data tree with platform-specific subdirectories that contain a bindings file and a binary each:

data/
    macos-arm64/
        _bindings.py
        binary.dylib
    linux-x64/
        _bindings.py
        binary.so
    windows-x64/
        _bindings.py
        binary.dll
    ...

And I have a source tree:

src/
    package_name/
        __init__.py
        __main__.py
        ...

Question: How can I build platform-specific packages that only contain the one corresponding binary and bindings?

The final directory tree as deployed to the end-user should structurally look like this:

package_name/
    __init__.py
    __main__.py
    _bindings.py
    binary.xyz
    ...
2 Answers

This is the solution I am currently using for pypdfium2:

  • Create a class of supported platforms whose values correspond to the data directory names:
class PlatformNames:
    darwin_x64  = "darwin_x64"
    linux_x64   = "linux_x64"
    windows_x64 = "windows_x64"
    # ...
    sourcebuild = "sourcebuild"
  • Wrap setuptools.setup() with a function that takes the platform name as argument and copies platform-dependent files into the source tree as required:
# A list of non-python file names to consider for inclusion in the installation, e. g.
Libnames = (
    "somelib.so",
    "somelib.dll",
    "somelib.dylib",
)

# _clean() removes possible old binaries/bindings
# _copy_bindings() copies the new stuff into the source tree
# _get_bdist() returns a custom `wheel.bdist_wheel` subclass with the `get_tag()` and `finalize_options()` functions overridden so as to tag the wheels according to their target platform.

def mkwheel(pl_name):
    _clean()
    _copy_bindings(pl_name)
    setuptools.setup(
        package_data = {"": Libnames},
        cmdclass = {"bdist_wheel": _get_bdist(pl_name)},
        # ...
    )
    # not cleaning up afterwards so that editable installs work (`pip3 install -e .`)
  • In setup.py, query for a custom environment variable defining the target platform (e. g. $PYP_TARGET_PLATFORM).
    • If set to a value that indicates the need for a source distribution (e. g. sdist), run the raw setuptools.setup() function without copying in any build artifacts.
    • If set to a platform name, build for the requested platform. This makes packaging platform-independent and avoids the need for native hosts to craft the wheels.
    • If not set, detect the host platform using sysconfig.get_platform() and call mkwheel() with the corresponding PlatformNames member.
      • In case the detected platform is not supported, trigger code that performs a source build, moves the created files into data/sourcebuild/ and runs mkwheel(PlatformNames.sourcebuild).
  • Write a script that iterates through the platform names, sets your environment variable and runs python3 -m build --no-isolation --skip-dependency-check --wheel for each. Also invoke build once with --sdist instead of --wheel and the environment variable set to the value for source distribution.

→ If all goes well, the platform-specific wheels and a source distribution will be written into dist/.

Perhaps this is a lot easier to understand just by looking at pypdfium2's code (especially setup.py, setup_base.py and craft_packages.py).

Disclaimer: I am not experienced with the setup infrastructure of Python and merely wrote this code out of personal need. I acknowledge that the approach is a bit "hacky". If there is a possibility to achieve the same goal while using the setuptools API in a more official sort of way, I'd be interested to hear about it.

Update 1: A negative implication of this concept is that the content wrongly ends up in a purelib folder, although it should be platlib as per PEP 427. I'm not sure how to instruct wheel/setuptools differently. Luckily, this is rather just a cosmetic problem.

Update 2: Found a fix to the purelib problem:

class BinaryDistribution (setuptools.Distribution):
    def has_ext_modules(self):
        return True

setuptools.setup(
    # ...
    distclass = BinaryDistribution,
)

Not quite what you described, but for simplicity I'd ship a wheel that is built for all supported platforms.

setup.py

import setuptools

setuptools.setup(
    name='package_name',
    packages=[
        'macos-arm64',
        'linux-x64',
        ...
    ],
    include_package_data=True
)

MANIFEST.in

include macos-arm64/binary.dylib
include linux-x64/binary.so
...

You'd need to adjust your import code a bit to import the correct bindings depending on platform.

The setuptools documentation describes in more detail how to do this.

Related