From 63a0d8bbe66771c951d7da12909f9770ffe32531 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 11 Aug 2026 16:13:47 +0200 Subject: [PATCH 1/4] 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 --- .github/workflows/python.yml | 12 +- .gitignore | 12 ++ README.md | 1 + bindings/python/hatch_build.py | 4 +- bindings/python/pyproject.toml | 41 +++- scripts/python_build_wheel.sh | 181 ++++++++++++++--- scripts/python_publish_package.sh | 227 ++++++++++++++++++++-- scripts/uniffi_bindgen_generate_python.sh | 52 +++-- 8 files changed, 478 insertions(+), 52 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index da555d192c..09fff05453 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest env: + CARGO_TARGET_DIR: /tmp/cargo-target-ldk-node-python-ci LDK_NODE_PYTHON_DIR: bindings/python steps: @@ -20,6 +21,11 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 + with: + version: "0.12.3" + + - name: Install supported Python versions + run: uv python install 3.10 3.14 - name: Generate Python bindings run: ./scripts/uniffi_bindgen_generate_python.sh @@ -35,4 +41,8 @@ jobs: ESPLORA_ENDPOINT: "http://127.0.0.1:3002" run: | cd $LDK_NODE_PYTHON_DIR - uv run --group dev python -m unittest discover -s src/ldk_node + for python_version in 3.10 3.14; do + UV_PROJECT_ENVIRONMENT=".venv-$python_version" \ + uv run --python "$python_version" --group dev \ + python -m unittest discover -s src/ldk_node + done diff --git a/.gitignore b/.gitignore index 3d24bbceb5..df13df05d1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,15 @@ swift.swiftdoc /bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.kt /bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.kt /bindings/kotlin/ldk-node-jvm/lib/src/main/resources/ + +# Ignore generated Python bindings and build output +/bindings/python/.venv/ +/bindings/python/.venv-*/ +/bindings/python/dist/ +/bindings/python/uv.lock +/bindings/python/wheelhouse/ +/bindings/python/src/ldk_node/ldk_node.py +/bindings/python/src/ldk_node/libldk_node.so +/bindings/python/src/ldk_node/libldk_node.dylib +/bindings/python/__pycache__/ +/bindings/python/src/ldk_node/__pycache__/ diff --git a/README.md b/README.md index 289ada1792..e59dba569b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Crate](https://img.shields.io/crates/v/ldk-node.svg?logo=rust)](https://crates.io/crates/ldk-node) [![Documentation](https://img.shields.io/static/v1?logo=read-the-docs&label=docs.rs&message=ldk-node&color=informational)](https://docs.rs/ldk-node) +[![PyPI](https://img.shields.io/pypi/v/ldk-node.svg?logo=python)](https://pypi.org/project/ldk-node/) [![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) [![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) [![Security Audit](https://github.com/lightningdevkit/ldk-node/actions/workflows/audit.yml/badge.svg)](https://github.com/lightningdevkit/ldk-node/actions/workflows/audit.yml) diff --git a/bindings/python/hatch_build.py b/bindings/python/hatch_build.py index a95abc4c70..863295a85e 100644 --- a/bindings/python/hatch_build.py +++ b/bindings/python/hatch_build.py @@ -1,4 +1,5 @@ import os +import sysconfig from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.metadata.plugin.interface import MetadataHookInterface @@ -20,7 +21,8 @@ def update(self, metadata): class CustomBuildHook(BuildHookInterface): def initialize(self, version, build_data): + platform_tag = sysconfig.get_platform().replace("-", "_").replace(".", "_") build_data["pure_python"] = False - build_data["infer_tag"] = True + build_data["tag"] = f"py3-none-{platform_tag}" if self.target_name == "sdist": build_data["force_include"][readme_path(self.root)] = "README.md" diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 8d4158db70..3eb81d5f76 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.32.0"] build-backend = "hatchling.build" [project] @@ -10,13 +10,20 @@ authors = [ ] description = "A ready-to-go Lightning node library built using LDK and BDK." dynamic = ["readme"] -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Topic :: Software Development :: Libraries", "Topic :: Security :: Cryptography", "License :: OSI Approved :: MIT License", "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] @@ -29,7 +36,37 @@ dev = ["requests"] [tool.hatch.build.targets.wheel] packages = ["src/ldk_node"] +exclude = ["src/ldk_node/test_ldk_node.py"] [tool.hatch.metadata.hooks.custom] [tool.hatch.build.hooks.custom] + +[tool.cibuildwheel] +archs = ["native"] +build = "cp3{10,11,12,13,14}-{manylinux,macosx}_*" +build-frontend = "uv" +test-command = 'python -c "import ldk_node; ldk_node.default_config()"' + +[tool.cibuildwheel.linux] +before-all = [ + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.95.0", + '. "$HOME/.cargo/env"', + "cd {package}/../.. && ./scripts/uniffi_bindgen_generate_python.sh", +] +environment-pass = ["CARGO_TARGET_DIR", "SOURCE_DATE_EPOCH"] +manylinux-x86_64-image = "manylinux_2_28" +manylinux-aarch64-image = "manylinux_2_28" + +[tool.cibuildwheel.macos] +archs = ["x86_64", "arm64"] +before-all = "rustup target add x86_64-apple-darwin aarch64-apple-darwin --toolchain 1.95.0" +before-build = "cd {package}/../.. && RUSTUP_TOOLCHAIN=1.95.0 ./scripts/uniffi_bindgen_generate_python.sh" + +[[tool.cibuildwheel.overrides]] +select = "*-macosx_x86_64" +environment = { MACOSX_DEPLOYMENT_TARGET = "10.12" } + +[[tool.cibuildwheel.overrides]] +select = "*-macosx_arm64" +environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" } diff --git a/scripts/python_build_wheel.sh b/scripts/python_build_wheel.sh index 4bae18479c..6a110bb6ed 100755 --- a/scripts/python_build_wheel.sh +++ b/scripts/python_build_wheel.sh @@ -1,31 +1,168 @@ #!/bin/bash -# Build a Python wheel for the current platform. +# Build and test native Python wheels on the current host. # -# This script compiles the Rust library, generates Python bindings via UniFFI, -# and builds a platform-specific wheel using uv + hatchling. -# -# Run this on each target platform (Linux, macOS) to collect wheels, then use -# scripts/python_publish_package.sh to publish them. +# Run this script once on each supported build host: +# Linux x86_64, Linux aarch64, macOS arm64. +# Each Linux host builds its native wheel. The macOS arm64 host builds both +# arm64 and x86_64 wheels and requires Rust 1.95.0 and Rosetta 2. -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$REPO_ROOT/bindings/python/wheelhouse" +ALLOW_DIRTY=false +CIBUILDWHEEL_VERSION="4.1.0" + +usage() { + echo "Usage: $0 [--output-dir DIR] [--allow-dirty]" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --output-dir) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + OUTPUT_DIR="$2" + shift 2 + ;; + --allow-dirty) + ALLOW_DIRTY=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "$OUTPUT_DIR" in + /*) ;; + *) OUTPUT_DIR="$REPO_ROOT/$OUTPUT_DIR" ;; +esac + +for command_name in git uv; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Required command not found: $command_name" >&2 + exit 1 + fi +done cd "$REPO_ROOT" -# Generate bindings and compile the native library -echo "Generating Python bindings..." -./scripts/uniffi_bindgen_generate_python.sh - -# Build the wheel -echo "Building wheel..." -cd bindings/python -uv build --wheel - -echo "" -echo "Wheel built successfully:" -ls -1 dist/*.whl -echo "" -echo "Collect wheels from all target platforms into dist/, then run:" -echo " ./scripts/python_publish_package.sh" +HOST_OS="$(uname -s)" +HOST_ARCH="$(uname -m)" + +case "$HOST_OS:$HOST_ARCH" in + Linux:x86_64|Linux:amd64|Linux:aarch64|Linux:arm64|Darwin:aarch64|Darwin:arm64) ;; + *) + echo "Unsupported build host: $HOST_OS $HOST_ARCH" >&2 + exit 1 + ;; +esac + +if [[ "$ALLOW_DIRTY" == false ]] && [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + echo "Refusing to build from a dirty worktree; commit the release first." >&2 + echo "Use --allow-dirty only while developing the build configuration." >&2 + exit 1 +fi + +case "$HOST_OS" in + Linux) + EXPECTED_WHEEL_COUNT=1 + case "${CIBW_CONTAINER_ENGINE:-}" in + podman*) CONTAINER_COMMAND=podman ;; + docker*|"") CONTAINER_COMMAND=docker ;; + *) + echo "Unsupported CIBW_CONTAINER_ENGINE: $CIBW_CONTAINER_ENGINE" >&2 + exit 1 + ;; + esac + + if ! command -v "$CONTAINER_COMMAND" >/dev/null 2>&1; then + if [[ -z "${CIBW_CONTAINER_ENGINE:-}" ]] && command -v podman >/dev/null 2>&1; then + export CIBW_CONTAINER_ENGINE=podman + else + echo "Linux wheel builds require Docker or Podman." >&2 + exit 1 + fi + fi + ;; + Darwin) + EXPECTED_WHEEL_COUNT=2 + if ! command -v rustup >/dev/null 2>&1 || \ + ! rustup run 1.95.0 rustc --version >/dev/null 2>&1; then + echo "macOS wheel builds require the Rust 1.95.0 toolchain." >&2 + echo "Install it with: rustup toolchain install 1.95.0 --profile minimal" >&2 + exit 1 + fi + if ! /usr/bin/arch -x86_64 /usr/bin/true >/dev/null 2>&1; then + echo "macOS x86_64 wheel tests require Rosetta 2." >&2 + echo "Install it with: softwareupdate --install-rosetta" >&2 + exit 1 + fi + ;; + *) + echo "Unsupported operating system: $HOST_OS" >&2 + exit 1 + ;; +esac + +mkdir -p "$OUTPUT_DIR" +shopt -s nullglob +existing_wheels=("$OUTPUT_DIR"/*.whl) +if [[ ${#existing_wheels[@]} -ne 0 ]]; then + echo "Output directory already contains wheels: $OUTPUT_DIR" >&2 + exit 1 +fi + +CARGO_TARGET_DIR="$(mktemp -d /tmp/cargo-target-ldk-node-python-wheels.XXXXXX)" +export CARGO_TARGET_DIR +SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" +export SOURCE_DATE_EPOCH + +cleanup() { + case "$CARGO_TARGET_DIR" in + /tmp/cargo-target-ldk-node-python-wheels.*) + rm -rf -- "$CARGO_TARGET_DIR" + ;; + esac +} +trap cleanup EXIT + +echo "Building Python wheel from commit $(git rev-parse HEAD)" +echo "Build host: $HOST_OS $HOST_ARCH" + +uv tool run --from "cibuildwheel[uv]==$CIBUILDWHEEL_VERSION" cibuildwheel \ + bindings/python \ + --config-file bindings/python/pyproject.toml \ + --output-dir "$OUTPUT_DIR" + +built_wheels=("$OUTPUT_DIR"/*.whl) +if [[ ${#built_wheels[@]} -ne $EXPECTED_WHEEL_COUNT ]]; then + echo "Expected $EXPECTED_WHEEL_COUNT wheels, found ${#built_wheels[@]}." >&2 + exit 1 +fi + +for wheel_path in "${built_wheels[@]}"; do + wheel_name="$(basename "$wheel_path")" + ( + cd "$OUTPUT_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$wheel_name" + else + shasum -a 256 "$wheel_name" + fi + ) > "$wheel_path.sha256" +done + +echo "Wheels built and tested successfully:" +for wheel_path in "${built_wheels[@]}"; do + echo " $wheel_path" + echo " $wheel_path.sha256" +done diff --git a/scripts/python_publish_package.sh b/scripts/python_publish_package.sh index 971a4eddac..c537982bb9 100755 --- a/scripts/python_publish_package.sh +++ b/scripts/python_publish_package.sh @@ -1,26 +1,223 @@ #!/bin/bash -# Publish Python wheels to PyPI (or TestPyPI). +# Validate and publish a complete Python wheel release. # -# Usage: -# ./scripts/python_publish_package.sh # publish to PyPI -# ./scripts/python_publish_package.sh --index testpypi # publish to TestPyPI -# -# Before running, collect wheels from all target platforms into bindings/python/dist/. +# Build the four native wheels with python_build_wheel.sh and collect the +# wheels and their .sha256 files in one directory. Publish the unchanged set +# to TestPyPI first. Production publication verifies TestPyPI has files with +# the same hashes before uploading to PyPI. -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -DIST_DIR="$REPO_ROOT/bindings/python/dist" +WHEEL_DIR="$REPO_ROOT/bindings/python/wheelhouse" +INDEX="" +VERSION="" +CHECK_ONLY=false + +usage() { + echo "Usage: $0 --index testpypi|pypi --version VERSION [--wheel-dir DIR] [--check-only]" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --index) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + INDEX="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + VERSION="$2" + shift 2 + ;; + --wheel-dir) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + WHEEL_DIR="$2" + shift 2 + ;; + --check-only) + CHECK_ONLY=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$INDEX" != testpypi && "$INDEX" != pypi ]]; then + echo "--index must be either testpypi or pypi." >&2 + exit 2 +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9.-]+)?$ ]]; then + echo "--version must be a Python package version such as 0.7.0." >&2 + exit 2 +fi + +case "$WHEEL_DIR" in + /*) ;; + *) WHEEL_DIR="$REPO_ROOT/$WHEEL_DIR" ;; +esac -if [ ! -d "$DIST_DIR" ] || [ -z "$(ls -A "$DIST_DIR"/*.whl 2>/dev/null)" ]; then - echo "Error: No wheels found in $DIST_DIR" - echo "Run ./scripts/python_build_wheel.sh on each target platform first." +if [[ ! -d "$WHEEL_DIR" ]]; then + echo "Wheel directory does not exist: $WHEEL_DIR" >&2 exit 1 fi -echo "Wheels to publish:" -ls -1 "$DIST_DIR"/*.whl -echo "" +if [[ "$CHECK_ONLY" == false && -z "${UV_PUBLISH_TOKEN:-}" ]]; then + echo "Set UV_PUBLISH_TOKEN to a token for $INDEX before publishing." >&2 + exit 1 +fi + +command -v uv >/dev/null 2>&1 || { echo "Required command not found: uv" >&2; exit 1; } + +uv run --no-project --python 3.10 python - "$VERSION" "$WHEEL_DIR" "$INDEX" <<'PY' +import email +import hashlib +import json +import re +import sys +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +version, wheel_dir_arg, index = sys.argv[1:] +wheel_dir = Path(wheel_dir_arg) + +platforms = ( + "manylinux_2_28_x86_64", + "manylinux_2_28_aarch64", + "macosx_10_12_x86_64", + "macosx_11_0_arm64", +) +expected = {f"ldk_node-{version}-py3-none-{platform}.whl" for platform in platforms} +actual = {path.name for path in wheel_dir.glob("*.whl")} + +if actual != expected: + missing = sorted(expected - actual) + unexpected = sorted(actual - expected) + if missing: + print(f"Missing wheels: {', '.join(missing)}", file=sys.stderr) + if unexpected: + print(f"Unexpected wheels: {', '.join(unexpected)}", file=sys.stderr) + raise SystemExit(1) + +source_archives = list(wheel_dir.glob("*.tar.gz")) + list(wheel_dir.glob("*.zip")) +if source_archives: + print("Source distributions must not be published for version 0.8.", file=sys.stderr) + raise SystemExit(1) + +digests = {} +for filename in sorted(expected): + wheel_path = wheel_dir / filename + digest = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + checksum_path = wheel_dir / f"{filename}.sha256" + if not checksum_path.is_file(): + print(f"Missing checksum: {checksum_path.name}", file=sys.stderr) + raise SystemExit(1) + + checksum_fields = checksum_path.read_text(encoding="utf-8").split() + if len(checksum_fields) != 2 or checksum_fields != [digest, filename]: + print(f"Checksum mismatch: {checksum_path.name}", file=sys.stderr) + raise SystemExit(1) + + with zipfile.ZipFile(wheel_path) as wheel: + names = wheel.namelist() + metadata_paths = [name for name in names if name.endswith(".dist-info/METADATA")] + wheel_paths = [name for name in names if name.endswith(".dist-info/WHEEL")] + if len(metadata_paths) != 1 or len(wheel_paths) != 1: + print(f"Invalid wheel metadata layout: {filename}", file=sys.stderr) + raise SystemExit(1) + + metadata = email.message_from_bytes(wheel.read(metadata_paths[0])) + wheel_metadata = email.message_from_bytes(wheel.read(wheel_paths[0])) + normalized_name = re.sub(r"[-_.]+", "-", metadata["Name"]).lower() + expected_tag = filename.removeprefix(f"ldk_node-{version}-").removesuffix(".whl") + native_library = ( + "ldk_node/libldk_node.so" + if "manylinux" in filename + else "ldk_node/libldk_node.dylib" + ) + + if normalized_name != "ldk-node" or metadata["Version"] != version: + print(f"Name or version mismatch in {filename}", file=sys.stderr) + raise SystemExit(1) + if metadata["Requires-Python"] != ">=3.10": + print(f"Python requirement mismatch in {filename}", file=sys.stderr) + raise SystemExit(1) + if wheel_metadata["Root-Is-Purelib"].lower() != "false": + print(f"Native wheel marked as pure: {filename}", file=sys.stderr) + raise SystemExit(1) + if wheel_metadata.get_all("Tag", []) != [expected_tag]: + print(f"Wheel tag mismatch in {filename}", file=sys.stderr) + raise SystemExit(1) + if "ldk_node/ldk_node.py" not in names or native_library not in names: + print(f"Generated bindings missing from {filename}", file=sys.stderr) + raise SystemExit(1) + + digests[filename] = digest + +if index == "pypi": + testpypi_url = f"https://test.pypi.org/pypi/ldk-node/{version}/json" + try: + with urllib.request.urlopen(testpypi_url, timeout=30) as response: + testpypi = json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as error: + print(f"Unable to verify TestPyPI release: {error}", file=sys.stderr) + raise SystemExit(1) + + uploaded = { + item["filename"]: item.get("digests", {}).get("sha256") + for item in testpypi.get("urls", []) + } + mismatches = [ + filename + for filename, digest in digests.items() + if uploaded.get(filename) != digest + ] + if mismatches: + print( + "TestPyPI does not contain matching artifacts: " + ", ".join(mismatches), + file=sys.stderr, + ) + raise SystemExit(1) + +print("Validated release artifacts:") +for filename in sorted(expected): + print(f" {filename} sha256:{digests[filename]}") +PY + +if [[ "$CHECK_ONLY" == true ]]; then + echo "Artifact validation completed without publishing." + exit 0 +fi + +shopt -s nullglob +wheels=("$WHEEL_DIR"/*.whl) + +case "$INDEX" in + testpypi) + PUBLISH_URL="https://test.pypi.org/legacy/" + CHECK_URL="https://test.pypi.org/simple/" + ;; + pypi) + PUBLISH_URL="https://upload.pypi.org/legacy/" + CHECK_URL="https://pypi.org/simple/" + ;; +esac -uv publish "$@" "$DIST_DIR"/*.whl +echo "Publishing ${#wheels[@]} wheels to $INDEX." +uv publish \ + --publish-url "$PUBLISH_URL" \ + --check-url "$CHECK_URL" \ + --trusted-publishing never \ + "${wheels[@]}" diff --git a/scripts/uniffi_bindgen_generate_python.sh b/scripts/uniffi_bindgen_generate_python.sh index abc4088ffa..8792d2bc23 100755 --- a/scripts/uniffi_bindgen_generate_python.sh +++ b/scripts/uniffi_bindgen_generate_python.sh @@ -1,20 +1,50 @@ #!/bin/bash -BINDINGS_DIR="./bindings/python/src/ldk_node" -UNIFFI_BINDGEN_BIN="cargo run --manifest-path bindings/uniffi-bindgen/Cargo.toml" + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BINDINGS_DIR="$REPO_ROOT/bindings/python/src/ldk_node" +TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" +CARGO_BUILD_ARGS=() case " ${RUSTFLAGS:-} " in *" --cfg tokio_unstable "*|*" --cfg=tokio_unstable "*) ;; *) export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }--cfg tokio_unstable" ;; esac -if [[ "$OSTYPE" == "linux-gnu"* ]]; then - DYNAMIC_LIB_PATH="./target/release-smaller/libldk_node.so" -else - DYNAMIC_LIB_PATH="./target/release-smaller/libldk_node.dylib" -fi +case "$(uname -s)" in + Linux) DYNAMIC_LIB_PATH="$TARGET_DIR/release-smaller/libldk_node.so" ;; + Darwin) + case "${ARCHFLAGS:-}" in + "") DYNAMIC_LIB_PATH="$TARGET_DIR/release-smaller/libldk_node.dylib" ;; + "-arch x86_64") + CARGO_BUILD_ARGS=(--target x86_64-apple-darwin) + DYNAMIC_LIB_PATH="$TARGET_DIR/x86_64-apple-darwin/release-smaller/libldk_node.dylib" + ;; + "-arch arm64") + CARGO_BUILD_ARGS=(--target aarch64-apple-darwin) + DYNAMIC_LIB_PATH="$TARGET_DIR/aarch64-apple-darwin/release-smaller/libldk_node.dylib" + ;; + *) + echo "Unsupported ARCHFLAGS: $ARCHFLAGS" >&2 + exit 1 + ;; + esac + ;; + *) + echo "Unsupported operating system: $(uname -s)" >&2 + exit 1 + ;; +esac -cargo build --profile release-smaller --features uniffi || exit 1 -$UNIFFI_BINDGEN_BIN generate bindings/ldk_node.udl --lib-file "$DYNAMIC_LIB_PATH" --language python -o "$BINDINGS_DIR" || exit 1 +cd "$REPO_ROOT" +mkdir -p "$BINDINGS_DIR" -mkdir -p $BINDINGS_DIR -cp "$DYNAMIC_LIB_PATH" "$BINDINGS_DIR" || exit 1 +cargo build "${CARGO_BUILD_ARGS[@]}" --profile release-smaller --features uniffi +cargo run --manifest-path bindings/uniffi-bindgen/Cargo.toml -- \ + generate bindings/ldk_node.udl \ + --lib-file "$DYNAMIC_LIB_PATH" \ + --language python \ + -o "$BINDINGS_DIR" +cp "$DYNAMIC_LIB_PATH" "$BINDINGS_DIR" From 4466ad296f4232852c880dd91e797cd29194c042 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 14 Aug 2026 19:44:54 +0000 Subject: [PATCH 2/4] Select Python 3.11 for cibuildwheel Assisted-by: OpenAI Codex --- scripts/python_build_wheel.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/python_build_wheel.sh b/scripts/python_build_wheel.sh index 6a110bb6ed..327c04c512 100755 --- a/scripts/python_build_wheel.sh +++ b/scripts/python_build_wheel.sh @@ -138,7 +138,7 @@ trap cleanup EXIT echo "Building Python wheel from commit $(git rev-parse HEAD)" echo "Build host: $HOST_OS $HOST_ARCH" -uv tool run --from "cibuildwheel[uv]==$CIBUILDWHEEL_VERSION" cibuildwheel \ +uv tool run --python 3.11 --from "cibuildwheel[uv]==$CIBUILDWHEEL_VERSION" cibuildwheel \ bindings/python \ --config-file bindings/python/pyproject.toml \ --output-dir "$OUTPUT_DIR" From 0b16b6050a97f49419564d4428bdf6899a011868 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 14 Aug 2026 19:44:59 +0000 Subject: [PATCH 3/4] Export clean source for cibuildwheel cibuildwheel copies the repository into its Linux build container. Ignored build outputs such as target/, virtual environments, and dist/ can make that context several gigabytes, slowing builds or exhausting Docker storage. Export tracked and non-ignored untracked files to a temporary source directory before invoking cibuildwheel. This keeps ignored artifacts out of the container context while preserving local changes for --allow-dirty builds. Assisted-by: OpenAI Codex --- scripts/python_build_wheel.sh | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/python_build_wheel.sh b/scripts/python_build_wheel.sh index 327c04c512..9e5cee1db8 100755 --- a/scripts/python_build_wheel.sh +++ b/scripts/python_build_wheel.sh @@ -123,6 +123,7 @@ fi CARGO_TARGET_DIR="$(mktemp -d /tmp/cargo-target-ldk-node-python-wheels.XXXXXX)" export CARGO_TARGET_DIR +BUILD_SOURCE_DIR="$(mktemp -d /tmp/ldk-node-python-wheels-source.XXXXXX)" SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" export SOURCE_DATE_EPOCH @@ -132,16 +133,30 @@ cleanup() { rm -rf -- "$CARGO_TARGET_DIR" ;; esac + case "$BUILD_SOURCE_DIR" in + /tmp/ldk-node-python-wheels-source.*) + rm -rf -- "$BUILD_SOURCE_DIR" + ;; + esac } trap cleanup EXIT +git ls-files --cached --others --exclude-standard -z | while IFS= read -r -d '' source_path; do + if [[ -e "$source_path" || -L "$source_path" ]]; then + printf '%s\0' "$source_path" + fi +done | tar --null --files-from=- -cf - | tar -xf - -C "$BUILD_SOURCE_DIR" + echo "Building Python wheel from commit $(git rev-parse HEAD)" echo "Build host: $HOST_OS $HOST_ARCH" -uv tool run --python 3.11 --from "cibuildwheel[uv]==$CIBUILDWHEEL_VERSION" cibuildwheel \ - bindings/python \ - --config-file bindings/python/pyproject.toml \ - --output-dir "$OUTPUT_DIR" +( + cd "$BUILD_SOURCE_DIR" + uv tool run --python 3.11 --from "cibuildwheel[uv]==$CIBUILDWHEEL_VERSION" cibuildwheel \ + bindings/python \ + --config-file bindings/python/pyproject.toml \ + --output-dir "$OUTPUT_DIR" +) built_wheels=("$OUTPUT_DIR"/*.whl) if [[ ${#built_wheels[@]} -ne $EXPECTED_WHEEL_COUNT ]]; then From 6c51028ee2cfdee37c68c308a8ee3ef913a06fdf Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 14 Aug 2026 19:45:04 +0000 Subject: [PATCH 4/4] Include license files in Python wheels Declare the dual-license expression and package both canonical license texts. Remove deprecated license classifiers so the SPDX expression is the authoritative declaration. Co-Authored-By: HAL 9000 --- bindings/python/LICENSE-APACHE | 1 + bindings/python/LICENSE-MIT | 1 + bindings/python/pyproject.toml | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 120000 bindings/python/LICENSE-APACHE create mode 120000 bindings/python/LICENSE-MIT diff --git a/bindings/python/LICENSE-APACHE b/bindings/python/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/bindings/python/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/bindings/python/LICENSE-MIT b/bindings/python/LICENSE-MIT new file mode 120000 index 0000000000..b2cfbdc7b0 --- /dev/null +++ b/bindings/python/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 3eb81d5f76..45fcbe869f 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -9,13 +9,13 @@ authors = [ { name="Elias Rohrer", email="dev@tnull.de" }, ] description = "A ready-to-go Lightning node library built using LDK and BDK." +license = "MIT OR Apache-2.0" +license-files = ["LICENSE-APACHE", "LICENSE-MIT"] dynamic = ["readme"] requires-python = ">=3.10" classifiers = [ "Topic :: Software Development :: Libraries", "Topic :: Security :: Cryptography", - "License :: OSI Approved :: MIT License", - "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3",