Files
leon-ai--leon/scripts/setup/setup-python-project-env.js
2026-07-13 12:44:08 +08:00

182 lines
4.7 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import execa from 'execa'
import {
PYTHON_RUNTIME_BIN_PATH,
UV_RUNTIME_BIN_PATH
} from '@/constants'
import { SystemHelper } from '@/helpers/system-helper'
import { createSetupStatus } from './setup-status'
const PYPROJECT_FILE_NAME = 'pyproject.toml'
/**
* Detect whether a command failed to spawn because the executable was missing.
*/
function isExecutableMissingError(error) {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
)
}
/**
* Make Python runtime resolution failures actionable during setup.
*/
async function ensurePythonRuntimeAvailable(name) {
try {
await execa(PYTHON_RUNTIME_BIN_PATH, ['--version'])
} catch (error) {
if (!isExecutableMissingError(error)) {
throw error
}
throw new Error(
`${name}: unable to resolve Leon's managed Python runtime at "${PYTHON_RUNTIME_BIN_PATH}". Make sure the managed Python setup completed successfully, or set \`LEON_PYTHON_PATH\` to an explicit Python executable if you intentionally want to override it.`,
{ cause: error }
)
}
}
/**
* Make uv runtime resolution failures actionable during setup.
*/
async function ensureUVRuntimeAvailable(name) {
try {
await execa(UV_RUNTIME_BIN_PATH, ['--version'])
} catch (error) {
if (!isExecutableMissingError(error)) {
throw error
}
throw new Error(
`${name}: unable to resolve Leon's managed uv runtime at "${UV_RUNTIME_BIN_PATH}". Make sure the managed uv setup completed successfully, or set \`LEON_UV_PATH\` to an explicit uv executable if you intentionally want to override it.`,
{ cause: error }
)
}
}
/**
* Read the dependency list from a Python project's `pyproject.toml` using the
* managed Python runtime and the standard-library `tomllib` parser.
*/
export async function getPyprojectDependencies(projectPath) {
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
if (!fs.existsSync(pyprojectPath)) {
return []
}
const readerScript = [
'import json',
'import sys',
'import tomllib',
'from pathlib import Path',
'data = tomllib.loads(Path(sys.argv[1]).read_text())',
'deps = data.get("project", {}).get("dependencies", [])',
'print(json.dumps(deps))'
].join('; ')
const result = await execa(PYTHON_RUNTIME_BIN_PATH, [
'-c',
readerScript,
pyprojectPath
])
return JSON.parse(result.stdout)
}
/**
* Resolve the Python executable path inside a project-local `.venv`.
*/
export function getProjectVenvPythonPath(projectPath) {
return path.join(
projectPath,
'.venv',
SystemHelper.isWindows() ? 'Scripts' : 'bin',
SystemHelper.isWindows() ? 'python.exe' : 'python'
)
}
/**
* Check whether a project dependency sync stamp is newer than its manifest.
*/
async function isSyncCurrent(projectPath, stampFileName) {
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
const stampPath = path.join(projectPath, stampFileName)
if (
!fs.existsSync(pyprojectPath) ||
!fs.existsSync(stampPath) ||
!fs.existsSync(getProjectVenvPythonPath(projectPath))
) {
return false
}
const [manifestStat, stampStat] = await Promise.all([
fs.promises.stat(pyprojectPath),
fs.promises.stat(stampPath)
])
return manifestStat.mtimeMs <= stampStat.mtimeMs
}
/**
* Create or refresh a project-local `.venv` and install dependencies declared
* in `pyproject.toml` through Leon's managed `uv` and Python runtimes.
*/
export async function setupPythonProjectEnv({
name,
projectPath,
stampFileName
}) {
const status = createSetupStatus(`Setting up ${name} dependencies...`).start()
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
const stampPath = path.join(projectPath, stampFileName)
const venvPath = path.join(projectPath, '.venv')
if (!fs.existsSync(pyprojectPath)) {
status.pause()
return
}
if (await isSyncCurrent(projectPath, stampFileName)) {
status.succeed(`${name}: up-to-date`)
return
}
await ensurePythonRuntimeAvailable(name)
await ensureUVRuntimeAvailable(name)
const dependencies = await getPyprojectDependencies(projectPath)
await fs.promises.rm(venvPath, { recursive: true, force: true })
await execa(UV_RUNTIME_BIN_PATH, [
'venv',
'--python',
PYTHON_RUNTIME_BIN_PATH,
venvPath
], { cwd: projectPath })
if (dependencies.length > 0) {
await execa(UV_RUNTIME_BIN_PATH, [
'pip',
'install',
'--python',
getProjectVenvPythonPath(projectPath),
...dependencies
], { cwd: projectPath })
}
await fs.promises.writeFile(stampPath, `${Date.now()}`)
status.succeed(`${name}: ready`)
}