SPB Git

spb/zyquo-mlx Public MIT

The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.

Swift 93.4% Python 3.8% Makefile 2.2% Shell 0.5%
5.3 KB · 150 lines swift
Raw Blame History
1//2//  PythonEnvironment.swift3//  Zyquo MLX4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation1011/// Manages the isolated, pinned Python environment in12/// `~/Library/Application Support/ZyquoMLX/py/venv` (docs/BUILD.md §3.4).13/// Provisioned with `uv` on first use; never touches system Python; lives14/// outside the signed bundle (notarization posture, docs/BUILD.md §4).15actor PythonEnvironment {1617    static let shared = PythonEnvironment()1819    /// Exact pins per docs/MLX-RESEARCH.md §6 (core set; mlx-vlm/whisper/audio20    /// install on demand when those capabilities are first used).21    static let pythonVersion = "3.12"22    static let corePins = ["mlx-lm==0.31.3", "pyyaml==6.0.3"]2324    enum EnvironmentError: LocalizedError {25        case uvNotFound26        case provisioningFailed(String)2728        var errorDescription: String? {29            switch self {30            case .uvNotFound:31                "The 'uv' tool is required to set up the Python environment. Install it with: brew install uv"32            case .provisioningFailed(let detail):33                "Python environment setup failed: \(detail)"34            }35        }36    }3738    var venvURL: URL {39        PersistenceService.pythonDirectory.appendingPathComponent("venv", isDirectory: true)40    }4142    var pythonURL: URL {43        venvURL.appendingPathComponent("bin/python")44    }4546    private var markerURL: URL {47        PersistenceService.pythonDirectory.appendingPathComponent("provisioned.json")48    }4950    var isProvisioned: Bool {51        FileManager.default.fileExists(atPath: pythonURL.path)52            && FileManager.default.fileExists(atPath: markerURL.path)53    }5455    /// Locate `uv` in the usual installation paths (the app has no shell PATH).56    static func findUV() -> URL? {57        let candidates = [58            "\(NSHomeDirectory())/.local/bin/uv",59            "/opt/homebrew/bin/uv",60            "/usr/local/bin/uv",61        ]62        return candidates.first { FileManager.default.fileExists(atPath: $0) }63            .map { URL(fileURLWithPath: $0) }64    }6566    /// Create the venv and install core pins. Idempotent: a pre-existing67    /// healthy venv (verified by importing the pinned packages) is adopted;68    /// a broken one is recreated in place (`uv venv --clear`).69    func provision(progress: @Sendable (String) -> Void = { _ in }) async throws {70        if isProvisioned { return }71        guard let uv = Self.findUV() else { throw EnvironmentError.uvNotFound }7273        try FileManager.default.createDirectory(74            at: PersistenceService.pythonDirectory, withIntermediateDirectories: true)7576        if FileManager.default.fileExists(atPath: pythonURL.path), verifyImports() {77            progress("Adopting existing Python environment.")78            try writeMarker()79            return80        }8182        progress("Installing Python \(Self.pythonVersion)…")83        try runUV(uv, ["python", "install", Self.pythonVersion])8485        progress("Creating environment…")86        try runUV(uv, ["venv", venvURL.path, "--clear", "--python", Self.pythonVersion])8788        progress("Installing MLX packages…")89        try runUV(uv, ["pip", "install", "--python", pythonURL.path] + Self.corePins)9091        try writeMarker()92        progress("Python environment ready.")93    }9495    /// True when the pinned core packages import cleanly.96    private func verifyImports() -> Bool {97        let process = Process()98        process.executableURL = pythonURL99        process.arguments = ["-c", "import mlx_lm, yaml"]100        process.standardOutput = Pipe()101        process.standardError = Pipe()102        do {103            try process.run()104            process.waitUntilExit()105            return process.terminationStatus == 0106        } catch {107            return false108        }109    }110111    private func writeMarker() throws {112        let marker: [String: String] = [113            "python": Self.pythonVersion,114            "pins": Self.corePins.joined(separator: " "),115            "provisionedAt": ISO8601DateFormatter().string(from: .now),116        ]117        try PersistenceService.saveJSON(marker, to: markerURL)118    }119120    /// Install additional pinned packages (e.g. mlx-vlm when VLM Python121    /// pipelines are first needed).122    func install(pins: [String]) throws {123        guard let uv = Self.findUV() else { throw EnvironmentError.uvNotFound }124        try runUV(uv, ["pip", "install", "--python", pythonURL.path] + pins)125    }126127    /// Delete and re-provision (Settings › Python Environment › Repair).128    func repair(progress: @Sendable (String) -> Void = { _ in }) async throws {129        try? FileManager.default.removeItem(at: venvURL)130        try? FileManager.default.removeItem(at: markerURL)131        try await provision(progress: progress)132    }133134    private func runUV(_ uv: URL, _ arguments: [String]) throws {135        let process = Process()136        process.executableURL = uv137        process.arguments = arguments138        let errPipe = Pipe()139        process.standardError = errPipe140        process.standardOutput = Pipe()141        try process.run()142        process.waitUntilExit()143        if process.terminationStatus != 0 {144            let data = errPipe.fileHandleForReading.readDataToEndOfFile()145            throw EnvironmentError.provisioningFailed(146                String(data: data, encoding: .utf8) ?? "exit \(process.terminationStatus)")147        }148    }149}150