Files
ts-scripts/python-ts/main.ts
2026-02-05 23:23:30 +08:00

531 lines
16 KiB
TypeScript
Executable File

#!/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<string, PythonVersion>;
profiles?: Record<string, PythonVenv>;
}
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<boolean> {
const fileInfo = await Deno.stat(path);
return fileInfo?.isFile;
}
async function loadPythonConfig(): Promise<PythonConfig> {
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<void> {
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<void> {
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<string> {
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==