diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 0da58124d..9cf97fcd3 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -27,6 +27,9 @@ jobs: - uses: ./.github/actions/stamp-version + - name: Install SDL2 (live microphone transcription) + run: brew install sdl2 + - name: Build native library run: | pip install -e python/ @@ -48,9 +51,22 @@ jobs: - name: Build and repair wheel working-directory: python run: | - pip install build delocate + pip install build delocate wheel python -m build --wheel -C="--build-option=--plat-name=macosx_14_0_arm64" - delocate-wheel --require-archs arm64 -w wheelhouse -v dist/*.whl + env -u MACOSX_DEPLOYMENT_TARGET delocate-wheel --require-archs arm64 -w wheelhouse dist/*.whl + whl="$(ls "$PWD"/wheelhouse/*.whl)" + work="$(mktemp -d)" + python -m wheel unpack "$whl" -d "$work" + pkg="$(ls -d "$work"/*/)" + cp "$(brew --prefix sdl3)/lib/libSDL3.0.dylib" "${pkg}cactus/.dylibs/libSDL3.dylib" + chmod +w "${pkg}cactus/.dylibs/libSDL3.dylib" + for lib in "${pkg}cactus/.dylibs/libSDL2-2.0.0.dylib" "${pkg}cactus/.dylibs/libSDL3.dylib"; do + vtool -set-build-version macos 14.0 14.0 -replace -output "$lib" "$lib" + codesign -f -s - "$lib" + done + sed -i '' -E 's/macosx_[0-9]+_[0-9]+_arm64/macosx_14_0_arm64/' "$(ls -d "${pkg}"*.dist-info)/WHEEL" + rm "$whl" + python -m wheel pack "$pkg" -d "$PWD/wheelhouse" - uses: actions/upload-artifact@v4 with: @@ -67,7 +83,8 @@ jobs: - name: Provision toolchain run: | - dnf install -y libcurl-devel openssl-devel + dnf install -y epel-release + dnf install -y libcurl-devel openssl-devel SDL2-devel pkgconfig echo "/opt/python/cp312-cp312/bin" >> "$GITHUB_PATH" - uses: ./.github/actions/stamp-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22960b595..c6766a43a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: version=$(python3 -c "raw='${raw}'; print(raw + '.0' if raw.count('.') == 1 else raw)") echo "version=$version" >> "$GITHUB_OUTPUT" - - name: Resolve sdist URL and SHA256 from PyPI + - name: Resolve platform wheel URLs and SHA256 from PyPI id: pypi env: VERSION: ${{ steps.ver.outputs.version }} @@ -47,16 +47,22 @@ jobs: sleep 30 done [ -n "$json" ] || { echo "PyPI metadata not available for ${PACKAGE} ${VERSION}"; exit 1; } - url=$(echo "$json" | jq -er '.urls[] | select(.packagetype == "sdist") | .url') - sha=$(echo "$json" | jq -er '.urls[] | select(.packagetype == "sdist") | .digests.sha256') - echo "url=$url" >> "$GITHUB_OUTPUT" - echo "sha=$sha" >> "$GITHUB_OUTPUT" + pick() { echo "$json" | jq -er --arg re "$1" '.urls[] | select(.packagetype=="bdist_wheel" and (.filename|test($re))) | .'"$2"; } + { + echo "macos_url=$(pick macosx url)" + echo "macos_sha=$(pick macosx digests.sha256)" + echo "linux_url=$(pick manylinux url)" + echo "linux_sha=$(pick manylinux digests.sha256)" + } >> "$GITHUB_OUTPUT" - name: Update tap formula env: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - URL: ${{ steps.pypi.outputs.url }} - SHA: ${{ steps.pypi.outputs.sha }} + VERSION: ${{ steps.ver.outputs.version }} + MACOS_URL: ${{ steps.pypi.outputs.macos_url }} + MACOS_SHA: ${{ steps.pypi.outputs.macos_sha }} + LINUX_URL: ${{ steps.pypi.outputs.linux_url }} + LINUX_SHA: ${{ steps.pypi.outputs.linux_sha }} run: | git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP}.git" tap mkdir -p tap/Formula @@ -64,27 +70,40 @@ jobs: class Cactus < Formula desc "On-device AI inference" homepage "https://cactuscompute.com" - url "${URL}" - sha256 "${SHA}" + version "${VERSION}" license "Cactus Compute License" include Language::Python::Virtualenv depends_on "python@3.12" - depends_on macos: :sonoma depends_on arch: :arm64 + on_macos do + url "${MACOS_URL}" + sha256 "${MACOS_SHA}" + end + + on_linux do + url "${LINUX_URL}" + sha256 "${LINUX_SHA}" + end + livecheck do url "https://pypi.org/pypi/${PACKAGE}/json" regex(/"version":\s*"(\d+(?:\.\d+)+)"/i) end def install - venv = virtualenv_create(libexec, "python3.12") - venv.pip_install "${PACKAGE}==#{version}" + virtualenv_create(libexec, "python3.12") + wheel = Dir[buildpath/"*.whl"].first + odie "no wheel found in build directory" if wheel.nil? + system Formula["python@3.12"].opt_bin/"python3.12", "-m", "pip", + "--python=#{libexec}/bin/python", "install", "--no-deps", wheel bin.install_symlink libexec/"bin/cactus" end test do assert_equal "cactus #{version}\n", shell_output("#{bin}/cactus --version") + assert_match "libcactus_engine", + Dir[libexec/"lib/python*/site-packages/cactus/bindings/lib/*"].join end end EOF diff --git a/CACTUS_VERSION b/CACTUS_VERSION index c0ec837a4..0ac852dde 100644 --- a/CACTUS_VERSION +++ b/CACTUS_VERSION @@ -1 +1 @@ -v2.0 +v2.0.1 diff --git a/cactus-engine/tests/transcribe.cpp b/cactus-engine/tests/transcribe.cpp index 84d22b0e9..52aac854e 100644 --- a/cactus-engine/tests/transcribe.cpp +++ b/cactus-engine/tests/transcribe.cpp @@ -337,8 +337,8 @@ static int run_live_transcription(cactus_model_t model, const std::string& langu #else static int run_live_transcription(cactus_model_t, const std::string&) { - std::cerr << "Live microphone transcription requires SDL2. Install SDL2 and rebuild, " - "or pass an audio file (brew install sdl2 / apt-get install libsdl2-dev).\n"; + std::cerr << "Live microphone transcription is unavailable in this build.\n" + "Transcribe an audio file instead: cactus transcribe --file audio.wav\n"; return 1; } diff --git a/python/cactus/cli/common.py b/python/cactus/cli/common.py index dd2afe714..87ae3daae 100644 --- a/python/cactus/cli/common.py +++ b/python/cactus/cli/common.py @@ -64,6 +64,22 @@ def mask_key(key): return key[:4] + "..." + key[-4:] if len(key) >= 8 else "***" +def convert_toolchain_error(): + """Message if the model-conversion toolchain (torch/transformers) is missing, else None.""" + import importlib.util + missing = [m for m in ("torch", "transformers") + if importlib.util.find_spec(m) is None] + if not missing: + return None + return ( + "the model-conversion toolchain is not installed (missing: " + + ", ".join(missing) + ").\n" + " Converting a model from source needs it. Either:\n" + " - use a prebuilt model: cactus run or cactus download \n" + " - install the toolchain: pip install \"cactus-compute[convert]\"" + ) + + BIN_DIR = SCRIPT_DIR.parent / "bin" diff --git a/python/cactus/cli/compile.py b/python/cactus/cli/compile.py index c49a0648a..acada64f7 100644 --- a/python/cactus/cli/compile.py +++ b/python/cactus/cli/compile.py @@ -170,7 +170,7 @@ def cmd_build(args): print_color(YELLOW, "SDL2 not found - voice input disabled") print_color(YELLOW, "Install SDL2 to enable voice input: brew install sdl2 (macOS)") - rc = build_binary("run", lib_path, sdl2=sdl2) + rc = build_binary("run", lib_path, sdl2=([], [])) if rc != 0: return rc rc = build_binary("transcribe", lib_path, sdl2=sdl2) diff --git a/python/cactus/cli/model.py b/python/cactus/cli/model.py index 63bfc5cbc..75455937f 100644 --- a/python/cactus/cli/model.py +++ b/python/cactus/cli/model.py @@ -13,6 +13,10 @@ def _convert_from_source(model_id, *, bits, token, weights_dir, skip_model_load=False): """Download from HuggingFace and run CQ conversion.""" + from .common import convert_toolchain_error + err = convert_toolchain_error() + if err: + raise RuntimeError(err) print_color(YELLOW, f"Converting {model_id} from HuggingFace source...") from ..convert.cli import main as cq_main @@ -253,6 +257,10 @@ def prepare_bundle(args, *, model_id=None, transpile=None, prebuilt=True, def ensure_bundle(model_id, *, bits=4, token=None, reconvert=False, output_dir=None, transpile=None, skip_model_load=False): + from .common import convert_toolchain_error + err = convert_toolchain_error() + if err: + raise RuntimeError(err) from .download import get_bundle_dir from .transpile import run_transpile from cactus.transpile.component_plan import infer_component_plan_from_output diff --git a/python/cactus/cli/transpile.py b/python/cactus/cli/transpile.py index fdeb1104d..05f77711d 100644 --- a/python/cactus/cli/transpile.py +++ b/python/cactus/cli/transpile.py @@ -33,8 +33,14 @@ def _prepend_python_path(env): def run_transpile(model_id, *, extra_args, execute_after_transpile=False, allow_unconverted_weights=False): + from .common import convert_toolchain_error from .runtime import ensure_python_runtime_library + err = convert_toolchain_error() + if err: + print_color(RED, f"Error: {err}") + return 1 + extra_args = list(extra_args or []) command = [sys.executable, "-m", "cactus.transpile.hf_model", "--model-id", model_id] default_weights_dir = get_weights_dir(model_id) diff --git a/python/pyproject.toml b/python/pyproject.toml index d42b37994..692623d2d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -28,20 +28,22 @@ classifiers = [ "Topic :: Scientific/Engineering :: Image Recognition", ] dependencies = [ - "torch>=2.8.0,<3", - "transformers==5.5.4", "numpy>=1.26.0,<3", "huggingface-hub>=1.5.0,<2.0", - "scipy>=1.13", - "Pillow>=11.0.0,<12", - "torchvision>=0.23.0,<1", - "sentencepiece>=0.2.0", ] [project.optional-dependencies] dev = ["pytest>=8.0", "httpx>=0.25.0", "einops>=0.7"] lora = ["peft>=0.15"] serve = ["fastapi>=0.100.0", "uvicorn>=0.20.0", "python-multipart>=0.0.5"] +convert = [ + "torch>=2.8.0,<3", + "torchvision>=0.23.0,<1", + "transformers==5.5.4", + "scipy>=1.13", + "Pillow>=11.0.0,<12", + "sentencepiece>=0.2.0", +] [project.urls] Homepage = "https://cactuscompute.com"