mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
|
|
import { basename, dirname, isAbsolute, join, relative } from 'node:path'
|
|
import { runProcess } from '../../shared/child-process/run-process'
|
|
import type { ProcessResult } from '../../shared/child-process/process-spec'
|
|
import type {
|
|
CreateVenvResult,
|
|
PythonEnvironment,
|
|
PythonEnvironments
|
|
} from '../../shared/notebook-kernel-types'
|
|
import { venvInterpreterSegments } from '../../shared/notebook-venv-location'
|
|
|
|
const PROBE = 'import sys, platform; print(sys.executable); print(platform.python_version())'
|
|
const PROBE_TIMEOUT_MS = 10_000
|
|
const WORKSPACE_ENV_DIRS = ['.venv', '.conda']
|
|
const INSTALL_TIMEOUT_MS = 10 * 60_000
|
|
const INSTALL_DETAIL_CHARS = 4000
|
|
const VENV_TIMEOUT_MS = 2 * 60_000
|
|
const PYVENV_CFG_MAX_BYTES = 64 * 1024
|
|
|
|
/** `.venv`/`.conda` interpreters from the notebook's folder up to the workspace root, nearest first. */
|
|
export function findWorkspaceInterpreters(
|
|
notebookPath: string,
|
|
rootPath: string | null,
|
|
platform: NodeJS.Platform = process.platform,
|
|
exists: (path: string) => boolean = existsSync
|
|
): string[] {
|
|
const interpreters: string[] = []
|
|
for (let dir = dirname(notebookPath); ; dir = dirname(dir)) {
|
|
for (const envDir of WORKSPACE_ENV_DIRS.map((name) => join(dir, name))) {
|
|
const venvInterpreter = join(envDir, ...venvInterpreterSegments(platform === 'win32'))
|
|
// Windows conda envs keep python.exe at the env root.
|
|
const candidates =
|
|
platform === 'win32' ? [venvInterpreter, join(envDir, 'python.exe')] : [venvInterpreter]
|
|
const interpreter = candidates.find(exists)
|
|
if (interpreter) {
|
|
interpreters.push(interpreter)
|
|
}
|
|
}
|
|
const fromRoot = rootPath === null ? '' : relative(rootPath, dir)
|
|
if (!fromRoot || fromRoot.startsWith('..') || isAbsolute(fromRoot) || dirname(dir) === dir) {
|
|
return interpreters
|
|
}
|
|
}
|
|
}
|
|
|
|
async function probe(
|
|
program: string,
|
|
args: string[],
|
|
name: (executable: string) => string
|
|
): Promise<PythonEnvironment | null> {
|
|
try {
|
|
const result = await runProcess({
|
|
program,
|
|
args: [...args, '-c', PROBE],
|
|
timeoutMs: PROBE_TIMEOUT_MS
|
|
})
|
|
const [executable, version] = result.stdout.trim().split(/\r?\n/)
|
|
return result.code === 0 && executable && version
|
|
? { path: executable, name: name(executable), version }
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** The environment folder an interpreter lives in, when it lives in one. */
|
|
function environmentDir(executable: string): string | undefined {
|
|
// venvs keep python in bin/ or Scripts\; Windows conda envs keep it at the env root.
|
|
return [dirname(dirname(executable)), dirname(executable)].find(
|
|
(dir) => existsSync(join(dir, 'pyvenv.cfg')) || existsSync(join(dir, 'conda-meta'))
|
|
)
|
|
}
|
|
|
|
/** Names an interpreter after its environment folder when it lives in one. */
|
|
function environmentName(executable: string): string {
|
|
return basename(environmentDir(executable) ?? executable)
|
|
}
|
|
|
|
/** The head of a regular file, read once into a fixed buffer. */
|
|
function readFileHead(path: string, maxBytes: number): string | undefined {
|
|
// Why not a FIFO or device: opening one can block; a symlinked procfs file passes and reports size 0.
|
|
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
return undefined
|
|
}
|
|
const fd = openSync(path, 'r')
|
|
try {
|
|
const buffer = Buffer.alloc(maxBytes)
|
|
return buffer.toString('utf8', 0, readSync(fd, buffer, 0, maxBytes, 0))
|
|
} finally {
|
|
closeSync(fd)
|
|
}
|
|
}
|
|
|
|
/** The Python version an environment records on disk: venv `pyvenv.cfg`, else conda's `conda-meta`. */
|
|
function recordedVersion(envDir: string): string | undefined {
|
|
try {
|
|
// Why bounded, not size-checked: a hostile repo can point pyvenv.cfg at an endless file.
|
|
const cfg = readFileHead(join(envDir, 'pyvenv.cfg'), PYVENV_CFG_MAX_BYTES)
|
|
const match = cfg && /^\s*version(?:_info)?\s*=\s*(\d+\.\d+(?:\.\d+)?)/m.exec(cfg)
|
|
if (match) {
|
|
return match[1]
|
|
}
|
|
const condaMeta = join(envDir, 'conda-meta')
|
|
if (existsSync(condaMeta)) {
|
|
for (const entry of readdirSync(condaMeta)) {
|
|
const match = /^python-(\d+\.\d+(?:\.\d+)?)-.*\.json$/.exec(entry)
|
|
if (match) {
|
|
return match[1]
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Unreadable metadata just leaves the version unknown.
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Describes a workspace interpreter from its files alone, without running it. */
|
|
function describeWithoutRunning(interpreter: string): PythonEnvironment {
|
|
const envDir = environmentDir(interpreter)
|
|
const version = envDir === undefined ? undefined : recordedVersion(envDir)
|
|
const name = basename(envDir ?? interpreter)
|
|
return version ? { path: interpreter, name, version } : { path: interpreter, name }
|
|
}
|
|
|
|
export function describePython(path: string): Promise<PythonEnvironment | null> {
|
|
return probe(path, [], environmentName)
|
|
}
|
|
|
|
/**
|
|
* Pythons a notebook can use. `runWorkspaceInterpreters` is false until the notebook is trusted:
|
|
* a repo can ship its own `.venv/bin/python`, so those are then read from disk, never run.
|
|
*/
|
|
export async function listPythonEnvironments(
|
|
notebookPath: string,
|
|
rootPath: string | null,
|
|
{ runWorkspaceInterpreters }: { runWorkspaceInterpreters: boolean }
|
|
): Promise<PythonEnvironments> {
|
|
const pathCommands =
|
|
process.platform === 'win32' ? [['py', '-3'], ['python']] : [['python3'], ['python']]
|
|
const workspaceInterpreters = findWorkspaceInterpreters(notebookPath, rootPath)
|
|
const [workspace, onPath] = await Promise.all([
|
|
runWorkspaceInterpreters
|
|
? Promise.all(workspaceInterpreters.map(describePython))
|
|
: workspaceInterpreters.map(describeWithoutRunning),
|
|
Promise.all(
|
|
pathCommands.map(([program, ...args]) =>
|
|
probe(program, args, () => [program, ...args].join(' '))
|
|
)
|
|
)
|
|
])
|
|
const seen = new Set<string>()
|
|
const unique = (environments: (PythonEnvironment | null)[]): PythonEnvironment[] =>
|
|
environments.filter((env): env is PythonEnvironment => {
|
|
if (!env || seen.has(env.path)) {
|
|
return false
|
|
}
|
|
seen.add(env.path)
|
|
return true
|
|
})
|
|
return { workspace: unique(workspace), path: unique(onPath) }
|
|
}
|
|
|
|
function failureDetail(result: ProcessResult): string {
|
|
const output = (result.stderr.trim() || result.stdout.trim()).slice(-INSTALL_DETAIL_CHARS)
|
|
if (output) {
|
|
return output
|
|
}
|
|
if (result.timedOut) {
|
|
return 'Timed out.'
|
|
}
|
|
return result.signal ? `Stopped by ${result.signal}.` : `Exited with code ${result.code}.`
|
|
}
|
|
|
|
function errorDetail(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
/** `pip install -U ipykernel` into the interpreter's environment, bootstrapping pip if it has none. */
|
|
export async function installIpykernel(python: string): Promise<{ ok: boolean; detail: string }> {
|
|
const run = (args: string[], timeoutMs = INSTALL_TIMEOUT_MS) =>
|
|
runProcess({ program: python, args, timeoutMs })
|
|
const pipInstall = ['-m', 'pip', 'install', '-U', 'ipykernel']
|
|
try {
|
|
let installed = await run(pipInstall)
|
|
// Why: uv-created venvs ship without pip; the stdlib's ensurepip bootstraps it.
|
|
if (installed.code !== 0 && installed.stderr.includes('No module named pip')) {
|
|
await run(['-m', 'ensurepip'])
|
|
installed = await run(pipInstall)
|
|
}
|
|
if (installed.code !== 0) {
|
|
return { ok: false, detail: failureDetail(installed) }
|
|
}
|
|
// Why: pip can succeed while a dependency such as pyzmq still fails to import.
|
|
const imported = await run(['-c', 'import ipykernel, jupyter_client.manager'], PROBE_TIMEOUT_MS)
|
|
return imported.code === 0
|
|
? { ok: true, detail: '' }
|
|
: { ok: false, detail: failureDetail(imported) }
|
|
} catch (error) {
|
|
return { ok: false, detail: errorDetail(error) }
|
|
}
|
|
}
|
|
|
|
/** Creates `<parent>/.venv` from `python` unless it exists, then installs ipykernel into it. */
|
|
export async function createNotebookVenv(
|
|
python: string,
|
|
parent: string
|
|
): Promise<CreateVenvResult> {
|
|
const venvPath = join(parent, '.venv')
|
|
const interpreter = join(venvPath, ...venvInterpreterSegments(process.platform === 'win32'))
|
|
// Why: re-running venv over an existing one can repoint it to another Python and strand its packages.
|
|
if (!existsSync(interpreter)) {
|
|
try {
|
|
const created = await runProcess({
|
|
program: python,
|
|
args: ['-m', 'venv', venvPath],
|
|
timeoutMs: VENV_TIMEOUT_MS
|
|
})
|
|
if (created.code !== 0) {
|
|
return { ok: false, detail: failureDetail(created) }
|
|
}
|
|
} catch (error) {
|
|
return { ok: false, detail: errorDetail(error) }
|
|
}
|
|
}
|
|
const installed = await installIpykernel(interpreter)
|
|
if (!installed.ok) {
|
|
return { ok: false, detail: installed.detail }
|
|
}
|
|
const environment = await describePython(interpreter)
|
|
return environment
|
|
? { ok: true, environment }
|
|
: { ok: false, detail: `${interpreter} did not run after the environment was created.` }
|
|
}
|