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 bd5f54d244..5a8b2b4cef 100644 --- a/bindings/python/hatch_build.py +++ b/bindings/python/hatch_build.py @@ -1,7 +1,22 @@ +import os +import sysconfig + from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.metadata.plugin.interface import MetadataHookInterface + + +class CustomMetadataHook(MetadataHookInterface): + def update(self, metadata): + readme_path = os.path.join(self.root, "README.md") + if not os.path.isfile(readme_path): + readme_path = os.path.normpath(os.path.join(self.root, "..", "..", "README.md")) + + with open(readme_path, encoding="utf-8") as readme_file: + metadata["readme"] = {"content-type": "text/markdown", "text": readme_file.read()} 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}" diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index b77801d457..b3e3649bca 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,22 +1,29 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.32.0"] build-backend = "hatchling.build" [project] name = "ldk_node" -version = "0.7.0" +version = "0.8.0" authors = [ { name="Elias Rohrer", email="dev@tnull.de" }, ] description = "A ready-to-go Lightning node library built using LDK and BDK." -readme = "../../README.md" -requires-python = ">=3.8" +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", + "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,5 +36,38 @@ dev = ["requests"] [tool.hatch.build.targets.wheel] packages = ["src/ldk_node"] +exclude = ["src/ldk_node/test_ldk_node.py"] + +[tool.hatch.build.targets.sdist.force-include] +"../../README.md" = "README.md" + +[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] +before-all = "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..56ee467878 100755 --- a/scripts/python_build_wheel.sh +++ b/scripts/python_build_wheel.sh @@ -1,31 +1,154 @@ #!/bin/bash -# Build a Python wheel for the current platform. +# Build and test one native Python wheel 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 native target: +# Linux x86_64, Linux aarch64, macOS x86_64, macOS arm64. +# Linux builds require Docker or Podman. macOS builds require Rust 1.95.0. -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" +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 "$(uname -s)" in + Linux) + 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) + 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 + ;; + *) + echo "Unsupported operating system: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + x86_64|amd64|aarch64|arm64) ;; + *) + echo "Unsupported architecture: $(uname -m)" >&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 "Target: $(uname -s) $(uname -m)" + +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 1 ]]; then + echo "Expected exactly one wheel, found ${#built_wheels[@]}." >&2 + exit 1 +fi + +wheel_path="${built_wheels[0]}" +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" + +echo "Wheel built and tested successfully:" +echo " $wheel_path" +echo " $wheel_path.sha256" diff --git a/scripts/python_publish_package.sh b/scripts/python_publish_package.sh index 971a4eddac..1b5c4e3541 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.8.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..dc3e95ce3f 100755 --- a/scripts/uniffi_bindgen_generate_python.sh +++ b/scripts/uniffi_bindgen_generate_python.sh @@ -1,20 +1,33 @@ #!/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}" 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) DYNAMIC_LIB_PATH="$TARGET_DIR/release-smaller/libldk_node.dylib" ;; + *) + 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 --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"