js/Python.js

/**
 * @file Manages the python script runtime
 * @author Dreamsavior
 * @since 5.9.1
 */

const nwPath = require("path");
const rootDir = common.getRootDir();

/**
 * NodeJS Python script wrapper and utility class.
 * @class
 * @example
 * const python = new Python();
 * await python.runPy("path/to/script.py", "path/to/cwd", ["arg1", "arg2"]);
 */
class Python extends require("www/js/BasicEventHandler.js") {
    /**
     * Python constructor.
     * @extends BasicEventHandler
     * @param {string} venvID - The virtual environment ID.
     * @param {string} version - The Python version.
     * @param {Object} options - The options for Python.
     */
    constructor(venvID="", version="3.10.5-x64", options={}) {
        super();
        this.venvID     = venvID || "def";
        this.version    = version;
        this.baseRepo   = options.baseRepo || "https://github.com/Quartzbell/portable-python/releases/download/portable-python/"
        this.baseDir    = options.baseDir || nwPath.join(rootDir, "python", version+"-"+this.venvID)
        this.configDir  = options.configDir || nwPath.join(this.baseDir, "tppconfig")
        
        
        this.pythonPath = nwPath.join(this.baseDir, "python.exe");
        this.isInitialized = false;
        this.defaultConfig = options.config||{};
        this.config     = this.defaultConfig;
        this.configFile = nwPath.join(this.baseDir, "config.json");

        /**
         * Load the configuration.
         * @returns {Object} The configuration.
         */
        this.loadConfig = async()=> {
            var loadedConfig = {};
            if (!await common.isFileAsync(this.configFile)) return this.config;
            try {
                loadedConfig = JSON.parse(await common.fileGetContents(this.configFile));
                this.config = {...loadedConfig, ...this.defaultConfig}
                return this.config;
            } catch (e) {
                return this.config;
            }
        }

        /**
         * Save the configuration.
         * @returns {Object} The configuration.
         */        
        this.saveConfig = async()=> {
            try {
                common.filePutContents(
                    this.configFile,
                    JSON.stringify(this.config, undefined, 2),
                    "utf8",
                    false);

            } catch (e) {
                // do nothing
            }
            this.resolveState("configIsReadyToSave")
            return this.config;
        }
        
        /**
         * Get the configuration.
         * @param {string} key - The key of the configuration.
         * @returns {Object} The configuration.
         */
        this.getConfig = (key)=> {
            if (!key) return this.config;
            if (typeof key == "string") return this.config[key]

        }

        /**
         * Set the configuration.
         * @param {string} key - The key of the configuration.
         * @param {any} value - The value to set.
         */
        this.setConfig = (key, value)=> {
            if (!key) return;
            this.config[key] = value;
            this.saveConfig();
        }
        
        /**
         * Install Python.
         * @returns {boolean} True if installation is successful, false otherwise.
         */
        this.installPython = async () => {
            var selfLoading = false;
            if (ui.isLoadingOverlayVisible()) {
                selfLoading = true;
                ui.showLoading();
                ui.loadingProgress("Installing", "Installing self contained Python", {consoleOnly:false, mode:'consoleOutput'});
            }
            const loadingEnd = ()=> {
                if (selfLoading) {
                    ui.hideLoading()
                }
            }
            ui.log("Installing a new portable python with environment ID: "+this.venvID);
            const downloadURL = this.baseRepo+"python-"+version+".zip"
            await common.mkDir(this.baseDir);
            ui.log("Downloading Python from "+downloadURL)
            let downloadedFile = await common.download(downloadURL, nwPath.join(rootDir, "data"), {resumeIfExist:true});
            if (!downloadedFile) {
                loadingEnd();
                return false;
            }
            ui.log("Download completed successfully!");
            ui.log("Extracting archive");
            await common.extract(downloadedFile, this.baseDir);
            await common.mkDir(this.configDir);
            // await common.unlink(downloadedFile);
            loadingEnd();
            return true;
        }

        /**
         * Initialize Python class.
         */
        this.init = async ()=> {
            if (this.isInitialized) return;
            if (!await common.isFileAsync(this.pythonPath)) {
                ui.log("Python installation not found...");
                let result = await this.installPython();
                if (!result) {
                    await ui.logError("Failed to initialize Python CLI")
                    this.rejectState("initialized")
                    return;
                }
            }
            ui.log("Initialization completed");
            await this.loadConfig();
            this.isInitialized = true;
            this.resolveState("initialized");
        }

    }
}

