141 lines
5.5 KiB
Swift
141 lines
5.5 KiB
Swift
// PR 2 (PR 11 follow-up) — locate the Python interpreter the parent will
|
|
// spawn.
|
|
//
|
|
// Resolution order (first match wins):
|
|
// 1. OMLX_PYTHON_OVERRIDE env var — dev escape hatch.
|
|
// 2. Bundle.main/Contents/Resources/Python/cpython-3.11/bin/python3 — production.
|
|
// Layout matches the venvstacks export tree, which
|
|
// apps/omlx-mac/Scripts/build.sh copies verbatim into the Swift .app.
|
|
// 3. Legacy bundle layouts under Contents/Python or Contents/Frameworks.
|
|
//
|
|
// In the bundled case the spawn environment also sets:
|
|
// PYTHONHOME = Contents/Resources/Python/cpython-3.11
|
|
// so the relocated interpreter finds its stdlib without grepping the
|
|
// host system's /usr/lib.
|
|
// PYTHONPATH = Contents/Resources : framework-mlx-base/site-packages
|
|
// : __venvstacks__/site-customize
|
|
// so `python -m omlx.cli` resolves both the omlx package (shipped as a
|
|
// pure source tree in Resources/omlx/, matching today's Python build)
|
|
// and the framework layer's wheels (mlx, transformers, fastapi, …).
|
|
// PYTHONDONTWRITEBYTECODE = 1
|
|
// so the read-only app bundle doesn't try to scribble .pyc files into
|
|
// itself at first import.
|
|
// OMLX_SUPERVISED = menubar
|
|
// so the admin restart endpoint knows the parent app can respawn the
|
|
// server after its delayed self-SIGTERM.
|
|
|
|
import Foundation
|
|
|
|
struct PythonRuntime {
|
|
let executable: URL
|
|
/// Extra PATH entries to prepend, matching today's Python menubar
|
|
/// (server_manager.py:328-340 — Homebrew paths needed for ffmpeg, etc.).
|
|
let homebrewPaths: [String]
|
|
/// PYTHONPATH entries to prepend. Empty when the override path is used.
|
|
let pythonPath: [URL]
|
|
/// PYTHONHOME — the cpython layer root. nil when using a system Python.
|
|
let pythonHome: URL?
|
|
/// True when the bundled runtime was found; false if we fell back.
|
|
let isBundled: Bool
|
|
|
|
enum ResolutionError: Error, CustomStringConvertible {
|
|
case notFound(triedPaths: [String])
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .notFound(let paths):
|
|
return "Python runtime not found. Tried: \(paths.joined(separator: ", "))"
|
|
}
|
|
}
|
|
}
|
|
|
|
static func resolve() throws -> PythonRuntime {
|
|
let env = ProcessInfo.processInfo.environment
|
|
var tried: [String] = []
|
|
|
|
if let override = env["OMLX_PYTHON_OVERRIDE"], !override.isEmpty {
|
|
let url = URL(fileURLWithPath: override)
|
|
tried.append(override)
|
|
if FileManager.default.isExecutableFile(atPath: url.path) {
|
|
return PythonRuntime(
|
|
executable: url,
|
|
homebrewPaths: defaultHomebrewPaths,
|
|
pythonPath: [],
|
|
pythonHome: nil,
|
|
isBundled: false
|
|
)
|
|
}
|
|
}
|
|
|
|
let bundleRoot = Bundle.main.bundleURL
|
|
let resources = bundleRoot.appendingPathComponent("Contents/Resources")
|
|
let pythonRoots = [
|
|
resources.appendingPathComponent("Python"),
|
|
bundleRoot.appendingPathComponent("Contents/Python"),
|
|
bundleRoot.appendingPathComponent("Contents/Frameworks"),
|
|
]
|
|
|
|
for pythonRoot in pythonRoots {
|
|
let cpython = pythonRoot.appendingPathComponent("cpython-3.11")
|
|
let bundled = cpython.appendingPathComponent("bin/python3")
|
|
tried.append(bundled.path)
|
|
guard FileManager.default.isExecutableFile(atPath: bundled.path) else {
|
|
continue
|
|
}
|
|
|
|
let mlxFramework = pythonRoot
|
|
.appendingPathComponent("framework-mlx-base/lib/python3.11/site-packages")
|
|
return PythonRuntime(
|
|
executable: bundled,
|
|
homebrewPaths: defaultHomebrewPaths,
|
|
pythonPath: [resources, mlxFramework],
|
|
pythonHome: cpython,
|
|
isBundled: true
|
|
)
|
|
}
|
|
|
|
throw ResolutionError.notFound(triedPaths: tried)
|
|
}
|
|
|
|
/// Build the spawn environment: parent env + supervisor marker +
|
|
/// Homebrew PATH + PYTHONPATH + PYTHONHOME. `PYTHONDONTWRITEBYTECODE=1`
|
|
/// is set in bundled mode so the read-only app bundle doesn't try to
|
|
/// scribble `__pycache__/` into itself.
|
|
func makeEnvironment() -> [String: String] {
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["OMLX_SUPERVISED"] = "menubar"
|
|
// macOS malloc otherwise keeps large empty arenas resident after
|
|
// repeated model load/unload cycles. This must be set before Python
|
|
// starts; setting it inside omlx.cli is too late for malloc init.
|
|
env["MallocSpaceEfficient"] = env["MallocSpaceEfficient"] ?? "1"
|
|
|
|
var path = env["PATH"] ?? ""
|
|
for prefix in homebrewPaths.reversed() where !path.contains(prefix) {
|
|
path = path.isEmpty ? prefix : "\(prefix):\(path)"
|
|
}
|
|
env["PATH"] = path
|
|
|
|
if !pythonPath.isEmpty {
|
|
let joined = pythonPath.map(\.path).joined(separator: ":")
|
|
if let existing = env["PYTHONPATH"], !existing.isEmpty {
|
|
env["PYTHONPATH"] = "\(joined):\(existing)"
|
|
} else {
|
|
env["PYTHONPATH"] = joined
|
|
}
|
|
}
|
|
|
|
if let home = pythonHome {
|
|
env["PYTHONHOME"] = home.path
|
|
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
private static let defaultHomebrewPaths = [
|
|
"/opt/homebrew/bin",
|
|
"/opt/homebrew/sbin",
|
|
"/usr/local/bin",
|
|
]
|
|
}
|