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 { 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 { 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 { 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() 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 `/.venv` from `python` unless it exists, then installs ipykernel into it. */ export async function createNotebookVenv( python: string, parent: string ): Promise { 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.` } }