feat: python scripting phase 0
This commit is contained in:
258
src/renderer/python/PythonRuntimeManager.ts
Normal file
258
src/renderer/python/PythonRuntimeManager.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
|
||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
|
||||
|
||||
type WorkerFactory = () => Worker;
|
||||
|
||||
interface InitializeDeferred {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface PendingRun {
|
||||
kind: 'run' | 'macro-v1';
|
||||
stdout: string;
|
||||
resolve: (value: PythonRunResult | PythonMacroV1Result) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export interface PythonRunResult {
|
||||
result: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
export interface PythonExecuteOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PythonMacroV1Result {
|
||||
result: MacroResultV1;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
export class PythonRuntimeManager {
|
||||
private worker: Worker | null = null;
|
||||
private initializingPromise: Promise<void> | null = null;
|
||||
private initializeDeferred: InitializeDeferred | null = null;
|
||||
private ready = false;
|
||||
private pendingRuns = new Map<string, PendingRun>();
|
||||
private requestCounter = 0;
|
||||
|
||||
constructor(private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker) {}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
if (this.ready) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.initializingPromise) {
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
this.worker = this.workerFactory();
|
||||
this.ready = false;
|
||||
|
||||
this.initializingPromise = new Promise<void>((resolve, reject) => {
|
||||
this.initializeDeferred = { resolve, reject };
|
||||
|
||||
if (!this.worker) {
|
||||
this.initializeDeferred = null;
|
||||
reject(new Error('Python runtime worker factory returned no worker'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.worker.onmessage = (event: MessageEvent<PythonWorkerMessage>) => {
|
||||
this.handleWorkerMessage(event.data);
|
||||
};
|
||||
this.worker.onerror = (event: ErrorEvent) => {
|
||||
this.handleWorkerError(event.error instanceof Error ? event.error : new Error(event.message || 'Python runtime worker failed to initialize'));
|
||||
};
|
||||
});
|
||||
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
async execute(code: string, options?: PythonExecuteOptions): Promise<PythonRunResult> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
const timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
return new Promise<PythonRunResult>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRuns.delete(requestId);
|
||||
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
|
||||
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRuns.set(requestId, {
|
||||
kind: 'run',
|
||||
stdout: '',
|
||||
resolve: (value) => resolve(value as PythonRunResult),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'run',
|
||||
requestId,
|
||||
code,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async renderMacroV1(code: string, context: unknown, options?: PythonExecuteOptions): Promise<PythonMacroV1Result> {
|
||||
const validatedContext = parseMacroContextV1(context);
|
||||
await this.initialize();
|
||||
|
||||
if (!this.worker || !this.ready) {
|
||||
throw new Error('Python runtime is not ready');
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
const timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
return new Promise<PythonMacroV1Result>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingRuns.delete(requestId);
|
||||
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
|
||||
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRuns.set(requestId, {
|
||||
kind: 'macro-v1',
|
||||
stdout: '',
|
||||
resolve: (value) => resolve(value as PythonMacroV1Result),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
const message: PythonWorkerRequest = {
|
||||
type: 'renderMacroV1',
|
||||
requestId,
|
||||
code,
|
||||
context: validatedContext,
|
||||
};
|
||||
|
||||
this.worker!.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.resetRuntime();
|
||||
}
|
||||
|
||||
private handleWorkerMessage(payload: PythonWorkerMessage): void {
|
||||
if (payload.type === 'ready') {
|
||||
this.ready = true;
|
||||
this.initializingPromise = null;
|
||||
this.initializeDeferred?.resolve();
|
||||
this.initializeDeferred = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'error') {
|
||||
this.handleWorkerError(new Error(payload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRun = this.pendingRuns.get(payload.requestId);
|
||||
if (!pendingRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'stdout') {
|
||||
pendingRun.stdout += payload.chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingRuns.delete(payload.requestId);
|
||||
if (pendingRun.timeoutId) {
|
||||
clearTimeout(pendingRun.timeoutId);
|
||||
}
|
||||
|
||||
if (payload.type === 'runResult') {
|
||||
if (pendingRun.kind !== 'run') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending macro request'));
|
||||
return;
|
||||
}
|
||||
pendingRun.resolve({ result: payload.result, stdout: pendingRun.stdout });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'macroResult') {
|
||||
if (pendingRun.kind !== 'macro-v1') {
|
||||
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedResult = parseMacroResultV1(payload.result);
|
||||
pendingRun.resolve({ result: validatedResult, stdout: pendingRun.stdout });
|
||||
} catch (error) {
|
||||
pendingRun.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRun.reject(new Error(payload.error));
|
||||
}
|
||||
|
||||
private handleWorkerError(error: Error): void {
|
||||
if (this.initializeDeferred) {
|
||||
this.initializeDeferred.reject(error);
|
||||
this.initializeDeferred = null;
|
||||
}
|
||||
|
||||
for (const run of this.pendingRuns.values()) {
|
||||
if (run.timeoutId) {
|
||||
clearTimeout(run.timeoutId);
|
||||
}
|
||||
run.reject(error);
|
||||
}
|
||||
|
||||
this.pendingRuns.clear();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.initializingPromise = null;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
private resetRuntime(timeoutErrorMessage?: string): void {
|
||||
if (this.initializeDeferred) {
|
||||
this.initializeDeferred.reject(new Error(timeoutErrorMessage ?? 'Python runtime reset'));
|
||||
this.initializeDeferred = null;
|
||||
}
|
||||
|
||||
for (const run of this.pendingRuns.values()) {
|
||||
if (run.timeoutId) {
|
||||
clearTimeout(run.timeoutId);
|
||||
}
|
||||
if (timeoutErrorMessage) {
|
||||
run.reject(new Error(timeoutErrorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingRuns.clear();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.initializingPromise = null;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
private nextRequestId(): string {
|
||||
this.requestCounter += 1;
|
||||
return `req-${this.requestCounter}`;
|
||||
}
|
||||
}
|
||||
52
src/renderer/python/abiV1.ts
Normal file
52
src/renderer/python/abiV1.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const jsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(jsonValueSchema),
|
||||
z.record(z.string(), jsonValueSchema),
|
||||
])
|
||||
);
|
||||
|
||||
export const macroContextV1Schema = z
|
||||
.object({
|
||||
env: z
|
||||
.object({
|
||||
isPreview: z.boolean(),
|
||||
mainLanguage: z.string().min(1).optional(),
|
||||
})
|
||||
.strict(),
|
||||
params: z.record(z.string(), jsonValueSchema).optional(),
|
||||
data: z.record(z.string(), jsonValueSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const macroResultV1Schema = z
|
||||
.object({
|
||||
html: z.string(),
|
||||
data: z.record(z.string(), jsonValueSchema).optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type MacroContextV1 = z.infer<typeof macroContextV1Schema>;
|
||||
export type MacroResultV1 = z.infer<typeof macroResultV1Schema>;
|
||||
|
||||
export function parseMacroContextV1(value: unknown): MacroContextV1 {
|
||||
const parsed = macroContextV1Schema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid macro context: ${parsed.error.issues[0]?.message ?? 'schema validation failed'}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseMacroResultV1(value: unknown): MacroResultV1 {
|
||||
const parsed = macroResultV1Schema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid macro result: ${parsed.error.issues[0]?.message ?? 'schema validation failed'}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
5
src/renderer/python/createPythonRuntimeWorker.ts
Normal file
5
src/renderer/python/createPythonRuntimeWorker.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import PythonRuntimeWorker from './pythonRuntime.worker?worker';
|
||||
|
||||
export function createPythonRuntimeWorker(): Worker {
|
||||
return new PythonRuntimeWorker();
|
||||
}
|
||||
128
src/renderer/python/pythonRuntime.worker.ts
Normal file
128
src/renderer/python/pythonRuntime.worker.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
|
||||
|
||||
let runtime: PyodideInterface | null = null;
|
||||
let activeRequestId: string | null = null;
|
||||
|
||||
function postRuntimeMessage(message: PythonWorkerMessage): void {
|
||||
self.postMessage(message);
|
||||
}
|
||||
|
||||
function toResultString(result: unknown): string {
|
||||
if (result === undefined || result === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
return String(result);
|
||||
}
|
||||
|
||||
async function runScript(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'run') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestId) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
const result = await runtime.runPythonAsync(request.code);
|
||||
postRuntimeMessage({
|
||||
type: 'runResult',
|
||||
requestId: request.requestId,
|
||||
result: toResultString(result),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMacroV1(request: PythonWorkerRequest): Promise<void> {
|
||||
if (request.type !== 'renderMacroV1') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestId) {
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequestId = request.requestId;
|
||||
|
||||
try {
|
||||
const validatedContext = parseMacroContextV1(request.context);
|
||||
runtime.globals.set('__bds_context_v1', validatedContext);
|
||||
|
||||
await runtime.runPythonAsync(request.code);
|
||||
|
||||
const rawJsonResult = await runtime.runPythonAsync(`
|
||||
import json
|
||||
json.dumps(render(__bds_context_v1))
|
||||
`);
|
||||
|
||||
const parsedResult = parseMacroResultV1(JSON.parse(toResultString(rawJsonResult)));
|
||||
postRuntimeMessage({
|
||||
type: 'macroResult',
|
||||
requestId: request.requestId,
|
||||
result: parsedResult,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapRuntime(): Promise<void> {
|
||||
try {
|
||||
runtime = await loadPyodide({
|
||||
stdout: (chunk) => {
|
||||
if (!activeRequestId) {
|
||||
return;
|
||||
}
|
||||
postRuntimeMessage({ type: 'stdout', requestId: activeRequestId, chunk });
|
||||
},
|
||||
});
|
||||
if (!runtime) {
|
||||
throw new Error('Pyodide initialization returned no runtime');
|
||||
}
|
||||
postRuntimeMessage({ type: 'ready' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postRuntimeMessage({ type: 'error', error: message });
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
|
||||
const request = event.data;
|
||||
if (request.type === 'run') {
|
||||
void runScript(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.type === 'renderMacroV1') {
|
||||
void runMacroV1(request);
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrapRuntime();
|
||||
117
src/renderer/python/pythonRuntimeBenchmark.ts
Normal file
117
src/renderer/python/pythonRuntimeBenchmark.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { loadPyodide } from 'pyodide';
|
||||
|
||||
export interface BenchmarkStats {
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
meanMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
coldStartMs: number;
|
||||
warmRunMs: number;
|
||||
repeatedMacro: {
|
||||
samplesMs: number[];
|
||||
stats: BenchmarkStats;
|
||||
};
|
||||
}
|
||||
|
||||
interface PythonRuntimeLike {
|
||||
runPythonAsync(code: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface BenchmarkOptions {
|
||||
iterations?: number;
|
||||
loadRuntime?: () => Promise<PythonRuntimeLike>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = (sorted.length - 1) * p;
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
|
||||
if (lower === upper) {
|
||||
return sorted[lower];
|
||||
}
|
||||
|
||||
const weight = index - lower;
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
export function summarizeDurations(durationsMs: number[]): BenchmarkStats {
|
||||
if (durationsMs.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
meanMs: 0,
|
||||
p50Ms: 0,
|
||||
p95Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const minMs = Math.min(...durationsMs);
|
||||
const maxMs = Math.max(...durationsMs);
|
||||
const meanMs = durationsMs.reduce((sum, value) => sum + value, 0) / durationsMs.length;
|
||||
|
||||
return {
|
||||
count: durationsMs.length,
|
||||
minMs: round2(minMs),
|
||||
maxMs: round2(maxMs),
|
||||
meanMs: round2(meanMs),
|
||||
p50Ms: round2(percentile(durationsMs, 0.5)),
|
||||
p95Ms: round2(percentile(durationsMs, 0.95)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPythonRuntimeBenchmark(options: BenchmarkOptions = {}): Promise<BenchmarkResult> {
|
||||
const iterations = options.iterations ?? 200;
|
||||
const loadRuntime = options.loadRuntime ?? (async () => loadPyodide());
|
||||
const now = options.now ?? (() => performance.now());
|
||||
|
||||
const coldStartStart = now();
|
||||
const runtime = await loadRuntime();
|
||||
const coldStartMs = now() - coldStartStart;
|
||||
|
||||
const warmStart = now();
|
||||
await runtime.runPythonAsync('1 + 1');
|
||||
const warmRunMs = now() - warmStart;
|
||||
|
||||
await runtime.runPythonAsync(`
|
||||
def render(context):
|
||||
title = context.get("title", "")
|
||||
return {"html": f"<p>{title}</p>"}
|
||||
`);
|
||||
|
||||
const samplesMs: number[] = [];
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const started = now();
|
||||
await runtime.runPythonAsync('render({"title": "Benchmark"})["html"]');
|
||||
samplesMs.push(now() - started);
|
||||
}
|
||||
|
||||
return {
|
||||
coldStartMs: round2(coldStartMs),
|
||||
warmRunMs: round2(warmRunMs),
|
||||
repeatedMacro: {
|
||||
samplesMs: samplesMs.map(round2),
|
||||
stats: summarizeDurations(samplesMs),
|
||||
},
|
||||
};
|
||||
}
|
||||
22
src/renderer/python/runtimeProtocol.ts
Normal file
22
src/renderer/python/runtimeProtocol.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { MacroContextV1, MacroResultV1 } from './abiV1';
|
||||
|
||||
export type PythonWorkerRequest =
|
||||
| {
|
||||
type: 'run';
|
||||
requestId: string;
|
||||
code: string;
|
||||
}
|
||||
| {
|
||||
type: 'renderMacroV1';
|
||||
requestId: string;
|
||||
code: string;
|
||||
context: MacroContextV1;
|
||||
};
|
||||
|
||||
export type PythonWorkerMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'stdout'; requestId: string; chunk: string }
|
||||
| { type: 'runResult'; requestId: string; result: string }
|
||||
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
|
||||
| { type: 'runError'; requestId: string; error: string };
|
||||
Reference in New Issue
Block a user