Skip to content

Commit 8fffe35

Browse files
committed
Prepare Python wheel releases
Build Python-ABI-independent wheels for supported Linux and macOS targets so users can install LDK Node directly from PyPI. Keep build and publication available through local scripts. Require the complete artifact set on TestPyPI before production, and exercise the bindings on the oldest and newest supported CPython versions. Co-Authored-By: HAL 9000
1 parent 5e4197b commit 8fffe35

8 files changed

Lines changed: 445 additions & 52 deletions

File tree

.github/workflows/python.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313

1414
env:
15+
CARGO_TARGET_DIR: /tmp/cargo-target-ldk-node-python-ci
1516
LDK_NODE_PYTHON_DIR: bindings/python
1617

1718
steps:
@@ -20,6 +21,11 @@ jobs:
2021

2122
- name: Install uv
2223
uses: astral-sh/setup-uv@v7
24+
with:
25+
version: "0.12.3"
26+
27+
- name: Install supported Python versions
28+
run: uv python install 3.10 3.14
2329

2430
- name: Generate Python bindings
2531
run: ./scripts/uniffi_bindgen_generate_python.sh
@@ -35,4 +41,8 @@ jobs:
3541
ESPLORA_ENDPOINT: "http://127.0.0.1:3002"
3642
run: |
3743
cd $LDK_NODE_PYTHON_DIR
38-
uv run --group dev python -m unittest discover -s src/ldk_node
44+
for python_version in 3.10 3.14; do
45+
UV_PROJECT_ENVIRONMENT=".venv-$python_version" \
46+
uv run --python "$python_version" --group dev \
47+
python -m unittest discover -s src/ldk_node
48+
done

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,15 @@ swift.swiftdoc
2727
/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.kt
2828
/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.kt
2929
/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/
30+
31+
# Ignore generated Python bindings and build output
32+
/bindings/python/.venv/
33+
/bindings/python/.venv-*/
34+
/bindings/python/dist/
35+
/bindings/python/uv.lock
36+
/bindings/python/wheelhouse/
37+
/bindings/python/src/ldk_node/ldk_node.py
38+
/bindings/python/src/ldk_node/libldk_node.so
39+
/bindings/python/src/ldk_node/libldk_node.dylib
40+
/bindings/python/__pycache__/
41+
/bindings/python/src/ldk_node/__pycache__/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
[![Crate](https://img.shields.io/crates/v/ldk-node.svg?logo=rust)](https://crates.io/crates/ldk-node)
44
[![Documentation](https://img.shields.io/static/v1?logo=read-the-docs&label=docs.rs&message=ldk-node&color=informational)](https://docs.rs/ldk-node)
5+
[![PyPI](https://img.shields.io/pypi/v/ldk-node.svg?logo=python)](https://pypi.org/project/ldk-node/)
56
[![Maven Central Android](https://img.shields.io/maven-central/v/org.lightningdevkit/ldk-node-android)](https://central.sonatype.com/artifact/org.lightningdevkit/ldk-node-android)
67
[![Maven Central JVM](https://img.shields.io/maven-central/v/org.lightningdevkit/ldk-node-jvm)](https://central.sonatype.com/artifact/org.lightningdevkit/ldk-node-jvm)
78
[![Security Audit](https://github.com/lightningdevkit/ldk-node/actions/workflows/audit.yml/badge.svg)](https://github.com/lightningdevkit/ldk-node/actions/workflows/audit.yml)

bindings/python/hatch_build.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import sysconfig
23

34
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
45
from hatchling.metadata.plugin.interface import MetadataHookInterface
@@ -20,7 +21,8 @@ def update(self, metadata):
2021

2122
class CustomBuildHook(BuildHookInterface):
2223
def initialize(self, version, build_data):
24+
platform_tag = sysconfig.get_platform().replace("-", "_").replace(".", "_")
2325
build_data["pure_python"] = False
24-
build_data["infer_tag"] = True
26+
build_data["tag"] = f"py3-none-{platform_tag}"
2527
if self.target_name == "sdist":
2628
build_data["force_include"][readme_path(self.root)] = "README.md"

bindings/python/pyproject.toml

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[build-system]
2-
requires = ["hatchling"]
2+
requires = ["hatchling==1.32.0"]
33
build-backend = "hatchling.build"
44

55
[project]
@@ -10,13 +10,20 @@ authors = [
1010
]
1111
description = "A ready-to-go Lightning node library built using LDK and BDK."
1212
dynamic = ["readme"]
13-
requires-python = ">=3.8"
13+
requires-python = ">=3.10"
1414
classifiers = [
1515
"Topic :: Software Development :: Libraries",
1616
"Topic :: Security :: Cryptography",
1717
"License :: OSI Approved :: MIT License",
1818
"License :: OSI Approved :: Apache Software License",
19+
"Operating System :: MacOS",
20+
"Operating System :: POSIX :: Linux",
1921
"Programming Language :: Python :: 3",
22+
"Programming Language :: Python :: 3.10",
23+
"Programming Language :: Python :: 3.11",
24+
"Programming Language :: Python :: 3.12",
25+
"Programming Language :: Python :: 3.13",
26+
"Programming Language :: Python :: 3.14",
2027
]
2128

2229
[project.urls]
@@ -29,7 +36,35 @@ dev = ["requests"]
2936

3037
[tool.hatch.build.targets.wheel]
3138
packages = ["src/ldk_node"]
39+
exclude = ["src/ldk_node/test_ldk_node.py"]
3240

3341
[tool.hatch.metadata.hooks.custom]
3442

3543
[tool.hatch.build.hooks.custom]
44+
45+
[tool.cibuildwheel]
46+
archs = ["native"]
47+
build = "cp3{10,11,12,13,14}-{manylinux,macosx}_*"
48+
build-frontend = "uv"
49+
test-command = 'python -c "import ldk_node; ldk_node.default_config()"'
50+
51+
[tool.cibuildwheel.linux]
52+
before-all = [
53+
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.95.0",
54+
'. "$HOME/.cargo/env"',
55+
"cd {package}/../.. && ./scripts/uniffi_bindgen_generate_python.sh",
56+
]
57+
environment-pass = ["CARGO_TARGET_DIR", "SOURCE_DATE_EPOCH"]
58+
manylinux-x86_64-image = "manylinux_2_28"
59+
manylinux-aarch64-image = "manylinux_2_28"
60+
61+
[tool.cibuildwheel.macos]
62+
before-all = "cd {package}/../.. && RUSTUP_TOOLCHAIN=1.95.0 ./scripts/uniffi_bindgen_generate_python.sh"
63+
64+
[[tool.cibuildwheel.overrides]]
65+
select = "*-macosx_x86_64"
66+
environment = { MACOSX_DEPLOYMENT_TARGET = "10.12" }
67+
68+
[[tool.cibuildwheel.overrides]]
69+
select = "*-macosx_arm64"
70+
environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" }

scripts/python_build_wheel.sh

Lines changed: 145 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,154 @@
11
#!/bin/bash
2-
# Build a Python wheel for the current platform.
2+
# Build and test one native Python wheel on the current host.
33
#
4-
# This script compiles the Rust library, generates Python bindings via UniFFI,
5-
# and builds a platform-specific wheel using uv + hatchling.
6-
#
7-
# Run this on each target platform (Linux, macOS) to collect wheels, then use
8-
# scripts/python_publish_package.sh to publish them.
4+
# Run this script once on each supported native target:
5+
# Linux x86_64, Linux aarch64, macOS x86_64, macOS arm64.
6+
# Linux builds require Docker or Podman. macOS builds require Rust 1.95.0.
97

10-
set -e
8+
set -euo pipefail
119

1210
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1311
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
12+
OUTPUT_DIR="$REPO_ROOT/bindings/python/wheelhouse"
13+
ALLOW_DIRTY=false
14+
CIBUILDWHEEL_VERSION="4.1.0"
15+
16+
usage() {
17+
echo "Usage: $0 [--output-dir DIR] [--allow-dirty]"
18+
}
19+
20+
while [[ $# -gt 0 ]]; do
21+
case "$1" in
22+
--output-dir)
23+
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
24+
OUTPUT_DIR="$2"
25+
shift 2
26+
;;
27+
--allow-dirty)
28+
ALLOW_DIRTY=true
29+
shift
30+
;;
31+
-h|--help)
32+
usage
33+
exit 0
34+
;;
35+
*)
36+
echo "Unknown argument: $1" >&2
37+
usage >&2
38+
exit 2
39+
;;
40+
esac
41+
done
42+
43+
case "$OUTPUT_DIR" in
44+
/*) ;;
45+
*) OUTPUT_DIR="$REPO_ROOT/$OUTPUT_DIR" ;;
46+
esac
47+
48+
for command_name in git uv; do
49+
if ! command -v "$command_name" >/dev/null 2>&1; then
50+
echo "Required command not found: $command_name" >&2
51+
exit 1
52+
fi
53+
done
1454

1555
cd "$REPO_ROOT"
1656

17-
# Generate bindings and compile the native library
18-
echo "Generating Python bindings..."
19-
./scripts/uniffi_bindgen_generate_python.sh
20-
21-
# Build the wheel
22-
echo "Building wheel..."
23-
cd bindings/python
24-
uv build --wheel
25-
26-
echo ""
27-
echo "Wheel built successfully:"
28-
ls -1 dist/*.whl
29-
echo ""
30-
echo "Collect wheels from all target platforms into dist/, then run:"
31-
echo " ./scripts/python_publish_package.sh"
57+
if [[ "$ALLOW_DIRTY" == false ]] && [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then
58+
echo "Refusing to build from a dirty worktree; commit the release first." >&2
59+
echo "Use --allow-dirty only while developing the build configuration." >&2
60+
exit 1
61+
fi
62+
63+
case "$(uname -s)" in
64+
Linux)
65+
case "${CIBW_CONTAINER_ENGINE:-}" in
66+
podman*) CONTAINER_COMMAND=podman ;;
67+
docker*|"") CONTAINER_COMMAND=docker ;;
68+
*)
69+
echo "Unsupported CIBW_CONTAINER_ENGINE: $CIBW_CONTAINER_ENGINE" >&2
70+
exit 1
71+
;;
72+
esac
73+
74+
if ! command -v "$CONTAINER_COMMAND" >/dev/null 2>&1; then
75+
if [[ -z "${CIBW_CONTAINER_ENGINE:-}" ]] && command -v podman >/dev/null 2>&1; then
76+
export CIBW_CONTAINER_ENGINE=podman
77+
else
78+
echo "Linux wheel builds require Docker or Podman." >&2
79+
exit 1
80+
fi
81+
fi
82+
;;
83+
Darwin)
84+
if ! command -v rustup >/dev/null 2>&1 || \
85+
! rustup run 1.95.0 rustc --version >/dev/null 2>&1; then
86+
echo "macOS wheel builds require the Rust 1.95.0 toolchain." >&2
87+
echo "Install it with: rustup toolchain install 1.95.0 --profile minimal" >&2
88+
exit 1
89+
fi
90+
;;
91+
*)
92+
echo "Unsupported operating system: $(uname -s)" >&2
93+
exit 1
94+
;;
95+
esac
96+
97+
case "$(uname -m)" in
98+
x86_64|amd64|aarch64|arm64) ;;
99+
*)
100+
echo "Unsupported architecture: $(uname -m)" >&2
101+
exit 1
102+
;;
103+
esac
104+
105+
mkdir -p "$OUTPUT_DIR"
106+
shopt -s nullglob
107+
existing_wheels=("$OUTPUT_DIR"/*.whl)
108+
if [[ ${#existing_wheels[@]} -ne 0 ]]; then
109+
echo "Output directory already contains wheels: $OUTPUT_DIR" >&2
110+
exit 1
111+
fi
112+
113+
CARGO_TARGET_DIR="$(mktemp -d /tmp/cargo-target-ldk-node-python-wheels.XXXXXX)"
114+
export CARGO_TARGET_DIR
115+
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
116+
export SOURCE_DATE_EPOCH
117+
118+
cleanup() {
119+
case "$CARGO_TARGET_DIR" in
120+
/tmp/cargo-target-ldk-node-python-wheels.*)
121+
rm -rf -- "$CARGO_TARGET_DIR"
122+
;;
123+
esac
124+
}
125+
trap cleanup EXIT
126+
127+
echo "Building Python wheel from commit $(git rev-parse HEAD)"
128+
echo "Target: $(uname -s) $(uname -m)"
129+
130+
uv tool run --from "cibuildwheel[uv]==$CIBUILDWHEEL_VERSION" cibuildwheel \
131+
bindings/python \
132+
--config-file bindings/python/pyproject.toml \
133+
--output-dir "$OUTPUT_DIR"
134+
135+
built_wheels=("$OUTPUT_DIR"/*.whl)
136+
if [[ ${#built_wheels[@]} -ne 1 ]]; then
137+
echo "Expected exactly one wheel, found ${#built_wheels[@]}." >&2
138+
exit 1
139+
fi
140+
141+
wheel_path="${built_wheels[0]}"
142+
wheel_name="$(basename "$wheel_path")"
143+
(
144+
cd "$OUTPUT_DIR"
145+
if command -v sha256sum >/dev/null 2>&1; then
146+
sha256sum "$wheel_name"
147+
else
148+
shasum -a 256 "$wheel_name"
149+
fi
150+
) > "$wheel_path.sha256"
151+
152+
echo "Wheel built and tested successfully:"
153+
echo " $wheel_path"
154+
echo " $wheel_path.sha256"

0 commit comments

Comments
 (0)