182 lines
4.7 KiB
JavaScript
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`)
|
|
}
|