IMPROVEMENT: bring Python trigger support to parity with PHP.

Add SQL bridge, support functions, cron jobs, error handling, and PyPI version pinning so Python triggers match PHP capabilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frederico Falcao 2026-06-28 00:13:18 +03:00
parent de40a2253d
commit 14f96e78f9
14 changed files with 308 additions and 36 deletions

View File

@ -9,6 +9,7 @@ v0.4 - has an "export" and "import" module that converts sql data into a "git re
## AS IS: ## 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.3 - supports Javascript / ECMA Script, and npm package manager
v0.2 - has a function called "sql_read_hidrate()" that gives the foreign keys hidrated 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) - (useful for trigger functions like telegram outbox that need data like recipients hidrated)

View File

@ -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`)
);

View File

@ -2,6 +2,7 @@ USE SYS_PRD_BND;
CREATE TABLE `PyPi` ( CREATE TABLE `PyPi` (
`LibName` varchar(255) NOT NULL, `LibName` varchar(255) NOT NULL,
`AliasName` varchar(255) DEFAULT NULL, `AliasName` varchar(255) DEFAULT NULL,
`VersionOrTag` varchar(25) DEFAULT NULL,
`LastUpdated` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), `LastUpdated` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`LibName`) PRIMARY KEY (`LibName`)
); );

View File

@ -20,6 +20,8 @@ function generatePHPCronCode($functionName, $PhpCode) {
// Assemble the complete PHP script // Assemble the complete PHP script
$wrappedPhpCode = <<<PHP $wrappedPhpCode = <<<PHP
<?php <?php
require_once '.env.php';
// Define constants // Define constants
$constants $constants

View File

@ -24,6 +24,8 @@ function generatePHPTriggerCode($functionName, $onUpdate_phpCode, $row) {
// Assemble the complete PHP script // Assemble the complete PHP script
$phpCode = <<<PHP $phpCode = <<<PHP
<?php <?php
require_once '.env.php';
// Define constants // Define constants
$constants $constants

View File

@ -0,0 +1,32 @@
<?php
/**
* Generates Python code for sandbox cron execution.
*
* @param string $functionName The name of the Python function to generate.
* @param string $pyCode The Python code to embed within the generated function.
*
* @return string The complete Python code ready for sandbox execution.
*/
function generatePythonCronCode($functionName, $pyCode) {
$constants = getPythonConstantsDefinition();
$supportFunctions = getPythonSupportFunctionsDefinition();
$imports = "import json\nimport sys as _sys\nimport os\n\n_sys.path.insert(0, os.path.join(os.getcwd(), 'sys'))\nfrom db_runtime import sql, sql_read, sql_write, sql_read_and_hydrate, get_tbl_primary_key_col_name, run_process\n" . getPythonImports();
$indentedCode = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", $pyCode)));
$pythonCode = <<<PY
$imports
$constants
$supportFunctions
def $functionName(error):
$indentedCode
error = [None]
_ret = $functionName(error)
if _ret is False or error[0]:
_sys.stderr.write("Error: " + str(error[0] or "") + "\\n")
_sys.exit(1)
PY;
return $pythonCode;
}

View File

@ -9,42 +9,27 @@
* @return string The complete Python code ready for sandbox execution. * @return string The complete Python code ready for sandbox execution.
*/ */
function generatePythonTriggerCode($functionName, $pyCode, $row) { function generatePythonTriggerCode($functionName, $pyCode, $row) {
// Build constant definitions $constants = getPythonConstantsDefinition();
$constants = ''; $supportFunctions = getPythonSupportFunctionsDefinition();
foreach (sql("SELECT Name, Type, Value FROM SYS_PRD_BND.Constants") as $const) { $imports = "import json\nimport sys as _sys\nimport os\n\n_sys.path.insert(0, os.path.join(os.getcwd(), 'sys'))\nfrom db_runtime import sql, sql_read, sql_write, sql_read_and_hydrate, get_tbl_primary_key_col_name, run_process\n" . getPythonImports();
switch ($const['Type']) {
case 'String':
$val = "'" . str_replace("'", "\\'", $const['Value']) . "'";
break;
case 'Json':
$val = 'json.loads(' . json_encode($const['Value']) . ')';
break;
default: // Int or Double
$val = $const['Value'];
}
$constants .= "{$const['Name']} = {$val}\n";
}
// Prepare imports $rowJson = json_encode($row, JSON_UNESCAPED_UNICODE);
$imports = "import json\n" . getPythonImports();
// Prepare row data for Python
$rowJson = json_encode($row, JSON_UNESCAPED_UNICODE);
$rowJsonPyLiteral = json_encode($rowJson); $rowJsonPyLiteral = json_encode($rowJson);
// Indent user-provided Python code
$indentedCode = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", $pyCode))); $indentedCode = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", $pyCode)));
// Assemble complete Python script
$pythonCode = <<<PY $pythonCode = <<<PY
$imports $imports
$constants $constants
$supportFunctions
def $functionName(data, error): def $functionName(data, error):
$indentedCode $indentedCode
data = json.loads($rowJsonPyLiteral) data = json.loads($rowJsonPyLiteral)
error = None error = [None]
$functionName(data, error) _ret = $functionName(data, error)
if _ret is False or error[0]:
_sys.stderr.write("Error: " + str(error[0] or "") + "\\n")
_sys.exit(1)
print(json.dumps(data)) print(json.dumps(data))
PY; PY;

View File

@ -0,0 +1,23 @@
<?php
/**
* Builds Python constant assignments from SYS_PRD_BND.Constants.
*/
function getPythonConstantsDefinition() {
$constants = "";
foreach (sql("SELECT Name, Type, Value FROM SYS_PRD_BND.Constants") as $const) {
switch ($const['Type']) {
case 'String':
$val = "'" . str_replace("'", "\\'", $const['Value']) . "'";
break;
case 'Json':
$val = 'json.loads(' . json_encode($const['Value']) . ')';
break;
default:
$val = $const['Value'];
}
$constants .= "{$const['Name']} = {$val}\n";
}
return $constants;
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Builds Python support function definitions from SYS_PRD_BND.SupportFunctions.
*/
function getPythonSupportFunctionsDefinition() {
$functions = "";
foreach (sql("SELECT Name, InputArgs_json, PythonCode FROM SYS_PRD_BND.SupportFunctions WHERE PythonCode IS NOT NULL") as $f) {
$inputArgs = json_decode($f['InputArgs_json'], true);
$argNames = is_array($inputArgs) ? array_keys($inputArgs) : [];
$argsString = implode(', ', $argNames);
$indentedBody = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", $f['PythonCode'])));
$functions .= "def {$f['Name']}($argsString):\n{$indentedBody}\n\n";
}
return $functions;
}

View File

@ -27,12 +27,12 @@
* Columns: Name, Type, Value * Columns: Name, Type, Value
* *
* - SYS_PRD_BND.SupportFunctions: * - SYS_PRD_BND.SupportFunctions:
* Stores reusable PHP functions available during trigger execution. * Stores reusable functions available during trigger execution.
* Columns: Name, InputArgs_json, PhpCode * Columns: Name, InputArgs_json, PhpCode, PythonCode, JavascriptCode
* *
* - SYS_PRD_BND.PyPi: * - SYS_PRD_BND.PyPi:
* Lists external Python libraries imported into Python trigger execution. * Lists external Python libraries imported into Python trigger execution.
* Columns: LibName, AliasName * Columns: LibName, AliasName, VersionOrTag
* *
* - SYS_PRD_BND.Npm: * - SYS_PRD_BND.Npm:
* Lists external Node modules imported into JavaScript trigger execution. * Lists external Node modules imported into JavaScript trigger execution.
@ -123,7 +123,7 @@ function processCronJobs() {
// 3. FETCH JOBS // 3. FETCH JOBS
// We check for an exact match OR a wildcard (-1) // We check for an exact match OR a wildcard (-1)
$sql = "SELECT id, PhpCode $sql = "SELECT id, PhpCode, PyCode
FROM SYS_PRD_BND.CronJobs FROM SYS_PRD_BND.CronJobs
WHERE WHERE
(Minute = MINUTE(NOW()) OR Minute = -1) (Minute = MINUTE(NOW()) OR Minute = -1)
@ -138,9 +138,11 @@ function processCronJobs() {
if ($jobs) { if ($jobs) {
foreach ($jobs as $cronJob) { foreach ($jobs as $cronJob) {
if (!empty($cronJob["PhpCode"])) { if (!empty($cronJob["PhpCode"])) {
// Pass a unique identifier (cronJob_ID) to the sandbox runner
runPHPCronCode("cronJob_" . $cronJob["id"], $cronJob["PhpCode"], $cronJob["id"]); 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); 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. * Ensures the 'LastUpdated' column exists, adding it if missing.

View File

@ -11,13 +11,19 @@
function runSandboxedPython($code, &$stdout = null, &$stderr = null) { function runSandboxedPython($code, &$stdout = null, &$stderr = null) {
// Create a temporary file to hold the Python code // Create a temporary file to hold the Python code
$tempFile = tempnam(sys_get_temp_dir(), 'sandboxed_') . '.py'; $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 // Write the generated Python code to the temporary file
file_put_contents($tempFile, $code); file_put_contents($tempFile, $code);
// Prepare the command for executing the Python 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 // Descriptor spec to capture stdout and stderr
$descriptorspec = [ $descriptorspec = [

View File

@ -12,13 +12,15 @@ try {
// Fetch dependencies from PyPi table // Fetch dependencies from PyPi table
// Note: We select LibName. If AliasName represents a specific version or URI, it can be appended. // 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 = []; $requirements = [];
foreach ($stmt as $row) { foreach ($stmt as $row) {
// Standard requirements.txt format is usually "PackageName" or "PackageName==Version" $line = $row['LibName'];
// If AliasName is used for versioning, you could use: $row['LibName'] . "==" . $row['AliasName'] if (!empty($row['VersionOrTag'])) {
$requirements[] = $row['LibName']; $line .= '==' . $row['VersionOrTag'];
}
$requirements[] = $line;
} }
// Build requirements.txt content (Plain Text, one per line) // Build requirements.txt content (Plain Text, one per line)

120
sys/db_runtime.py Normal file
View File

@ -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

54
sys/sql_bridge.php Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env php
<?php
/**
* CLI bridge so sandboxed Python can call the same SQL helpers as PHP (sys.php).
* Reads JSON from stdin, writes JSON to stdout, errors to stderr.
*/
if (php_sapi_name() !== 'cli') {
fwrite(STDERR, "CLI only\n");
exit(1);
}
require_once dirname(__DIR__) . '/.env.php';
require_once __DIR__ . '/sys.php';
$input = json_decode(file_get_contents('php://stdin'), true);
if (!is_array($input) || empty($input['action'])) {
fwrite(STDERR, json_encode(['error' => '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);
}