1 #!/usr/bin/env bash
2 # ──────────────────────────────────────────────────────────────────────────────
3 # build-python.sh — Build the onde Python package using Maturin
4 # ──────────────────────────────────────────────────────────────────────────────
5 #
6 # This script:
7 # 1. Checks that `maturin` is available (or installs it).
8 # 2. Builds the Python wheel using `maturin build --release` from the
9 # python/ directory.
10 # 3. Optionally runs `maturin develop` for development installs.
11 # 4. Shows a summary with the output wheel location.
12 #
13 # Prerequisites:
14 # - Rust toolchain (cargo, rustc on PATH)
15 # - Python 3.9+ with pip or uv
16 # - uniffi-bindgen ==0.31.0 on PATH (auto-installed if missing)
17 #
18 # Usage:
19 # ./build-python.sh # Build release wheel
20 # ./build-python.sh --develop # Install into current venv (editable)
21 # ./build-python.sh --release # Build release wheel (explicit, default)
22 # ./build-python.sh --help # Show usage
23 #
24 # Environment variables:
25 # ONDE_FEATURES — Extra Cargo features to enable (e.g. "whisper")
26 # CARGO — Path to cargo binary (default: cargo)
27 # PYTHON — Path to python binary (default: python3)
28 # MATURIN — Path to maturin binary (default: auto-detected)
29 # UNIFFI_BINDGEN — Path to uniffi-bindgen binary (default: auto-detected)
30 # ──────────────────────────────────────────────────────────────────────────────
31 set -euo pipefail
32
33 # ── Constants ─────────────────────────────────────────────────────────────────
34
35 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
36 CRATE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
37 PYTHON_DIR="$SCRIPT_DIR"
38
39 CARGO="${CARGO:-cargo}"
40 PYTHON="${PYTHON:-python3}"
41
42 # ── Helpers ───────────────────────────────────────────────────────────────────
43
44 RED='\033[0;31m'
45 GREEN='\033[0;32m'
46 YELLOW='\033[0;33m'
47 CYAN='\033[0;36m'
48 BOLD='\033[1m'
49 NC='\033[0m' # No Color
50
51 log() { echo -e "${CYAN}[onde]${NC} $*"; }
52 ok() { echo -e "${GREEN}[onde]${NC} $*"; }
53 warn() { echo -e "${YELLOW}[onde]${NC} $*"; }
54 err() { echo -e "${RED}[onde]${NC} $*" >&2; }
55 die() { err "$@"; exit 1; }
56
57 usage() {
58 cat <<EOF
59 Usage: $(basename "$0") [OPTIONS]
60
61 Build the onde Python package using Maturin.
62
63 Options:
64 --release Build a release wheel (default).
65 --develop Install the package into the current virtualenv
66 using \`maturin develop\` (editable/debug build).
67 --publish Build and publish to PyPI (skips sdist).
68 --help, -h Show this help message.
69
70 Environment:
71 ONDE_FEATURES Extra Cargo features, e.g. "whisper"
72 CARGO Path to cargo (default: cargo)
73 PYTHON Path to python3 (default: python3)
74 MATURIN Path to maturin (default: auto-detected)
75 UNIFFI_BINDGEN Path to uniffi-bindgen (default: auto-detected)
76
77 Examples:
78 # Build a release wheel
79 ./build-python.sh
80
81 # Development install into current venv
82 ./build-python.sh --develop
83
84 # Build with whisper feature enabled
85 ONDE_FEATURES=whisper ./build-python.sh
86
87 # Publish to PyPI (requires MATURIN_PYPI_TOKEN or ~/.pypirc)
88 ./build-python.sh --publish
89 EOF
90 exit 0
91 }
92
93 # ── Argument parsing ──────────────────────────────────────────────────────────
94
95 MODE="release"
96
97 while [[ $# -gt 0 ]]; do
98 case "$1" in
99 --release)
100 MODE="release"
101 shift
102 ;;
103 --develop)
104 MODE="develop"
105 shift
106 ;;
107 --publish)
108 MODE="publish"
109 shift
110 ;;
111 --help|-h)
112 usage
113 ;;
114 *)
115 die "Unknown option: $1 (try --help)"
116 ;;
117 esac
118 done
119
120 # ── Step 1: Ensure maturin is available ───────────────────────────────────────
121
122 find_or_install_maturin() {
123 # 1. Explicit MATURIN env var.
124 if [[ -n "${MATURIN:-}" ]]; then
125 if command -v "$MATURIN" &>/dev/null; then
126 echo "$MATURIN"
127 return
128 else
129 die "MATURIN is set to '$MATURIN' but it is not executable."
130 fi
131 fi
132
133 # 2. Already on PATH.
134 if command -v maturin &>/dev/null; then
135 echo "maturin"
136 return
137 fi
138
139 # 3. Try installing via uv (preferred — fast, no venv needed).
140 if command -v uv &>/dev/null; then
141 warn "maturin not found. Installing via ${BOLD}uv tool install maturin${NC}..."
142 uv tool install maturin >/dev/null 2>&1 || true
143 if command -v maturin &>/dev/null; then
144 echo "maturin"
145 return
146 fi
147 fi
148
149 # 4. Try installing via pip.
150 if command -v pip3 &>/dev/null; then
151 warn "maturin not found. Installing via ${BOLD}pip3 install maturin${NC}..."
152 pip3 install maturin >/dev/null 2>&1 || true
153 if command -v maturin &>/dev/null; then
154 echo "maturin"
155 return
156 fi
157 elif command -v pip &>/dev/null; then
158 warn "maturin not found. Installing via ${BOLD}pip install maturin${NC}..."
159 pip install maturin >/dev/null 2>&1 || true
160 if command -v maturin &>/dev/null; then
161 echo "maturin"
162 return
163 fi
164 fi
165
166 return 1
167 }
168
169 log "Checking for ${BOLD}maturin${NC}..."
170
171 MATURIN_BIN=$(find_or_install_maturin) || die \
172 "maturin not found and could not be installed.\n" \
173 "Install it manually: ${BOLD}pip install maturin${NC} or ${BOLD}uv tool install maturin${NC}"
174
175 MATURIN_VERSION=$($MATURIN_BIN --version 2>/dev/null || echo "unknown")
176 ok "maturin ready: ${BOLD}$MATURIN_BIN${NC} ($MATURIN_VERSION)"
177
178 # ── Step 2: Ensure uniffi-bindgen is available ────────────────────────────────
179 #
180 # Maturin with `bindings = "uniffi"` shells out to `uniffi-bindgen` to generate
181 # the Python bindings. The version MUST match the `uniffi` crate version
182 # pinned in Cargo.toml (==0.31.0). We check several locations:
183 # 1. Explicit UNIFFI_BINDGEN env var
184 # 2. Already on PATH
185 # 3. The project's local uniffi-bindgen binary (uniffi-bindgen/ crate)
186 # 4. pip install uniffi-bindgen==0.31.0
187
188 UNIFFI_VERSION="0.31.0"
189
190 find_or_install_uniffi_bindgen() {
191 # 1. Explicit env var.
192 if [[ -n "${UNIFFI_BINDGEN:-}" ]]; then
193 if command -v "$UNIFFI_BINDGEN" &>/dev/null; then
194 echo "$UNIFFI_BINDGEN"
195 return
196 elif [[ -x "$UNIFFI_BINDGEN" ]]; then
197 echo "$UNIFFI_BINDGEN"
198 return
199 else
200 die "UNIFFI_BINDGEN is set to '$UNIFFI_BINDGEN' but it is not executable."
201 fi
202 fi
203
204 # 2. Already on PATH.
205 if command -v uniffi-bindgen &>/dev/null; then
206 echo "uniffi-bindgen"
207 return
208 fi
209
210 # 3. Project-local binary — use it if already built, otherwise build it.
211 local local_bin="$CRATE_DIR/uniffi-bindgen/target/release/uniffi-bindgen"
212 if [[ -x "$local_bin" ]]; then
213 echo "$local_bin"
214 return
215 fi
216
217 # 3b. Binary not built yet — try to compile the local uniffi-bindgen crate.
218 local local_crate="$CRATE_DIR/uniffi-bindgen"
219 if [[ -f "$local_crate/Cargo.toml" ]]; then
220 warn "Local uniffi-bindgen binary not found. Building from ${BOLD}$local_crate${NC}..."
221 if $CARGO build --release --manifest-path "$local_crate/Cargo.toml" 2>&1; then
222 if [[ -x "$local_bin" ]]; then
223 echo "$local_bin"
224 return
225 fi
226 else
227 warn "Failed to build local uniffi-bindgen crate, trying other methods..."
228 fi
229 fi
230
231 # 4. Check common pip --user bin directories.
232 for candidate in \
233 "$HOME/Library/Python/3.9/bin/uniffi-bindgen" \
234 "$HOME/Library/Python/3.10/bin/uniffi-bindgen" \
235 "$HOME/Library/Python/3.11/bin/uniffi-bindgen" \
236 "$HOME/Library/Python/3.12/bin/uniffi-bindgen" \
237 "$HOME/Library/Python/3.13/bin/uniffi-bindgen" \
238 "$HOME/.local/bin/uniffi-bindgen"; do
239 if [[ -x "$candidate" ]]; then
240 echo "$candidate"
241 return
242 fi
243 done
244
245 # 5. Install via pip.
246 warn "uniffi-bindgen not found. Installing ${BOLD}uniffi-bindgen==$UNIFFI_VERSION${NC} via pip..."
247 if command -v pip3 &>/dev/null; then
248 pip3 install "uniffi-bindgen==$UNIFFI_VERSION" >/dev/null 2>&1 || true
249 elif command -v pip &>/dev/null; then
250 pip install "uniffi-bindgen==$UNIFFI_VERSION" >/dev/null 2>&1 || true
251 fi
252
253 # Re-check PATH and common locations after install.
254 if command -v uniffi-bindgen &>/dev/null; then
255 echo "uniffi-bindgen"
256 return
257 fi
258 for candidate in \
259 "$HOME/Library/Python/3.9/bin/uniffi-bindgen" \
260 "$HOME/Library/Python/3.10/bin/uniffi-bindgen" \
261 "$HOME/Library/Python/3.11/bin/uniffi-bindgen" \
262 "$HOME/Library/Python/3.12/bin/uniffi-bindgen" \
263 "$HOME/Library/Python/3.13/bin/uniffi-bindgen" \
264 "$HOME/.local/bin/uniffi-bindgen"; do
265 if [[ -x "$candidate" ]]; then
266 echo "$candidate"
267 return
268 fi
269 done
270
271 return 1
272 }
273
274 log "Checking for ${BOLD}uniffi-bindgen${NC} (==$UNIFFI_VERSION)..."
275
276 UNIFFI_BIN=$(find_or_install_uniffi_bindgen) || die \
277 "uniffi-bindgen not found and could not be installed.\n" \
278 "Install it manually: ${BOLD}pip install uniffi-bindgen==$UNIFFI_VERSION${NC}\n" \
279 "The version MUST match the uniffi crate version in Cargo.toml."
280
281 # Ensure the discovered binary is on PATH so maturin can find it.
282 UNIFFI_BIN_DIR="$(dirname "$UNIFFI_BIN")"
283 case ":$PATH:" in
284 *":$UNIFFI_BIN_DIR:"*) ;;
285 *) export PATH="$UNIFFI_BIN_DIR:$PATH" ;;
286 esac
287
288 UNIFFI_DETECTED_VERSION=$($UNIFFI_BIN --version 2>/dev/null || echo "unknown")
289 ok "uniffi-bindgen ready: ${BOLD}$UNIFFI_BIN${NC} ($UNIFFI_DETECTED_VERSION)"
290
291 # ── Step 3: Verify Python & Rust toolchains ───────────────────────────────────
292
293 if ! command -v "$PYTHON" &>/dev/null; then
294 die "Python not found at '$PYTHON'. Set the PYTHON env var or install Python 3.8+."
295 fi
296
297 PYTHON_VERSION=$($PYTHON --version 2>&1)
298 log "Python: ${BOLD}$PYTHON_VERSION${NC}"
299
300 if ! command -v "$CARGO" &>/dev/null; then
301 die "Cargo not found at '$CARGO'. Install Rust: https://rustup.rs"
302 fi
303
304 CARGO_VERSION=$($CARGO --version 2>&1)
305 log "Cargo: ${BOLD}$CARGO_VERSION${NC}"
306
307 # ── Step 4: Prepare feature flags ────────────────────────────────────────────
308
309 FEATURES_ARGS=()
310 if [[ -n "${ONDE_FEATURES:-}" ]]; then
311 # Split comma-separated features and pass each via --features
312 IFS=',' read -ra FEAT_LIST <<< "$ONDE_FEATURES"
313 for feat in "${FEAT_LIST[@]}"; do
314 feat=$(echo "$feat" | xargs) # trim whitespace
315 if [[ -n "$feat" ]]; then
316 FEATURES_ARGS+=("--cargo-extra-args=--features=$feat")
317 fi
318 done
319 log "Features: ${BOLD}${ONDE_FEATURES}${NC}"
320 fi
321
322 # ── Step 5: Build / develop / publish ─────────────────────────────────────────
323
324 cd "$PYTHON_DIR"
325
326 if [[ "$MODE" == "develop" ]]; then
327 # ── Development install ───────────────────────────────────────────────
328 log "Running ${BOLD}maturin develop${NC} (editable install into current venv)..."
329 echo ""
330
331 $MATURIN_BIN develop \
332 --manifest-path "$CRATE_DIR/Cargo.toml" \
333 "${FEATURES_ARGS[@]+"${FEATURES_ARGS[@]}"}"
334
335 echo ""
336 ok "Development install complete."
337 ok "The ${BOLD}onde_inference${NC} package is now available in your current Python environment."
338
339 elif [[ "$MODE" == "publish" ]]; then
340 # ── Publish to PyPI ───────────────────────────────────────────────────
341 # All Rust deps use git refs (no local path deps), and
342 # uniffi-bindgen==0.31.0 is in [build-system] requires so PEP 517
343 # isolated builds install it automatically.
344 log "Running ${BOLD}maturin publish${NC}..."
345 echo ""
346
347 $MATURIN_BIN publish \
348 --manifest-path "$CRATE_DIR/Cargo.toml" \
349 --skip-existing \
350 --compatibility pypi \
351 "${FEATURES_ARGS[@]+"${FEATURES_ARGS[@]}"}"
352
353 echo ""
354 ok "Published to PyPI."
355
356 else
357 # ── Release wheel build ───────────────────────────────────────────────
358 log "Running ${BOLD}maturin build --release${NC}..."
359 echo ""
360
361 $MATURIN_BIN build \
362 --release \
363 --manifest-path "$CRATE_DIR/Cargo.toml" \
364 --out "$PYTHON_DIR/dist" \
365 "${FEATURES_ARGS[@]+"${FEATURES_ARGS[@]}"}"
366
367 echo ""
368
369 # Find the built wheel(s).
370 WHEEL_DIR="$PYTHON_DIR/dist"
371 if [[ -d "$WHEEL_DIR" ]]; then
372 WHEEL_FILES=()
373 while IFS= read -r -d '' whl; do
374 WHEEL_FILES+=("$whl")
375 done < <(find "$WHEEL_DIR" -name '*.whl' -newer "$PYTHON_DIR" -print0 2>/dev/null || true)
376
377 # If the timestamp trick didn't find any, just list all wheels.
378 if [[ ${#WHEEL_FILES[@]} -eq 0 ]]; then
379 while IFS= read -r -d '' whl; do
380 WHEEL_FILES+=("$whl")
381 done < <(find "$WHEEL_DIR" -name '*.whl' -print0 2>/dev/null || true)
382 fi
383 fi
384 fi
385
386 # ── Step 6: Summary ──────────────────────────────────────────────────────────
387
388 echo ""
389 ok "════════════════════════════════════════════════════════════════════"
390 ok " Onde Python package build complete!"
391 ok "════════════════════════════════════════════════════════════════════"
392 echo ""
393
394 if [[ "$MODE" == "develop" ]]; then
395 log "Mode: ${BOLD}develop${NC} (editable install)"
396 log "Python: ${BOLD}$PYTHON_VERSION${NC}"
397 echo ""
398 log "Verify the install:"
399 log " ${BOLD}$PYTHON -c \"from onde_inference import __version__; print(__version__)\"${NC}"
400
401 elif [[ "$MODE" == "publish" ]]; then
402 log "Mode: ${BOLD}publish${NC} (PyPI)"
403 log "Python: ${BOLD}$PYTHON_VERSION${NC}"
404 echo ""
405 log "Install from PyPI:"
406 log " ${BOLD}pip install onde-inference${NC}"
407
408 else
409 log "Mode: ${BOLD}release${NC} (wheel)"
410 log "Python: ${BOLD}$PYTHON_VERSION${NC}"
411
412 if [[ ${#WHEEL_FILES[@]} -gt 0 ]]; then
413 echo ""
414 log "Built wheel(s):"
415 for whl in "${WHEEL_FILES[@]}"; do
416 SIZE=$(du -h "$whl" | cut -f1)
417 ok " $(basename "$whl") (${SIZE})"
418 log " ${BOLD}$whl${NC}"
419 done
420 else
421 warn "No .whl files found in $WHEEL_DIR"
422 fi
423
424 echo ""
425 log "Install the wheel:"
426 if [[ ${#WHEEL_FILES[@]} -gt 0 ]]; then
427 log " ${BOLD}pip install ${WHEEL_FILES[-1]}${NC}"
428 else
429 log " ${BOLD}pip install $WHEEL_DIR/<wheel-file>.whl${NC}"
430 fi
431 fi
432
433 if [[ -n "${ONDE_FEATURES:-}" ]]; then
434 echo ""
435 log "Features: ${BOLD}${ONDE_FEATURES}${NC}"
436 fi
437
438 echo ""