Compare commits

...

1 Commits

Author SHA1 Message Date
Jake Hillion
527837f2ef nix: add Python packaging with uv2nix
The Python environment needed to be built with Nix to enable fully
reproducible builds and integrate with the existing Nix-based CI/CD
pipeline. This also allows the PyInstaller bundle to be built as a Nix
derivation.

Added uv2nix, pyproject-nix, and pyproject-build-systems inputs to
build Python packages from uv.lock. Created python/parts.nix with
overlays to inject Nix-built Rust bindings and stub out MLX packages
on Linux (which require Apple Metal). Updated build-app.yml to use
the Nix-built PyInstaller derivation instead of invoking uv directly.

This unifies the build process under Nix, simplifies CI by removing
UV/Python setup steps, and ensures the PyInstaller bundle uses the
same dependency resolution as the rest of the Nix build.

Test plan:
- Run `nix flake check` to verify all checks pass
- Build exo-pyinstaller on macOS: `nix build .#exo-pyinstaller`
- Push to test-app branch to trigger build-app.yml workflow
2026-01-14 18:23:55 +00:00
7 changed files with 250 additions and 108 deletions

View File

@@ -1,12 +0,0 @@
name: Type Check
description: "Run type checker"
runs:
using: "composite"
steps:
- name: Run type checker
run: |
nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just sync
nix --extra-experimental-features nix-command --extra-experimental-features flakes develop -c just check
shell: bash

View File

