diff --git a/README.md b/README.md index f6e28f6..1a67627 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ v0.4 - has an "export" and "import" module that converts sql data into a "git re ## AS IS: +v0.4 - Python triggers at parity with PHP (support functions, sql bridge, cron, error handling) v0.3 - supports Javascript / ECMA Script, and npm package manager v0.2 - has a function called "sql_read_hidrate()" that gives the foreign keys hidrated - (useful for trigger functions like telegram outbox that need data like recipients hidrated) diff --git a/install/SYS_PRD_BND.CronJobs.sql b/install/SYS_PRD_BND.CronJobs.sql new file mode 100644 index 0000000..e5acd90 --- /dev/null +++ b/install/SYS_PRD_BND.CronJobs.sql @@ -0,0 +1,14 @@ +USE SYS_PRD_BND; +CREATE TABLE IF NOT EXISTS `CronJobs` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `Minute` int(11) NOT NULL DEFAULT -1, + `Hour` int(11) NOT NULL DEFAULT -1, + `DayOfMonth` int(11) NOT NULL DEFAULT -1, + `Month` int(11) NOT NULL DEFAULT -1, + `DayOfWeek` int(11) NOT NULL DEFAULT -1, + `PhpCode` text DEFAULT NULL, + `PyCode` text DEFAULT NULL, + `LastUpdated` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `LastError` text DEFAULT NULL, + PRIMARY KEY (`id`) +); diff --git a/install/SYS_PRD_BND.PyPi.sql b/install/SYS_PRD_BND.PyPi.sql index 625dac9..624b1bf 100644 --- a/install/SYS_PRD_BND.PyPi.sql +++ b/install/SYS_PRD_BND.PyPi.sql @@ -2,6 +2,7 @@ USE SYS_PRD_BND; CREATE TABLE `PyPi` ( `LibName` varchar(255) NOT NULL, `AliasName` varchar(255) DEFAULT NULL, + `VersionOrTag` varchar(25) DEFAULT NULL, `LastUpdated` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`LibName`) ); diff --git a/plt/generatePHPCronCode.inc.php b/plt/generatePHPCronCode.inc.php index a4319ad..3c75d8b 100644 --- a/plt/generatePHPCronCode.inc.php +++ b/plt/generatePHPCronCode.inc.php @@ -20,6 +20,8 @@ function generatePHPCronCode($functionName, $PhpCode) { // Assemble the complete PHP script $wrappedPhpCode = << ' ' . $line, explode("\n", $pyCode))); + + $pythonCode = << ' ' . $line, explode("\n", $pyCode))); - // Assemble complete Python script $pythonCode = << ' ' . $line, explode("\n", $f['PythonCode']))); + $functions .= "def {$f['Name']}($argsString):\n{$indentedBody}\n\n"; + } + + return $functions; +} diff --git a/plt/index.php b/plt/index.php index 061da66..bbd803f 100755 --- a/plt/index.php +++ b/plt/index.php @@ -27,12 +27,12 @@ * Columns: Name, Type, Value * * - SYS_PRD_BND.SupportFunctions: -* Stores reusable PHP functions available during trigger execution. -* Columns: Name, InputArgs_json, PhpCode +* Stores reusable functions available during trigger execution. +* Columns: Name, InputArgs_json, PhpCode, PythonCode, JavascriptCode * * - SYS_PRD_BND.PyPi: * Lists external Python libraries imported into Python trigger execution. -* Columns: LibName, AliasName +* Columns: LibName, AliasName, VersionOrTag * * - SYS_PRD_BND.Npm: * Lists external Node modules imported into JavaScript trigger execution. @@ -123,7 +123,7 @@ function processCronJobs() { // 3. FETCH JOBS // We check for an exact match OR a wildcard (-1) - $sql = "SELECT id, PhpCode + $sql = "SELECT id, PhpCode, PyCode FROM SYS_PRD_BND.CronJobs WHERE (Minute = MINUTE(NOW()) OR Minute = -1) @@ -138,9 +138,11 @@ function processCronJobs() { if ($jobs) { foreach ($jobs as $cronJob) { if (!empty($cronJob["PhpCode"])) { - // Pass a unique identifier (cronJob_ID) to the sandbox runner runPHPCronCode("cronJob_" . $cronJob["id"], $cronJob["PhpCode"], $cronJob["id"]); } + if (!empty($cronJob["PyCode"])) { + runPythonCronCode("cronJob_" . $cronJob["id"], $cronJob["PyCode"], $cronJob["id"]); + } } } } @@ -155,6 +157,17 @@ function runPHPCronCode($functionName,$code,$cronJobId) { handleCronJobExecutionResult($result, $stdout,$stderr,$cronJobId); } +/** + * Executes database-level dynamic Python cron code. + */ +function runPythonCronCode($functionName, $code, $cronJobId) { + echo "Running Python CRON code for cronJobId $cronJobId...\n"; + $pyCode = generatePythonCronCode($functionName, $code); + $result = runSandboxedPython($pyCode, $stdout, $stderr); + + handleCronJobExecutionResult($result, $stdout, $stderr, $cronJobId); +} + /** * Ensures the 'LastUpdated' column exists, adding it if missing. diff --git a/plt/runSandboxedPython.inc.php b/plt/runSandboxedPython.inc.php index 77e443c..a712af7 100644 --- a/plt/runSandboxedPython.inc.php +++ b/plt/runSandboxedPython.inc.php @@ -11,13 +11,19 @@ function runSandboxedPython($code, &$stdout = null, &$stderr = null) { // Create a temporary file to hold the Python code $tempFile = tempnam(sys_get_temp_dir(), 'sandboxed_') . '.py'; - echo "Creating ".redText("python temp code file")." for sandboxed execution at: $tempFile \n"; // Write the generated Python code to the temporary file file_put_contents($tempFile, $code); // Prepare the command for executing the Python code - $command = "/usr/bin/python3 " . escapeshellarg($tempFile); + $pythonBin = getenv('REACTIVE_PYTHON_BIN') ?: '/usr/bin/python3'; + if (!is_file($pythonBin)) { + $which = trim((string) shell_exec('command -v python3 2>/dev/null')); + if ($which !== '') { + $pythonBin = $which; + } + } + $command = $pythonBin . " " . escapeshellarg($tempFile); // Descriptor spec to capture stdout and stderr $descriptorspec = [ diff --git a/requirements.txt.php b/requirements.txt.php index 9e57f94..b8461e0 100644 --- a/requirements.txt.php +++ b/requirements.txt.php @@ -12,13 +12,15 @@ try { // Fetch dependencies from PyPi table // Note: We select LibName. If AliasName represents a specific version or URI, it can be appended. - $stmt = $pdo->query("SELECT LibName, AliasName FROM PyPi ORDER BY LibName"); + $stmt = $pdo->query("SELECT LibName, VersionOrTag FROM PyPi ORDER BY LibName"); $requirements = []; foreach ($stmt as $row) { - // Standard requirements.txt format is usually "PackageName" or "PackageName==Version" - // If AliasName is used for versioning, you could use: $row['LibName'] . "==" . $row['AliasName'] - $requirements[] = $row['LibName']; + $line = $row['LibName']; + if (!empty($row['VersionOrTag'])) { + $line .= '==' . $row['VersionOrTag']; + } + $requirements[] = $line; } // Build requirements.txt content (Plain Text, one per line) diff --git a/sys/db_runtime.py b/sys/db_runtime.py new file mode 100644 index 0000000..ce0d3df --- /dev/null +++ b/sys/db_runtime.py @@ -0,0 +1,120 @@ +"""Database runtime for sandboxed Python triggers (mirrors sys/sys.php via sql_bridge.php).""" + +import json +import os +import shutil +import subprocess +from typing import Any, Optional + + +def _project_root() -> str: + return os.getcwd() + + +def _php_binary() -> str: + for candidate in ( + os.environ.get("REACTIVE_PHP_BIN"), + "/usr/bin/php", + shutil.which("php"), + ): + if candidate and os.path.isfile(candidate): + return candidate + if candidate and shutil.which(candidate): + return shutil.which(candidate) # type: ignore[return-value] + raise RuntimeError("PHP binary not found for sql_bridge") + + +def _bridge(action: str, payload: dict) -> Any: + bridge = os.path.join(_project_root(), "sys", "sql_bridge.php") + proc = subprocess.run( + [_php_binary(), bridge], + input=json.dumps({"action": action, **payload}), + capture_output=True, + text=True, + ) + if proc.returncode != 0: + err = proc.stderr.strip() + try: + err_obj = json.loads(err) + err = err_obj.get("error", err) + except json.JSONDecodeError: + pass + raise RuntimeError(err or "sql_bridge failed") + data = json.loads(proc.stdout) + return data["result"] + + +def sql( + query: str, + db_host: Optional[str] = None, + db_user: Optional[str] = None, + db_pass: Optional[str] = None, + db_name: Optional[str] = None, +) -> Any: + return _bridge( + "sql", + { + "query": query, + "db_host": db_host, + "db_user": db_user, + "db_pass": db_pass, + "db_name": db_name, + }, + ) + + +def sql_read( + query: str, + db_host: Optional[str] = None, + db_user: Optional[str] = None, + db_pass: Optional[str] = None, + db_name: Optional[str] = None, +) -> Any: + return _bridge( + "sql_read", + { + "query": query, + "db_host": db_host, + "db_user": db_user, + "db_pass": db_pass, + "db_name": db_name, + }, + ) + + +def sql_write( + query: str, + db_host: Optional[str] = None, + db_user: Optional[str] = None, + db_pass: Optional[str] = None, + db_name: Optional[str] = None, +) -> Any: + return _bridge( + "sql_write", + { + "query": query, + "db_host": db_host, + "db_user": db_user, + "db_pass": db_pass, + "db_name": db_name, + }, + ) + + +def sql_read_and_hydrate(query: str) -> Any: + return _bridge("sql_read_and_hydrate", {"query": query}) + + +def get_tbl_primary_key_col_name(table: str) -> list: + return _bridge("get_tbl_primary_key_col_name", {"table": table}) + + +def run_process(cmd: str, stdin: str = "") -> tuple[int, str, str]: + proc = subprocess.run( + cmd, + input=stdin, + shell=True, + capture_output=True, + text=True, + ) + return proc.returncode, proc.stdout, proc.stderr diff --git a/sys/sql_bridge.php b/sys/sql_bridge.php new file mode 100644 index 0000000..4e68024 --- /dev/null +++ b/sys/sql_bridge.php @@ -0,0 +1,54 @@ +#!/usr/bin/env php + 'Invalid input'])); + exit(1); +} + +$dbHost = $input['db_host'] ?? null; +$dbUser = $input['db_user'] ?? null; +$dbPass = $input['db_pass'] ?? null; +$dbName = $input['db_name'] ?? null; + +try { + switch ($input['action']) { + case 'sql': + $result = sql($input['query'], $dbHost, $dbUser, $dbPass, $dbName); + echo json_encode(['result' => $result]); + break; + case 'sql_read': + $result = sql_read($input['query'], $dbHost, $dbUser, $dbPass, $dbName); + echo json_encode(['result' => $result]); + break; + case 'sql_write': + $result = sql_write($input['query'], $dbHost, $dbUser, $dbPass, $dbName); + echo json_encode(['result' => $result]); + break; + case 'sql_read_and_hydrate': + $result = sql_read_and_hydrate($input['query']); + echo json_encode(['result' => $result]); + break; + case 'get_tbl_primary_key_col_name': + $result = getTblPrimaryKeyColName($input['table']); + echo json_encode(['result' => $result]); + break; + default: + throw new Exception('Unknown action: ' . $input['action']); + } +} catch (Throwable $e) { + fwrite(STDERR, json_encode(['error' => $e->getMessage()])); + exit(1); +}