Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement cross-platform executable compilation and distribution (WIP) #451

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ __pycache__/
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
Expand Down Expand Up @@ -171,3 +170,6 @@ cython_debug/

**/*.xcodeproj/*
.aider*

.DS_Store
.vscode
18 changes: 18 additions & 0 deletions Formula/exo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
cask "exo" do
version "0.1.0"
sha256 "57fc9b838688a4dbd4842db4a96888f7627d5df16fd633bf2401340a7388cba6"

url "http://localhost:8000/exo-0.1.0-darwin-arm64.zip"
name "Exo"
desc "MLX-powered AI assistant"
homepage "https://github.com/exo-explorer/exo"

depends_on macos: ">= :ventura"
depends_on arch: :arm64

binary "#{staged_path}/exo-0.1.0-darwin-arm64/exo"

postflight do
set_permissions "#{staged_path}/exo-0.1.0-darwin-arm64/exo", "0755"
end
end
62 changes: 62 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/bin/bash
set -e

# Configuration
VERSION="0.1.0"
APP_NAME="exo"
DIST_DIR="dist"
PACKAGE_NAME="${APP_NAME}-${VERSION}-darwin-arm64"

# 1. Clean previous builds
echo "Cleaning previous builds..."
rm -rf build dist

# 2. Run PyInstaller
echo "Building with PyInstaller..."
pyinstaller exo.spec

# 3. Create a clean distribution directory
echo "Creating distribution package..."
mkdir -p "${DIST_DIR}/${PACKAGE_NAME}"
cp -r "dist/${APP_NAME}/"* "${DIST_DIR}/${PACKAGE_NAME}/"

# 4. Create ZIP file
echo "Creating ZIP archive..."
cd "${DIST_DIR}"
zip -r "${PACKAGE_NAME}.zip" "${PACKAGE_NAME}"
cd ..

# 5. Calculate SHA256
echo "Calculating SHA256..."
SHA256=$(shasum -a 256 "${DIST_DIR}/${PACKAGE_NAME}.zip" | cut -d' ' -f1)

# 6. Generate Homebrew Cask formula
echo "Generating Homebrew formula..."
cat > Formula/exo.rb << EOL
cask "exo" do
version "${VERSION}"
sha256 "${SHA256}"

url "https://github.com/sethburkart123/exo/releases/download/test/exo-0.1.0-darwin-arm64.zip"
name "Exo"
desc "MLX-powered AI assistant"
homepage "https://github.com/exo-explorer/exo"

depends_on macos: ">= :ventura"
depends_on arch: :arm64

binary "#{staged_path}/exo-${VERSION}-darwin-arm64/exo"

postflight do
set_permissions "#{staged_path}/exo-${VERSION}-darwin-arm64/exo", "0755"
end
end
EOL

echo "Done! Package created at: ${DIST_DIR}/${PACKAGE_NAME}.zip"
echo "SHA256: ${SHA256}"
echo ""
echo "Next steps:"
echo "1. Upload ${PACKAGE_NAME}.zip to GitHub releases"
echo "2. Update the URL in the formula with your actual GitHub repository"
echo "3. Test the formula locally with: brew install --cask ./Formula/exo.rb"
201 changes: 201 additions & 0 deletions exo.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
import shutil
from PyInstaller.utils.hooks import collect_all, collect_submodules, copy_metadata

# Basic Configuration
block_cipher = None
name = os.environ.get('EXO_NAME', 'exo')
spec_dir = os.path.dirname(os.path.abspath(SPEC))
root_dir = os.path.abspath(os.path.join(spec_dir))

# Get Python library path dynamically
python_exec = sys.executable
python_prefix = sys.prefix
if sys.platform.startswith('darwin'):
# On macOS, construct library path based on current Python version
version = f"{sys.version_info.major}.{sys.version_info.minor}"
lib_patterns = [
os.path.join(python_prefix, 'lib', f'libpython{version}.dylib'),
os.path.join(python_prefix, 'lib', f'libpython{version}m.dylib'),
os.path.join(os.path.dirname(python_exec), '..', 'lib', f'libpython{version}.dylib'),
]

PYTHON_LIB = None
for pattern in lib_patterns:
if os.path.exists(pattern):
PYTHON_LIB = pattern
break

if not PYTHON_LIB:
raise FileNotFoundError(f"Could not find Python library for Python {version}")
print(f"Using Python library: {PYTHON_LIB}")

# Create a local copy of the Python library
local_lib_dir = os.path.join(spec_dir, 'lib')
os.makedirs(local_lib_dir, exist_ok=True)
local_python_lib = os.path.join(local_lib_dir, 'libpython3.12.dylib')
shutil.copy2(PYTHON_LIB, local_python_lib)
print(f"Copied Python library to: {local_python_lib}")

# Model Collection
models_dir = os.path.join(root_dir, 'exo', 'inference', 'mlx', 'models')
model_files = []
for root, dirs, files in os.walk(models_dir):
for file in files:
if file.endswith('.py') and file not in ['__init__.py', 'base.py']:
model_files.append(os.path.join(root, file))

model_imports = [
f"exo.inference.mlx.models.{os.path.basename(f)[:-3]}"
for f in model_files
if '__pycache__' not in f
]

# Data Collection
datas = [
(os.path.join(root_dir, 'exo/tinychat'), 'exo/tinychat'),
(os.path.join(root_dir, 'exo'), 'exo'),
(local_python_lib, '.'),
]

# Collect Transformers Data
print("Collecting transformers data...")
try:
trans_datas, _, _ = collect_all('transformers')
filtered_datas = [(src, dst) for src, dst in trans_datas
if not any(x in dst.lower() for x in ['.git', 'test', 'examples'])]
datas.extend(filtered_datas)
datas.extend(copy_metadata('transformers'))
except Exception as e:
print(f"Warning: Could not collect transformers data: {e}")

# MLX Integration
if sys.platform.startswith('darwin'):
print("Configuring macOS specific settings...")
mlx_locations = [
'/opt/homebrew/Caskroom/miniconda/base/envs/exo/lib/python3.12/site-packages/mlx',
os.path.join(root_dir, 'venv/lib/python3.12/site-packages/mlx'),
os.path.join(python_prefix, 'lib/python3.12/site-packages/mlx'), # Added new search path
]

mlx_path = None
for loc in mlx_locations:
if os.path.exists(loc):
mlx_path = os.path.abspath(loc)
print(f"Found MLX at: {mlx_path}")
break

if mlx_path:
datas.append((mlx_path, 'mlx'))
# Search for metallib in multiple possible locations
metallib_locations = [
os.path.join(mlx_path, 'backend/metal/kernels/mlx.metallib'),
os.path.join(mlx_path, 'mlx/backend/metal/kernels/mlx.metallib'),
os.path.join(python_prefix, 'lib/python3.12/site-packages/mlx/backend/metal/kernels/mlx.metallib'),
]

metallib_found = False
for metallib_path in metallib_locations:
if os.path.exists(metallib_path):
print(f"Found metallib at: {metallib_path}")
# Add metallib to both the root and the MLX directory structure
datas.extend([
(metallib_path, '.'),
(metallib_path, 'mlx/backend/metal/kernels')
])
metallib_found = True
break

if not metallib_found:
print("ERROR: Could not find mlx.metallib in any expected location!")
print("Searched locations:", "\n".join(metallib_locations))
sys.exit(1)
else:
print("ERROR: MLX package not found in expected locations")
sys.exit(1)

# Initial binaries list with Python library
binaries = []
if sys.platform.startswith('darwin'):
binaries.append((local_python_lib, '.'))

# Analysis Configuration
a = Analysis(
[os.path.join(root_dir, 'exo/main.py')],
pathex=[root_dir],
binaries=binaries,
datas=datas,
hiddenimports=[
'transformers',
'safetensors',
'safetensors.torch',
'exo',
'packaging.version',
'packaging.specifiers',
'packaging.requirements',
'packaging.markers',
'charset_normalizer',
'requests',
'urllib3',
'certifi',
'idna',
'mlx',
'mlx.core',
'mlx.nn',
'mlx.backend',
'mlx.backend.metal',
'mlx.backend.metal.kernels',
'_sysconfigdata__darwin_darwin',
] + model_imports,
hookspath=[],
hooksconfig={
'urllib3': {'ssl': True},
'transformers': {'module': True}
},
runtime_hooks=[],
excludes=[
'pytest',
'sentry_sdk'
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)

# Make sure Python library is included in both datas and binaries
a.datas = list(dict.fromkeys(a.datas + [(os.path.basename(local_python_lib), local_python_lib, 'DATA')]))
a.binaries = list(dict.fromkeys(a.binaries + [(os.path.basename(local_python_lib), local_python_lib, 'BINARY')]))

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=name,
debug=False, # Enable debug mode
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch='arm64',
codesign_identity=None,
entitlements_file=None,
)
# Create the collection
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name=name,
)