Skip to content

Commit

Permalink
Build Python wheel containing OpenSlide shared object
Browse files Browse the repository at this point in the history
Generate a wheel for an openslide-bin Python package, containing the
OpenSlide shared object and an __init__.py that loads it with ctypes.
OpenSlide Python can look for this package and use its shared object
rather than requiring OpenSlide to be installed directly on the system.

Hand-roll the wheel rather than building it with an existing tool like
meson-python.  Existing Python build tools expect to be in control of the
entire build process, but we want to produce the wheel as a side effect of
an existing build.  Our needs are simple, so just implement the underlying
packaging specs directly.

Signed-off-by: Benjamin Gilbert <bgilbert@cs.cmu.edu>
  • Loading branch information
bgilbert committed Mar 17, 2024
1 parent 06489b6 commit 4202688
Show file tree
Hide file tree
Showing 18 changed files with 694 additions and 13 deletions.
16 changes: 14 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ jobs:
brew update
# python-packaging needed by glib
brew install meson python-packaging
python3 -m pip install --break-system-packages tomli
- name: Download source tarball
uses: actions/download-artifact@v4
with:
Expand Down Expand Up @@ -201,12 +202,20 @@ jobs:
./bintool bdist -x "${{ inputs.suffix }}" $werror
archive=$(echo openslide-bin-"${{ needs.sdist.outputs.version }}"-*)
echo "archive=$archive" >> $GITHUB_OUTPUT
wheel=$(echo openslide_bin-"${{ needs.sdist.outputs.version }}"-*-*-*.whl)
echo "wheel=$wheel" >> $GITHUB_OUTPUT
- name: Upload archive
uses: actions/upload-artifact@v4
with:
name: ${{ steps.build.outputs.archive }}
path: ${{ steps.build.outputs.archive }}
compression-level: 0
- name: Upload wheel
uses: actions/upload-artifact@v4
with:
name: ${{ steps.build.outputs.wheel }}
path: ${{ steps.build.outputs.wheel }}
compression-level: 0

finalize:
name: Finalize
Expand All @@ -220,7 +229,7 @@ jobs:
- name: Download archives
uses: actions/download-artifact@v4
with:
pattern: "openslide-bin-${{ needs.sdist.outputs.version }}*"
pattern: "openslide[-_]bin-${{ needs.sdist.outputs.version }}*"
path: archives
merge-multiple: true
- name: Unpack source tarball
Expand All @@ -238,4 +247,7 @@ jobs:
>> $GITHUB_STEP_SUMMARY
- name: Windows smoke test
shell: bash
run: ./bintool smoke "archives/openslide-bin-${{ needs.sdist.outputs.version }}-windows-x64.zip"
run: |
./bintool smoke \
"archives/openslide-bin-${{ needs.sdist.outputs.version }}-windows-x64.zip" \
"archives/openslide_bin-${{ needs.sdist.outputs.version }}-py3-none-win_amd64.whl"
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: "openslide-bin-*"
pattern: "openslide[-_]bin-*"
path: upload
merge-multiple: true
- name: Unpack source tarball
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/openslide-bin-*
/openslide[-_]bin-*
/override
/work

Expand Down
19 changes: 19 additions & 0 deletions artifacts/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ env.set('MESONINTROSPECT', mesonintrospect)
env.set('MESON_SOURCE_ROOT', meson.project_source_root())
env.set('LD', find_program('ld').full_path())
if system == 'linux'
env.set('AUDITWHEEL', find_program('auditwheel').full_path())
env.set('PATCHELF', find_program('patchelf').full_path())
endif
if system == 'darwin'
Expand Down Expand Up @@ -80,6 +81,9 @@ foreach bin : [
output : [name, name + (system == 'darwin' ? '.dSYM' : '.debug')],
env : env,
)
if bin.name() == libopenslide.name()
libopenslide_postprocessed = artifacts[-1][0]
endif
endforeach

