From e459606e89937208db06984534440e58a0eb9c05 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sun, 13 Sep 2026 19:10:51 -0500 Subject: [PATCH] feat(packaging): ship as a tool install or a self-contained directory Two ways to get `ccn-transcribe` on PATH. `uv tool install .` is the light one. scripts/build-binary.sh produces a ~450 MB directory that needs neither Python nor uv, which turned out to work despite OpenVINO resolving 47 plugins by dlopen at runtime -- the frozen build reports CPU, GPU and transcribes on the GPU to a byte-identical transcript. Two things that build needs. PyInstaller points sys.prefix at the bundle, so deno goes in bin/ and the runtime lookup finds it with no frozen-app special case in the package. And a frozen executable is re-invoked to start a multiprocessing child, which parsed the interpreter's own -B flag as a CLI option and printed "No such option '-B'" twice per run before dying; freeze_support() in the entry point makes that child do its job and exit. onedir, not onefile: onefile would extract 450 MB to /tmp on every launch. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++++ README.md | 25 +++++++++++++++++++++++++ scripts/build-binary.sh | 25 +++++++++++++++++++++++++ src/ccn_transcribe/__main__.py | 6 ++++++ 4 files changed, 60 insertions(+) create mode 100755 scripts/build-binary.sh diff --git a/.gitignore b/.gitignore index 20049d7..48d0491 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ htmlcov/ # Tool caches .mypy_cache/ .ruff_cache/ + +# PyInstaller output +/dist/ +/build/ diff --git a/README.md b/README.md index f576754..da0ba75 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,31 @@ the retry loop lives here instead. yt-dlp defaults to deno only, and installing it as a dependency means it works under cron without any PATH setup. +## Installing + +Either route puts `ccn-transcribe` on your PATH; `ccn-transcribe doctor` tells you +whether the machine can actually run it. + +**As a tool** (needs uv; tracks nothing but what it installed): + +```bash +uv tool install . # then: ccn-transcribe doctor +uv tool install . --reinstall # pick up later commits +uv tool install . --editable # or track the checkout instead +``` + +**As a self-contained directory** (needs neither Python nor uv at runtime): + +```bash +scripts/build-binary.sh # then: dist/ccn-transcribe/ccn-transcribe doctor +``` + +~450 MB, most of it OpenVINO and its 47 runtime-loaded plugins. It is `onedir` +rather than `onefile` on purpose: `onefile` extracts the whole bundle to `/tmp` +on every launch. Move or copy the whole `ccn-transcribe/` directory, not just +the executable inside it. `ffmpeg`, `ffprobe` and `aria2c` are still expected on +the system either way — they are not bundled. + ## Development ```bash diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh new file mode 100755 index 0000000..3c30f4f --- /dev/null +++ b/scripts/build-binary.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Build a self-contained ccn-transcribe directory that runs without Python or uv. +# +# onedir, never onefile: onefile extracts ~450 MB to /tmp on every launch. +# deno lands in bin/ because PyInstaller points sys.prefix at the bundle, which +# is where the package already looks for the JS runtime it ships with. +set -euo pipefail + +cd "$(dirname "$0")/.." +DIST=${1:-dist} + +uv run --locked --with pyinstaller pyinstaller \ + --noconfirm --onedir --name ccn-transcribe \ + --distpath "$DIST" --workpath "build/pyinstaller" --specpath "build" \ + --collect-all openvino \ + --collect-all openvino_genai \ + --collect-all openvino_tokenizers \ + --collect-all yt_dlp \ + --collect-all yt_dlp_ejs \ + --add-binary "$PWD/.venv/bin/deno:bin" \ + src/ccn_transcribe/__main__.py + +echo +echo "built $DIST/ccn-transcribe ($(du -sh "$DIST/ccn-transcribe" | cut -f1))" +echo "verify it with: $DIST/ccn-transcribe/ccn-transcribe doctor" diff --git a/src/ccn_transcribe/__main__.py b/src/ccn_transcribe/__main__.py index 10043fc..e332f3f 100644 --- a/src/ccn_transcribe/__main__.py +++ b/src/ccn_transcribe/__main__.py @@ -9,4 +9,10 @@ __all__ = ["main"] # Python 3.14 uses forkserver, which re-imports __main__; without this guard a # dependency touching multiprocessing runs a second copy of the program. if __name__ == "__main__": + # A frozen build re-invokes this executable to start a multiprocessing child. + # Without this the child parses the interpreter's own flags as CLI options and + # prints "No such option '-B'" before failing to start. + import multiprocessing + + multiprocessing.freeze_support() main()