@@ -102,17 +102,6 @@ jobs:
- name: Install Homebrew packages
run: brew install just awscli macmon
- name: Install UV
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Setup Python
run: |
uv python install
uv sync --locked
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
@@ -124,12 +113,6 @@ jobs:
name: exo
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build dashboard
run: |
DASHBOARD_OUT=$(nix build .#dashboard --print-build-logs --no-link --print-out-paths)
mkdir -p dashboard/build
cp -r "$DASHBOARD_OUT"/* dashboard/build/
- name: Install Sparkle CLI
run: |
CLI_URL="${SPARKLE_CLI_URL:-https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz}"
@@ -185,7 +168,10 @@ jobs:
# ============================================================
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
run: |
PYINSTALLER_OUT=$(nix build .#exo-pyinstaller --print-build-logs --no-link --print-out-paths)
mkdir -p dist
cp -r "$PYINSTALLER_OUT" dist/exo
- name: Build Swift app
env:

View File

@@ -26,73 +26,9 @@ jobs:
name: exo
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Configure git user
- name: Run type checker
run: |
git config --local user.email "github-actions@users.noreply.github.com"
git config --local user.name "github-actions bot"
shell: bash
- name: Pull LFS files
run: |
echo "Pulling Git LFS files..."
git lfs pull
shell: bash
- name: Setup Nix Environment
run: |
echo "Checking for nix installation..."
# Check if nix binary exists directly
if [ -f /nix/var/nix/profiles/default/bin/nix ]; then
echo "Found nix binary at /nix/var/nix/profiles/default/bin/nix"
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
echo "PATH=$PATH" >> $GITHUB_ENV
nix --version
elif [ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then
echo "Found nix profile script, sourcing..."
source /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
nix --version
elif command -v nix >/dev/null 2>&1; then
echo "Nix already in PATH"
nix --version
else
echo "Nix not found. Debugging info:"
echo "Contents of /nix/var/nix/profiles/default/:"
ls -la /nix/var/nix/profiles/default/ 2>/dev/null || echo "Directory not found"
echo "Contents of /nix/var/nix/profiles/default/bin/:"
ls -la /nix/var/nix/profiles/default/bin/ 2>/dev/null || echo "Directory not found"
exit 1
fi
shell: bash
- name: Configure basedpyright include for local MLX
run: |
RUNNER_LABELS='${{ toJSON(runner.labels) }}'
if echo "$RUNNER_LABELS" | grep -q "local_mlx"; then
if [ -d "/Users/Shared/mlx" ]; then
echo "Updating [tool.basedpyright].include to use /Users/Shared/mlx"
awk '
BEGIN { in=0 }
/^\[tool\.basedpyright\]/ { in=1; print; next }
in && /^\[/ { in=0 } # next section
in && /^[ \t]*include[ \t]*=/ {
print "include = [\"/Users/Shared/mlx\"]"
next
}
{ print }
' pyproject.toml > pyproject.toml.tmp && mv pyproject.toml.tmp pyproject.toml
echo "New [tool.basedpyright] section:"
sed -n '/^\[tool\.basedpyright\]/,/^\[/p' pyproject.toml | sed '$d' || true
else
echo "local_mlx tag present but /Users/Shared/mlx not found; leaving pyproject unchanged."
fi
else
echo "Runner does not have 'local_mlx' tag; leaving pyproject unchanged."
fi
shell: bash
- uses: ./.github/actions/typecheck
nix develop -c bash -c "uv sync --all-packages && uv run basedpyright --project pyproject.toml"
nix:
name: Build and check (${{ matrix.system }})

65
flake.lock generated
View File

@@ -21,7 +21,9 @@
"nixpkgs"
],
"purescript-overlay": "purescript-overlay",
"pyproject-nix": "pyproject-nix"
"pyproject-nix": [
"pyproject-nix"
]
},
"locked": {
"lastModified": 1765953015,
@@ -149,19 +151,44 @@
"type": "github"
}
},
"pyproject-build-systems": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
],
"uv2nix": [
"uv2nix"
]
},
"locked": {
"lastModified": 1763662255,
"narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "042904167604c681a090c07eb6967b4dd4dae88c",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"pyproject-nix": {
"inputs": {
"nixpkgs": [
"dream2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1763017646,
"narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=",
"lastModified": 1764134915,
"narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "47bd6f296502842643078d66128f7b5e5370790c",
"rev": "2c8df1383b32e5443c921f61224b198a2282a657",
"type": "github"
},
"original": {
@@ -178,7 +205,10 @@
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"nixpkgs-swift": "nixpkgs-swift",
"treefmt-nix": "treefmt-nix"
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix",
"treefmt-nix": "treefmt-nix",
"uv2nix": "uv2nix"
}
},
"rust-analyzer-src": {
@@ -239,6 +269,29 @@
"repo": "treefmt-nix",
"type": "github"
}
},
"uv2nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
]
},
"locked": {
"lastModified": 1767701098,
"narHash": "sha256-CJhKZnWb3gumR9oTRjFvCg/6lYTGbZRU7xtvcyWIRwU=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "9d357f0d2ce6f5f35ec7959d7e704452352eb4da",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
}
},
"root": "root",

View File

@@ -24,6 +24,26 @@
dream2nix = {
url = "github:nix-community/dream2nix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.pyproject-nix.follows = "pyproject-nix";
};
# Python packaging with uv2nix
pyproject-nix = {
url = "github:pyproject-nix/pyproject.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
uv2nix = {
url = "github:pyproject-nix/uv2nix";
inputs.pyproject-nix.follows = "pyproject-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
pyproject-build-systems = {
url = "github:pyproject-nix/build-system-pkgs";
inputs.pyproject-nix.follows = "pyproject-nix";
inputs.uv2nix.follows = "uv2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Pinned nixpkgs for swift-format (swift is broken on x86_64-linux in newer nixpkgs)
@@ -48,6 +68,7 @@
inputs.treefmt-nix.flakeModule
./dashboard/parts.nix
./rust/parts.nix
./python/parts.nix
];
perSystem =
@@ -81,12 +102,6 @@
};
};
checks.lint = pkgs.runCommand "lint-check" { } ''
export RUFF_CACHE_DIR="$TMPDIR/ruff-cache"
${pkgs.ruff}/bin/ruff check ${inputs.self}/
touch $out
'';
devShells.default = with pkgs; pkgs.mkShell {
inputsFrom = [ self'.checks.cargo-build ];

View File

@@ -104,6 +104,8 @@ environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
# Use wheels for MLX packages (complex Metal SDK build requirements)
no-build-package = ["mlx", "mlx-metal", "mlx-cpu"]
###
# ruff configuration

162
python/parts.nix Normal file
View File

@@ -0,0 +1,162 @@
{ inputs, ... }:
{
perSystem =
{ config, self', pkgs, lib, system, ... }:
let
# Load workspace from uv.lock
workspace = inputs.uv2nix.lib.workspace.loadWorkspace {
workspaceRoot = inputs.self;
};
# Create overlay from workspace
# Use wheels for now - building from source requires more complex
# build system resolution that we can investigate later
overlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
};
# Override overlay to inject Nix-built components
exoOverlay = final: prev: {
# Replace editable exo_pyo3_bindings with Nix-built wheel
exo-pyo3-bindings = pkgs.stdenv.mkDerivation {
pname = "exo-pyo3-bindings";
version = "0.1.0";
src = self'.packages.exo_pyo3_bindings;
# Install from pre-built wheel
nativeBuildInputs = [ final.pyprojectWheelHook ];
dontStrip = true;
};
};
python = pkgs.python313;
# Overlay to provide build systems for source builds and platform stubs
buildSystemsOverlay = final: prev:
# On Darwin, mlx-lm is a git dependency that needs setuptools
lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin
{
mlx-lm = prev.mlx-lm.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
final.setuptools
];
});
}
# On Linux, MLX packages don't work (require Metal framework)
# Provide minimal stubs so the build can complete
// lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
mlx = pkgs.stdenv.mkDerivation {
pname = "mlx";
version = "0.30.1";
dontUnpack = true;
installPhase = ''
mkdir -p $out/${python.sitePackages}/mlx
echo 'raise ImportError("MLX requires macOS with Apple Silicon")' > $out/${python.sitePackages}/mlx/__init__.py
'';
};
mlx-cpu = pkgs.stdenv.mkDerivation {
pname = "mlx-cpu";
version = "0.30.1";
dontUnpack = true;
installPhase = ''
mkdir -p $out/${python.sitePackages}
touch $out/${python.sitePackages}/mlx_cpu_stub.py
'';
};
mlx-lm = pkgs.stdenv.mkDerivation {
pname = "mlx-lm";
version = "0.30.2";
dontUnpack = true;
installPhase = ''
mkdir -p $out/${python.sitePackages}/mlx_lm
echo 'raise ImportError("mlx-lm requires macOS with Apple Silicon")' > $out/${python.sitePackages}/mlx_lm/__init__.py
'';
};
};
pythonSet = (pkgs.callPackage inputs.pyproject-nix.build.packages {
inherit python;
}).overrideScope (
lib.composeManyExtensions [
inputs.pyproject-build-systems.overlays.default
overlay
exoOverlay
buildSystemsOverlay
]
);
exoVenv = pythonSet.mkVirtualEnv "exo-env" workspace.deps.default;
exoPackage = pkgs.runCommand "exo"
{
nativeBuildInputs = [ pkgs.makeWrapper ];
}
''
mkdir -p $out/bin $out/share/exo
# Copy dashboard
cp -r ${self'.packages.dashboard} $out/share/exo/dashboard
# Copy system_custodian binary
cp ${self'.packages.system_custodian}/bin/system_custodian $out/bin/
# Create wrapper scripts
for script in exo exo-master exo-worker; do
makeWrapper ${exoVenv}/bin/$script $out/bin/$script \
--set DASHBOARD_DIR $out/share/exo/dashboard
done
'';
pyinstallerPackage =
let
venv = pythonSet.mkVirtualEnv "exo-pyinstaller-env" (
workspace.deps.default
// {
# Include pyinstaller in the environment
exo = [ "dev" ];
}
);
in
pkgs.stdenv.mkDerivation {
pname = "exo-pyinstaller";
version = "0.3.0";
src = inputs.self;
nativeBuildInputs = [ venv pkgs.makeWrapper pkgs.macmon pkgs.darwin.system_cmds ];
buildPhase = ''
# macmon must be in PATH for PyInstaller to bundle it
export PATH="${pkgs.macmon}/bin:$PATH"
# HOME must be writable for PyInstaller's cache
export HOME="$TMPDIR"
# Copy dashboard to expected location
mkdir -p dashboard/build
cp -r ${self'.packages.dashboard}/* dashboard/build/
# Run PyInstaller
${venv}/bin/python -m PyInstaller packaging/pyinstaller/exo.spec
'';
installPhase = ''
cp -r dist/exo $out
'';
};
in
{
packages = {
exo = exoPackage;
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
exo-pyinstaller = pyinstallerPackage;
};
# Python checks
checks = {
# Ruff linting
lint = pkgs.runCommand "ruff-lint" { } ''
export RUFF_CACHE_DIR="$TMPDIR/ruff-cache"
${pkgs.ruff}/bin/ruff check ${inputs.self}/
touch $out
'';
};
};
}