Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1122 lines
39 KiB
Python

#!/usr/bin/env python3
"""
Build the venvstacks Python layers embedded inside the Swift macOS bundle.
The PyObjC menubar `.app` and DMG pipeline this script used to drive
retired with the Swift rewrite; the Swift bundle is now produced by
`apps/omlx-mac/Scripts/build.sh`, which invokes this script with
`--venvstacks-only` (or `--print-fingerprint`) to refresh the export.
Usage:
python build.py --venvstacks-only
python build.py --print-fingerprint
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
BUILD_DIR = SCRIPT_DIR / "_build"
EXPORT_DIR = SCRIPT_DIR / "_export"
DIST_DIR = SCRIPT_DIR / "dist"
WHEELS_DIR = SCRIPT_DIR / "_wheels"
APP_NAME = "oMLX"
def _read_version() -> str:
"""Read version from omlx/_version.py (single source of truth)."""
version_file = SCRIPT_DIR.parent / "omlx" / "_version.py"
content = version_file.read_text()
match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
if not match:
raise RuntimeError(f"Cannot find __version__ in {version_file}")
return match.group(1)
VERSION = _read_version()
def clean_all(preserve_venv: bool = False):
"""Remove build artifacts and caches for a clean build.
Args:
preserve_venv: When True, keep _build/, _export/, _wheels/ and
requirements/ so that --skip-venv can reuse them.
"""
print("\n[Clean] Removing build artifacts...")
venv_dirs = {BUILD_DIR, EXPORT_DIR, WHEELS_DIR, SCRIPT_DIR / "requirements"}
dirs_to_clean = [
BUILD_DIR, # _build/
EXPORT_DIR, # _export/
WHEELS_DIR, # _wheels/
DIST_DIR, # dist/
SCRIPT_DIR / "requirements", # venvstacks lock files
]
files_to_clean = [
SCRIPT_DIR / "_venvstacks_resolved.toml",
]
def _rm_onerror(func, path, exc_info):
"""Handle .DS_Store, permission errors, and non-empty dirs during rmtree."""
os.chmod(path, 0o777)
if os.path.isdir(path):
shutil.rmtree(path)
else:
func(path)
for d in dirs_to_clean:
if preserve_venv and d in venv_dirs:
continue
if d.exists():
shutil.rmtree(d, onerror=_rm_onerror)
print(f" Removed {d.relative_to(SCRIPT_DIR)}/")
for f in files_to_clean:
if preserve_venv and f.name == "_venvstacks_resolved.toml":
continue
if f.exists():
f.unlink()
print(f" Removed {f.relative_to(SCRIPT_DIR)}")
print(" ✓ Clean complete\n")
def run_cmd(cmd: list, cwd: Path = None, check: bool = True):
"""Run a command and print output."""
print(f" → {' '.join(str(c) for c in cmd)}")
result = subprocess.run(cmd, cwd=cwd, capture_output=False)
if check and result.returncode != 0:
print(f" ✗ Command failed with code {result.returncode}")
sys.exit(1)
return result
def _resolve_mlx_version(toml_path: Path) -> str:
"""Resolve the mlx version that venvstacks locked.
Reads the locked requirements file to find the exact mlx version,
falling back to the latest version from PyPI if no lock file exists.
"""
req_file = (
SCRIPT_DIR
/ "requirements"
/ "framework-mlx-base"
/ "requirements-framework-mlx-base-macosx_arm64.txt"
)
if req_file.exists():
import re as _re
content = req_file.read_text()
match = _re.search(r"^mlx==(\S+)", content, _re.MULTILINE)
if match:
return match.group(1)
# No lock file yet — query PyPI for the latest version
import json
import urllib.request
data = json.loads(
urllib.request.urlopen("https://pypi.org/pypi/mlx/json").read()
)
return data["info"]["version"]
def swap_platform_wheels(
export_dir: Path, macos_target: str, python_version: str = "3.11"
):
"""Replace mlx and mlx-metal in exported venvstacks with platform-specific wheels.
Downloads the wheels for the given macOS target (e.g. "26.0") and replaces
the existing packages in the framework layer's site-packages. This allows
building on macOS 15 while targeting macOS 26 wheels that contain
M5 Neural Accelerator matmul kernels.
"""
import zipfile
site_packages = (
export_dir
/ "framework-mlx-base"
/ "lib"
/ f"python{python_version}"
/ "site-packages"
)
if not site_packages.exists():
print(f" ✗ site-packages not found: {site_packages}")
sys.exit(1)
platform_tag = f"macosx_{macos_target.replace('.', '_')}_arm64"
toml_path = SCRIPT_DIR / "venvstacks.toml"
mlx_version = _resolve_mlx_version(toml_path)
packages = ["mlx", "mlx-metal"]
print(f"\n Swapping mlx/mlx-metal to {platform_tag} (v{mlx_version})...")
# Download platform-specific wheels
wheels_tmp = SCRIPT_DIR / "_platform_wheels"
if wheels_tmp.exists():
shutil.rmtree(wheels_tmp)
wheels_tmp.mkdir()
for pkg in packages:
run_cmd([
sys.executable, "-m", "pip", "download",
f"{pkg}=={mlx_version}",
"--platform", platform_tag,
f"--python-version={python_version}",
"--only-binary", ":all:",
"--no-deps",
"-d", str(wheels_tmp),
])
# Remove existing mlx/mlx-metal from site-packages
for item in site_packages.iterdir():
name = item.name.lower()
if name in ("mlx", "mlx_metal") or name.startswith(
("mlx-", "mlx_metal-")
):
if item.is_dir():
shutil.rmtree(item)
print(f" Removed {item.name}")
# Install downloaded wheels into site-packages
for whl in wheels_tmp.glob("*.whl"):
print(f" Installing {whl.name}")
with zipfile.ZipFile(whl) as zf:
zf.extractall(site_packages)
# Cleanup
shutil.rmtree(wheels_tmp)
print(f" ✓ Swapped to {platform_tag}")
def _parse_git_requirements(toml_path: Path) -> list[tuple[str, str]]:
"""Extract git-based requirements from venvstacks.toml.
Returns list of (full_requirement_string, git_url) tuples.
e.g. ("mlx-lm @ git+https://...@sha", "git+https://...@sha")
"""
content = toml_path.read_text()
# Match lines like: "mlx-lm @ git+https://github.com/...@commit"
pattern = r'"([^"]*\s*@\s*(git\+https://[^""]*))"'
return re.findall(pattern, content)
def _wheel_version(whl_path: Path) -> str:
"""Extract version from wheel filename (e.g. mlx_lm-0.30.6-py3-none-any.whl -> 0.30.6)."""
parts = whl_path.stem.split("-")
if len(parts) >= 2:
return parts[1]
return "0.0.0"
def _wheel_pkg_name(whl_path: Path) -> str:
"""Extract normalized package name from wheel filename."""
return whl_path.stem.split("-")[0].replace("_", "-").lower()
def _find_target_python() -> str:
"""Find a Python interpreter matching the venvstacks target version.
Sdist-only packages may compile C extensions, so the wheel must be built
with the same Python version that venvstacks targets (e.g. 3.11).
Falls back to sys.executable if no matching version is found.
"""
toml_path = SCRIPT_DIR / "venvstacks.toml"
content = toml_path.read_text()
match = re.search(r'python_implementation\s*=\s*"cpython@(\d+\.\d+)', content)
if not match:
return sys.executable
target_minor = match.group(1) # e.g. "3.11"
candidates = [
shutil.which(f"python{target_minor}"),
str(BUILD_DIR / f"cpython-{target_minor}" / "bin" / f"python{target_minor}"),
]
for path in candidates:
if not path or not Path(path).exists():
continue
# Skip interpreters without pip (e.g. venvstacks runtimes strip it)
check = subprocess.run(
[path, "-m", "pip", "--version"],
capture_output=True,
)
if check.returncode == 0:
return path
print(f" Warning: python{target_minor} not found, using {sys.executable}")
return sys.executable
def _build_sdist_wheel(pkg_name: str) -> bool:
"""Build a wheel for a sdist-only package into _wheels/.
Uses the target Python version so C extensions get the correct ABI tag.
Returns True if the wheel was built successfully.
"""
target_python = _find_target_python()
print(f" Building wheel for {pkg_name} (sdist-only, using {target_python})...")
result = subprocess.run(
[target_python, "-m", "pip", "wheel", pkg_name, "--no-deps",
"-w", str(WHEELS_DIR)],
capture_output=False,
)
return result.returncode == 0
def build_local_wheels(source_toml: Path | None = None):
"""Pre-build wheels for git-pinned packages.
venvstacks/uv disables source builds (--only-binary :all:), so git-pinned
packages must be pre-built as wheels. This function:
1. Parses git URLs from `source_toml` (defaults to the resolved
venvstacks.toml emitted by `_generate_venvstacks_toml`)
2. Builds wheels via pip
3. Returns a mapping of package_name -> version for toml rewriting
Sdist-only dependencies (packages with no pre-built wheel on PyPI) are
handled separately by _lock_with_sdist_retry() during the lock step.
"""
print("\n[0/4] Building local wheels...")
if source_toml is None:
source_toml = SCRIPT_DIR / "_venvstacks_resolved.toml"
if not source_toml.exists():
source_toml = SCRIPT_DIR / "venvstacks.toml"
git_reqs = _parse_git_requirements(source_toml)
# Clean and recreate wheels dir for fresh builds
if WHEELS_DIR.exists():
shutil.rmtree(WHEELS_DIR)
WHEELS_DIR.mkdir(parents=True)
# Build wheels from git-pinned packages
for full_req, git_url in git_reqs:
pkg_name = full_req.split("@")[0].strip()
print(f" Building wheel for {pkg_name} ...")
run_cmd([
sys.executable, "-m", "pip", "wheel",
git_url,
"--no-deps",
"-w", str(WHEELS_DIR),
])
# Build version mapping from git-pinned wheels only
# (used for rewriting venvstacks.toml git URLs to local file:// paths)
git_pkg_names = {
full_req.split("@")[0].strip().lower().replace("-", "_")
for full_req, _ in git_reqs
}
version_map = {}
for whl in WHEELS_DIR.glob("*.whl"):
name = _wheel_pkg_name(whl)
version = _wheel_version(whl)
if name.replace("-", "_") in git_pkg_names:
version_map[name] = version
print(f" {name} == {version}")
total = len(list(WHEELS_DIR.glob("*.whl")))
print(f" ✓ {total} wheel(s) built in {WHEELS_DIR}")
return version_map
def _lock_with_sdist_retry(lock_cmd: list, max_retries: int = 10):
"""Run venvstacks lock, auto-building wheels for sdist-only packages.
When uv fails with "has no usable wheels", extract the package name,
build a wheel locally into _wheels/, and retry. Repeats up to
max_retries times to handle transitive sdist-only dependencies.
"""
built = set()
for attempt in range(max_retries):
result = subprocess.run(
lock_cmd, capture_output=True, text=True,
)
if result.returncode == 0:
if result.stdout:
print(result.stdout, end="")
return
stderr = result.stderr or ""
stdout = result.stdout or ""
combined = stderr + stdout
# Pattern: "Because <pkg>==<ver> has no usable wheels"
match = re.search(
r"Because\s+(\S+)==\S+\s+has no usable wheels", combined
)
if not match:
# Not a sdist-only failure — print output and abort
if stdout:
print(stdout, end="")
if stderr:
print(stderr, end="", file=sys.stderr)
print(f" ✗ Command failed with code {result.returncode}")
sys.exit(1)
pkg = match.group(1)
if pkg in built:
# Already tried this package, something else is wrong
if stdout:
print(stdout, end="")
if stderr:
print(stderr, end="", file=sys.stderr)
print(f" ✗ Already built {pkg} but lock still fails")
sys.exit(1)
print(f" sdist-only dependency detected: {pkg}, building wheel...")
if not _build_sdist_wheel(pkg):
print(f" ✗ Failed to build wheel for {pkg}")
sys.exit(1)
built.add(pkg)
print(f" Retrying lock (attempt {attempt + 2})...")
print(f" ✗ Lock still failing after {max_retries} sdist wheel builds")
sys.exit(1)
def _find_wheel_for_package(pkg_name: str) -> Path | None:
"""Find the built wheel file for a package name."""
normalized = pkg_name.lower().replace("-", "_")
for whl in WHEELS_DIR.glob("*.whl"):
whl_name = whl.stem.split("-")[0].lower()
if whl_name == normalized:
return whl
return None
def _write_engine_commits(omlx_pkg_dir: Path):
"""Write _engine_commits.json to the omlx package for runtime SHA display.
Extracts commit SHAs from venvstacks.toml git URLs and writes them
so _get_engine_info() can show clickable commit links in the admin dashboard.
"""
import json
# Git pins now live in pyproject.toml (single source of truth); the
# venvstacks.toml template no longer carries version-bearing entries.
pyproject_path = SCRIPT_DIR.parent / "pyproject.toml"
git_reqs = _parse_git_requirements(pyproject_path)
repo_urls = {
"mlx-lm": "https://github.com/ml-explore/mlx-lm",
"mlx-vlm": "https://github.com/Blaizzy/mlx-vlm",
"mlx-embeddings": "https://github.com/Blaizzy/mlx-embeddings",
"mlx-audio": "https://github.com/Blaizzy/mlx-audio",
}
commits = {}
for full_req, git_url in git_reqs:
pkg_name = full_req.split("@")[0].strip().lower().split("[", 1)[0]
# git_url format: git+https://github.com/ml-explore/mlx-lm@bcf6306...
if "@" in git_url:
commit = git_url.rsplit("@", 1)[1]
if pkg_name in repo_urls:
commits[pkg_name] = {
"commit": commit,
"url": repo_urls[pkg_name],
}
if commits:
commits_file = omlx_pkg_dir / "_engine_commits.json"
commits_file.write_text(json.dumps(commits, indent=2) + "\n")
print(f" Generated _engine_commits.json: {list(commits.keys())}")
# Maps each venvstacks layer name to the pyproject.toml sections whose
# requirements feed into it. Entries:
# "project" → [project] dependencies (PEP 621)
# "<extra>" → [project.optional-dependencies].<extra>
# Layers not listed here are left empty (e.g. cpython-3.11 has no deps).
# When Jun's release path reintroduces a Python menubar application
# layer, add an entry like {"omlx-app": ["menubar"]} alongside.
# Later sources override earlier ones on a name collision (PEP 503
# normalized). [bundle] last means a bundle-specific [audio]-extra entry
# wins over [project]'s plain entry for the same package.
_LAYER_REQUIREMENTS_SOURCES = {
"mlx-base": ["project", "bundle"],
}
def _read_pyproject_requirements() -> dict[str, list[str]]:
"""Read pyproject.toml and return {section: [req_string, ...]}.
section is "project" for the main dependencies or the name of an
entry under [project.optional-dependencies].
"""
import tomllib
pyproject = SCRIPT_DIR.parent / "pyproject.toml"
data = tomllib.loads(pyproject.read_text())
out: dict[str, list[str]] = {
"project": list(data["project"]["dependencies"]),
}
for name, reqs in data["project"].get("optional-dependencies", {}).items():
out[name] = list(reqs)
return out
def _generate_venvstacks_toml() -> Path:
"""Render the venvstacks layer template + pyproject.toml deps into a
resolved venvstacks.toml the rest of the build pipeline can consume.
The committed packaging/venvstacks.toml carries only layer structure
+ dynlib_exclude + [tool.uv] settings; its `requirements = []` arrays
are placeholders. This function fills them in from pyproject.toml so
Python dep versions live in exactly one place.
Stdlib-only implementation: tomllib to parse the template for layer
discovery, then plain text substitution to inject populated arrays.
Avoids a tomlkit dependency on the host Python that runs build.py.
"""
import tomllib
template_path = SCRIPT_DIR / "venvstacks.toml"
out_path = SCRIPT_DIR / "_venvstacks_resolved.toml"
pp_reqs = _read_pyproject_requirements()
template_text = template_path.read_text()
parsed = tomllib.loads(template_text)
# Split the template on `[[...]]` table-array headers so we can find
# and modify the right section without disturbing siblings.
sections = re.split(r"(?m)(?=^\[\[)", template_text)
populated = []
for layer in parsed.get("frameworks", []) + parsed.get("applications", []):
layer_name = str(layer["name"])
sources = _LAYER_REQUIREMENTS_SOURCES.get(layer_name)
if not sources:
continue
# Merge requirements from each pyproject section in order, deduping
# by PEP 503 normalized name with extras stripped. Later sources
# win — so [bundle]'s extras-bearing entries override [project]'s
# plain entries for the same package (e.g. mistral-common[audio]).
merged_map: dict[str, str] = {}
for src in sources:
if src not in pp_reqs:
print(
f" ✗ Layer {layer_name!r} references pyproject section "
f"{src!r} which is not declared. "
"Add it to pyproject.toml [project.optional-dependencies].",
file=sys.stderr,
)
sys.exit(1)
for req in pp_reqs[src]:
key = req.split("@", 1)[0].split(";", 1)[0]
key = re.split(r"[<>=!~]", key, maxsplit=1)[0].strip()
key = key.split("[", 1)[0].lower() # strip PEP 508 extras
merged_map[key] = req
merged = list(merged_map.values())
formatted_array = (
"requirements = [\n"
+ "".join(f' "{r}",\n' for r in merged)
+ "]"
)
# Find the section starting with [[frameworks]] or [[applications]]
# whose `name = "X"` matches this layer, and replace its
# `requirements = []` placeholder.
layer_re = re.compile(
rf'^name\s*=\s*"{re.escape(layer_name)}"\s*$',
re.MULTILINE,
)
placeholder_re = re.compile(
r"^requirements\s*=\s*\[\s*\]\s*$",
re.MULTILINE,
)
section_re = re.compile(r"^\[\[(?:frameworks|applications)\]\]")
injected = False
for i, section in enumerate(sections):
if not section_re.match(section):
continue
if not layer_re.search(section):
continue
new_section, n = placeholder_re.subn(
formatted_array, section, count=1
)
if n == 0:
continue
sections[i] = new_section
injected = True
break
if not injected:
print(
f" ✗ Could not find `requirements = []` placeholder in layer "
f"{layer_name!r}. The template at {template_path} must contain "
"a stand-alone `requirements = []` line within the layer block.",
file=sys.stderr,
)
sys.exit(1)
populated.append(f"{layer_name} ({len(merged)} reqs)")
out_path.write_text("".join(sections))
print(f" Resolved layer requirements from pyproject.toml: {', '.join(populated)}")
return out_path
def _create_resolved_toml(version_map: dict[str, str], base_toml: Path) -> Path:
"""Rewrite git URLs in base_toml to local file:// wheel paths.
Git-built wheels have different hashes than PyPI releases of the
same version, so we must point directly to the local wheel files to
avoid hash mismatches. The input toml comes from
_generate_venvstacks_toml() and the output overwrites it in place.
"""
content = base_toml.read_text()
for full_req, git_url in _parse_git_requirements(base_toml):
pkg_name = full_req.split("@")[0].strip()
whl = _find_wheel_for_package(pkg_name)
if whl:
whl_uri = whl.resolve().as_uri()
old_line = f'"{full_req}"'
new_line = f'"{pkg_name} @ {whl_uri}"'
content = content.replace(old_line, new_line)
print(f" {pkg_name} @ git+... → {whl.name}")
base_toml.write_text(content)
return base_toml
def _venvstacks_driver() -> list[str]:
"""Pick an available venvstacks driver as a command prefix.
Resolution order (first that works wins):
1. `<sys.executable> -m venvstacks` when venvstacks is importable
from the Python running build.py.
2. `uvx venvstacks` — uv's pipx-equivalent.
3. `pipx run venvstacks` — historical default.
4. `venvstacks` on PATH as a last resort.
Why -m first: the PATH-installed `venvstacks` script's shebang may
point at a dotted interpreter like `.../python3.11`. venvstacks 0.7
computes `Path(sys.executable).suffix` to derive the runtime binary
extension; on `python3.11` that returns ".11", producing a bogus
`bin/python.11` path and breaking `pip --python`. Running via the
current interpreter (typically `.../python3`, suffix "") sidesteps
the bug. See https://github.com/lmstudio-ai/venvstacks/issues/ for
the upstream report.
"""
try:
subprocess.run(
[sys.executable, "-c", "import venvstacks"],
check=True, capture_output=True,
)
return [sys.executable, "-m", "venvstacks"]
except (subprocess.CalledProcessError, FileNotFoundError):
pass
if shutil.which("uvx") is not None:
return ["uvx", "venvstacks"]
if shutil.which("pipx") is not None:
return ["pipx", "run", "venvstacks"]
if shutil.which("venvstacks") is not None:
return ["venvstacks"]
print(
" ✗ No venvstacks driver found. Install with one of:\n"
" pip install -e \".[dev]\" (pip-managed venv)\n"
" uv sync --dev (uv-managed venv)\n"
" pipx install venvstacks (host-global tool)",
file=sys.stderr,
)
sys.exit(1)
def build_venvstacks():
"""Build venvstacks layers."""
print("\n[1/4] Building venvstacks layers...")
venvstacks_cmd = _venvstacks_driver()
print(f" Using venvstacks driver: {' '.join(venvstacks_cmd)}")
# Step 0: Render the resolved venvstacks.toml from pyproject.toml deps
print("\n Resolving layer requirements from pyproject.toml...")
resolved_toml = _generate_venvstacks_toml()
venvstacks_cmd = _venvstacks_driver()
print(f" Using venvstacks driver: {' '.join(venvstacks_cmd)}")
# Step 1: Build wheels from git-pinned packages
version_map = build_local_wheels(resolved_toml)
# Step 2: Swap git URLs in the resolved toml for local wheel paths
if version_map:
print("\n Resolving git requirements to local wheel paths...")
_create_resolved_toml(version_map, resolved_toml)
# Local wheels args
local_wheels_args = []
if WHEELS_DIR.exists() and any(WHEELS_DIR.glob("*.whl")):
local_wheels_args = ["--local-wheels", str(WHEELS_DIR)]
# Step 3: Lock environments (always re-lock to match current wheels)
# If lock fails due to sdist-only packages (no pre-built wheel on PyPI),
# _lock_with_sdist_retry() builds them locally and retries automatically.
print("\n Locking environments...")
lock_cmd = venvstacks_cmd + [
"lock",
str(resolved_toml),
] + local_wheels_args
if version_map:
# Force re-lock when git packages changed (hashes will differ)
lock_cmd += ["--reset-lock", "*"]
else:
lock_cmd += ["--if-needed"]
_lock_with_sdist_retry(lock_cmd)
# Step 4: Build environments
print("\n Building environments (this may take a while)...")
run_cmd(venvstacks_cmd + [
"build",
str(resolved_toml),
"--no-lock",
] + local_wheels_args)
# Step 5: Export to local directory for app bundle
print("\n Exporting environments...")
if EXPORT_DIR.exists():
shutil.rmtree(EXPORT_DIR)
run_cmd(venvstacks_cmd + [
"local-export",
str(resolved_toml),
"--output-dir", str(EXPORT_DIR),
])
# Cleanup the generated resolved toml — always temporary now that it's
# produced fresh each build from pyproject.toml + venvstacks.toml.
if resolved_toml.exists():
resolved_toml.unlink()
# Install mlx-audio separately: build wheel from git, install --no-deps.
# mlx-audio pins mlx-lm==0.31.1 which conflicts with our git-pinned mlx-lm,
# so it can't go through venvstacks' uv resolver.
_install_mlx_audio(EXPORT_DIR)
# Install paroquant --no-deps. The official [mlx] extra requires
# torchvision which the mlx load path doesn't actually use; verified
# end-to-end on 0.1.14. All real deps (mlx, mlx-lm, mlx-vlm, numpy,
# huggingface_hub) are already in the framework layer.
_install_paroquant(EXPORT_DIR)
# Install xgrammar + apache-tvm-ffi --no-deps; oMLX uses the non-torch
# paths only, and omlx/_torch_stub.py satisfies xgrammar's import-time
# references to torch so the runtime torch dep is unnecessary.
_install_xgrammar(EXPORT_DIR)
# Bundle spacy language model for Kokoro TTS.
# misaki's en.G2P tries spacy.cli.download() at runtime, which fails in
# the code-signed app bundle (read-only site-packages).
_install_spacy_model(EXPORT_DIR)
# Strip large packages that are only needed for model conversion / data
# loading, not inference. Saves ~780 MB in the app bundle.
_strip_unused_packages(EXPORT_DIR)
return EXPORT_DIR
# mlx-audio git commit — aligned with pyproject.toml [audio] extra
_MLX_AUDIO_GIT = "git+https://github.com/Blaizzy/mlx-audio@51753266e0a4f766fd5e6fbc46652224efc23981"
def _install_mlx_audio(export_dir: Path):
"""Build mlx-audio wheel from git and install into exported framework."""
print("\n Building mlx-audio from git...")
audio_wheels = SCRIPT_DIR / "_audio_wheels"
if audio_wheels.exists():
shutil.rmtree(audio_wheels)
audio_wheels.mkdir()
# Build wheel
run_cmd([
sys.executable, "-m", "pip", "wheel",
"--no-deps", "--wheel-dir", str(audio_wheels),
_MLX_AUDIO_GIT,
])
# Install into framework site-packages
fw_site = (
export_dir
/ "framework-mlx-base"
/ "lib"
/ "python3.11"
/ "site-packages"
)
if not fw_site.exists():
print(f" ✗ site-packages not found: {fw_site}")
return
import zipfile
for whl in audio_wheels.glob("*.whl"):
print(f" Installing {whl.name} (--no-deps)")
with zipfile.ZipFile(whl) as zf:
zf.extractall(fw_site)
shutil.rmtree(audio_wheels)
print(" ✓ mlx-audio installed")
# paroquant version — keep in sync with pyproject.toml [paroquant] extra
_PAROQUANT_VERSION = "0.1.14"
def _install_paroquant(export_dir: Path):
"""Build paroquant wheel from PyPI and install --no-deps into framework."""
print("\n Building paroquant wheel...")
paro_wheels = SCRIPT_DIR / "_paroquant_wheels"
if paro_wheels.exists():
shutil.rmtree(paro_wheels)
paro_wheels.mkdir()
run_cmd([
sys.executable, "-m", "pip", "wheel",
"--no-deps", "--wheel-dir", str(paro_wheels),
f"paroquant=={_PAROQUANT_VERSION}",
])
fw_site = (
export_dir
/ "framework-mlx-base"
/ "lib"
/ "python3.11"
/ "site-packages"
)
if not fw_site.exists():
print(f" ✗ site-packages not found: {fw_site}")
return
import zipfile
for whl in paro_wheels.glob("*.whl"):
print(f" Installing {whl.name} (--no-deps)")
with zipfile.ZipFile(whl) as zf:
zf.extractall(fw_site)
shutil.rmtree(paro_wheels)
print(" ✓ paroquant installed")
# xgrammar / tvm-ffi versions — single source of truth lives in
# omlx/_torch_stub.py (the stub MUST track the actually-installed versions
# or imports fail). Importing keeps the two files from drifting apart.
try:
from omlx._torch_stub import (
_TARGET_TVM_FFI_VERSIONS,
_TARGET_XGRAMMAR_VERSIONS,
)
_XGRAMMAR_VERSION = _TARGET_XGRAMMAR_VERSIONS[0]
_TVM_FFI_VERSION = _TARGET_TVM_FFI_VERSIONS[0]
except Exception: # pragma: no cover — build runs may not have omlx on path yet
_XGRAMMAR_VERSION = "0.2.3"
_TVM_FFI_VERSION = "0.1.11"
def _install_xgrammar(export_dir: Path):
"""Install xgrammar + apache-tvm-ffi --no-deps into framework site-packages.
xgrammar declares torch>=1.10.0 as a runtime dep, but oMLX only exercises
its non-torch paths (numpy bitmasks + MLX kernel). Shipping torch would
add ~500 MB to the bundle. omlx/_torch_stub.py satisfies xgrammar's
import-time torch references so the package loads without real torch.
Idempotent: a sentinel file is written after the last extract; if it's
present we skip. Trusting both ``xgrammar/`` and ``tvm_ffi/`` to exist
isn't enough — an interruption between the two extracts would otherwise
leave a half-installed state the next run accepts.
"""
fw_site = (
export_dir
/ "framework-mlx-base"
/ "lib"
/ "python3.11"
/ "site-packages"
)
sentinel = fw_site / (
f"_omlx_xgrammar_{_XGRAMMAR_VERSION}_tvmffi_{_TVM_FFI_VERSION}.installed"
)
if sentinel.exists():
print(" ✓ xgrammar + apache-tvm-ffi already installed, skipping")
return
print("\n Downloading xgrammar wheels...")
xgr_wheels = SCRIPT_DIR / "_xgrammar_wheels"
if xgr_wheels.exists():
shutil.rmtree(xgr_wheels)
xgr_wheels.mkdir()
# Explicit platform tags so the build host's Python version doesn't matter.
run_cmd([
sys.executable, "-m", "pip", "download",
"--no-deps", "--dest", str(xgr_wheels),
"--python-version", "3.11",
"--platform", "macosx_11_0_arm64",
"--only-binary=:all:",
f"xgrammar=={_XGRAMMAR_VERSION}",
f"apache-tvm-ffi=={_TVM_FFI_VERSION}",
])
if not fw_site.exists():
print(f" ✗ site-packages not found: {fw_site}")
return
import zipfile
for whl in xgr_wheels.glob("*.whl"):
print(f" Installing {whl.name} (--no-deps)")
with zipfile.ZipFile(whl) as zf:
zf.extractall(fw_site)
shutil.rmtree(xgr_wheels)
# Integrity check before sentinel-write: zipfile.extractall is not
# atomic per file, so a build-host interrupt (SIGKILL, ENOSPC,
# inode exhaustion) mid-extract can leave truncated __init__.py
# files on disk. ``sentinel.exists()`` would still accept the next
# run, masking the partial install. Verify the package roots
# exist with non-empty __init__.py before writing the sentinel.
integrity_checks = (
("xgrammar", fw_site / "xgrammar" / "__init__.py"),
("tvm_ffi", fw_site / "tvm_ffi" / "__init__.py"),
)
for pkg_name, init_path in integrity_checks:
if not init_path.exists() or init_path.stat().st_size == 0:
print(
f" ✗ {pkg_name} install incomplete ({init_path}); refusing "
"to write sentinel — next run will retry"
)
return
# Atomic sentinel write: write to a tmp file in the same directory
# then ``os.replace`` (POSIX-atomic on the same filesystem). A bare
# ``Path.write_text`` is open+write+close and can itself be interrupted
# mid-write, leaving a zero-length sentinel that ``sentinel.exists()``
# would still accept — exactly the failure mode this sentinel is
# supposed to guard against.
sentinel_tmp = sentinel.with_suffix(sentinel.suffix + ".tmp")
sentinel_tmp.write_text(
f"xgrammar=={_XGRAMMAR_VERSION}\napache-tvm-ffi=={_TVM_FFI_VERSION}\n"
)
os.replace(sentinel_tmp, sentinel)
print(" ✓ xgrammar + apache-tvm-ffi installed")
# spacy language model — required by misaki (Kokoro TTS G2P)
# Update version when spacy is bumped in venvstacks.toml
_SPACY_MODEL = "en_core_web_sm"
_SPACY_MODEL_VERSION = "3.8.0"
_SPACY_MODEL_URL = (
"https://github.com/explosion/spacy-models/releases/download/"
f"{_SPACY_MODEL}-{_SPACY_MODEL_VERSION}/"
f"{_SPACY_MODEL}-{_SPACY_MODEL_VERSION}-py3-none-any.whl"
)
def _install_spacy_model(export_dir: Path):
"""Download and install spacy en_core_web_sm into exported framework."""
import urllib.request
import zipfile
fw_site = (
export_dir
/ "framework-mlx-base"
/ "lib"
/ "python3.11"
/ "site-packages"
)
if not fw_site.exists():
print(f" ✗ site-packages not found: {fw_site}")
return
# Skip if already installed
if (fw_site / _SPACY_MODEL).exists():
print(f" ✓ {_SPACY_MODEL} already installed, skipping")
return
print(f"\n Installing {_SPACY_MODEL}-{_SPACY_MODEL_VERSION}...")
whl_path = SCRIPT_DIR / f"{_SPACY_MODEL}-{_SPACY_MODEL_VERSION}.whl"
try:
urllib.request.urlretrieve(_SPACY_MODEL_URL, whl_path)
with zipfile.ZipFile(whl_path) as zf:
zf.extractall(fw_site)
print(f" ✓ {_SPACY_MODEL} installed")
finally:
whl_path.unlink(missing_ok=True)
# Packages to strip from the app bundle. These are transitive dependencies
# pulled in by modelscope (datasets→pyarrow/pandas) and mlx-vlm (opencv)
# but are NOT needed for inference at runtime. torch/sympy kept as safety
# net in case any future dependency pulls them in transitively.
_STRIP_PACKAGES = [
"torch",
"sympy", # torch dep (safety net)
"cv2", # opencv-python, mlx-vlm only uses it for image loading (Pillow suffices)
"pyarrow", # datasets dep
"pandas", # datasets dep
"datasets", # modelscope dep, not used at inference
# dist-info dirs (matched by prefix)
]
# Prefixes for dist-info directories to remove alongside the packages above.
_STRIP_DIST_PREFIXES = [
"torch-", "sympy-", "opencv_python-", "pyarrow-", "pandas-", "datasets-",
]
def _strip_unused_packages(export_dir: Path):
"""Remove large packages not needed for inference from exported framework."""
fw_site = (
export_dir
/ "framework-mlx-base"
/ "lib"
/ "python3.11"
/ "site-packages"
)
if not fw_site.exists():
return
print("\n Stripping unused packages from app bundle...")
saved = 0
for item in sorted(fw_site.iterdir()):
name = item.name
should_strip = (
name in _STRIP_PACKAGES
or any(name.startswith(p) for p in _STRIP_DIST_PREFIXES)
)
if should_strip and item.exists():
size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file())
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
saved += size
print(f" Removed {name} ({size / 1024 / 1024:.0f} MB)")
print(f" ✓ Stripped {saved / 1024 / 1024:.0f} MB total")
# Post-strip invariant: no torch artifact must survive. A partial torch
# (some files but not enough for xgrammar) would be the worst possible
# outcome — _torch_stub.find_spec("torch") would return a real spec,
# install() would short-circuit, and xgrammar would import the broken
# half-torch and fail at runtime with confusing errors. Fail fast.
surviving_torch = [
p.name for p in fw_site.iterdir()
if p.name == "torch"
or p.name.startswith("torch-")
or p.name.startswith("torch_")
]
if surviving_torch:
raise RuntimeError(
"Post-strip integrity check failed: torch artifacts survived "
f"the strip step: {surviving_torch}. _torch_stub.find_spec would "
"see a real torch and short-circuit install(), leaving xgrammar "
"to fail at runtime against a half-installed torch. Add the "
"leftover names to _STRIP_PACKAGES / _STRIP_DIST_PREFIXES."
)
def _compute_donor_fingerprint() -> str:
"""Hash of the inputs that determine the venvstacks export shape.
Used by `apps/omlx-mac/Scripts/build.sh` to decide whether the cached
`_export/` is still in sync with the current sources, or needs a
rebuild.
"""
import hashlib
h = hashlib.sha256()
inputs = [
SCRIPT_DIR.parent / "pyproject.toml",
SCRIPT_DIR / "venvstacks.toml",
SCRIPT_DIR.parent / "uv.lock",
]
for path in inputs:
if path.exists():
h.update(path.read_bytes())
return h.hexdigest()
def _write_export_fingerprint():
"""Write the current fingerprint into _export/ so callers can detect drift."""
if not EXPORT_DIR.exists():
return
fingerprint = _compute_donor_fingerprint()
(EXPORT_DIR / ".fingerprint").write_text(fingerprint + "\n")
print(f" Wrote _export/.fingerprint ({fingerprint[:12]}…)")
def main():
parser = argparse.ArgumentParser(
description="Build venvstacks Python layers consumed by the Swift "
"macOS bundle. The legacy PyObjC menubar `.app` and DMG "
"pipeline lived here too; both retired with the Swift "
"rewrite. Today this script is invoked by "
"`apps/omlx-mac/Scripts/build.sh` to produce / fingerprint "
"the export layers — there is no full-app path anymore."
)
parser.add_argument("--venvstacks-only", action="store_true",
help="Run venvstacks lock+build+export and stop. "
"Currently the only supported run mode.")
parser.add_argument("--print-fingerprint", action="store_true",
help="Print the donor fingerprint and exit. "
"`build.sh` uses this to detect drift between "
"sources and the cached `_export/`.")
parser.add_argument("--write-engine-commits",
metavar="OMLX_PACKAGE_DIR",
help="Write _engine_commits.json into the given "
"staged omlx package directory and exit. "
"`build.sh` uses this after copying Resources/omlx.")
parser.add_argument("--macos-target",
help="Target macOS version for mlx/mlx-metal wheels "
"(e.g. 26.0). Downloads platform-specific wheels "
"with M5 Neural Accelerator support.")
args = parser.parse_args()
if args.print_fingerprint:
print(_compute_donor_fingerprint())
return
if args.write_engine_commits:
_write_engine_commits(Path(args.write_engine_commits))
return
print(f"Building {APP_NAME} v{VERSION}")
print("=" * 50)
if not args.venvstacks_only:
parser.error(
"Pass --venvstacks-only. The Swift bundle is built by "
"apps/omlx-mac/Scripts/build.sh; this script only produces "
"the Python layers it embeds."
)
build_venvstacks()
if args.macos_target:
swap_platform_wheels(EXPORT_DIR, args.macos_target)
_write_export_fingerprint()
print("\n" + "=" * 50)
print("venvstacks export ready at:")
print(f" {EXPORT_DIR}")
if __name__ == "__main__":
main()