diff --git a/docs/ELECTRON_EXTERNAL_PYTHON.md b/docs/ELECTRON_EXTERNAL_PYTHON.md new file mode 100644 index 0000000..de9cde3 --- /dev/null +++ b/docs/ELECTRON_EXTERNAL_PYTHON.md @@ -0,0 +1,142 @@ +# External Python Mode (Electron-embedded Python) + +This variant skips PyInstaller. We vendor all Python dependencies (and `mlxk2` itself) into a folder and run the CLI with the Python interpreter you bundle inside the Electron app (`MyApp.app/Contents/Resources/python/bin/python3`). + +## Build quick reference + +```bash +# Preferred: install using the embedded Python +EMBEDDED_PYTHON="/path/to/MyApp.app/Contents/Resources/python/bin/python3" \ + scripts/build_external_python.sh + +# Dev fallback (uses ./python/bin/python3 if present, else system python3) +scripts/build_external_python.sh + +# Provide custom MLX / MLX-LM wheels +scripts/build_external_python.sh \ + --mlx-wheel scripts/mlx-custom.whl \ + --mlx-lm-wheel scripts/mlx-lm-custom.whl + +# Wheels-only mode (no interpreter execution) +TARGET_PY_VERSION=3.12 \ +VENDOR_VIA_WHEELS=1 \ +scripts/build_external_python.sh +``` + +Output: `dist-ext-python//` (default `msty-mlx-studio/`), containing: + +- `_vendor/` – vendored site-packages (deps + project) +- `msty-mlx-studio` – launcher that runs `-m mlxk2.cli` with `PYTHONPATH=_vendor` + +### Build options reference + +- `EMBEDDED_PYTHON` – interpreter used for installs (falls back to `./python/bin/python3`, then system python3). +- `MLX_WHEEL`, `MLX_LM_WHEEL`, `--mlx-wheel`, `--mlx-lm-wheel` – custom package wheels; defaults auto-detect `scripts/mlx-custom.whl` and `scripts/mlx-lm-custom.whl` if present. +- `VERSION` – temporary override for `mlxk2.__version__` while bundling; README references adjust automatically. +- `DIST_DIR`, `BIN_NAME` – output location/name (default `dist-ext-python/msty-mlx-studio`). +- `CLEAN=1` – delete the output directory before writing. +- `PIP_ARGS="..."` – extra pip flags (shared by install/download steps). +- `VENDOR_VIA_WHEELS=1`, `TARGET_PY_VERSION`, `TARGET_PLATFORM`, `TARGET_ABI` – download wheels without executing the embedded interpreter. + +### Wheels-only mode (Gatekeeper-friendly) + +If macOS blocks your embedded Python during the build, set `VENDOR_VIA_WHEELS=1` and choose a compatible `TARGET_PY_VERSION`. The script uses the system python to `pip download` macOS arm64 wheels and unpacks them into `_vendor`. Ensure the chosen Python minor version matches your embedded interpreter so compiled wheels load correctly. + +### Custom MLX / MLX-LM wheels + +Provide custom builds to keep the MLX stack in sync: + +```bash +scripts/build_external_python.sh \ + --mlx-wheel /abs/path/mlx.whl \ + --mlx-lm-wheel /abs/path/mlx_lm.whl +``` + +Both options can also be set via environment variables. The script filters `mlx` and `mlx-lm` out of `requirements.txt`, then installs your wheels last so they override PyPI pins in `_vendor`. + +### Diagnostics + +After building, run the launcher with these flags to confirm what was bundled: + +```bash +./dist-ext-python/msty-mlx-studio/msty-mlx-studio --python-info # interpreter summary +./dist-ext-python/msty-mlx-studio/msty-mlx-studio --mlx-info # mlx version + dist-info path +./dist-ext-python/msty-mlx-studio/msty-mlx-studio --pkg-info mlx_lm +./dist-ext-python/msty-mlx-studio/msty-mlx-studio --check-mlx-stack +``` + +`--check-mlx-stack` imports both `mlx` and `mlx_lm` to ensure API compatibility—run this after swapping in custom wheels. + +### Version override + +Stamp the vendored package with a release number: + +```bash +VERSION=2.0.0b4 scripts/build_external_python.sh +``` + +The script temporarily rewrites `mlxk2/__version__` and README references for the build and restores them afterward, so your working tree stays clean. + +## Packaging into Electron + +Place the output under app resources, for example: + +``` +MyApp.app/Contents/Resources/ + python/bin/python3 # bundled interpreter + mlxk2/ + _vendor/ + msty-mlx-studio # launcher +``` + +### Option A – use the launcher + +- Command: `join(process.resourcesPath, 'mlxk2', 'msty-mlx-studio')` +- Env: set `RESOURCES_PATH=process.resourcesPath` (the launcher uses it to locate `python/bin/python3`). + +```js +import { spawn } from 'child_process'; +import { join } from 'path'; +import { app } from 'electron'; + +const launcher = join(process.resourcesPath, 'mlxk2', 'msty-mlx-studio'); + +export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) { + const args = ['serve', '--host', host, '--port', String(port)]; + if (maxTokens != null) args.push('--max-tokens', String(maxTokens)); + const env = { ...process.env, RESOURCES_PATH: process.resourcesPath }; + const child = spawn(launcher, args, { env, stdio: ['ignore', 'inherit', 'inherit'] }); + return child; +} +``` + +### Option B – call Python directly + +- Python: `join(process.resourcesPath, 'python', 'bin', 'python3')` +- Vendor: `join(process.resourcesPath, 'mlxk2', '_vendor')` +- Env: `PYTHONPATH=[:$PYTHONPATH]`, `PYTHONNOUSERSITE=1` + +```js +import { spawn } from 'child_process'; +import { join } from 'path'; +import { app } from 'electron'; + +export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) { + const pythonPath = join(process.resourcesPath, 'python', 'bin', 'python3'); + const vendor = join(process.resourcesPath, 'mlxk2', '_vendor'); + const args = ['-m', 'mlxk2.cli', 'serve', '--host', host, '--port', String(port)]; + if (maxTokens != null) args.push('--max-tokens', String(maxTokens)); + const env = { ...process.env, PYTHONNOUSERSITE: '1', PYTHONPATH: vendor }; + const child = spawn(pythonPath, args, { env, stdio: ['ignore', 'inherit', 'inherit'] }); + return child; +} +``` + +## Notes + +- Ship a compatible macOS arm64 Python with required system libs. +- For private/offline wheels, pass `PIP_ARGS="--no-index --find-links /path/to/wheels"`. +- Clear Gatekeeper quarantine on the embedded Python during development with `scripts/clear_quarantine_python.sh /path/to/Resources/python`. +- Sanity-check at runtime: + `PYTHONPATH= -c "import mlxk2, fastapi, uvicorn, mlx, mlx_lm; print('ok')"` +- Sign + notarize the final `.app` for distribution. diff --git a/docs/ELECTRON_PACKAGING.md b/docs/ELECTRON_PACKAGING.md index c34db5f..8375e9f 100644 --- a/docs/ELECTRON_PACKAGING.md +++ b/docs/ELECTRON_PACKAGING.md @@ -36,6 +36,19 @@ USE_HF_XET=0 scripts/build.sh The script creates a virtualenv, installs runtime deps from `requirements.txt` (`mlx`, `mlx-lm`, `fastapi`, `uvicorn`, etc.) plus the project itself, then builds a PyInstaller bundle and packages it into `artifacts/.tgz`. +### Build options reference + +Environment variables / flags: + +- `VERSION` – temporary override for `mlxk2.__version__` while bundling. +- `BIN_NAME` – output folder/binary name (default `msty-mlx-studio`). +- `ONEFILE=1` – build a single-file PyInstaller binary (defaults to one-dir). +- `USE_HF_XET=0` – skip the optional Hugging Face Xet plugin. +- `PYTHON_EXE=/abs/path/python3` – force a specific interpreter for the build venv. +- `MLX_WHEEL=/abs/path/mlx.whl` or `--mlx-wheel` – install a custom `mlx` wheel after deps. +- `MLX_LM_WHEEL=/abs/path/mlx_lm.whl` or `--mlx-lm-wheel` – install a matching custom `mlx-lm` wheel. +- `PIP_ARGS="..."` – forwarded to pip install commands if you need custom indexes. + Quick test after build: ```bash @@ -65,6 +78,90 @@ Version reporting: - No upstream source is modified. All adjustments happen in the generated entry shim used only at build time. - The previous v1 helper worker is no longer built or packaged. +### Custom Python runtime + +To freeze with a specific Python (and embed that version in the bundle), place it at `python/bin/python3` in the repo. The build script will automatically prefer this interpreter for creating the build venv and running PyInstaller. Alternatively, set `PYTHON_EXE=/absolute/path/to/python3` to override. + +#### Custom MLX / MLX-LM wheels + +If you need to ship custom builds: + +```bash +# Auto-detected defaults: scripts/mlx-custom.whl, scripts/mlx-lm-custom.whl +MLX_WHEEL=/abs/path/mlx.whl \ +MLX_LM_WHEEL=/abs/path/mlx_lm.whl \ + scripts/build.sh + +# Equivalent flag form +scripts/build.sh \ + --mlx-wheel /abs/path/mlx.whl \ + --mlx-lm-wheel /abs/path/mlx_lm.whl +``` + +Notes: +- `mlx` and `mlx-lm` are filtered out of `requirements.txt`; your wheels install last and override PyPI pins. +- Provide compatible builds (matching Python ABI / MLX API surface) to avoid runtime mismatches. +- Use `--check-mlx-stack` (see below) on the finished binary to confirm both packages import cleanly. + +### Version override + +Set `VERSION` when invoking the build to stamp the bundle with a release number: + +```bash +VERSION=2.0.0b4 scripts/build.sh +``` + +Details: +- The script temporarily rewrites `mlxk2/__version__` (and related README references) during the build. +- Files are restored to their original contents after the script exits, so your working tree stays clean. + +Steps: +- Put your desired macOS arm64 Python at `python/bin/python3`. +- Run `scripts/build.sh`. +- Verify the embedded Python in the resulting binary: + +```bash +./dist/msty-mlx-studio/msty-mlx-studio --python-info +# or +MLXK2_PY_INFO=1 ./dist/msty-mlx-studio/msty-mlx-studio --version +``` + +This prints JSON like: +``` +{ + "python_version": "3.12.5", + "executable": "/path/to/dist/msty-mlx-studio/msty-mlx-studio", + "prefix": "/path/to/dist/msty-mlx-studio", + "base_prefix": null, + "frozen": true, + "meipass": "/path/to/_MEIxxxx", + "platform": "darwin" +} +``` + +Notes: +- `python_version` confirms which interpreter is embedded. `executable` is the frozen launcher (expected for PyInstaller). +- Ensure your chosen Python is compatible with the pinned PyInstaller (>= 6.6). For very new Python releases, update PyInstaller if needed. + +### Quick diagnostics on the output bundle + +- `./dist// --python-info` – JSON summary of the embedded interpreter. +- `./dist// --mlx-info` – JSON showing `mlx` version, module path, and dist-info directory. +- `./dist// --pkg-info mlx_lm` – same diagnostic for `mlx-lm`. +- `./dist// --check-mlx-stack` – verifies both `mlx` and `mlx_lm` import successfully. + +Use these after applying custom wheels to ensure the expected versions were bundled. + +### Clearing quarantine during development + +macOS Gatekeeper may block unsigned interpreters inside the bundle. During local builds/tests, run: + +```bash +scripts/clear_quarantine_python.sh dist//python +``` + +Or target your Electron resources path. This recurses through the folder and removes the `com.apple.quarantine` extended attribute, reporting any leftovers. (Sign and notarize for production distribution.) + ## Runtime Integration - Our Electron app handles model downloads directly (see `ELECTRON_INTEGRATION_GUIDE.md`). diff --git a/scripts/build.sh b/scripts/build.sh index e86baf7..6922fdb 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -18,6 +18,107 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" +# ----------------------------------------------------------------------------- +# Temporary version override (VERSION env) +# ----------------------------------------------------------------------------- +ORIG_VERSION="$(python3 - <<'PY' +import pathlib, re +text = pathlib.Path("mlxk2/__init__.py").read_text() +m = re.search(r'__version__\s*=\s*"([^"]+)"', text) +print(m.group(1) if m else "0.0.0") +PY +)" +TARGET_VERSION="${VERSION:-$ORIG_VERSION}" +TMP_INIT="" +TMP_README="" +RESTORE_VERSION=0 + +cleanup_version() { + if [[ "$RESTORE_VERSION" == "1" ]]; then + if [[ -n "$TMP_INIT" && -f "$TMP_INIT" ]]; then + cp "$TMP_INIT" mlxk2/__init__.py + rm -f "$TMP_INIT" + fi + if [[ -n "$TMP_README" && -f "$TMP_README" ]]; then + cp "$TMP_README" README.md + rm -f "$TMP_README" + fi + fi +} +trap cleanup_version EXIT + +if [[ "$TARGET_VERSION" != "$ORIG_VERSION" ]]; then + echo "[version] Updating project version: $ORIG_VERSION -> $TARGET_VERSION" + TMP_INIT="$(mktemp)" + cp mlxk2/__init__.py "$TMP_INIT" + if [[ -f README.md ]]; then + TMP_README="$(mktemp)" + cp README.md "$TMP_README" + fi + RESTORE_VERSION=1 + python3 - "$ORIG_VERSION" "$TARGET_VERSION" <<'PY' +import sys +import pathlib +import re + +old, new = sys.argv[1:3] + +def tag(ver: str) -> str: + if 'b' in ver: + base, beta = ver.split('b', 1) + if beta and beta.isdigit(): + return f"v{base}-beta.{beta}" + return f"v{ver}" + +init_path = pathlib.Path("mlxk2/__init__.py") +text = init_path.read_text() +text = re.sub(r'__version__\s*=\s*".*?"', f'__version__ = "{new}"', text) +init_path.write_text(text) + +readme_path = pathlib.Path("README.md") +if readme_path.exists(): + readme = readme_path.read_text() + readme = readme.replace(old, new) + readme = readme.replace(tag(old), tag(new)) + readme_path.write_text(readme) +PY +fi + +# ----------------------------------------------------------------------------- +# CLI flags (custom wheels) +# ----------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --mlx-wheel) + shift + if [[ -n "${1:-}" ]]; then + export MLX_WHEEL="${1:-}" + shift || true + else + echo "ERROR: --mlx-wheel requires a path" >&2 + exit 1 + fi + ;; + --mlx-lm-wheel) + shift + if [[ -n "${1:-}" ]]; then + export MLX_LM_WHEEL="${1:-}" + shift || true + else + echo "ERROR: --mlx-lm-wheel requires a path" >&2 + exit 1 + fi + ;; + *) + echo "Unknown flag: $1" >&2 + exit 1 + ;; + esac +done + +# ----------------------------------------------------------------------------- +# Platform checks +# ----------------------------------------------------------------------------- if [[ "$(uname -s)" != "Darwin" ]]; then echo "This script must run on macOS." >&2 exit 1 @@ -35,26 +136,99 @@ SPEC_DIR="$ROOT_DIR/.pyi-spec" mkdir -p "$ART_DIR" "$SPEC_DIR" +# ----------------------------------------------------------------------------- +# Create build virtualenv +# ----------------------------------------------------------------------------- echo "[1/3] Creating build venv..." -python3 -m venv "$BUILD_VENV" +CUSTOM_PY_DIR="$ROOT_DIR/python" +if [[ -n "${PYTHON_EXE:-}" ]]; then + echo "Using override Python (PYTHON_EXE): $PYTHON_EXE" +elif [[ -x "$CUSTOM_PY_DIR/bin/python3" ]]; then + PYTHON_EXE="$CUSTOM_PY_DIR/bin/python3" + echo "Using project Python: $PYTHON_EXE" +else + PYTHON_EXE="$(command -v python3)" + echo "Using system Python: $PYTHON_EXE" +fi + +"$PYTHON_EXE" -m venv "$BUILD_VENV" source "$BUILD_VENV/bin/activate" + +if ! python -m pip --version >/dev/null 2>&1; then + echo "Bootstrapping pip via ensurepip..." + python -m ensurepip --upgrade || true +fi python -m pip install --upgrade pip setuptools wheel +# ----------------------------------------------------------------------------- +# Install dependencies and project +# ----------------------------------------------------------------------------- echo "[2/3] Installing project, deps and build tools..." -# Install runtime deps for MLX-Knife 2.x -if [[ -f requirements.txt ]]; then - python -m pip install -r requirements.txt +CUSTOM_MLX_WHEEL="${MLX_WHEEL:-}" +if [[ -z "$CUSTOM_MLX_WHEEL" && -f "$ROOT_DIR/scripts/mlx-custom.whl" ]]; then + CUSTOM_MLX_WHEEL="$ROOT_DIR/scripts/mlx-custom.whl" fi -# Install the project itself (exposes version, console entry, etc.) -python -m pip install . +if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then + echo "[opt] Using custom MLX wheel: $CUSTOM_MLX_WHEEL" + [[ -f "$CUSTOM_MLX_WHEEL" ]] || { echo "ERROR: MLX wheel not found: $CUSTOM_MLX_WHEEL" >&2; exit 1; } +fi + +CUSTOM_MLX_LM_WHEEL="${MLX_LM_WHEEL:-}" +if [[ -z "$CUSTOM_MLX_LM_WHEEL" && -f "$ROOT_DIR/scripts/mlx-lm-custom.whl" ]]; then + CUSTOM_MLX_LM_WHEEL="$ROOT_DIR/scripts/mlx-lm-custom.whl" +fi +if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then + echo "[opt] Using custom MLX-LM wheel: $CUSTOM_MLX_LM_WHEEL" + [[ -f "$CUSTOM_MLX_LM_WHEEL" ]] || { echo "ERROR: MLX-LM wheel not found: $CUSTOM_MLX_LM_WHEEL" >&2; exit 1; } +fi + +if [[ -f requirements.txt ]]; then + if [[ -n "$CUSTOM_MLX_WHEEL" || -n "$CUSTOM_MLX_LM_WHEEL" ]]; then + REQ_TMP="$(mktemp)" + awk 'BEGIN{IGNORECASE=0} { + line=$0; trimmed=line; gsub(/^[ \t]+/,"",trimmed); + if (trimmed ~ /^#/ || trimmed ~ /^$/) { print line; next } + if (ENVIRON["FILTER_MLX"] && trimmed ~ /^mlx([ \t]|[><=]|$)/) { next } + if (ENVIRON["FILTER_MLX_LM"] && trimmed ~ /^mlx-lm([ \t]|[><=]|$)/) { next } + print line + }' FILTER_MLX=$([[ -n "$CUSTOM_MLX_WHEEL" ]] && echo 1) FILTER_MLX_LM=$([[ -n "$CUSTOM_MLX_LM_WHEEL" ]] && echo 1) requirements.txt > "$REQ_TMP" + python -m pip install ${PIP_ARGS:-} -r "$REQ_TMP" + rm -f "$REQ_TMP" + else + python -m pip install ${PIP_ARGS:-} -r requirements.txt + fi +fi + +if [[ -n "${MLX_REPO_REF:-}" ]]; then + echo "[opt] Overriding MLX from: ${MLX_REPO_REF}" + python -m pip install ${PIP_ARGS:-} --no-deps "${MLX_REPO_REF}" +fi +if [[ -n "${MLX_LM_REPO_REF:-}" ]]; then + echo "[opt] Overriding MLX-LM from: ${MLX_LM_REPO_REF}" + python -m pip install ${PIP_ARGS:-} --no-deps "${MLX_LM_REPO_REF}" +fi + +python -m pip install ${PIP_ARGS:-} . if [[ "${USE_HF_XET:-1}" == "1" ]]; then echo "[opt] Installing Hugging Face Xet plugin..." python -m pip install "huggingface_hub[hf_xet]" || true fi +if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then + echo "[opt] Installing custom MLX wheel: $CUSTOM_MLX_WHEEL" + python -m pip install ${PIP_ARGS:-} --force-reinstall "$CUSTOM_MLX_WHEEL" +fi +if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then + echo "[opt] Installing custom MLX-LM wheel: $CUSTOM_MLX_LM_WHEEL" + python -m pip install ${PIP_ARGS:-} --force-reinstall "$CUSTOM_MLX_LM_WHEEL" +fi + python -m pip install "pyinstaller>=6.6" +# ----------------------------------------------------------------------------- +# Entry script for PyInstaller +# ----------------------------------------------------------------------------- ENTRY_SCRIPT="$ROOT_DIR/.entry_mlxk_build.py" cat > "$ENTRY_SCRIPT" <<'PY' """ @@ -66,10 +240,11 @@ Implements supervised shutdown by default in a frozen binary: - Parent supervises: SIGTERM on first Ctrl-C, SIGKILL on timeout/second Ctrl-C. Also supports a child mode keyed by MLXK2_CHILD_SERVER=1 to run the server -directly without going through CLI argument parsing. + directly without going through CLI argument parsing. """ import os +import json import signal import subprocess import sys @@ -81,7 +256,7 @@ def _to_bool(val: str) -> bool: # Fast path: strip CLI name from version output for compatibility -if "--version" in sys.argv and "--json" not in sys.argv: +if "--version" in sys.argv and "--json" not in sys.argv and "--python-info" not in sys.argv: try: from mlxk2 import __version__ as _ver except Exception: @@ -89,6 +264,93 @@ if "--version" in sys.argv and "--json" not in sys.argv: print(_ver) sys.exit(0) +# Diagnostic: print embedded Python details +if "--python-info" in sys.argv or os.environ.get("MLXK2_PY_INFO") == "1": + info = { + "python_version": sys.version.split(" (", 1)[0], + "executable": sys.executable, + "prefix": sys.prefix, + "base_prefix": getattr(sys, "base_prefix", None), + "frozen": bool(getattr(sys, "frozen", False)), + "meipass": getattr(sys, "_MEIPASS", None), + "platform": sys.platform, + } + print(json.dumps(info, indent=2)) + sys.exit(0) + +# Diagnostic: package info helpers +if "--mlx-info" in sys.argv or "--pkg-info" in sys.argv: + try: + if "--mlx-info" in sys.argv: + pkg = "mlx" + else: + idx = sys.argv.index("--pkg-info") + pkg = sys.argv[idx + 1] + import importlib + mod = importlib.import_module(pkg) + version = None + try: + import importlib.metadata as md + try: + version = md.version(pkg) + except Exception: + version = None + except Exception: + pass + if version is None: + version = getattr(mod, "__version__", None) + dist_info_dir = None + try: + import importlib.metadata as md + dist = md.distribution(pkg) + files = list(dist.files or []) + for f in files: + if str(f).endswith("METADATA"): + dist_info_dir = os.fspath(dist.locate_file(f)) + dist_info_dir = os.path.dirname(dist_info_dir) + break + except Exception: + dist_info_dir = None + out = { + "package": pkg, + "version": version, + "module_file": getattr(mod, "__file__", None), + "dist_info": dist_info_dir, + } + print(json.dumps(out, indent=2)) + sys.exit(0) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(2) + +# Diagnostic: verify MLX stack imports cleanly +if "--check-mlx-stack" in sys.argv: + out = {"ok": False, "mlx": None, "mlx_lm": None, "error": None} + try: + import importlib + mlx = importlib.import_module("mlx") + out["mlx"] = { + "version": getattr(mlx, "__version__", None), + "file": getattr(mlx, "__file__", None), + } + try: + mlx_lm = importlib.import_module("mlx_lm") + out["mlx_lm"] = { + "version": getattr(mlx_lm, "__version__", None), + "file": getattr(mlx_lm, "__file__", None), + } + except Exception as e: + out["error"] = f"import mlx_lm failed: {e}" + print(json.dumps(out, indent=2)) + sys.exit(2) + out["ok"] = True + print(json.dumps(out, indent=2)) + sys.exit(0) + except Exception as e: + out["error"] = str(e) + print(json.dumps(out, indent=2)) + sys.exit(2) + # Child mode: run the server in-process (bypasses CLI parsing) if os.environ.get("MLXK2_CHILD_SERVER") == "1": from mlxk2.core.server_base import run_server as _run_server @@ -103,7 +365,6 @@ if os.environ.get("MLXK2_CHILD_SERVER") == "1": _run_server(host=host, port=port, max_tokens=max_tokens, reload=reload, log_level=log_level) sys.exit(0) - # Parent mode: patch start_server to supervise a child process from mlxk2.core.server_base import run_server as _run_server import mlxk2.operations.serve as _serve @@ -119,22 +380,18 @@ def _run_supervised_server(*, host: str, port: int, log_level: str, reload: bool if max_tokens is not None: env["MLXK2_MAX_TOKENS"] = str(max_tokens) - # Start child in a new process group for clean signaling proc = subprocess.Popen([sys.executable], start_new_session=True, env=env) - # Install signal handlers so SIGINT/SIGTERM to the parent trigger supervised cleanup shutting_down = {"done": False} def _graceful_shutdown(_sig=None, _frame=None): if shutting_down["done"]: return shutting_down["done"] = True - # Ask child to stop gracefully try: os.killpg(proc.pid, signal.SIGTERM) except Exception: pass - # Wait briefly, then escalate to SIGKILL if needed deadline = time.time() + 5.0 while time.time() < deadline: ret = proc.poll() @@ -160,15 +417,12 @@ def _run_supervised_server(*, host: str, port: int, log_level: str, reload: bool try: return proc.wait() except KeyboardInterrupt: - # Suppress further SIGINT while we clean up previous = signal.signal(signal.SIGINT, signal.SIG_IGN) try: - # First Ctrl-C: ask child to stop gracefully try: os.killpg(proc.pid, signal.SIGTERM) except Exception: pass - # Wait briefly, then force kill if still alive deadline = time.time() + 5.0 while time.time() < deadline: ret = proc.poll() @@ -177,20 +431,17 @@ def _run_supervised_server(*, host: str, port: int, log_level: str, reload: bool try: time.sleep(0.1) except KeyboardInterrupt: - # Second Ctrl-C: escalate to SIGKILL immediately break try: os.killpg(proc.pid, signal.SIGKILL) except Exception: pass - # Wait for child without being interrupted while True: ret = proc.poll() if ret is not None: return ret time.sleep(0.05) finally: - # Restore previous handler try: signal.signal(signal.SIGINT, previous) except Exception: @@ -199,19 +450,19 @@ def _run_supervised_server(*, host: str, port: int, log_level: str, reload: bool def _patched_start_server(*, model=None, port=8000, host="127.0.0.1", max_tokens=None, reload=False, log_level="info", verbose=False, supervise=True): - # Always run in-process (single PID for Electron to manage) return _run_server(host=host, port=port, max_tokens=max_tokens, reload=reload, log_level=log_level) -# Install patch before CLI main runs so cli imports get the patched version _serve.start_server = _patched_start_server - if __name__ == "__main__": from mlxk2.cli import main main() PY +# ----------------------------------------------------------------------------- +# Build with PyInstaller +# ----------------------------------------------------------------------------- echo "[3/3] Building and packaging..." PYI_OPTS=( --clean @@ -236,7 +487,9 @@ fi pyinstaller "${PYI_OPTS[@]}" "$ENTRY_SCRIPT" +# ----------------------------------------------------------------------------- # Package archive +# ----------------------------------------------------------------------------- TAR_BASE="$APP_NAME" if [[ -n "${VERSION:-}" ]]; then TAR_BASE="$TAR_BASE-${VERSION}" diff --git a/scripts/build_external_python.sh b/scripts/build_external_python.sh index 267a6ce..110d729 100755 --- a/scripts/build_external_python.sh +++ b/scripts/build_external_python.sh @@ -5,34 +5,114 @@ set -euo pipefail # (e.g., the interpreter embedded in your Electron app at # MyApp.app/Contents/Resources/python/bin/python3). # -# This script vendors all Python deps and this project into a folder and -# generates a small launcher that executes: -m mlxk2.cli ... -# with PYTHONPATH pointing at the vendored deps. -# -# Usage: -# # Preferred: point to your embedded Python -# EMBEDDED_PYTHON="/path/to/MyApp.app/Contents/Resources/python/bin/python3" \ -# scripts/build_external_python.sh -# -# # Dev fallback (uses repo-local python/bin if present, else system python3) -# scripts/build_external_python.sh -# -# Optional env: -# BIN_NAME=my-cli -> output folder/launcher name (default msty-mlx-studio) -# DIST_DIR=dist-ext-python -> output root (default dist-ext-python) -# PIP_ARGS="..." -> extra pip args (e.g., --no-index --find-links ...) -# CLEAN=1 -> delete output dir before build -# +# The script vendors dependencies into dist-ext-python//_vendor and +# generates a launcher that executes: -m mlxk2.cli ... with PYTHONPATH +# pointed at that vendor directory. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" +# ----------------------------------------------------------------------------- +# Temporary version override (VERSION env) +# ----------------------------------------------------------------------------- +ORIG_VERSION="$(python3 - <<'PY' +import pathlib, re +text = pathlib.Path("mlxk2/__init__.py").read_text() +m = re.search(r'__version__\s*=\s*"([^"]+)"', text) +print(m.group(1) if m else "0.0.0") +PY +)" +TARGET_VERSION="${VERSION:-$ORIG_VERSION}" +TMP_INIT="" +TMP_README="" +RESTORE_VERSION=0 + +cleanup_version() { + if [[ "$RESTORE_VERSION" == "1" ]]; then + if [[ -n "$TMP_INIT" && -f "$TMP_INIT" ]]; then + cp "$TMP_INIT" mlxk2/__init__.py + rm -f "$TMP_INIT" + fi + if [[ -n "$TMP_README" && -f "$TMP_README" ]]; then + cp "$TMP_README" README.md + rm -f "$TMP_README" + fi + fi +} +trap cleanup_version EXIT + +if [[ "$TARGET_VERSION" != "$ORIG_VERSION" ]]; then + echo "[version] Updating project version: $ORIG_VERSION -> $TARGET_VERSION" + TMP_INIT="$(mktemp)" + cp mlxk2/__init__.py "$TMP_INIT" + if [[ -f README.md ]]; then + TMP_README="$(mktemp)" + cp README.md "$TMP_README" + fi + RESTORE_VERSION=1 + python3 - "$ORIG_VERSION" "$TARGET_VERSION" <<'PY' +import sys +import pathlib +import re + +old, new = sys.argv[1:3] + +def tag(ver: str) -> str: + if 'b' in ver: + base, beta = ver.split('b', 1) + if beta and beta.isdigit(): + return f"v{base}-beta.{beta}" + return f"v{ver}" + +init_path = pathlib.Path("mlxk2/__init__.py") +text = init_path.read_text() +text = re.sub(r'__version__\s*=\s*".*?"', f'__version__ = "{new}"', text) +init_path.write_text(text) + +readme_path = pathlib.Path("README.md") +if readme_path.exists(): + readme = readme_path.read_text() + readme = readme.replace(old, new) + readme = readme.replace(tag(old), tag(new)) + readme_path.write_text(readme) +PY +fi + +# ----------------------------------------------------------------------------- +# CLI flags (custom wheels) +# ----------------------------------------------------------------------------- +MLX_WHEEL_ARG="" +MLX_LM_WHEEL_ARG="" +while [[ $# -gt 0 ]]; do + case "$1" in + --mlx-wheel) + shift + MLX_WHEEL_ARG="${1:-}" + shift || true + ;; + --mlx-lm-wheel) + shift + MLX_LM_WHEEL_ARG="${1:-}" + shift || true + ;; + *) + echo "Unknown flag: $1" >&2 + exit 1 + ;; + esac +done + +# ----------------------------------------------------------------------------- +# Output layout +# ----------------------------------------------------------------------------- APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}" OUT_ROOT="${DIST_DIR:-dist-ext-python}" OUT_DIR="$OUT_ROOT/$APP_NAME" VENDOR_DIR="$OUT_DIR/_vendor" -# Resolve external Python +# ----------------------------------------------------------------------------- +# Resolve Python interpreter used for installs +# ----------------------------------------------------------------------------- if [[ -n "${EMBEDDED_PYTHON:-}" ]]; then PY="$EMBEDDED_PYTHON" echo "Using EMBEDDED_PYTHON: $PY" @@ -49,12 +129,44 @@ if [[ ! -x "$PY" ]]; then exit 1 fi -# Clean output if requested +# ----------------------------------------------------------------------------- +# Prepare output +# ----------------------------------------------------------------------------- if [[ "${CLEAN:-0}" == "1" ]]; then rm -rf "$OUT_DIR" fi mkdir -p "$VENDOR_DIR" +# ----------------------------------------------------------------------------- +# Custom wheel resolution +# ----------------------------------------------------------------------------- +CUSTOM_MLX_WHEEL="${MLX_WHEEL:-}" +if [[ -z "$CUSTOM_MLX_WHEEL" && -n "$MLX_WHEEL_ARG" ]]; then + CUSTOM_MLX_WHEEL="$MLX_WHEEL_ARG" +fi +if [[ -z "$CUSTOM_MLX_WHEEL" && -f "$ROOT_DIR/scripts/mlx-custom.whl" ]]; then + CUSTOM_MLX_WHEEL="$ROOT_DIR/scripts/mlx-custom.whl" +fi +if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then + echo "[opt] Using custom MLX wheel: $CUSTOM_MLX_WHEEL" + [[ -f "$CUSTOM_MLX_WHEEL" ]] || { echo "ERROR: MLX wheel not found: $CUSTOM_MLX_WHEEL" >&2; exit 1; } +fi + +CUSTOM_MLX_LM_WHEEL="${MLX_LM_WHEEL:-}" +if [[ -z "$CUSTOM_MLX_LM_WHEEL" && -n "$MLX_LM_WHEEL_ARG" ]]; then + CUSTOM_MLX_LM_WHEEL="$MLX_LM_WHEEL_ARG" +fi +if [[ -z "$CUSTOM_MLX_LM_WHEEL" && -f "$ROOT_DIR/scripts/mlx-lm-custom.whl" ]]; then + CUSTOM_MLX_LM_WHEEL="$ROOT_DIR/scripts/mlx-lm-custom.whl" +fi +if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then + echo "[opt] Using custom MLX-LM wheel: $CUSTOM_MLX_LM_WHEEL" + [[ -f "$CUSTOM_MLX_LM_WHEEL" ]] || { echo "ERROR: MLX-LM wheel not found: $CUSTOM_MLX_LM_WHEEL" >&2; exit 1; } +fi + +# ----------------------------------------------------------------------------- +# Vendoring strategy +# ----------------------------------------------------------------------------- if [[ "${VENDOR_VIA_WHEELS:-0}" == "1" ]]; then echo "[1/3] Vendoring via wheels (does not execute embedded Python)" SYS_PY="$(command -v python3)" @@ -62,38 +174,56 @@ if [[ "${VENDOR_VIA_WHEELS:-0}" == "1" ]]; then echo "ERROR: python3 not found on PATH for wheel download" >&2 exit 1 fi - : "${TARGET_PY_VERSION:?Set TARGET_PY_VERSION (e.g., 3.10)}" + : "${TARGET_PY_VERSION:?Set TARGET_PY_VERSION (e.g., 3.12)}" TARGET_PLATFORM="${TARGET_PLATFORM:-macosx_11_0_arm64}" - # Derive ABI from version (e.g., 3.10 -> cp310) - ver_nodot="${TARGET_PY_VERSION/.}" # 3.10 -> 310 + ver_nodot="${TARGET_PY_VERSION/.}" # 3.12 -> 312 TARGET_ABI="${TARGET_ABI:-cp${ver_nodot}}" WHEELS_DIR="$OUT_DIR/_wheels" - rm -rf "$WHEELS_DIR" && mkdir -p "$WHEELS_DIR" + rm -rf "$WHEELS_DIR" + mkdir -p "$WHEELS_DIR" - echo "[1a] Downloading wheels for target Python ${TARGET_PY_VERSION} (${TARGET_ABI}) platform ${TARGET_PLATFORM}..." - if [[ -f requirements.txt ]]; then - "$SYS_PY" -m pip download -r requirements.txt -d "$WHEELS_DIR" \ + echo "[1a] Downloading wheels for Python ${TARGET_PY_VERSION} (${TARGET_ABI}) platform ${TARGET_PLATFORM}..." + REQ_FILE="requirements.txt" + if [[ ( -n "$CUSTOM_MLX_WHEEL" || -n "$CUSTOM_MLX_LM_WHEEL" ) && -f requirements.txt ]]; then + REQ_TMP="$(mktemp)" + awk 'BEGIN{IGNORECASE=0} { + line=$0; trimmed=line; gsub(/^[ \t]+/,"",trimmed); + if (trimmed ~ /^#/ || trimmed ~ /^$/) { print line; next } + if (ENVIRON["FILTER_MLX"] && trimmed ~ /^mlx([ \t]|[><=]|$)/) { next } + if (ENVIRON["FILTER_MLX_LM"] && trimmed ~ /^mlx-lm([ \t]|[><=]|$)/) { next } + print line + }' FILTER_MLX=$([[ -n "$CUSTOM_MLX_WHEEL" ]] && echo 1) FILTER_MLX_LM=$([[ -n "$CUSTOM_MLX_LM_WHEEL" ]] && echo 1) requirements.txt > "$REQ_TMP" + REQ_FILE="$REQ_TMP" + fi + + if [[ -f "$REQ_FILE" ]]; then + "$SYS_PY" -m pip download -r "$REQ_FILE" -d "$WHEELS_DIR" \ --only-binary=:all: --implementation cp --platform "$TARGET_PLATFORM" \ --python-version "${TARGET_PY_VERSION}" --abi "$TARGET_ABI" ${PIP_ARGS:-} fi + if [[ "${REQ_FILE}" != "requirements.txt" ]]; then + rm -f "$REQ_FILE" + fi + + [[ -z "$CUSTOM_MLX_WHEEL" ]] || cp "$CUSTOM_MLX_WHEEL" "$WHEELS_DIR/" + [[ -z "$CUSTOM_MLX_LM_WHEEL" ]] || cp "$CUSTOM_MLX_LM_WHEEL" "$WHEELS_DIR/" + echo "[1b] Building wheel for local package..." "$SYS_PY" -m pip wheel -w "$WHEELS_DIR" . ${PIP_ARGS:-} echo "[2/3] Unpacking wheels into vendor..." + shopt -s nullglob for whl in "$WHEELS_DIR"/*.whl; do - [ -e "$whl" ] || { echo "No wheels found in $WHEELS_DIR" >&2; exit 1; } unzip -oq "$whl" -d "$VENDOR_DIR" done + shopt -u nullglob - # Sanity: detect missing MLX wheel for the chosen Python version if grep -Eqi '^\s*mlx([>=<]|\s|$)' "$ROOT_DIR/requirements.txt" 2>/dev/null; then if ! ls "$WHEELS_DIR"/mlx-*.whl >/dev/null 2>&1; then - echo "" >&2 + echo >&2 echo "ERROR: No mlx wheel was downloaded for TARGET_PY_VERSION=${TARGET_PY_VERSION}." >&2 - echo "MLX publishes wheels only for certain Python versions (typically 3.11/3.12)." >&2 - echo "Try one of these options:" >&2 - echo " - Use TARGET_PY_VERSION=3.12 (or 3.11) with VENDOR_VIA_WHEELS=1" >&2 - echo " - Or omit VENDOR_VIA_WHEELS to let the embedded Python build from source (requires unquarantined/signed interpreter)" >&2 + echo "MLX publishes wheels only for select Python versions (typically 3.11/3.12)." >&2 + echo "Use TARGET_PY_VERSION=3.12 (or 3.11) or disable VENDOR_VIA_WHEELS to build from source." >&2 exit 1 fi fi @@ -110,27 +240,43 @@ PY echo "[2/3] Installing project dependencies into: $VENDOR_DIR" if [[ -f requirements.txt ]]; then - "$PY" -m pip install --target "$VENDOR_DIR" -r requirements.txt ${PIP_ARGS:-} + if [[ -n "$CUSTOM_MLX_WHEEL" || -n "$CUSTOM_MLX_LM_WHEEL" ]]; then + REQ_TMP="$(mktemp)" + awk 'BEGIN{IGNORECASE=0} { + line=$0; trimmed=line; gsub(/^[ \t]+/,"",trimmed); + if (trimmed ~ /^#/ || trimmed ~ /^$/) { print line; next } + if (ENVIRON["FILTER_MLX"] && trimmed ~ /^mlx([ \t]|[><=]|$)/) { next } + if (ENVIRON["FILTER_MLX_LM"] && trimmed ~ /^mlx-lm([ \t]|[><=]|$)/) { next } + print line + }' FILTER_MLX=$([[ -n "$CUSTOM_MLX_WHEEL" ]] && echo 1) FILTER_MLX_LM=$([[ -n "$CUSTOM_MLX_LM_WHEEL" ]] && echo 1) requirements.txt > "$REQ_TMP" + "$PY" -m pip install --target "$VENDOR_DIR" -r "$REQ_TMP" ${PIP_ARGS:-} + rm -f "$REQ_TMP" + else + "$PY" -m pip install --target "$VENDOR_DIR" -r requirements.txt ${PIP_ARGS:-} + fi fi echo "[2/3b] Installing mlxk2 package into vendor..." "$PY" -m pip install --target "$VENDOR_DIR" . ${PIP_ARGS:-} + + if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then + echo "[opt] Installing custom MLX wheel into vendor..." + "$PY" -m pip install --target "$VENDOR_DIR" --force-reinstall "$CUSTOM_MLX_WHEEL" ${PIP_ARGS:-} + fi + if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then + echo "[opt] Installing custom MLX-LM wheel into vendor..." + "$PY" -m pip install --target "$VENDOR_DIR" --force-reinstall "$CUSTOM_MLX_LM_WHEEL" ${PIP_ARGS:-} + fi fi +# ----------------------------------------------------------------------------- +# Launcher +# ----------------------------------------------------------------------------- echo "[3/3] Writing launcher: $OUT_DIR/$APP_NAME" cat > "$OUT_DIR/$APP_NAME" <<'SH' #!/usr/bin/env bash set -euo pipefail -# Launcher for running mlxk2 against an external Python interpreter. -# -# Picks the interpreter from: -# 1) MLXK_PYTHON override -# 2) MyApp Electron resources path (macOS): -# "$RESOURCES_PATH/python/bin/python3" if RESOURCES_PATH is set -# 3) repo-local ./python/bin/python3 relative to this launcher -# 4) system python3 - SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENDOR_DIR="$SELF_DIR/_vendor" @@ -160,6 +306,84 @@ PY exit 0 fi +if [[ "${1:-}" == "--mlx-info" || "${1:-}" == "--pkg-info" ]]; then + cmd="$1"; shift || true + pkg="mlx" + if [[ "$cmd" == "--pkg-info" ]]; then + pkg="${1:-mlx}"; shift || true + fi + "$PY" - </dev/null || true echo "Built external-Python dist at: $OUT_DIR" echo "Quick test:" echo " $OUT_DIR/$APP_NAME --python-info" +echo " $OUT_DIR/$APP_NAME --check-mlx-stack" echo " MLXK_PYTHON=\"/path/to/MyApp.app/Contents/Resources/python/bin/python3\" \\ $OUT_DIR/$APP_NAME --version"