#!/usr/bin/env runts -- --allow-env --allow-run --allow-import --allow-read --allow-write import { execCommand, execCommandShell, existsPath, joinPath, log, ProcessBar, readFileToString, resolveFilename, term, writeStringToFile, } from "https://global.hatter.ink/script/get/@27/deno-commons-mod.ts"; import {parseArgs} from "jsr:@std/cli/parse-args"; import {assertEquals} from "jsr:@std/assert"; const PYTHON_CONFIG_FILE = "~/.config/python-config.json"; const PYTHON_VENV_DEFAULT_BASE_DIR = "~/.venv/"; interface PythonConfig { python_venv_base_path?: string; versions: Record; profiles?: Record; } interface PythonVersion { version: string; path: string; // Python path dir or Python binary file comment?: string; } interface PythonVenv { version: string; path: string; alias?: string[]; comment?: string; } function matchVersion(version: string, versionFilter: string): boolean { if (version === versionFilter) { return true; } return version.startsWith( versionFilter.endsWith(".") ? versionFilter : (versionFilter + "."), ); } function findPythonVersion( pythonConfig: PythonConfig, version: string, ): PythonVersion | null { if (!pythonConfig.versions) { return null; } let pythonVersion: PythonVersion | null = null; for (let ver in pythonConfig.versions) { if (matchVersion(ver, version)) { if (pythonVersion !== null) { throw `Too many versions matched, version filter: ${version}`; } pythonVersion = pythonConfig.versions[ver]; } } return pythonVersion; } async function isFile(path: string): Promise { const fileInfo = await Deno.stat(path); return fileInfo?.isFile; } async function loadPythonConfig(): Promise { const pythonConfigFile = resolveFilename(PYTHON_CONFIG_FILE); const pythonConfigJson = await readFileToString(pythonConfigFile); if (!pythonConfigJson) { throw `Could not read python config file: ${pythonConfigFile}`; } return JSON.parse(pythonConfigJson) as PythonConfig; } async function savePythonConfig(pythonConfig: PythonConfig): Promise { const pythonConfigFile = resolveFilename(PYTHON_CONFIG_FILE); await writeStringToFile( pythonConfigFile, JSON.stringify(pythonConfig, null, 2), ); } function handleHelp(_args: string[]) { const help = `[green]python.ts[/green] - Python version and virtual environment management tool [green]python.ts[/green] [bold]python[/bold] - management python version [yellow]\\[alias: py, ver, version][/yellow] [green]python.ts[/green] [bold]add-python[/bold] - add python version [yellow]\\[alias: add-py][/yellow] [green]python.ts[/green] [bold]env[/bold] - management python virtual environment [yellow]\\[alias: env][/yellow] [green]python.ts[/green] [bold]add-venv[/bold] - add python virtual environment [yellow]\\[alias: add-env][/yellow] [green]python.ts[/green] [bold]remove-venv[/bold] - remove python virtual environment [yellow]\\[alias: rm-venv, rm-env][/yellow] [green]python.ts[/green] [bold]active-venv[/bold] - active python virtual environment [yellow]\\[alias: active, active-env][/yellow]`; // source <(cat ~/.venv-python-3.13.5/bin/activate) // source <(python.ts venv test1) console.log(term.auto(help)); } async function addVirtualEnv( pythonVersion: string | null, pythonVenv: string, alias?: string, comment?: string, ): Promise { const pythonConfig = await loadPythonConfig(); if (!pythonVersion) { throw `No Python version assigned.`; } if (!pythonVenv) { throw `No Python virtual environment assigned.`; } const pythonVersionProfile = findPythonVersion(pythonConfig, pythonVersion); if (!pythonVersionProfile) { throw `Python version: ${pythonVersion} not found`; } const realPythonVersion = pythonVersionProfile.version || pythonVersion; log.success( `Found Python version: ${realPythonVersion}`, ); const pythonVenvProfile = pythonConfig.profiles && pythonConfig.profiles[pythonVenv]; if (pythonVenvProfile) { throw `Python virtual environment already exists: ${pythonVenv}`; } const pythonVenvBaseDir = resolveFilename( pythonConfig.python_venv_base_path || PYTHON_VENV_DEFAULT_BASE_DIR, ); if (!await existsPath(pythonVenvBaseDir)) { log.info( `Make python virtual environment base dir: ${pythonVenvBaseDir}`, ); Deno.mkdirSync(pythonVenvBaseDir); } const pythonVenvLeafDir = `${pythonVenv}_python_${realPythonVersion}`; const pythonVenvDir = joinPath(pythonVenvBaseDir, pythonVenvLeafDir); if (await existsPath(pythonVenvDir)) { throw `Python venv: ${pythonVenvDir} already exists`; } // python3 -m venv myenv const python3Cmd = pythonVersionProfile.path; log.success(`Found Python: ${python3Cmd}`); const pythonVenvArgs = ["-m", "venv", pythonVenvDir]; log.info("Create Python venv, python:", [python3Cmd, pythonVenvArgs]); await new ProcessBar("Creating Python virtual environment").call( async () => { await execCommandShell(python3Cmd, pythonVenvArgs); }, ); const newPythonVenvProfile: PythonVenv = { version: realPythonVersion, path: pythonVenvDir, comment: `Python venv:${pythonVenv}, version: ${realPythonVersion}${ comment ? (", user comment: " + comment) : "" }`, }; if (alias) { newPythonVenvProfile.alias = [alias]; } if (!pythonConfig.profiles) { pythonConfig.profiles = {}; } pythonConfig.profiles[pythonVenv] = newPythonVenvProfile; await savePythonConfig(pythonConfig); } function versionSort(a: string, b: string): number { const versionAParts = a.split("."); const versionBParts = b.split("."); const minLen = Math.min(versionAParts.length, versionBParts.length); for (let i: number = 0; i < minLen; i++) { const ai = parseInt(versionAParts[i], 10); const bi = parseInt(versionBParts[i], 10); if (ai !== bi) { return (ai < bi) ? -1 : 1; } } if (versionAParts.length === versionBParts.length) { return 0; } return (versionAParts.length < versionBParts.length) ? -1 : 1; } async function handlePython(args: string[]) { const flags = parseArgs(Deno.args, { boolean: ["help"], }); if (flags.help) { console.log("Help massage for python"); return; } const pythonConfig = await loadPythonConfig(); if (!pythonConfig.versions) { log.error("No Python versions configured"); return; } const versions: string[] = []; let maxVersionLength = 0; for (const version in pythonConfig.versions) { versions.push(version); if (version.length > maxVersionLength) { maxVersionLength = version.length; } } versions.sort(versionSort); console.log( term.auto( `Found [bold][green]${versions.length}[/green][/bold] Python version${ versions.length == 1 ? "" : "s" }:`, ), ); for (const version of versions) { const pythonVersion = pythonConfig.versions[version]; const versionPadding = " ".repeat( maxVersionLength - version.length, ); console.log( term.auto( `- version: [green][bold]${version}[/green][/bold]` + `; path: [blue]${pythonVersion.path}[/blue]` + `; comment: [yellow][under]${pythonVersion.comment}[/under][/yellow]`, ), ); // console.log("- Python:", version, pythonVersion,); } } async function getPythonVersion(pythonBinPath: string): Promise { const pythonVersion = await execCommand(pythonBinPath, ["--version"]); const pythonVersionLower = pythonVersion.assertSuccess() .getStdoutAsStringThenTrim().toLowerCase(); if (!pythonVersionLower.startsWith("python ")) { throw new Error(`Invalid Python version: ${pythonVersionLower}`); } return pythonVersionLower.substring("python ".length).trim(); } async function handleAddPython(args: string[]) { const flags = parseArgs(Deno.args, { boolean: ["help"], string: ["path"], }); if (args.length === 0 || flags.help) { console.log(`Help massage for python add-python python.ts add-python --path python-path`); return; } if (!flags.path) { throw new Error("Path is empty"); } const path = flags.path as string; let pythonPath = path; if (!path.includes("/")) { const whichPath = await execCommand("which", [path]); pythonPath = whichPath.assertSuccess().getStdoutAsStringThenTrim(); } log.info(`Python path: ${pythonPath}`); const isPythonPathFile = await isFile(pythonPath); let pythonBinPath = ""; if (!isPythonPathFile) { const pythonPath1 = joinPath(pythonPath, "bin", "python3"); if (await isFile(pythonPath1)) { pythonBinPath = pythonPath1; } else { const pythonPath2 = joinPath(pythonPath, "bin", "python"); if (await isFile(pythonPath2)) { pythonBinPath = pythonPath2; } else { throw new Error( `Python bin path not found, python path: ${pythonPath}`, ); } } } else { pythonBinPath = pythonPath; } const pythonVersion = await getPythonVersion(pythonBinPath); log.info(`Python version: ${pythonVersion}`); const pythonConfig = await loadPythonConfig(); if (!pythonConfig.versions) { pythonConfig.versions = {}; } if (pythonConfig.versions[pythonVersion]) { throw new Error(`Python version ${pythonVersion} exists`); } const pyVersion: PythonVersion = { version: pythonVersion, path: pythonBinPath, comment: `Python v${pythonVersion}`, }; log.info("Found Python version", pyVersion); pythonConfig.versions[pythonVersion] = pyVersion; log.info("Save to Python config"); await savePythonConfig(pythonConfig); } async function handleVenv(args: string[]) { const flags = parseArgs(args, { boolean: ["help"], string: ["name"], alias: { n: "name", }, }); if (flags.help) { console.log(`Help massage for venv python.ts venv [--name|-n filter-name]`); return; } let pythonConfig = await loadPythonConfig(); if (!pythonConfig.profiles) { console.warn("No Python virtual environments found"); return; } let totalProfilesCount = 0; let filterProfilesCount = 0; Object.entries(pythonConfig.profiles).forEach( ([venv, pythonVenvProfile]) => { totalProfilesCount++; if (flags.name && venv.indexOf(flags.name) < 0) { return; } filterProfilesCount++; console.log( "-", term.auto(`[blue][under][bold]${venv}[/bold][/under][/blue]`), term.yellow( pythonVenvProfile.alias ? `[alias: ${pythonVenvProfile.alias.join(", ")}]` : "-", ), pythonVenvProfile, `, active virtual environment command: ${ term.under("source <(python.ts active " + venv + ")") }`, ); }, ); if (totalProfilesCount !== filterProfilesCount) { console.log( `\nFilter profiles with '${flags.name}': ${filterProfilesCount} of ${totalProfilesCount}`, ); } console.log( "\nNOTE: use --name|-n filter virtual environments, use `source <(python.rs active[-venv] venv-name)`", ); } async function handleAddVenv(args: string[]) { const flags = parseArgs(args, { boolean: ["help"], string: ["version", "venv", "comment", "alias"], alias: { V: "version", c: "comment", }, }); if (args.length === 0 || flags.help) { console.log(`Help massage for add-venv python.ts add-venv --version 3.10 --venv test-env [--comment comment]`); return; } if (!flags.version) { log.error("Version missing"); return; } if (!flags.venv) { log.error("Venv is missing"); return; } await addVirtualEnv(flags.version, flags.venv, flags.alias, flags.comment); } async function handleRemoveVenv(args: string[]) { const flags = parseArgs(args, { boolean: ["help"], string: ["venv"], }); if (args.length === 0 || flags.help) { console.log(`Help massage for remove-venv python.ts remove-venv --venv test-env`); return; } if (!flags.venv) { log.error("Venv is missing"); return; } const pythonConfig = await loadPythonConfig(); const pythonVenvProfile = pythonConfig.profiles && pythonConfig.profiles[flags.venv]; if (!pythonVenvProfile) { throw `Python virtual environment not exists: ${flags.venv}`; } console.log( `Pending remove virtual environment [PLEASE CONFIRM]: `, pythonVenvProfile, ); if (confirm("Please confirm:")) { const path = pythonVenvProfile.path; log.info(`Remove path: ${path}`); await new ProcessBar(`Removing path ${path}`).call(async () => { await Deno.remove(path, { recursive: true }); }); if (pythonConfig.profiles) { delete pythonConfig.profiles[flags.venv]; } await savePythonConfig(pythonConfig); log.success( `Virtual environment: '${flags.venv}' removed, path: ${path}`, ); } else { console.log( `Your skip virtual environment removal`, ); } } async function handleActiveVenv(args: string[]) { if (args.length === 0) { console.log(`Help massage for active-venv source <(python.rs active[-venv] test-env)`); return; } const venv = args[0]; const pythonConfig = await loadPythonConfig(); const pythonVenvProfile = pythonConfig.profiles && pythonConfig.profiles[venv]; if (!pythonVenvProfile) { throw `Python virtual environment not exists: ${venv}`; } const pythonVenvActiveFile = joinPath( pythonVenvProfile.path, "bin", "activate", ); const pythonVenvActiveFileContent = await readFileToString( pythonVenvActiveFile, ); console.log(pythonVenvActiveFileContent); } async function main() { const args = Deno.args; if (args.length === 0) { log.warn("No subcommand assigned"); handleHelp([]); return; } const subcommand = args[0]; const remainingArgs = args.slice(1); switch (subcommand) { case "help": handleHelp(remainingArgs); break; case "py": case "ver": case "version": case "python": await handlePython(remainingArgs); break; case "add-py": case "add-python": await handleAddPython(remainingArgs); break; case "env": case "venv": await handleVenv(remainingArgs); break; case "add-env": case "add-venv": await handleAddVenv(remainingArgs); break; case "rm-env": case "rm-venv": case "remote-env": case "remove-venv": await handleRemoveVenv(remainingArgs); break; case "active": case "active-env": case "active-venv": await handleActiveVenv(remainingArgs); break; default: log.error(`Unknown subcommand: ${subcommand}`); break; } } main().catch((e) => log.error(e)); Deno.test("versionSort", () => { const versions1 = ["3.10", "3.7"]; versions1.sort(versionSort); assertEquals(["3.7", "3.10"], versions1); const versions2 = ["3.10", "3.10.1", "3.11", "3.7"]; versions2.sort(versionSort); assertEquals(["3.7", "3.10", "3.10.1", "3.11"], versions2); }); // @SCRIPT-SIGNATURE-V1: yk-r1.ES256.20260205T232313+08:00.MEQCIHC9xGfzR6iiMjJjpPAc // lJ3lyEUT5a+7qLUml7HcEV6WAiA7ZZ5yUdtfk0J0zQBnam92VOHjYvH2sLrVBI/88vB1sQ==