Search code examples
pythonsetuptoolsdistutils

Place pre-compiled extensions in root folder of non-pure Python Wheel package


TL;DR How do you get distutils/setuptools to include a non-pure data file correctly?

I've got a project that does some non-trivial code generation using a custom toolchain, and then wraps that generated code with SWIG, and finally builds Python extensions. Cmake encapsulates all this excellently and in the end I have a single folder in the project's root that works exactly as any other Python package.

I'd like to have a simple setup.py so I can wrap this package into a wheel and ship it off to PyPI so that normal Python users don't have to deal with the build process. There are plenty of answers on SO for how to force setuptools to generate a non-pure wheel, and then you can bundle the extensions by using the package_data field or a MANIFEST.in file.

The problem is this method results in a malformed wheel, because the extensions get included under purelib instead of the root directory (where they belong in a Root-Is-Pure: False wheel). Some tools and distros rely on this seperation being correct.

Answers I'm not interested in: Custom extensions to run cmake from within setup.py (Don't want to add another layer of indirection for configuring the project, don't want to maintain it when build options change), modifying the generated wheel, and I'd prefer to avoid adding any more files to the project root than just setup.py


Solution

  • Update 27Nov2023:

    Use py-build-cmake

    Original Answer:

    This works. distutils and setuptools have to be some of the worst designed pieces of central Python infrastructure that exist.

    from setuptools import setup, find_packages, Extension
    from setuptools.command.build_ext import build_ext
    import os
    import pathlib
    import shutil
    
    suffix = '.pyd' if os.name == 'nt' else '.so'
    
    class CustomDistribution(Distribution):
      def iter_distribution_names(self):
        for pkg in self.packages or ():
          yield pkg
        for module in self.py_modules or ():
          yield module
    
    class CustomExtension(Extension):
      def __init__(self, path):
        self.path = path
        super().__init__(pathlib.PurePath(path).name, [])
    
    class build_CustomExtensions(build_ext):
      def run(self):
        for ext in (x for x in self.extensions if isinstance(x, CustomExtension)):
          source = f"{ext.path}{suffix}"
          build_dir = pathlib.PurePath(self.get_ext_fullpath(ext.name)).parent
          os.makedirs(f"{build_dir}/{pathlib.PurePath(ext.path).parent}",
              exist_ok = True)
          shutil.copy(f"{source}", f"{build_dir}/{source}")
    
    def find_extensions(directory):
      extensions = []
      for path, _, filenames in os.walk(directory):
        for filename in filenames:
          filename = pathlib.PurePath(filename)
          if pathlib.PurePath(filename).suffix == suffix:
            extensions.append(CustomExtension(os.path.join(path, filename.stem)))
      return extensions
    
    setup(
      # Stuff
      ext_modules = find_extensions("PackageRoot"),
      cmdclass = {'build_ext': build_CustomExtensions}
      distclass = CustomDistribution
    )
    

    I'm copying extensions into the build directory, and that's it. We override the distribution to lie to the egg-info writers about having any extensions, and everything is gravy.