// Python script executor for font editing import { spawn } from 'child_process'; import fs from 'fs-extra'; import path from 'path'; import { BaseService } from '@/core/base/BaseService.js'; import type { PythonScriptConfig, EditResult } from '@/modules/edit/types.js'; export class PythonScriptRunner extends BaseService { private pythonExecutable: string; private workingDirectory: string; private venvDir: string; private venvPython: string; private venvCreated: boolean = false; constructor(workingDirectory?: string, pythonExecutable?: string) { super('PythonScriptRunner'); this.workingDirectory = workingDirectory ?? process.cwd(); this.pythonExecutable = pythonExecutable ?? 'python3'; this.venvDir = path.join(this.workingDirectory, '.venv-scripts'); this.venvPython = path.join(this.venvDir, 'bin', 'python'); } async checkPythonEnvironment(): Promise { return this.executeWithErrorHandling(async () => { this.log('๐Ÿ” Checking Python environment...'); // Check Python const pythonWorks = await this.checkCommand(this.pythonExecutable, [ '--version', ]); if (!pythonWorks) { this.log(`โŒ Python not found: ${this.pythonExecutable}`, 'error'); return false; } // Create virtual environment if it doesn't exist await this.ensureVirtualEnvironment(); this.log('โœ… Python environment is ready'); return true; }, 'check Python environment'); } private async ensureVirtualEnvironment(): Promise { if (await fs.pathExists(this.venvDir)) { this.log('๐Ÿ“ฆ Virtual environment already exists'); this.venvCreated = true; return; } this.log('๐Ÿ”ง Creating virtual environment for script execution...'); const venvCreated = await this.createVirtualEnvironment(); if (!venvCreated) { throw new Error('Failed to create virtual environment'); } this.venvCreated = true; this.log('โœ… Virtual environment created successfully'); } private async createVirtualEnvironment(): Promise { return new Promise((resolve) => { const venv = spawn(this.pythonExecutable, ['-m', 'venv', this.venvDir], { cwd: this.workingDirectory, stdio: ['pipe', 'pipe', 'pipe'], }); let stderr = ''; venv.stderr?.on('data', (data) => { stderr += data.toString(); }); venv.on('close', (code) => { if (code === 0) { this.log('โœ… Virtual environment created'); resolve(true); } else { this.log( `โŒ Failed to create virtual environment (exit code: ${code})`, 'error' ); if (stderr) { this.log(`Error details: ${stderr.trim()}`, 'error'); } resolve(false); } }); venv.on('error', (error) => { this.log( `โŒ Failed to create virtual environment: ${error.message}`, 'error' ); resolve(false); }); }); } private async checkCommand( command: string, args: string[] ): Promise { return new Promise((resolve) => { const process = spawn(command, args, { cwd: this.workingDirectory, stdio: ['pipe', 'pipe', 'pipe'], }); let output = ''; process.stdout?.on('data', (data) => { output += data.toString(); }); process.stderr?.on('data', (data) => { output += data.toString(); }); process.on('close', (code) => { if (code === 0) { this.log( `โœ… Command working: ${command} ${args.join( ' ' )} -> ${output.trim()}` ); resolve(true); } else { this.log( `โŒ Command failed: ${command} ${args.join( ' ' )} (exit code: ${code})`, 'error' ); resolve(false); } }); process.on('error', (error) => { this.log( `โŒ Command error: ${command} ${args.join(' ')} -> ${error.message}`, 'error' ); resolve(false); }); }); } async installRequiredPackages(packages: string[]): Promise { if (packages.length === 0) return true; return this.executeWithErrorHandling(async () => { this.log(`๐Ÿ“ฆ Checking required packages: ${packages.join(', ')}`); // First check which packages are already installed const missingPackages: string[] = []; for (const pkg of packages) { const isInstalled = await this.checkPackageInstalled(pkg); if (!isInstalled) { missingPackages.push(pkg); } else { this.log(`โœ… Package already installed: ${pkg}`, 'debug'); } } if (missingPackages.length === 0) { this.log('โœ… All required packages are already installed'); return true; } this.log(`๐Ÿ“ฆ Installing missing packages: ${missingPackages.join(', ')}`); // Use virtual environment for package installation to avoid externally-managed-environment errors const success = await this.attemptPackageInstallation( missingPackages, [] ); return success; }, 'install required packages'); } private async checkPackageInstalled(packageName: string): Promise { return new Promise((resolve) => { const pythonExec = this.venvCreated ? this.venvPython : this.pythonExecutable; const check = spawn(pythonExec, ['-m', 'pip', 'show', packageName], { cwd: this.workingDirectory, stdio: ['pipe', 'pipe', 'pipe'], }); check.on('close', (code) => { resolve(code === 0); }); check.on('error', () => { resolve(false); }); }); } async runScript( scriptConfig: PythonScriptConfig, fontId: string, fontPath: string ): Promise { return this.executeWithErrorHandling(async () => { const startTime = Date.now(); this.log(`๐Ÿ Running script ${scriptConfig.name} for font: ${fontId}`); // Check if script file exists const scriptPath = path.resolve(this.workingDirectory, scriptConfig.path); if (!(await fs.pathExists(scriptPath))) { throw new Error(`Script not found: ${scriptPath}`); } // Install required packages if specified if ( scriptConfig.requiredPackages && scriptConfig.requiredPackages.length > 0 ) { const packagesInstalled = await this.installRequiredPackages( scriptConfig.requiredPackages ); if (!packagesInstalled) { return { fontId, script: scriptConfig.name, status: 'error', message: 'Failed to install required packages', executionTime: Date.now() - startTime, }; } } // Execute the script directly with the font path as an argument const result = await this.executeScript(scriptPath, fontId, fontPath); return { ...result, executionTime: Date.now() - startTime, }; }, `run script ${scriptConfig.name} for ${fontId}`); } private async executeScript( scriptPath: string, fontId: string, fontPath: string ): Promise { return new Promise((resolve) => { const pythonExec = this.venvCreated ? this.venvPython : this.pythonExecutable; // Pass the font path as a command line argument const python = spawn(pythonExec, [scriptPath, fontPath], { cwd: this.workingDirectory, }); let stdout = ''; let stderr = ''; python.stdout.on('data', (data) => { stdout += data.toString(); }); python.stderr.on('data', (data) => { stderr += data.toString(); }); python.on('close', (code) => { if (code === 0) { this.log(`โœ… Script completed successfully for ${fontId}`); resolve({ fontId, script: path.basename(scriptPath), status: 'success', message: stdout.trim(), }); } else { this.log( `โŒ Script failed for ${fontId} (exit code: ${code})`, 'error' ); if (stderr) { this.log(`Error output: ${stderr}`, 'error'); } resolve({ fontId, script: path.basename(scriptPath), status: 'error', message: stderr.trim() || `Script exited with code ${code}`, }); } }); python.on('error', (error) => { this.log( `โŒ Failed to execute script for ${fontId}: ${error.message}`, 'error' ); resolve({ fontId, script: path.basename(scriptPath), status: 'error', message: error.message, }); }); }); } private async attemptPackageInstallation( packages: string[], extraArgs: string[] ): Promise { return new Promise((resolve) => { const pythonExec = this.venvCreated ? this.venvPython : this.pythonExecutable; const pip = spawn( pythonExec, ['-m', 'pip', 'install', ...extraArgs, ...packages], { cwd: this.workingDirectory, stdio: ['pipe', 'pipe', 'pipe'], // Capture output for better error handling } ); let stderr = ''; pip.stderr?.on('data', (data) => { stderr += data.toString(); }); pip.on('close', (code) => { if (code === 0) { this.log( `โœ… Packages installed successfully: ${packages.join(', ')}` ); resolve(true); } else { this.log( `โŒ Failed to install packages: ${packages.join( ', ' )} (exit code: ${code})`, 'error' ); if (stderr) { this.log(`Error details: ${stderr.trim()}`, 'error'); } resolve(false); } }); pip.on('error', (error) => { this.log(`โŒ Failed to install packages: ${error.message}`, 'error'); resolve(false); }); }); } async cleanup(): Promise { return this.executeWithErrorHandling(async () => { // Note: We don't automatically clean up the virtual environment // as it can be reused across multiple runs for efficiency // To clean it manually, delete the .venv-scripts directory this.log('๐Ÿงน Python script runner cleanup completed'); }, 'cleanup Python script runner'); } }