/**
 * Run a python script and return the result
 * @param {String} pathToPy - Path to the python script
 * @param {String} [cwd] - Directory where the python script will be called
 * @param {String[]} [args] - Arguments to be passed to the script
 * @returns {String} Output of the execution
 */
Python.prototype.runPy = async function(pathToPy, cwd="", args=[]) {
    console.log("Running python script...", arguments);
    pathToPy = nwPath.resolve(pathToPy)
    if (!await common.isFile(pathToPy)) {
        ui.logError(pathToPy+" is not found")
        return;
    }
    cwd ||= nwPath.dirname(pathToPy);
    await this.init();
    args.unshift(pathToPy);
    return common.aSpawn(this.pythonPath, args, {
        cwd: cwd
    })
}


/**
 * Run a python script as a new shell window
 * @param {String} pathToPy - Path to the python script
 * @param {String} [cwd] - Directory where the python script will be called
 * @param {String[]} [args] - Arguments to be passed to the script
 * @returns {String} Output of the execution
 */
Python.prototype.runPyDetached = async function(pathToPy, cwd="", args=[]) {
    pathToPy = nwPath.resolve(pathToPy);
    if (!await common.isFile(pathToPy)) {
        ui.logError(pathToPy+" is not found")
        return;
    }
    cwd ||= nwPath.dirname(pathToPy);
    await this.init();
    args.unshift(pathToPy);
    return common.aSpawn(this.pythonPath, args, {
        cwd: cwd,
        shell:true,
        detached:true
    })
}

Python.prototype.killProcess = async function(pythonScript) {
    // todo
    // wmic process where "commandline like '%D:\\App\\eztrans\\ezTransWeb\\eztrans.py%'" get processid,commandLine
}

/**
 * Lists all installed Python packages.
 * @returns {Promise<Array>} A promise that resolves to an array of installed packages.
 */
Python.prototype.listPackages = async function() {
    await this.init();

    var listPackage = await common.aSpawn("python.exe", ["-m", "pip", "list", "--format", "json"], {
        cwd: this.baseDir
    });
    if (typeof listPackage == "string") return []
    return listPackage;
}

/**
 * Checks if a specific Python package is installed.
 * @param {string} packageName - The name of the package to check.
 * @returns {Promise<boolean>} A promise that resolves to true if the package is installed, false otherwise.
 */
Python.prototype.isPackage = async function(packageName) {
    await this.init();

    if (!packageName) return false;
    const packages = await this.listPackages();
    for (let i in packages) {
        if (!packages[i]?.name) continue;
        if (packages[i].name.toLowerCase() == packageName.toLowerCase()) return true;
    }
    return false;
}

/**
 * Checks if a specific script exists.
 * @param {string} scriptName - The name of the script to check.
 * @returns {Promise<boolean>} A promise that resolves to true if the script exists, false otherwise.
 */
Python.prototype.isScript = async function(scriptName) {
    await this.init();
    const scriptDir = nwPath.join(this.baseDir, "Scripts");

    if (nwPath.extname(scriptDir).toLowerCase() != ".exe") {
        scriptName = scriptName+".exe";
    }

    return await common.isFileAsync(nwPath.join(scriptDir, scriptName));
}

/**
 * Gets information about a specific Python package.
 * @param {string} pkg - The name of the package to get information about.
 * @returns {Promise<Object>} A promise that resolves to an object containing information about the package.
 */
Python.prototype.getPackageInfo = async function(pkg) {
    await this.init();

    if (!pkg) return;

    var info = await common.aSpawn("python.exe", ["-m", "pip", "show", pkg], {
        cwd: this.baseDir
    });
    if (!info) return;
    var result = {}
    var lines = info.replaceAll("\r", "").split("\n");
    for (let i in lines) {
        let thisLine = lines[i];
        if (!thisLine.trim()) continue;
        let segm = thisLine.split(": ");
        let key = segm.shift()
        result[key.trim()] = segm.join(": ").trim()
    }
    return result;
}

