chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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`)
|
||||
}
|
||||
Reference in New Issue
Block a user