Files
jundot--omlx/apps/omlx-mac/Sources/Server/PythonRuntime.swift
T
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

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",
]
}