/**
 * Installing a package
 * @param {String} pkg - Package name
 * @example
 * await python.installPackage("litellm");
 * await python.installPackage("fastapi==0.109.0");
 */
Python.prototype.installPackage = async function(pkg, extraArgs=[]) {
    if (!pkg) return;

    await this.init();

    let spawnArgs = ["-m", "pip", "install"];

    if (extraArgs?.length) {
        spawnArgs = spawnArgs.concat(extraArgs);
    }

    if (Array.isArray(pkg)) {
        spawnArgs = spawnArgs.concat(pkg);
    } else {
        spawnArgs.push(pkg);
    }



    await common.aSpawn("python.exe", spawnArgs, {
        cwd: this.baseDir
    });
}

/**
 * Uninstalls a specific Python package.
 * @param {string | Array<string>} pkg - The name of the package(s) to uninstall.
 * @returns {Promise<void>} A promise that resolves when the package(s) have been uninstalled.
 */
Python.prototype.uninstallPackage = async function(pkg) {
    if (!pkg) return;

    await this.init();

    let spawnArgs = ["-m", "pip", "uninstall", "-y"];

    if (Array.isArray(pkg)) {
        spawnArgs = spawnArgs.concat(pkg);
    } else {
        spawnArgs.push(pkg);
    }

    await common.aSpawn("python.exe", spawnArgs, {
        cwd: this.baseDir
    });
}

/**
 * Checks if a specific requirements file is installed.
 * @param {string} requirementsFile - The path to the requirements file.
 * @returns {boolean} True if the requirements file is installed, false otherwise.
 */
Python.prototype.requirementIsInstalled = function(requirementsFile="") {
    var installedRequirements = this.getConfig("installedRequirements") || {};
    if (installedRequirements[requirementsFile]) return true;
    return false;
}

/**
 * Installs the packages from a specific requirements file.
 * @param {string} requirementsFile - The path to the requirements file.
 * @param {boolean} force - Whether to force the installation even if the requirements file is already installed.
 * @returns {Promise<void>} A promise that resolves when the packages have been installed.
 */
Python.prototype.installRequirements = async function(requirementsFile="", force=false) {
    if (!await common.isFile(requirementsFile)) return;

    await this.init();
    if (this.requirementIsInstalled(requirementsFile)) {
        console.log("Requirement file is already installed...");

        if (!force) return;
    }
    console.log("Requirement file is not installed, so installing...");
    const absPath = nwPath.resolve(requirementsFile);
    await common.aSpawn("python.exe", ["-m", "pip", "install", "-r", `${absPath}`], {
        cwd: this.baseDir
    });

    var installedRequirements = this.getConfig("installedRequirements") || {};
    installedRequirements[requirementsFile] = true;
    this.setConfig("installedRequirements", installedRequirements)
}

/**
 * Uninstalls the packages from a specific requirements file.
 * @param {string} requirementsFile - The path to the requirements file.
 * @returns {Promise<void>} A promise that resolves when the packages have been uninstalled.
 */
Python.prototype.uninstallRequirements = async function(requirementsFile="") {
    if (!await common.isFile(requirementsFile)) return;
    await this.init();
    const absPath = nwPath.resolve(requirementsFile);
    await common.aSpawn("python.exe", ["-m", "pip", "uninstall", "-y", "-r", `${absPath}`], {
        cwd: this.baseDir
    });

    var installedRequirements = this.getConfig("installedRequirements") || {};
    installedRequirements[requirementsFile] = false;
    this.setConfig("installedRequirements", installedRequirements)
}

// Static
/**
 * Downloads a file using Python.
 * @static
 * @param {string} from - The URL of the file to download.
 * @param {string} to - The path where to save the downloaded file.
 * @returns {Promise<string>} A promise that resolves to the path of the downloaded file.
 */
Python.downloader = async function(from, to) {
    const python = new Python();
    await python.installPackage(["tqdm", "requests"]);

    await common.mkDir(nwPath.dirname(to));
    to = nwPath.resolve(to);
    await python.runPyDetached("www/py/download.py", "", [from, to])

    return to;
}

module.exports = Python;