This commit is contained in:
Nikesh Parajuli
2026-02-24 22:19:59 -05:00
parent d4cd89fab0
commit 6ea9bd33f5
19 changed files with 2850 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Version (e.g., 2.0.0b6)"
required: true
variant:
description: "Build variant"
required: false
default: external
type: choice
options: [external, pyinstaller]
cpython_url:
description: "Standalone CPython URL (python-build-standalone)"
required: false
default: ""
r2_path:
description: "R2 path prefix (default: releases/<version>/)"
required: false
default: ""
jobs:
build-sign-notarize:
runs-on: macos-14
env:
VERSION: ${{ inputs.version }}
SIGN_ID: ${{ secrets.CSC_NAME }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve CPython (external variant)
if: ${{ inputs.variant == 'external' }}
run: |
if [[ -z "${{ inputs.cpython_url }}" ]]; then
if [[ ! -x python/bin/python3 && ! -x python/bin/python3.10 && ! -x python/bin/python3.12 ]]; then
echo "No embedded python present; provide cpython_url input or commit ./python/bin/python3" >&2
exit 1
fi
fi
- name: Import Developer ID certificate (if provided)
if: env.SIGN_ID != '' && secrets.MACOS_CERT_P12 != ''
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
KEYCHAIN=build.keychain
security create-keychain -p "" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "" "$KEYCHAIN"
echo "$MACOS_CERT_P12" | base64 --decode > cert.p12
security import cert.p12 -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security list-keychain -d user -s "$KEYCHAIN" login.keychain-db
rm -f cert.p12
- name: Release build (external)
if: ${{ inputs.variant == 'external' }}
run: |
chmod +x scripts/*.sh || true
if [[ -n "${{ inputs.cpython_url }}" ]]; then
CPYTHON_URL="${{ inputs.cpython_url }}" scripts/release.sh --variant external --disable-libval
else
scripts/release.sh --variant external --embedded-python "$PWD/python/bin/python3" --disable-libval
fi
- name: Release build (pyinstaller)
if: ${{ inputs.variant == 'pyinstaller' }}
run: |
chmod +x scripts/*.sh || true
scripts/release.sh --variant pyinstaller --disable-libval
- name: Upload artifacts (tgz only)
uses: actions/upload-artifact@v4
with:
name: release-artifacts-${{ inputs.version }}
path: |
artifacts/*.tgz
- name: Upload to R2 (tgz)
if: ${{ secrets.R2_ACCESS_KEY_ID != '' }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_PATH_INPUT: ${{ inputs.r2_path }}
run: |
TGZ=$(ls -1 artifacts/*.tgz | tail -n1)
if [[ -z "$TGZ" ]]; then echo "No tgz found" >&2; exit 1; fi
pipx install awscli || python3 -m pip install --user awscli
DEST_PATH="${R2_PATH_INPUT:-releases/${{ inputs.version }}/}"
aws s3 cp "$TGZ" "s3://$R2_BUCKET/${DEST_PATH}" --endpoint-url "$R2_ENDPOINT"
+8
View File
@@ -39,3 +39,11 @@ benchmarks/reports/2026-0[12]*.jsonl
# Benchmark reports (ADR-013 Phase 0)
# These reports ARE tracked in git for historical data
#!benchmarks/reports/*.jsonl
.pack-tmp
.pyi-spec
.venv
.venv-build
artifacts
python
dist-ext-python
package-mac-signed.sh
+142
View File
@@ -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/<BIN_NAME>/` (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=<vendor>[:$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=<vendor> <embedded-python> -c "import mlxk2, fastapi, uvicorn, mlx, mlx_lm; print('ok')"`
- Sign + notarize the final `.app` for distribution.
+136
View File
@@ -0,0 +1,136 @@
# MLX Knife Electron Integration Guide (macOS arm64)
This guide covers how our Electron app embeds the upstream `mlxk2` CLI and how we download models ourselves (without `pull`).
- Build the CLI once with `scripts/build.sh` (see `docs/ELECTRON_PACKAGING.md`).
- Extract the archive into `app.getPath('userData')/msty-mlx-studio` (or whatever `BIN_NAME` you built).
- Clear quarantine (`xattr -dr com.apple.quarantine <folder>`) on first install.
## CLI usage (list/show/run/server)
Wrap the CLI in a helper so we can spawn it with consistent env vars:
```js
// electron/main/mlxk.js
import { spawn } from 'child_process';
import { join } from 'path';
import { app } from 'electron';
const binRoot = join(app.getPath('userData'), 'msty-mlx-studio');
const exePath = join(binRoot, 'msty-mlx-studio');
function baseEnv(extra = {}) {
return {
...process.env,
HF_HOME: join(app.getPath('userData'), 'hf'),
TRANSFORMERS_NO_TORCH: '1',
TRANSFORMERS_NO_TF: '1',
TRANSFORMERS_NO_FLAX: '1',
HF_HUB_DISABLE_TELEMETRY: '1',
...extra,
};
}
export function spawnMlxk(args, options = {}) {
const { env = {}, stdio = ['ignore', 'inherit', 'inherit'] } = options;
return spawn(exePath, args, { env: baseEnv(env), stdio });
}
```
You can then build helpers such as:
```js
export function listModels({ all = false, verbose = false } = {}) {
const args = ['list'];
if (all) args.push('--all');
if (verbose) args.push('--verbose');
return new Promise((resolve, reject) => {
const child = spawnMlxk(args, { stdio: ['ignore', 'pipe', 'pipe'] });
const output = [];
child.stdout.on('data', (d) => output.push(d.toString()))
child.stderr.on('data', (d) => output.push(d.toString()))
child.on('exit', (code) => code === 0 ? resolve(output.join('')) : reject(code));
});
}
```
The same `spawnMlxk` wrapper works for `show`, `rm`, `run`, or starting the HTTP server (`serve`/`server`). The packaged binary runs the server in-process (single PID), so a single `SIGINT` stops it.
## Downloads handled in Node
Rather than calling the CLI `pull`, we download models from Hugging Face directly so we have full control over progress and caching. Use `MLXKModelDownloader` (see `apps/desktop/src/mlx/MLXKModelDownloader.ts`). It mirrors the existing `MLXModelDownloader` API:
```ts
import { MLXKModelDownloader } from './MLXKModelDownloader';
const downloader = new MLXKModelDownloader(app.getPath('userData'));
await downloader.downloadModel('mlx-community/Qwen3-4B-Instruct-2507-8bit', (progress) => {
// progress.fileName, progress.percentage, progress.speed, etc.
});
```
The downloader writes into the Hugging Face cache layout:
```
HF_HOME/hub/models--<owner>--<name>/snapshots/<commit>/...
HF_HOME/hub/models--<owner>--<name>/refs/main # contains <commit>
```
Once the files are in place, the CLI can list/run the model immediately.
### Cancellation
`MLXKModelDownloader.cancelDownloadForModel(modelId)` stops in-flight downloads, removes partial files, and cleans up empty directories.
## Environment for CLI processes
If you spawn the launcher directly from Electron, set these env vars:
- `MLXK_PYTHON` absolute path to embedded Python (e.g., `…/msty-mlx-studio/python/bin/python3`).
- `RESOURCES_PATH` bundle root (folder containing the launcher and `python/`).
- `HF_HOME` shared cache location for downloads and CLI usage.
- `TRANSFORMERS_NO_TORCH=1`, `TRANSFORMERS_NO_TF=1`, `TRANSFORMERS_NO_FLAX=1` faster start, no unwanted backend checks.
- `HF_HUB_DISABLE_TELEMETRY=1` avoid telemetry from Hugging Face Hub.
Note: the native launcher compiled by our release process sets `MLXK2_SUPERVISE=0` by default so uvicorn runs inprocess (no extra python supervisor). This improves stop behavior from Electron.
## Server quick start
```bash
HF_HOME="/Users/<user>/.myapp/hf" \
~/Library/Application\ Support/MyApp/msty-mlx-studio/msty-mlx-studio \
serve --host 127.0.0.1 --port 8000 --max-tokens 4000
```
Interact via the OpenAI-compatible endpoints:
```bash
curl -X POST "http://127.0.0.1:8000/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{"model": "Phi-3-mini-4k-instruct-4bit", "messages": [{"role":"user","content":"Hello"}]}'
```
## Recommended server lifecycle from Electron
```js
// Start server
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 child = spawnMlxk(args, { stdio: ['ignore', 'inherit', 'inherit'] });
return child; // caller holds this
}
// Stop server
export function stopServer(child) {
if (!child || child.killed) return;
try { child.kill('SIGINT'); } catch {}
}
```
Notes:
- The server runs inprocess in the packaged binary (no uvicorn supervisor). If `SIGINT` doesnt stop it (rare), escalate to `SIGKILL`.
- The CLI also supports `server` as an alias for `serve` for backward compatibility with v1.
Process model:
- You should see `msty-mlx-studio` as the parent process and a single `python` child while the server is running.
- Stopping the parent with `SIGINT`/`SIGTERM` stops the child promptly. A second signal escalates.
Thats the entire integration: package the CLI once, manage downloads yourself with `MLXKModelDownloader`, and spawn the CLI for listing/running or serving models.
+186
View File
@@ -0,0 +1,186 @@
# MLX Knife Electron Packaging (macOS arm64)
This document explains how we package the upstream `mlxk2` CLI for use inside our Electron app. We ship a self-contained binary bundle and keep upstream code unmodified. Model downloads happen in Electron, so we do not rely on `pull` at runtime.
## Overview
- Target: macOS Apple Silicon (M1/M2/M3), arm64.
- Builder: PyInstaller via `scripts/build.sh`.
- Output: `artifacts/<name>.tgz` containing a one-dir bundle by default (recommended) or a one-file binary.
- Binary/folder name: defaults to `msty-mlx-studio` (configurable via `BIN_NAME`).
## Prerequisites
- Apple Silicon Mac.
- Xcode Command Line Tools (`xcode-select --install`).
- Python 3.9+ available as `python3`.
## Build Script
```bash
# Onedir build (recommended)
scripts/build.sh
# Onefile build
ONEFILE=1 scripts/build.sh
# Versioned archive name
VERSION=1.2.3 scripts/build.sh
# Custom binary name (default is msty-mlx-studio)
BIN_NAME=my-cli scripts/build.sh
# Skip optional Hugging Face Xet plugin (installed by default)
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/<name>.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
mkdir -p ~/mlxk-test
tar -xzf artifacts/msty-mlx-studio.tgz -C ~/mlxk-test
xattr -dr com.apple.quarantine ~/mlxk-test/msty-mlx-studio
~/mlxk-test/msty-mlx-studio/msty-mlx-studio --version # prints just the numeric version
```
Where binaries land:
- Onedir: `dist/<name>/<name>` (folder with libs + executable)
- Onefile: `dist/<name>` (single executable)
## Server behavior
- The packaged binary runs the HTTP server in-process by default (no uvicorn supervisor). The native launcher sets `MLXK2_SUPERVISE=0` so theres no extra python supervisor process.
- Pressing Ctrl-C stops the process; from Electron, send `SIGINT` to the spawned process handle or `SIGKILL` if needed.
Process naming:
- The release pipeline compiles a native launcher so the parent process name appears as the launcher binary (e.g., `msty-mlx-studio`).
- Youll see one `python` child process while the server runs. Stopping the parent with `SIGINT`/`SIGTERM` stops the child promptly; a second signal escalates to `SIGKILL`.
Version reporting:
- The packaged binary prints only the numeric version for `--version`.
- For structured output, use `--version --json` to get `{ cli_version, json_api_spec_version }`.
## Notes
- Optional Hugging Face Xet plugin (`hf_xet`) is installed by default; disable with `USE_HF_XET=0` if a wheel isnt available.
- The script copies `LICENSE` into the archive.
- 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/<name>/<name> --python-info` JSON summary of the embedded interpreter.
- `./dist/<name>/<name> --mlx-info` JSON showing `mlx` version, module path, and dist-info directory.
- `./dist/<name>/<name> --pkg-info mlx_lm` same diagnostic for `mlx-lm`.
- `./dist/<name>/<name> --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/<name>/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.)
### Verify signing & notarization (local)
1) Extract TGZ to a temp folder and locate the launcher.
2) Reapply quarantine (simulating a download):
`xattr -w com.apple.quarantine "0081;$(date +%s);mlxk2;GatekeeperTest" <path-to-launcher>`
3) Gatekeeper check (should be accepted as Notarized Developer ID):
`spctl -a -t exec -vv <path-to-launcher>`
4) Codesign verification on key Mach-O files:
`codesign --verify --deep --strict --verbose=2 <path-to-launcher>`
`codesign --verify --strict --verbose=2 <bundle>/_vendor/pydantic_core/_pydantic_core*.so`
## Runtime Integration
- Our Electron app handles model downloads directly (see `ELECTRON_INTEGRATION_GUIDE.md`).
- The packaged CLI is used for listing/showing models, running prompts, and serving the HTTP API.
- Set `HF_HOME` to the same cache location used by the Electron downloader so the CLI sees downloaded models.
Thats it—build, ship, and run the CLI as a single-folder artifact without patching upstream.
+48
View File
@@ -0,0 +1,48 @@
# Build-Time Patches
Patches in `scripts/patches/` are applied during the build process (`build.sh` and `build_external_python.sh`) to modify source files before packaging. They are automatically cleaned up on exit via the `cleanup_patches()` trap.
These patches exist separately from the main source to keep the development codebase clean while applying build-specific fixes and enhancements to the packaged binary.
## Status
| Patch | Upstream Fix? | Why Still Needed |
|---|---|---|
| **cli.patch** | No | `main` still hardcodes `supervise=True`. Build scripts still use the two-tier parent/child architecture requiring `MLXK2_SUPERVISE=0` to prevent double supervision. |
| **server_models.patch** | No | `main` still filters out unhealthy models with `health != "healthy"` check. |
| **server_streaming_usage.patch** | No | `main` still uses rough `len(text.split()) * 1.3` estimation in `count_tokens()` with no tokenizer parameter. Streaming responses have no `usage` block. |
| **runner_decode.patch** | No | `main` still uses `_decode_tokens()` which resets and re-processes all tokens on every call (O(n^2)). The issue is in this project's usage pattern, not an upstream mlx-lm bug. |
---
## cli.patch
**File:** `mlxk2/cli.py`
Adds the `MLXK2_SUPERVISE` environment variable to allow disabling subprocess uvicorn supervision.
The packaged binary uses a two-tier server architecture: a parent process (PyInstaller wrapper) spawns and supervises a child process that runs the actual uvicorn server. The parent handles graceful shutdown (SIGTERM then SIGKILL). Without this patch, the child would also try to spawn its own supervised subprocess, creating redundant nesting. Setting `MLXK2_SUPERVISE=0` lets the child run the server in-process.
## server_models.patch
**File:** `mlxk2/core/server_base.py`
Removes the `health != "healthy"` filter from the `/v1/models` endpoint, keeping only the `runtime_compatible` filter.
The `health` field indicates file integrity issues (corrupted files, missing snapshots), while `runtime_compatible` indicates feature compatibility (e.g., vision model without mlx-vlm installed). By filtering out unhealthy models entirely, users had no way to see why a model disappeared from the list. With this patch, unhealthy models remain visible with their status, so users can diagnose and repair them.
## server_streaming_usage.patch
**File:** `mlxk2/core/server_base.py`
Adds token usage statistics to streaming responses and switches `count_tokens()` from rough word-count estimation to actual tokenizer-based counting.
The OpenAI streaming API spec includes a `usage` block (`prompt_tokens`, `completion_tokens`, `total_tokens`) in the final SSE chunk. Without this patch, streaming responses omit usage entirely, and non-streaming responses use an inaccurate `len(text.split()) * 1.3` approximation. The patch accumulates generated text during streaming, counts tokens via `tokenizer.encode()`, and includes the usage block in the final chunk.
## runner_decode.patch
**File:** `mlxk2/core/runner/__init__.py`
Replaces `self._decode_tokens()` with `self.tokenizer.decode()` to fix an O(n^2) performance regression during streaming inference.
The custom `_decode_tokens()` method calls `detokenizer.reset()` and re-processes all tokens on every invocation. When called in the streaming loop for each new token, this causes quadratic complexity, dropping inference speed from ~200 tokens/sec to ~5 tokens/sec. Using `tokenizer.decode()` directly is a single-pass O(n) operation and handles BPE space markers correctly.
+108
View File
@@ -0,0 +1,108 @@
# Release Guide (External Python, Signed + Notarized)
This project ships a single-folder bundle that runs with a Python you provide. Use one command to build, sign, package, and notarize a release.
## Prerequisites
- macOS Apple Silicon runner (local or GitHub Actions).
- Embedded Python at `python/bin/python3` (arm64).
- Apple Developer ID Application certificate (for codesign).
- Apple ID app-specific password (for notarization).
- Cloudflare R2 credentials if uploading.
## Oneshot local release (defaults)
```
# VERSION is picked from package.json if not set (override by exporting VERSION)
# export VERSION=2.0.0b6
export SIGN_ID="Developer ID Application: Your Name (TEAMID)"
export APPLE_ID="you@apple.com"
export APPLE_TEAM_ID="TEAMID"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
# Minimal: external variant, uses ./python/bin/python3 automatically,
# disables library validation by default, signs + notarizes and emits TGZ
scripts/release.sh
### Automatic CPython download
If you dont have `./python` prepared, provide a standalone CPython URL and the release script will download + extract it and use it as the embedded interpreter:
```
export SIGN_ID="Developer ID Application: Your Name (TEAMID)"
export APPLE_ID=… APPLE_TEAM_ID=… APPLE_APP_SPECIFIC_PASSWORD=…
CPYTHON_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19+20251014-aarch64-apple-darwin-install_only.tar.gz" \
scripts/release.sh --variant external --disable-libval
```
By default, it extracts into `./python` and uses `./python/bin/python3`.
```
Outputs:
- Folder: `dist-ext-python/msty-mlx-studio` (includes `python/` and `_vendor/`)
- Tgz (for your inapp download): `artifacts/msty-mlx-studio-<VERSION>.tgz` (signed)
Notes:
- `--disable-libval` adds the entitlement `com.apple.security.cs.disable-library-validation` to allow loading signed thirdparty libraries while using hardened runtime.
- For a local dry-run without notarization, add `--skip-notarize`.
## NPM helper
```
# Uses the embedded python at ./python/bin/python3
# Environment variables for signing/notarization must be set as above
npm run mlx:release
```
## GitHub Actions CI
Use `.github/workflows/release.yml`:
- Triggers manually with `workflow_dispatch`.
- Requires secrets:
- `CSC_NAME` your codesign identity (Developer ID Application: … (TEAMID))
- `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`
- (Optional) `MACOS_CERT_P12`, `MACOS_CERT_PASSWORD` base64 .p12 if importing the cert in CI
- (Optional) `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_ENDPOINT` for Cloudflare R2 upload (S3 compatible)
- (Optional) Input `cpython_url` if provided, CI will download CPython automatically; otherwise expects `./python/bin/python3` checked in.
- (Optional) Input `r2_path` custom path under the bucket (default `releases/<version>/`).
Artifacts:
- Uploaded to the workflow run (`artifacts/*.tgz`).
- Optionally uploaded to R2 at `s3://$R2_BUCKET/releases/<version>/`.
## Process model and signals
- The release builds a native launcher to keep the parent process name as `msty-mlx-studio`.
- The launcher sets `MLXK2_SUPERVISE=0` so uvicorn runs in-process. Expect one `python` child under the launcher.
- Stop via `SIGINT`/`SIGTERM` to the launcher; the python child receives the same and exits promptly. A second signal escalates to `SIGKILL`.
## Verify signing & notarization
Extract the TGZ, reapply quarantine to the launcher, then run Gatekeeper and codesign checks:
```
TMP=$(mktemp -d)
tar -xzf artifacts/msty-mlx-studio-<VERSION>.tgz -C "$TMP"
APP="$TMP/msty-mlx-studio/msty-mlx-studio"
xattr -w com.apple.quarantine "0081;$(date +%s);mlxk2;GatekeeperTest" "$APP"
spctl -a -t exec -vv "$APP" # Expect: accepted (Notarized Developer ID)
codesign --verify --deep --strict --verbose=2 "$APP"
```
## Troubleshooting
- Gatekeeper blocks embedded Python during build:
- The release script presigns the embedded interpreter and clears quarantine.
- pydantic_core import error in PyInstaller bundle:
- We collect `pydantic_core` explicitly.
- MLX `libmlx.dylib` missing:
- External bundle places it under `_vendor/`. For PyInstaller, the build adds it under `dist/<name>/_internal/`.
## Minimal flags
We intentionally avoid extra knobs. Provide only:
- `--variant external` (default)
- `--embedded-python <path>` (required for external)
- `--disable-libval` (recommended)
- Signing/notary env vars as shown above.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "mlx-knife-build-tools",
"version": "2.0.0b3",
"private": true,
"scripts": {
"mlx:clear-quarantine:python": "scripts/clear_quarantine_python.sh python",
"mlx:sign:python:adhoc": "SIGN_ID=- scripts/sign_and_notarize.sh python --no-runtime",
"mlx:sign:python:adhoc:libval": "SIGN_ID=- scripts/sign_and_notarize.sh python --disable-libval",
"mlx:dev-unblock:python": "npm run mlx:clear-quarantine:python && npm run mlx:sign:python:adhoc",
"mlx:dev-unblock:python:libval": "npm run mlx:clear-quarantine:python && npm run mlx:sign:python:adhoc:libval",
"mlx:build:external": "EMBEDDED_PYTHON=\"$(pwd)/python/bin/python3\" scripts/build_external_python.sh",
"mlx:verify:external": "MLXK_PYTHON=\"$(pwd)/python/bin/python3\" ./dist-ext-python/msty-mlx-studio/msty-mlx-studio --python-info && MLXK_PYTHON=\"$(pwd)/python/bin/python3\" ./dist-ext-python/msty-mlx-studio/msty-mlx-studio --check-mlx-stack",
"mlx:release": "scripts/release.sh"
}
}
+20
View File
@@ -0,0 +1,20 @@
# mlx_knife requirements
# Core dependencies for HuggingFace model management
huggingface-hub>=0.34.0
requests>=2.32.0
mlx-lm==0.30.5
mlx>=0.30.0
# API Server dependencies (for 'mlxk server' command)
fastapi>=0.116.0
uvicorn>=0.35.0
pydantic>=2.11.0
# Test dependencies (for FastAPI TestClient)
httpx>=0.27.0
# Vision Language Models support
mlx-vlm==0.3.10
# Note: Python 3.10+ required for vision support, tested on Apple Silicon M1/M2/M3/M4
+101
View File
@@ -0,0 +1,101 @@
# External Python Mode (Electron-embedded Python)
This variant does not use PyInstaller. Instead, it vendors all Python deps (including `mlxk2`) into a folder and runs against the Python interpreter you ship inside your Electron app (`MyApp.app/Contents/Resources/python/bin/python3`).
## Build
```bash
# Recommended: point to your 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
```
Output: `dist-ext-python/<BIN_NAME>/` (default `msty-mlx-studio/`), containing:
- `_vendor/` vendored site-packages (deps + project)
- `msty-mlx-studio` launcher that runs `-m mlxk2.cli` with `PYTHONPATH=_vendor`
Verify the Python in use:
```bash
./dist-ext-python/msty-mlx-studio/msty-mlx-studio --python-info
### Building without executing the embedded Python (avoids Gatekeeper during dev)
If macOS blocks running your embedded Python during the build, you can vendor wheels for a target version without executing that interpreter:
```bash
TARGET_PY_VERSION=3.10 \
VENDOR_VIA_WHEELS=1 \
scripts/build_external_python.sh
```
Notes:
- This uses your system Python to `pip download` wheels for macOS arm64 and the specified Python version, then unpacks them into `_vendor`.
- At runtime, the Electron-embedded Python imports from `_vendor` via `PYTHONPATH`.
- For compiled packages, you must choose a `TARGET_PY_VERSION` that matches your embedded Python minor version (e.g., 3.10 → cp310 wheels).
```
## Packaging into Electron
Place the folder under your app resources, for example:
```
MyApp.app/Contents/Resources/
python/bin/python3 # your embedded Python
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` (so the launcher finds `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=<vendor>[:$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
- You are responsible for shipping a compatible macOS arm64 Python with all required system libs.
- If using private or offline wheels, pass extra pip args: `PIP_ARGS="--no-index --find-links /path/to/wheels" scripts/build_external_python.sh`.
- To sanity check deps at runtime:
`PYTHONPATH=<vendor> <embedded-python> -c "import mlxk2, fastapi, uvicorn, mlx, mlx_lm; print('ok')"`
+551
View File
@@ -0,0 +1,551 @@
#!/usr/bin/env bash
set -euo pipefail
# Build and bundle a macOS arm64 (Apple Silicon) CLI tarball using PyInstaller.
# Invoked as scripts/build.sh
#
# Outputs: artifacts/<name>.tgz containing dist/<name>/...
# Optional env:
# ONEFILE=1 -> build single-file binary (defaults to onedir for reliability)
# VERSION=1.1 -> append version to tarball name
# BIN_NAME=my-cli -> override binary/folder name (default: msty-mlx-studio)
# USE_HF_XET=0 -> skip installing Hugging Face Xet plugin
#
# Requirements (run on an Apple Silicon Mac):
# - Xcode Command Line Tools
# - Python 3.9+ available as `python3`
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
APPLIED_PATCHES=()
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
}
cleanup_patches() {
if [[ "${#APPLIED_PATCHES[@]}" -gt 0 ]]; then
for pf in "${APPLIED_PATCHES[@]}"; do
patch -l -p1 -R < "$pf" >/dev/null 2>&1 || true
done
fi
}
cleanup_all() {
cleanup_version
cleanup_patches
}
trap cleanup_all 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
# -----------------------------------------------------------------------------
# Apply local CLI patch (idempotent)
# -----------------------------------------------------------------------------
apply_patch_with_cleanup() {
local patch_file="$1"
[[ -f "$patch_file" ]] || return
if patch --dry-run -l -p1 < "$patch_file" >/dev/null 2>&1; then
echo "[patch] Applying patch from $patch_file"
patch -l -p1 < "$patch_file"
APPLIED_PATCHES+=("$patch_file")
elif patch --dry-run -R -l -p1 < "$patch_file" >/dev/null 2>&1; then
echo "[patch] Patch already applied; skipping ($patch_file)"
else
echo "ERROR: Failed to apply patch: $patch_file" >&2
exit 1
fi
}
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/cli.patch"
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_models.patch"
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_streaming_usage.patch"
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/runner_decode.patch"
# -----------------------------------------------------------------------------
# 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
fi
if [[ "$(uname -m)" != "arm64" ]]; then
echo "This script targets Apple Silicon (arm64) only." >&2
exit 1
fi
APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}"
BUILD_VENV=".venv-build"
DIST_DIR="$ROOT_DIR/dist"
ART_DIR="$ROOT_DIR/artifacts"
SPEC_DIR="$ROOT_DIR/.pyi-spec"
mkdir -p "$ART_DIR" "$SPEC_DIR"
# -----------------------------------------------------------------------------
# Create build virtualenv
# -----------------------------------------------------------------------------
echo "[1/3] Creating 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..."
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
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'
"""
Custom entry shim for PyInstaller bundle.
Implements supervised shutdown by default in a frozen binary:
- Parent process patches mlxk2.operations.serve.start_server to spawn a child
process (the same frozen binary) that runs the server in-process.
- 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.
"""
import os
import json
import signal
import subprocess
import sys
import time
def _to_bool(val: str) -> bool:
return str(val).lower() in ("1", "true", "yes", "on")
# Fast path: strip CLI name from version output for compatibility
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:
_ver = "0.0.0"
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
host = os.environ.get("MLXK2_HOST", "127.0.0.1")
port = int(os.environ.get("MLXK2_PORT", "8000"))
log_level = os.environ.get("MLXK2_LOG_LEVEL", "info")
reload = _to_bool(os.environ.get("MLXK2_RELOAD", "0"))
_mt = os.environ.get("MLXK2_MAX_TOKENS", "")
max_tokens = int(_mt) if _mt.strip() else None
_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
def _run_supervised_server(*, host: str, port: int, log_level: str, reload: bool, max_tokens):
env = os.environ.copy()
env["MLXK2_CHILD_SERVER"] = "1"
env["MLXK2_HOST"] = host
env["MLXK2_PORT"] = str(port)
env["MLXK2_LOG_LEVEL"] = log_level
env["MLXK2_RELOAD"] = "1" if reload else "0"
if max_tokens is not None:
env["MLXK2_MAX_TOKENS"] = str(max_tokens)
proc = subprocess.Popen([sys.executable], start_new_session=True, env=env)
shutting_down = {"done": False}
def _graceful_shutdown(_sig=None, _frame=None):
if shutting_down["done"]:
return
shutting_down["done"] = True
try:
os.killpg(proc.pid, signal.SIGTERM)
except Exception:
pass
deadline = time.time() + 5.0
while time.time() < deadline:
ret = proc.poll()
if ret is not None:
return
try:
time.sleep(0.1)
except Exception:
pass
try:
os.killpg(proc.pid, signal.SIGKILL)
except Exception:
pass
try:
signal.signal(signal.SIGINT, _graceful_shutdown)
except Exception:
pass
try:
signal.signal(signal.SIGTERM, _graceful_shutdown)
except Exception:
pass
try:
return proc.wait()
except KeyboardInterrupt:
previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
try:
try:
os.killpg(proc.pid, signal.SIGTERM)
except Exception:
pass
deadline = time.time() + 5.0
while time.time() < deadline:
ret = proc.poll()
if ret is not None:
return ret
try:
time.sleep(0.1)
except KeyboardInterrupt:
break
try:
os.killpg(proc.pid, signal.SIGKILL)
except Exception:
pass
while True:
ret = proc.poll()
if ret is not None:
return ret
time.sleep(0.05)
finally:
try:
signal.signal(signal.SIGINT, previous)
except Exception:
pass
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):
return _run_server(host=host, port=port, max_tokens=max_tokens, reload=reload, log_level=log_level)
_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
--noconfirm
--name "$APP_NAME"
--log-level=WARN
--specpath "$SPEC_DIR"
--collect-all mlx
--collect-all mlx_lm
--collect-all huggingface_hub
--collect-all requests
--collect-all fastapi
--collect-all pydantic
--collect-all uvicorn
)
if [[ "${ONEFILE:-0}" == "1" ]]; then
PYI_OPTS+=(--onefile)
else
PYI_OPTS+=(--onedir)
fi
pyinstaller "${PYI_OPTS[@]}" "$ENTRY_SCRIPT"
# -----------------------------------------------------------------------------
# Package archive
# -----------------------------------------------------------------------------
TAR_BASE="$APP_NAME"
if [[ -n "${VERSION:-}" ]]; then
TAR_BASE="$TAR_BASE-${VERSION}"
fi
TAR_NAME="${ARCHIVE_NAME:-${TAR_BASE}.tgz}"
PACK_DIR="$ROOT_DIR/.pack-tmp"
rm -rf "$PACK_DIR"
mkdir -p "$PACK_DIR/$APP_NAME"
if [[ "${ONEFILE:-0}" == "1" ]]; then
cp "$DIST_DIR/$APP_NAME" "$PACK_DIR/$APP_NAME/$APP_NAME"
else
rsync -a --delete "$DIST_DIR/$APP_NAME/" "$PACK_DIR/$APP_NAME/"
fi
cp -f LICENSE "$PACK_DIR/$APP_NAME/" 2>/dev/null || true
tar -C "$PACK_DIR" -czf "$ART_DIR/$TAR_NAME" "$APP_NAME"
echo "Created: $ART_DIR/$TAR_NAME"
echo "Build complete. Binaries are in dist/, archive in artifacts/."
rm -f "$ENTRY_SCRIPT"
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env bash
set -euo pipefail
# Build a "no-PyInstaller" distribution that runs against an external Python
# (e.g., the interpreter embedded in your Electron app at
# MyApp.app/Contents/Resources/python/bin/python3).
#
# The script vendors dependencies into dist-ext-python/<BIN_NAME>/_vendor and
# generates a launcher that executes: <python> -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
APPLIED_PATCHES=()
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
}
cleanup_patches() {
if [[ "${#APPLIED_PATCHES[@]}" -gt 0 ]]; then
for pf in "${APPLIED_PATCHES[@]}"; do
patch -l -p1 -R < "$pf" >/dev/null 2>&1 || true
done
fi
}
cleanup_all() {
cleanup_version
cleanup_patches
}
trap cleanup_all 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
# -----------------------------------------------------------------------------
# Apply local CLI patch (idempotent)
# -----------------------------------------------------------------------------
apply_patch_with_cleanup() {
local patch_file="$1"
[[ -f "$patch_file" ]] || return
if patch --dry-run -l -p1 < "$patch_file" >/dev/null 2>&1; then
echo "[patch] Applying patch from $patch_file"
patch -l -p1 < "$patch_file"
APPLIED_PATCHES+=("$patch_file")
elif patch --dry-run -R -l -p1 < "$patch_file" >/dev/null 2>&1; then
echo "[patch] Patch already applied; skipping ($patch_file)"
else
echo "ERROR: Failed to apply patch: $patch_file" >&2
exit 1
fi
}
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/cli.patch"
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_models.patch"
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_streaming_usage.patch"
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/runner_decode.patch"
# -----------------------------------------------------------------------------
# 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"
ART_DIR="$ROOT_DIR/artifacts"
mkdir -p "$ART_DIR"
# -----------------------------------------------------------------------------
# Resolve Python interpreter used for installs
# -----------------------------------------------------------------------------
if [[ -n "${EMBEDDED_PYTHON:-}" ]]; then
PY="$EMBEDDED_PYTHON"
echo "Using EMBEDDED_PYTHON: $PY"
elif [[ -x "$ROOT_DIR/python/bin/python3" ]]; then
PY="$ROOT_DIR/python/bin/python3"
echo "Using repo-local Python: $PY"
else
PY="$(command -v python3)"
echo "WARNING: Falling back to system python3: $PY" >&2
fi
if [[ ! -x "$PY" ]]; then
echo "ERROR: Python interpreter not executable: $PY" >&2
exit 1
fi
# -----------------------------------------------------------------------------
# Prepare output (always clean)
# -----------------------------------------------------------------------------
rm -rf "$OUT_DIR"
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)"
if [[ -z "$SYS_PY" ]]; 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.12)}"
TARGET_PLATFORM="${TARGET_PLATFORM:-macosx_11_0_arm64}"
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"
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
unzip -oq "$whl" -d "$VENDOR_DIR"
done
shopt -u nullglob
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 "ERROR: No mlx wheel was downloaded for TARGET_PY_VERSION=${TARGET_PY_VERSION}." >&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
else
echo "[1/3] Installing/Upgrading pip in external Python..."
"$PY" - <<'PY'
import ensurepip, sys
try:
ensurepip.bootstrap()
except Exception:
pass
PY
"$PY" -m pip install --upgrade pip setuptools wheel ${PIP_ARGS:-}
echo "[2/3] Installing project dependencies into: $VENDOR_DIR"
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"
"$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 --upgrade --force-reinstall --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
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENDOR_DIR="$SELF_DIR/_vendor"
PY="${MLXK_PYTHON:-}"
if [[ -z "$PY" && -n "${RESOURCES_PATH:-}" && -x "$RESOURCES_PATH/python/bin/python3" ]]; then
PY="$RESOURCES_PATH/python/bin/python3"
fi
if [[ -z "$PY" && -x "$SELF_DIR/python/bin/python3" ]]; then
PY="$SELF_DIR/python/bin/python3"
fi
if [[ -z "$PY" && -x "$SELF_DIR/../python/bin/python3" ]]; then
PY="$SELF_DIR/../python/bin/python3"
fi
if [[ -z "$PY" ]]; then
PY="$(command -v python3)"
fi
if [[ "${1:-}" == "--python-info" ]]; then
shift || true
"$PY" - <<'PY'
import sys, json
print(json.dumps({
"python_version": sys.version.split(" (", 1)[0],
"executable": sys.executable,
"prefix": sys.prefix,
"base_prefix": getattr(sys, "base_prefix", None),
"platform": sys.platform
}, indent=2))
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" - <<PY
import json, importlib, sys, os
pkg = ${pkg@P}
try:
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
print(json.dumps({
"package": pkg,
"version": version,
"module_file": getattr(mod, "__file__", None),
"dist_info": dist_info_dir,
}, indent=2))
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(2)
PY
exit 0
fi
if [[ "${1:-}" == "--check-mlx-stack" ]]; then
"$PY" - <<'PY'
import json
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 exc:
out["error"] = f"import mlx_lm failed: {exc}"
print(json.dumps(out, indent=2))
raise SystemExit(2)
out["ok"] = True
print(json.dumps(out, indent=2))
except Exception as exc:
out["error"] = str(exc)
print(json.dumps(out, indent=2))
raise SystemExit(2)
PY
exit 0
fi
export PYTHONNOUSERSITE=1
if [[ -n "${PYTHONPATH:-}" ]]; then
export PYTHONPATH="$VENDOR_DIR:$PYTHONPATH"
else
export PYTHONPATH="$VENDOR_DIR"
fi
exec "$PY" -m mlxk2.cli "$@"
SH
chmod +x "$OUT_DIR/$APP_NAME"
cp -f LICENSE "$OUT_DIR/" 2>/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"
# -----------------------------------------------------------------------------
# Package tarball for distribution (unless skipped)
# -----------------------------------------------------------------------------
if [[ "${SKIP_TAR:-0}" != "1" ]]; then
TAR_BASE="$APP_NAME"
if [[ -n "${VERSION:-}" ]]; then
TAR_BASE="$TAR_BASE-${VERSION}"
fi
TAR_NAME="${ARCHIVE_NAME:-${TAR_BASE}.tgz}"
echo "[package] Creating $ART_DIR/$TAR_NAME"
tar -C "$OUT_ROOT" -czf "$ART_DIR/$TAR_NAME" "$APP_NAME"
echo "Created: $ART_DIR/$TAR_NAME"
fi
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
# Recursively remove macOS Gatekeeper quarantine from an embedded Python folder
# and verify any leftovers (executables, .dylib, .so, etc.).
#
# Usage:
# scripts/clear_quarantine_python.sh /path/to/MyApp.app/Contents/Resources/python
# # or, if your repo has ./python
# scripts/clear_quarantine_python.sh
#
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $0 [python_folder]"
echo "Example: $0 /Applications/MyApp.app/Contents/Resources/python"
exit 0
fi
PY_DIR="${1:-}"
if [[ -z "$PY_DIR" ]]; then
if [[ -d "python" ]]; then
PY_DIR="python"
else
echo "ERROR: No python folder provided and ./python not found." >&2
echo "Provide the path to your embedded Python folder." >&2
exit 1
fi
fi
if [[ ! -d "$PY_DIR" ]]; then
echo "ERROR: Not a directory: $PY_DIR" >&2
exit 1
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "WARNING: This script is intended for macOS (Darwin). Proceeding anyway..." >&2
fi
echo "Target python folder: $PY_DIR"
echo "Scanning for quarantined items before..."
before_list=$(mktemp)
find "$PY_DIR" -type f -print0 \
| xargs -0 -I{} sh -c 'xattr -p com.apple.quarantine "$1" >/dev/null 2>&1 && printf "%s\n" "$1"' -- {} \
| tee "$before_list" >/dev/null || true
before_count=$(wc -l < "$before_list" | tr -d ' ')
echo "Quarantined files found: ${before_count}"
echo "Clearing quarantine recursively..."
xattr -dr com.apple.quarantine "$PY_DIR" || true
echo "Re-scanning for leftovers..."
after_list=$(mktemp)
find "$PY_DIR" -type f -print0 \
| xargs -0 -I{} sh -c 'xattr -p com.apple.quarantine "$1" >/dev/null 2>&1 && printf "%s\n" "$1"' -- {} \
| tee "$after_list" >/dev/null || true
after_count=$(wc -l < "$after_list" | tr -d ' ')
if [[ "$after_count" -eq 0 ]]; then
echo "Success: no quarantined files remain under $PY_DIR."
else
echo "WARNING: ${after_count} quarantined files remain (showing up to 20):"
head -n 20 "$after_list"
echo "You may need elevated permissions or to remove quarantine on parent folders."
fi
rm -f "$before_list" "$after_list"
+23
View File
@@ -0,0 +1,23 @@
diff --git a/mlxk2/cli.py b/mlxk2/cli.py
index c07ade8..24e4047 100644
--- a/mlxk2/cli.py
+++ b/mlxk2/cli.py
@@ -447,6 +447,9 @@ def main():
# Start server (this will run indefinitely)
# Lazy import to avoid hard dependency on FastAPI/uvicorn at import time
from .operations.serve import start_server
+ # Supervision: allow disabling subprocess uvicorn via env (for embedded/bundled launchers)
+ supervise_env = os.getenv("MLXK2_SUPERVISE", "1").strip().lower()
+ supervise_flag = supervise_env not in ("0", "false", "no", "off")
start_server(
model=getattr(args, "model", None),
port=args.port,
@@ -455,7 +458,7 @@ def main():
reload=getattr(args, "reload", False),
log_level=getattr(args, "log_level", "info"),
verbose=getattr(args, "verbose", False),
- supervise=True
+ supervise=supervise_flag
)
# Should never reach here (server runs indefinitely)
+57
View File
@@ -0,0 +1,57 @@
diff --git a/mlxk2/core/runner/__init__.py b/mlxk2/core/runner/__init__.py
index d22aacc..ca9ad06 100644
--- a/mlxk2/core/runner/__init__.py
+++ b/mlxk2/core/runner/__init__.py
@@ -553,7 +553,7 @@ class MLXRunner:
# Use sliding window for proper decoding
start_idx = max(0, len(generated_tokens) - context_window)
window_tokens = generated_tokens[start_idx:]
- window_text = self._decode_tokens(window_tokens)
+ window_text = self.tokenizer.decode(window_tokens)
# Extract new text
if start_idx == 0:
@@ -565,13 +565,13 @@ class MLXRunner:
new_text = window_text
previous_decoded = window_text
else:
- new_text = self._decode_tokens(window_tokens)
+ new_text = self.tokenizer.decode(window_tokens)
if len(window_tokens) > 1:
- prefix = self._decode_tokens(window_tokens[:-1])
+ prefix = self.tokenizer.decode(window_tokens[:-1])
if new_text.startswith(prefix):
new_text = new_text[len(prefix):]
else:
- new_text = self._decode_tokens([token_id])
+ new_text = self.tokenizer.decode([token_id])
if new_text:
accumulated_response += new_text
@@ -750,7 +750,7 @@ class MLXRunner:
# Decode full response using the streaming detokenizer
# This properly converts BPE space markers (Ġ U+0120) to spaces (U+0020)
# while tokenizer.decode() for slow tokenizers (LlamaTokenizer) does NOT.
- full_response = self._decode_tokens(all_tokens)
+ full_response = self.tokenizer.decode(all_tokens)
# Debug: Show raw generated tokens for quality analysis (enabled via --verbose)
if self.verbose:
@@ -762,7 +762,7 @@ class MLXRunner:
for tid in last_3_ids:
try:
# Use detokenizer for debug output too
- decoded = self._decode_tokens([tid])
+ decoded = self.tokenizer.decode([tid])
last_3_decoded.append(f"{tid}={decoded!r}")
except Exception:
last_3_decoded.append(f"{tid}=<error>")
@@ -778,7 +778,7 @@ class MLXRunner:
response = full_response[len(formatted_prompt):]
else:
# Decode generated tokens only (use detokenizer)
- decoded = self._decode_tokens(generated_tokens)
+ decoded = self.tokenizer.decode(generated_tokens)
response = decoded if isinstance(decoded, str) else str(decoded)
# Filter stop tokens (strings only)
+15
View File
@@ -0,0 +1,15 @@
diff --git a/mlxk2/core/server_base.py b/mlxk2/core/server_base.py
index be7db39..2835552 100644
--- a/mlxk2/core/server_base.py
+++ b/mlxk2/core/server_base.py
@@ -790,9 +790,7 @@ async def list_models():
# Use shared build_model_object (single source of truth)
model_obj = build_model_object(model_name, model_dir, selected_path)
- # Filter: healthy AND runtime_compatible
- if model_obj.get("health") != "healthy":
- continue
+ # Filter: runtime_compatible only (include unhealthy models for visibility)
if not model_obj.get("runtime_compatible"):
continue
@@ -0,0 +1,195 @@
--- a/mlxk2/core/server_base.py
+++ b/mlxk2/core/server_base.py
@@ -278,9 +278,12 @@
yield f"data: {json.dumps(initial_response)}\n\n"
+ # Track tokens for usage stats
+ accumulated_text = ""
+ prompt_tokens = count_tokens(prompt, runner.tokenizer)
+
# Stream tokens
try:
- token_count = 0
for token in runner.generate_streaming(
prompt=prompt,
max_tokens=get_effective_max_tokens(runner, request.max_tokens, server_mode=True),
@@ -292,7 +295,7 @@
# Stop promptly if server is shutting down
if _shutdown_event.is_set():
raise KeyboardInterrupt()
- token_count += 1
+ accumulated_text += token
chunk_response = {
"id": completion_id,
@@ -364,9 +367,18 @@
}
yield f"data: {json.dumps(error_response)}\n\n"
- # Final response (skip if shutting down)
+ # Final response with usage (skip if shutting down)
if _shutdown_event.is_set():
return
+
+ # Calculate completion tokens
+ completion_tokens = count_tokens(accumulated_text, runner.tokenizer)
+
+ # Debug logging
+ import os
+ if os.environ.get("MLXK2_DEBUG"):
+ print(f"[DEBUG] Sending final chunk with usage: prompt={prompt_tokens}, completion={completion_tokens}, total={prompt_tokens + completion_tokens}")
+
final_response = {
"id": completion_id,
"object": "text_completion",
@@ -379,12 +391,16 @@
"logprobs": None,
"finish_reason": "stop"
}
- ]
+ ],
+ "usage": {
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ "total_tokens": prompt_tokens + completion_tokens
+ }
}
yield f"data: {json.dumps(final_response)}\n\n"
yield "data: [DONE]\n\n"
-
async def generate_chat_stream(
@@ -419,6 +435,12 @@
yield f"data: {json.dumps(initial_response)}\n\n"
+ # Track tokens for usage stats
+ accumulated_text = ""
+ # Count prompt tokens from original messages
+ total_prompt = "\n\n".join([msg.content for msg in messages])
+ prompt_tokens = count_tokens(total_prompt, runner.tokenizer)
+
# Stream tokens
try:
for token in runner.generate_streaming(
@@ -433,6 +455,8 @@
# Stop promptly if server is shutting down
if _shutdown_event.is_set():
raise KeyboardInterrupt()
+ accumulated_text += token
+
chunk_response = {
"id": completion_id,
"object": "chat.completion.chunk",
@@ -531,9 +555,18 @@
}
yield f"data: {json.dumps(error_response)}\n\n"
- # Final response (skip if shutting down)
+ # Final response with usage (skip if shutting down)
if _shutdown_event.is_set():
return
+
+ # Calculate completion tokens
+ completion_tokens = count_tokens(accumulated_text, runner.tokenizer)
+
+ # Debug logging
+ import os
+ if os.environ.get("MLXK2_DEBUG"):
+ print(f"[DEBUG] Sending final chunk with usage: prompt={prompt_tokens}, completion={completion_tokens}, total={prompt_tokens + completion_tokens}")
+
final_response = {
"id": completion_id,
"object": "chat.completion.chunk",
@@ -545,12 +578,16 @@
"delta": {},
"finish_reason": "stop"
}
- ]
+ ],
+ "usage": {
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ "total_tokens": prompt_tokens + completion_tokens
+ }
}
yield f"data: {json.dumps(final_response)}\n\n"
yield "data: [DONE]\n\n"
-
def format_chat_messages_for_runner(messages: List[ChatMessage]) -> List[Dict[str, str]]:
@@ -596,8 +633,23 @@
return 2048
-def count_tokens(text: str) -> int:
- """Rough token count estimation."""
+def count_tokens(text: str, tokenizer=None) -> int:
+ """Count tokens using tokenizer if available, otherwise estimate.
+
+ Args:
+ text: Text to count tokens for
+ tokenizer: Optional tokenizer for accurate counting
+
+ Returns:
+ Token count
+ """
+ if tokenizer is not None:
+ try:
+ tokens = tokenizer.encode(text)
+ return len(tokens) if isinstance(tokens, (list, tuple)) else 1
+ except Exception:
+ # Fallback to approximation if tokenizer fails
+ pass
return int(len(text.split()) * 1.3) # Approximation, convert to int
@@ -865,8 +917,8 @@
use_chat_template=False
)
- prompt_tokens = count_tokens(prompt)
- completion_tokens = count_tokens(generated_text)
+ prompt_tokens = count_tokens(prompt, runner.tokenizer)
+ completion_tokens = count_tokens(generated_text, runner.tokenizer)
return CompletionResponse(
id=completion_id,
@@ -980,8 +1032,8 @@
repetition_penalty=request.repetition_penalty or 1.0,
)
- prompt_tokens = count_tokens(prompt)
- completion_tokens = count_tokens(generated_text)
+ prompt_tokens = count_tokens(prompt, getattr(runner, 'tokenizer', None))
+ completion_tokens = count_tokens(generated_text, getattr(runner, 'tokenizer', None))
# Graceful degradation: emulate SSE for stream=true
if request.stream:
@@ -1044,8 +1096,8 @@
# Token counting (handle both string and list content - use filtered messages)
total_prompt = _extract_text_from_messages(messages)
- prompt_tokens = count_tokens(total_prompt)
- completion_tokens = count_tokens(generated_text)
+ prompt_tokens = count_tokens(total_prompt, runner.tokenizer)
+ completion_tokens = count_tokens(generated_text, runner.tokenizer)
return ChatCompletionResponse(
id=completion_id,
@@ -1155,8 +1207,8 @@
)
# Token counting
- prompt_tokens = count_tokens(prompt)
- completion_tokens = count_tokens(generated_text)
+ prompt_tokens = count_tokens(prompt, getattr(runner, 'tokenizer', None))
+ completion_tokens = count_tokens(generated_text, getattr(runner, 'tokenizer', None))
# Graceful degradation: emulate SSE for stream=true
if request.stream:
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env bash
set -euo pipefail
# All-in-one release script: build (external or pyinstaller), sign, package, and notarize.
#
# Usage examples:
# VERSION=2.0.0bp6 \
# SIGN_ID="Developer ID Application: Your Name (TEAMID)" \
# APPLE_ID="you@apple.com" APPLE_TEAM_ID="TEAMID" APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
# scripts/release.sh --variant external --embedded-python /path/to/Resources/python/bin/python3
#
# # With custom wheels and custom name
# VERSION=2.0.0bp6 BIN_NAME=my-cli \
# SIGN_ID="$CSC_NAME" APPLE_ID=... APPLE_TEAM_ID=... APPLE_APP_SPECIFIC_PASSWORD=... \
# scripts/release.sh --variant pyinstaller --mlx-wheel scripts/mlx-custom.whl --mlx-lm-wheel scripts/mlx-lm-custom.whl
#
# Flags:
# --variant external|pyinstaller (default: external)
# --embedded-python <path> (required for external variant)
# --mlx-wheel <path> override MLX wheel
# --mlx-lm-wheel <path> override MLX-LM wheel
# --skip-notarize sign only; do not notarize
# --entitlements <plist> optional entitlements for signing
# --disable-libval add disable-library-validation entitlement
#
# Env:
# VERSION, BIN_NAME version stamp and output name
# MLX_WHEEL, MLX_LM_WHEEL alternative to flags
# SIGN_ID or CSC_NAME codesign identity (Developer ID Application: … (TEAMID))
# APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD for notarytool
#
VARIANT=external
EMBED_PY=""
MLX_WHEEL_ARG=""
MLX_LM_WHEEL_ARG=""
SKIP_NOTARIZE=0
ENTITLEMENTS=""
DISABLE_LIBVAL=1
CPYTHON_URL_ARG=""
CPYTHON_DIR_ARG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--variant) VARIANT="${2:-}"; shift 2;;
--embedded-python) EMBED_PY="${2:-}"; shift 2;;
--cpython-url) CPYTHON_URL_ARG="${2:-}"; shift 2;;
--cpython-dir) CPYTHON_DIR_ARG="${2:-}"; shift 2;;
--mlx-wheel) MLX_WHEEL_ARG="${2:-}"; shift 2;;
--mlx-lm-wheel) MLX_LM_WHEEL_ARG="${2:-}"; shift 2;;
--skip-notarize) SKIP_NOTARIZE=1; shift;;
--entitlements) ENTITLEMENTS="${2:-}"; shift 2;;
--disable-libval) DISABLE_LIBVAL=1; shift;;
--no-disable-libval) DISABLE_LIBVAL=0; shift;;
*) echo "Unknown flag: $1" >&2; exit 1;;
esac
done
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
# Determine version: prefer env VERSION, otherwise package.json:version
if [[ -z "${VERSION:-}" ]]; then
PKG_VER="$(python3 - <<'PY' 2>/dev/null || true
import json, sys
try:
with open('package.json') as f:
print(json.load(f).get('version',''))
except Exception:
print('')
PY
)"
if [[ -n "$PKG_VER" ]]; then
export VERSION="$PKG_VER"
echo "[version] Using package.json version: $VERSION"
fi
fi
APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}"
ART_DIR="$ROOT_DIR/artifacts"
mkdir -p "$ART_DIR"
# Determine signing identity early (used for pre-signing embedded Python)
SIGN_ID_EFFECTIVE="${SIGN_ID:-${CSC_NAME:-}}"
ENV_ASSIGN=()
if [[ -n "${VERSION:-}" ]]; then ENV_ASSIGN+=(VERSION="$VERSION"); fi
ENV_ASSIGN+=(BIN_NAME="$APP_NAME")
if [[ -n "$MLX_WHEEL_ARG" ]]; then ENV_ASSIGN+=(MLX_WHEEL="$MLX_WHEEL_ARG"); fi
if [[ -n "${MLX_WHEEL:-}" ]]; then ENV_ASSIGN+=(MLX_WHEEL="$MLX_WHEEL"); fi
if [[ -n "$MLX_LM_WHEEL_ARG" ]]; then ENV_ASSIGN+=(MLX_LM_WHEEL="$MLX_LM_WHEEL_ARG"); fi
if [[ -n "${MLX_LM_WHEEL:-}" ]]; then ENV_ASSIGN+=(MLX_LM_WHEEL="$MLX_LM_WHEEL"); fi
case "$VARIANT" in
external)
# Resolve CPython source if not explicitly provided
CPYTHON_URL="${CPYTHON_URL_ARG:-${CPYTHON_URL:-}}"
CPYTHON_DIR="${CPYTHON_DIR_ARG:-${CPYTHON_DIR:-${ROOT_DIR}/python}}"
if [[ -z "$EMBED_PY" ]]; then
if [[ -n "$CPYTHON_URL" ]]; then
echo "[fetch] Downloading CPython from: $CPYTHON_URL"
TMP_TAR="$(mktemp -t cpython-XXXXXX).tar.gz"
curl -L "$CPYTHON_URL" -o "$TMP_TAR"
mkdir -p "$CPYTHON_DIR"
# Extract; detect if payload contains a top-level 'python' folder
TAR_TOP="$(tar -tzf "$TMP_TAR" | head -n1 | cut -d/ -f1)"
tar -xzf "$TMP_TAR" -C "$CPYTHON_DIR" --strip-components=0
rm -f "$TMP_TAR"
# Common layouts: either extracted files under CPYTHON_DIR directly or nested python/*
if [[ -x "$CPYTHON_DIR/bin/python3" ]]; then
EMBED_PY="$CPYTHON_DIR/bin/python3"
elif [[ -x "$CPYTHON_DIR/python/bin/python3" ]]; then
EMBED_PY="$CPYTHON_DIR/python/bin/python3"
else
# Try to find any python3 in subtree
EMBED_PY="$(find "$CPYTHON_DIR" -type f -path '*/bin/python3*' -perm -111 -print -quit 2>/dev/null || true)"
fi
[[ -n "$EMBED_PY" ]] || { echo "ERROR: Unable to find python3 in extracted CPython at $CPYTHON_DIR" >&2; exit 1; }
echo "[fetch] Embedded Python resolved to: $EMBED_PY"
elif [[ -x "$ROOT_DIR/python/bin/python3" ]]; then
EMBED_PY="$ROOT_DIR/python/bin/python3"
fi
fi
[[ -n "$EMBED_PY" ]] || { echo "ERROR: Embedded Python not found. Provide --embedded-python or CPYTHON_URL/CPYTHON_DIR or place it at ./python/bin/python3" >&2; exit 1; }
echo "[build] External-Python variant"
# Pre-sign embedded Python so we can execute it during build (and clear quarantine)
PYSRC_DIR="$(cd "$(dirname "$EMBED_PY")/.." && pwd)"
if [[ -d "$PYSRC_DIR" ]]; then
echo "[prep] Clearing quarantine and signing embedded Python at: $PYSRC_DIR"
dos2unix scripts/* || true
scripts/clear_quarantine_python.sh "$PYSRC_DIR" || true
PRE_SIGN_ARGS=("$PYSRC_DIR")
if [[ "$DISABLE_LIBVAL" == "1" ]]; then PRE_SIGN_ARGS+=(--disable-libval); fi
if [[ -n "$ENTITLEMENTS" ]]; then PRE_SIGN_ARGS+=(--entitlements "$ENTITLEMENTS"); fi
if [[ -n "$SIGN_ID_EFFECTIVE" ]]; then SIGN_ID="$SIGN_ID_EFFECTIVE" scripts/sign_and_notarize.sh "${PRE_SIGN_ARGS[@]}"; else echo "[prep] SIGN_ID not set; skipping pre-sign (may still run locally if quarantine is clear)"; fi
fi
# Always clean external bundle output for release builds
env "${ENV_ASSIGN[@]}" EMBEDDED_PYTHON="$EMBED_PY" CLEAN=1 SKIP_TAR=1 scripts/build_external_python.sh
BUNDLE_DIR="$ROOT_DIR/dist-ext-python/$APP_NAME"
# Include the embedded Python inside the bundle so the launcher can auto-resolve SELF_DIR/python/bin/python3
if [[ -d "$PYSRC_DIR" ]]; then
echo "[bundle] Embedding Python into bundle at: $BUNDLE_DIR/python"
rsync -a --delete "$PYSRC_DIR/" "$BUNDLE_DIR/python/"
fi
# Replace shell launcher with a tiny Mach-O wrapper so the process name shows as "$APP_NAME"
echo "[bundle] Building native launcher to keep process name as $APP_NAME"
cat > "$BUNDLE_DIR/.launcher.c" <<'C'
#include <spawn.h>
#include <sys/wait.h>
#include <mach-o/dyld.h>
#include <libgen.h>
#include <limits.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <signal.h>
extern char **environ;
static volatile sig_atomic_t got_sig = 0;
static volatile pid_t child_pid = -1;
static void dirname_of(const char *path, char *out, size_t outsz) {
strncpy(out, path, outsz - 1); out[outsz-1] = '\0';
char *d = dirname(out);
if (d != out) strncpy(out, d, outsz - 1);
}
static void handle_signal(int sig) {
got_sig++;
pid_t pid = child_pid;
if (pid > 0) {
if (got_sig == 1) kill(pid, sig == SIGINT ? SIGINT : SIGTERM);
else kill(pid, SIGKILL);
}
}
int main(int argc, char *argv[]) {
// Resolve executable path
char execPath[PATH_MAX]; uint32_t size = sizeof(execPath);
if (_NSGetExecutablePath(execPath, &size) != 0) return 127;
char bundleRoot[PATH_MAX]; dirname_of(execPath, bundleRoot, sizeof(bundleRoot));
// Construct embedded python and vendor paths
char pyPath[PATH_MAX]; snprintf(pyPath, sizeof(pyPath), "%s/python/bin/python3", bundleRoot);
char vendorPath[PATH_MAX]; snprintf(vendorPath, sizeof(vendorPath), "%s/_vendor", bundleRoot);
// Prepare environment
setenv("MLXK_PYTHON", pyPath, 1);
setenv("RESOURCES_PATH", bundleRoot, 1);
setenv("PYTHONNOUSERSITE", "1", 0);
// Disable supervised uvicorn subprocess; run server in-process
setenv("MLXK2_SUPERVISE", "0", 1);
// Ensure any `python3` shebang or /usr/bin/env python3 resolves to embedded one
const char *old_path = getenv("PATH");
size_t newlen = strlen(bundleRoot) + strlen("/python/bin") + 1 + (old_path ? strlen(old_path) : 0) + 1;
char *newpath = (char*)malloc(newlen);
if (newpath) {
if (old_path && *old_path)
snprintf(newpath, newlen, "%s/python/bin:%s", bundleRoot, old_path);
else
snprintf(newpath, newlen, "%s/python/bin", bundleRoot);
setenv("PATH", newpath, 1);
free(newpath);
}
// Help Python find its stdlib explicitly
char pyHome[PATH_MAX]; snprintf(pyHome, sizeof(pyHome), "%s/python", bundleRoot);
setenv("PYTHONHOME", pyHome, 1);
setenv("PYTHONEXECUTABLE", pyPath, 1);
// Prepend vendor to PYTHONPATH
const char *pp = getenv("PYTHONPATH");
if (pp && *pp) {
size_t len = strlen(vendorPath) + 1 + strlen(pp) + 1;
char *buf = (char*)malloc(len);
if (!buf) return 127;
snprintf(buf, len, "%s:%s", vendorPath, pp);
setenv("PYTHONPATH", buf, 1);
free(buf);
} else {
setenv("PYTHONPATH", vendorPath, 1);
}
// Build argv for python: python -m mlxk2.cli [args...]
int n = argc + 3; // python, -m, module, args...
char **pargv = (char**)calloc(n + 1, sizeof(char*));
if (!pargv) return 127;
pargv[0] = pyPath;
pargv[1] = "-m";
pargv[2] = "mlxk2.cli";
for (int i = 1; i < argc; ++i) pargv[2 + i] = argv[i];
pargv[n] = NULL;
// Install signal handlers to forward to child
struct sigaction sa; memset(&sa, 0, sizeof(sa));
sa.sa_handler = handle_signal; sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
// Spawn python child; keep this process name as launcher
pid_t pid; int rc = posix_spawn(&pid, pyPath, NULL, NULL, pargv, environ);
if (rc != 0) return rc;
child_pid = pid;
// Wait and propagate exit
int status = 0; while (1) {
pid_t w = waitpid(pid, &status, 0);
if (w == -1) continue; // interrupted by signal
break;
}
if (WIFEXITED(status)) return WEXITSTATUS(status);
if (WIFSIGNALED(status)) return 128 + WTERMSIG(status);
return 0;
}
C
clang -O2 -Wall -mmacosx-version-min=11 -o "$BUNDLE_DIR/.launcher" "$BUNDLE_DIR/.launcher.c"
mv -f "$BUNDLE_DIR/.launcher" "$BUNDLE_DIR/$APP_NAME"
rm -f "$BUNDLE_DIR/.launcher.c"
;;
pyinstaller)
echo "[build] PyInstaller variant"
# Clean old bundle output to avoid stale files across releases
rm -rf "$ROOT_DIR/dist/$APP_NAME" "$ROOT_DIR/build/$APP_NAME"
env "${ENV_ASSIGN[@]}" scripts/build.sh
BUNDLE_DIR="$ROOT_DIR/dist/$APP_NAME"
;;
*)
echo "Unknown variant: $VARIANT" >&2; exit 1;
;;
esac
[[ -d "$BUNDLE_DIR" ]] || { echo "ERROR: bundle directory not found: $BUNDLE_DIR" >&2; exit 1; }
# Post-build version report: ensure bundled mlx + mlx-lm satisfy requirements.txt
REQ_FILE="$ROOT_DIR/requirements.txt"
if [[ -f "$REQ_FILE" ]]; then
echo "[verify] MLX bundle version report"
python3 - "$BUNDLE_DIR" "$REQ_FILE" <<'PY'
import os
import re
import sys
bundle_dir = sys.argv[1]
req_path = sys.argv[2]
targets = {"mlx", "mlx-lm"}
Requirement = None
try:
from packaging.requirements import Requirement # type: ignore
except Exception:
try:
from pip._vendor.packaging.requirements import Requirement # type: ignore
except Exception:
Requirement = None
def find_metadata(root: str):
for dirpath, _, filenames in os.walk(root):
if "METADATA" in filenames:
yield os.path.join(dirpath, "METADATA")
def parse_metadata(path: str):
name = None
version = None
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
if line.startswith("Name:"):
name = line.split(":", 1)[1].strip()
elif line.startswith("Version:"):
version = line.split(":", 1)[1].strip()
if name and version:
break
except Exception:
return None, None
return name, version
search_root = os.path.join(bundle_dir, "_vendor")
roots = [search_root] if os.path.isdir(search_root) else [bundle_dir]
found = {}
duplicates = {}
for root in roots:
for meta in find_metadata(root):
name, version = parse_metadata(meta)
if not name or not version:
continue
key = name.lower()
if key not in targets:
continue
if key in found and found[key]["version"] != version:
duplicates.setdefault(key, []).append({"version": version, "metadata": meta})
else:
found.setdefault(key, {"version": version, "metadata": meta})
def parse_requirements(path: str):
reqs = {}
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line.startswith(("-r", "--")):
continue
if Requirement is not None:
try:
req = Requirement(line)
except Exception:
continue
name = req.name.lower()
if name in targets:
if req.marker is not None and not req.marker.evaluate():
continue
reqs[name] = {"raw": line, "specifier": req.specifier}
else:
match = re.match(r"([A-Za-z0-9_.-]+)(.*)", line)
if not match:
continue
name = match.group(1).lower()
if name in targets:
reqs[name] = {"raw": line, "specifier": match.group(2).strip()}
return reqs
def simple_satisfies(version: str, spec) -> bool | None:
spec_str = str(spec or "").strip()
if not spec_str:
return None
match = re.match(r"(==|>=|<=|<|>)(.+)", spec_str)
if not match:
return None
op = match.group(1)
req_ver = match.group(2).strip()
def split_nums(v: str):
parts = []
for part in re.split(r"[.+-]", v):
if part.isdigit():
parts.append(int(part))
else:
break
return parts
a = split_nums(version)
b = split_nums(req_ver)
if not a or not b:
return None
n = max(len(a), len(b))
a += [0] * (n - len(a))
b += [0] * (n - len(b))
if a < b:
cmp = -1
elif a > b:
cmp = 1
else:
cmp = 0
if op == "==":
return cmp == 0
if op == ">=":
return cmp >= 0
if op == "<=":
return cmp <= 0
if op == ">":
return cmp > 0
if op == "<":
return cmp < 0
return None
reqs = parse_requirements(req_path)
errors = []
notes = []
root_label = roots[0]
print(f" source: {root_label}")
for name in sorted(targets):
inst = found.get(name)
req = reqs.get(name)
inst_ver = inst["version"] if inst else "MISSING"
req_spec = str(req["specifier"]) if req else "MISSING"
print(f" - {name}: {inst_ver} (requirement: {req_spec})")
if not inst:
errors.append(f"{name} not found in bundle (searched {root_label})")
continue
if req and req.get("specifier"):
ok = None
if Requirement is not None:
try:
ok = req["specifier"].contains(inst["version"], prereleases=True)
except Exception:
ok = None
if ok is None:
ok = simple_satisfies(inst["version"], req["specifier"])
if ok is False:
errors.append(f"{name} {inst['version']} does not satisfy '{req_spec}'")
elif ok is None:
notes.append(f"{name}: unable to verify requirement '{req_spec}' (no packaging)")
if duplicates:
for name, dups in duplicates.items():
versions = ", ".join(sorted({d["version"] for d in dups}))
errors.append(f"{name} multiple versions found: {versions}")
for note in notes:
print(f" note: {note}")
if errors:
for err in errors:
print(f" error: {err}")
sys.exit(1)
PY
else
echo "[verify] requirements.txt not found; skipping MLX version report"
fi
SIGN_ID_EFFECTIVE="${SIGN_ID:-${CSC_NAME:-}}"
[[ -n "$SIGN_ID_EFFECTIVE" ]] || { echo "ERROR: set SIGN_ID or CSC_NAME to your codesigning identity" >&2; exit 1; }
SIGN_ARGS=("$BUNDLE_DIR")
if [[ -n "$ENTITLEMENTS" ]]; then SIGN_ARGS+=(--entitlements "$ENTITLEMENTS"); fi
if [[ "$DISABLE_LIBVAL" == "1" ]]; then SIGN_ARGS+=(--disable-libval); fi
echo "[sign] Codesigning bundle with: $SIGN_ID_EFFECTIVE"
SIGN_ID="$SIGN_ID_EFFECTIVE" scripts/sign_and_notarize.sh "${SIGN_ARGS[@]}"
TGZ_NAME_BASE="$APP_NAME"; [[ -n "${VERSION:-}" ]] && TGZ_NAME_BASE+="-${VERSION}"
TGZ_PATH="$ART_DIR/${TGZ_NAME_BASE}.tgz"
# Notarize using a temporary zip (Apple Notary Service prefers zip input).
if [[ "$SKIP_NOTARIZE" == "0" ]]; then
TMP_ZIP="$(mktemp -t ${APP_NAME}-notary-XXXXXX).zip"
echo "[notary] Submitting for notarization via temporary zip"
SIGN_ID="$SIGN_ID_EFFECTIVE" \
APPLE_ID="${APPLE_ID:-}" APPLE_TEAM_ID="${APPLE_TEAM_ID:-}" APPLE_APP_SPECIFIC_PASSWORD="${APPLE_APP_SPECIFIC_PASSWORD:-}" \
scripts/sign_and_notarize.sh "$BUNDLE_DIR" --notarize --zip "$TMP_ZIP"
rm -f "$TMP_ZIP" || true
fi
# Always produce TGZ for distribution
echo "[package] Creating tgz: $TGZ_PATH"
tar -C "$(dirname "$BUNDLE_DIR")" -czf "$TGZ_PATH" "$APP_NAME"
echo "[done] Release ready"
echo "- Bundle: $BUNDLE_DIR"
echo "- TGZ: $TGZ_PATH"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
# Codesign all Mach-O binaries in a folder (or .app) and optionally notarize.
#
# Usage:
# SIGN_ID="Developer ID Application: Your Name (TEAMID)" \
# scripts/sign_and_notarize.sh <target-folder-or-app> [--notarize] [--zip <out.zip>] [--entitlements <plist>]
#
# Optional env:
# KEYCHAIN_PROFILE=AC_PROFILE # notarytool keychain profile (xcrun notarytool store-credentials ...)
#
# Notes:
# - For local dev only, you can ad-hoc sign with SIGN_ID="-" (won't pass Gatekeeper on other Macs).
# - For proper distribution, use a real Developer ID Application cert and --notarize.
TARGET="${1:-}"
[[ -n "$TARGET" ]] || { echo "ERROR: missing target (folder or .app)" >&2; exit 1; }
shift || true
NOTARIZE=0
ZIP_OUT=""
ENTITLEMENTS=""
RUNTIME=1
DISABLE_LIBVAL=0
while [[ $# -gt 0 ]]; do
case "$1" in
--notarize) NOTARIZE=1; shift;;
--zip) ZIP_OUT="${2:-}"; shift 2;;
--entitlements) ENTITLEMENTS="${2:-}"; shift 2;;
--no-runtime) RUNTIME=0; shift;;
--disable-library-validation|--disable-libval) DISABLE_LIBVAL=1; shift;;
*) echo "Unknown flag: $1" >&2; exit 1;;
esac
done
[[ -n "${SIGN_ID:-}" ]] || { echo "ERROR: set SIGN_ID to your 'Developer ID Application: … (TEAMID)' or '-' for ad-hoc" >&2; exit 1; }
if [[ ! -e "$TARGET" ]]; then
echo "ERROR: target not found: $TARGET" >&2
exit 1
fi
echo "[sign] Clearing quarantine (dev convenience)"
xattr -dr com.apple.quarantine "$TARGET" || true
codesign_file() {
local f="$1"
if file -b "$f" | grep -Eq 'Mach-O|universal binary'; then
local args=(--force --timestamp --sign "$SIGN_ID")
if [[ "$RUNTIME" == "1" ]]; then
args+=(--options runtime)
fi
if [[ -n "$ENTITLEMENTS" ]]; then
args+=(--entitlements "$ENTITLEMENTS")
fi
codesign "${args[@]}" "$f" 2>/dev/null || {
echo "[warn] codesign failed for $f; retrying verbose" >&2
codesign "${args[@]}" --verbose "$f"
}
fi
}
if [[ "$DISABLE_LIBVAL" == "1" && -z "$ENTITLEMENTS" ]]; then
echo "[sign] Creating temporary entitlements with disable-library-validation"
ENT_TMP="$(mktemp)"
cat > "$ENT_TMP" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
PLIST
ENTITLEMENTS="$ENT_TMP"
# Hardened runtime generally required when using this entitlement
RUNTIME=1
fi
echo "[sign] Walking files under: $TARGET"
if [[ -d "$TARGET" ]]; then
while IFS= read -r -d '' f; do
codesign_file "$f"
done < <(find "$TARGET" -type f -print0)
else
codesign_file "$TARGET"
fi
# If an .app, sign the bundle itself (deep)
if [[ "$TARGET" == *.app ]]; then
echo "[sign] Deep-signing app bundle"
args=(--force --timestamp --sign "$SIGN_ID" --deep)
if [[ "$RUNTIME" == "1" ]]; then
args+=(--options runtime)
fi
[[ -n "$ENTITLEMENTS" ]] && args+=(--entitlements "$ENTITLEMENTS")
codesign "${args[@]}" "$TARGET"
fi
echo "[verify] Sample verification"
spctl -a -t exec -vv "$TARGET" || true
if [[ "$NOTARIZE" == "1" ]]; then
ZIP_OUT=${ZIP_OUT:-"$(basename "$TARGET").zip"}
echo "[zip] Creating: $ZIP_OUT"
ditto -c -k --keepParent "$TARGET" "$ZIP_OUT"
echo "[notary] Submitting to Apple Notary Service"
if [[ -n "${KEYCHAIN_PROFILE:-}" ]]; then
echo " using keychain profile: $KEYCHAIN_PROFILE"
xcrun notarytool submit "$ZIP_OUT" --keychain-profile "$KEYCHAIN_PROFILE" --wait
else
[[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] || {
echo "ERROR: provide KEYCHAIN_PROFILE or APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD" >&2; exit 1; }
xcrun notarytool submit "$ZIP_OUT" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait
fi
# Staple if it's an .app
if [[ "$TARGET" == *.app ]]; then
echo "[staple] Stapling ticket to app"
xcrun stapler staple "$TARGET" || true
fi
echo "[done] Notarization complete"
fi
echo "[done] Codesigning finished"