custom_target(
Expand All @@ -96,3 +100,18 @@ custom_target(
env : env,
build_by_default : true,
)

subdir('python')

custom_target(
command : [
find_program('write-wheel.py'), '-o', '@OUTPUT@', '@INPUT@'
],
input : py_artifacts,
output : 'openslide_bin-@0@-py3-none-@1@.whl'.format(
meson.project_version(),
meson.get_external_property('python_platform_tag')
),
env : env,
build_by_default : true,
)
72 changes: 72 additions & 0 deletions artifacts/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# openslide-bin

[OpenSlide Python][] requires a copy of [OpenSlide][]. You could install
it from your package manager, or you could download binaries from the
OpenSlide website. Either choice might be inconvenient, and depending on
your package manager, it might get you an old version of OpenSlide.

[openslide-bin][] is a pip-installable, self-contained build of OpenSlide
for Linux, macOS, and Windows. It's built by the OpenSlide maintainers,
ships the same binaries as the OpenSlide website, and has no dependencies
you don't already have on your system. And it always has the latest version
of OpenSlide.

[OpenSlide Python]: https://pypi.org/project/openslide-python/
[OpenSlide]: https://openslide.org/
[openslide-bin]: https://github.com/openslide/openslide-bin/

## Installing

Install with `pip install openslide-bin`. OpenSlide Python ≥ 1.4.0 will
automatically find openslide-bin and use it.

openslide-bin is available for Python 3.8+ on the following platforms:

- Linux x86_64 with glibc 2.28+ (Debian, Fedora, RHEL 8+, Ubuntu, many others)
- macOS 11+ (arm64 and x86_64)
- Windows 10+ and Windows Server 2016+ (x64)

pip older than 20.3 cannot install openslide-bin, claiming that it `is not a
supported wheel on this platform`. On platforms with these versions of pip
(RHEL 8 and Ubuntu 20.04), upgrade pip first with `pip install --upgrade
pip`.

## Using

Use OpenSlide via [OpenSlide Python][]. The OpenSlide Python
[API documentation][] will get you started.

[API documentation]: https://openslide.org/api/python/

## Building from source

You should probably [build OpenSlide from source][openslide-build] instead.

The wheels are built by a [custom script][] that runs [Meson][] in builder
containers. The source tarball includes all the source code and scripts,
and the wheels are built directly from the tarball, but the build cannot be
invoked from a [PEP 517][] frontend like `build` or `pip`. If wheels are
not available for your system, building openslide-bin from source is not
likely to help, and you'll likely have better luck installing OpenSlide from
source directly.

[openslide-build]: https://github.com/openslide/openslide/#compiling
[custom script]: https://github.com/openslide/openslide-bin/#readme
[Meson]: https://mesonbuild.com/
[PEP 517]: https://peps.python.org/pep-0517/

## License

OpenSlide and openslide-bin are released under the terms of the
[GNU Lesser General Public License, version 2.1][lgpl].

openslide-bin includes components released under the LGPL 2.1 and other
compatible licenses. A complete set of component licenses is installed in
the `licenses` subdirectory of openslide-bin's `dist-info` metadata.

OpenSlide and openslide-bin are distributed in the hope that they will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
General Public License for more details.

[lgpl]: https://openslide.org/license/
44 changes: 44 additions & 0 deletions artifacts/python/__init__.in.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#
# openslide-bin - Wrapper for OpenSlide binary build
#
# Copyright (c) 2023 Benjamin Gilbert
#
# This library is free software; you can redistribute it and/or modify it
# under the terms of version 2.1 of the GNU Lesser General Public License
# as published by the Free Software Foundation.
#
# This library is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
# License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this library; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

from __future__ import annotations

from ctypes import CDLL, cdll
import importlib.resources as res
import platform


def _load_openslide() -> CDLL:
if platform.system() == 'Windows':
name = 'libopenslide-1.dll'
elif platform.system() == 'Darwin':
name = 'libopenslide.1.dylib'
else:
name = 'libopenslide.so.1'
try:
# Python >= 3.9
with res.as_file(res.files(__name__).joinpath(name)) as path:
return cdll.LoadLibrary(path.as_posix())
except AttributeError:
with res.path(__name__, name) as path:
return cdll.LoadLibrary(path.as_posix())


libopenslide1 = _load_openslide()
__version__ = '@version@'
18 changes: 18 additions & 0 deletions artifacts/python/meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
py_config = {
'version': meson.project_version(),
}

py_artifacts = [
configure_file(
configuration : py_config,
input : 'pyproject.in.toml',
output : 'pyproject.toml',
),
configure_file(
configuration : py_config,
input : '__init__.in.py',
output : '__init__.py',
),
libopenslide_postprocessed,
licenses,
]
35 changes: 35 additions & 0 deletions artifacts/python/pyproject.in.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[project]
name = "openslide-bin"
version = "@version@"
maintainers = [
{name = "OpenSlide project", email = "openslide-users@lists.andrew.cmu.edu"}
]
description = "Binary build of OpenSlide"
readme = "artifacts/python/README.md"
license = {text = "GNU Lesser General Public License, version 2.1"}
keywords = ["OpenSlide", "whole-slide image", "virtual slide", "library"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Healthcare Industry",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Bio-Informatics",
]
requires-python = ">= 3.8"

[project.urls]
Homepage = "https://openslide.org/"
# use GitHub Releases page because it has subproject versions
"Release notes" = "https://github.com/openslide/openslide-bin/releases"
Repository = "https://github.com/openslide/openslide-bin"
95 changes: 95 additions & 0 deletions artifacts/write-wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
#
# Tools for building OpenSlide and its dependencies
#
# Copyright (c) 2023 Benjamin Gilbert
# All rights reserved.
#
# This script is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License, version 2.1,
# as published by the Free Software Foundation.
#
# This script is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this script. If not, see <http://www.gnu.org/licenses/>.
#

from __future__ import annotations

import argparse
from contextlib import ExitStack
from email.message import Message
from io import BytesIO
import os
from pathlib import Path
import re
import subprocess
from typing import BinaryIO

from common.archive import FileMember, WheelWriter
from common.argparse import TypedArgs
from common.meson import meson_host
from common.python import pyproject_to_message


class Args(TypedArgs):
artifacts: list[Path]
output: BinaryIO


args = Args('write-wheel', description='Write Python wheel.')
args.add_arg(
'-o',
'--output',
type=argparse.FileType('wb'),
required=True,
help='output file',
)
args.add_arg(
'artifacts',
metavar='artifact',
nargs='+',
type=Path,
help='built artifact',
)
args.parse()

with ExitStack() as inputs:
with WheelWriter(args.output) as whl:
for path in args.artifacts:
if path.is_file():
fh = inputs.enter_context(path.open('rb'))
if path.name == 'pyproject.toml':
meta = pyproject_to_message(fh.read().decode())
whl.add(
FileMember(
whl.metadir / 'METADATA', BytesIO(meta.as_bytes())
)
)
elif path.name == 'licenses':
whl.add_tree(whl.metadir, path)
else:
name = re.sub('(\\.so\\.[0-9]+)\\.[0-9.]+', '\\1', path.name)
whl.add(FileMember(whl.datadir / name, fh))

meta = Message()
meta['Wheel-Version'] = '1.0'
meta['Generator'] = 'openslide-bin'
meta['Root-Is-Purelib'] = 'false'
meta['Tag'] = whl.tag
whl.add(FileMember(whl.metadir / 'WHEEL', BytesIO(meta.as_bytes())))

if meson_host() == 'linux':
report = subprocess.check_output(
[
os.environ['AUDITWHEEL'],
'show',
args.output.name,
],
).decode()
if f'"{whl.platform}"' not in report:
raise Exception(f'Wheel audit failed: {report}')
Loading

0 comments on commit 4202688

Please sign in to comment.