diff --git a/.gitignore b/.gitignore index 814af48c7..4384d7aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -171,3 +170,6 @@ cython_debug/ **/*.xcodeproj/* .aider* + +.DS_Store +.vscode \ No newline at end of file diff --git a/Formula/exo.rb b/Formula/exo.rb new file mode 100644 index 000000000..9eb6036ba --- /dev/null +++ b/Formula/exo.rb @@ -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 \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..44aef3f4e --- /dev/null +++ b/build.sh @@ -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" \ No newline at end of file diff --git a/exo.spec b/exo.spec new file mode 100644 index 000000000..48fc6dcaa --- /dev/null +++ b/exo.spec @@ -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, +)