feat: more work on python scriptiong basics

This commit is contained in:
2026-02-23 11:45:13 +01:00
parent 94b7ca2c80
commit caa3f3c061
18 changed files with 752 additions and 33 deletions

View File

@@ -63,9 +63,6 @@ export class ScriptEngine extends EventEmitter {
const scriptId = uuidv4();
const filePath = this.getScriptFilePath(uniqueSlug);
await fs.mkdir(this.getScriptsDir(), { recursive: true });
await fs.writeFile(filePath, input.content, 'utf-8');
const row: NewScript = {
id: scriptId,
projectId: this.currentProjectId,
@@ -80,6 +77,9 @@ export class ScriptEngine extends EventEmitter {
updatedAt: now,
};
await fs.mkdir(this.getScriptsDir(), { recursive: true });
await fs.writeFile(filePath, this.serializeScriptFile(row as Script, input.content), 'utf-8');
await getDatabase().getLocal().insert(scripts).values(row);
const created = await this.toScriptData(row as Script);
@@ -108,20 +108,39 @@ export class ScriptEngine extends EventEmitter {
await fs.rename(existing.filePath, nextFilePath);
}
if (typeof updates.content === 'string') {
await fs.writeFile(nextFilePath, updates.content, 'utf-8');
}
const nextTitle = updates.title ?? existing.title;
const nextKind = updates.kind ?? existing.kind;
const nextEntrypoint = updates.entrypoint ?? existing.entrypoint;
const nextEnabled = updates.enabled ?? existing.enabled;
const nextVersion = existing.version + 1;
const nextContent = typeof updates.content === 'string'
? updates.content
: await this.readScriptBody(nextFilePath);
const nextRow = {
...existing,
title: nextTitle,
slug: nextSlug,
kind: nextKind,
entrypoint: nextEntrypoint,
enabled: nextEnabled,
filePath: nextFilePath,
version: nextVersion,
updatedAt: now,
};
await fs.writeFile(nextFilePath, this.serializeScriptFile(nextRow, nextContent), 'utf-8');
await getDatabase().getLocal()
.update(scripts)
.set({
title: updates.title ?? existing.title,
title: nextTitle,
slug: nextSlug,
kind: updates.kind ?? existing.kind,
entrypoint: updates.entrypoint ?? existing.entrypoint,
enabled: updates.enabled ?? existing.enabled,
kind: nextKind,
entrypoint: nextEntrypoint,
enabled: nextEnabled,
filePath: nextFilePath,
version: existing.version + 1,
version: nextVersion,
updatedAt: now,
})
.where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId)));
@@ -187,15 +206,7 @@ export class ScriptEngine extends EventEmitter {
}
private async toScriptData(row: Script): Promise<ScriptData> {
let content = '';
try {
content = await fs.readFile(row.filePath, 'utf-8');
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
}
const content = await this.readScriptBody(row.filePath);
return {
id: row.id,
@@ -256,6 +267,57 @@ export class ScriptEngine extends EventEmitter {
return `${baseSlug}_${suffix}`;
}
private serializeScriptFile(row: Pick<Script, 'id' | 'projectId' | 'slug' | 'title' | 'kind' | 'entrypoint' | 'enabled' | 'version' | 'createdAt' | 'updatedAt'>, content: string): string {
const lines = [
'"""',
'---',
`id: ${this.toYamlString(row.id)}`,
`projectId: ${this.toYamlString(row.projectId)}`,
`slug: ${this.toYamlString(row.slug)}`,
`title: ${this.toYamlString(row.title)}`,
`kind: ${this.toYamlString(row.kind)}`,
`entrypoint: ${this.toYamlString(row.entrypoint)}`,
`enabled: ${row.enabled ? 'true' : 'false'}`,
`version: ${row.version}`,
`createdAt: ${this.toYamlString(row.createdAt.toISOString())}`,
`updatedAt: ${this.toYamlString(row.updatedAt.toISOString())}`,
'---',
'"""',
content,
];
return lines.join('\n');
}
private toYamlString(value: string): string {
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
return `"${escaped}"`;
}
private parseScriptBody(rawContent: string): string {
const frontmatterDocstringPattern = /^(?:"""|''')\r?\n---\r?\n[\s\S]*?\r?\n---\r?\n(?:"""|''')\r?\n?/;
if (!frontmatterDocstringPattern.test(rawContent)) {
return rawContent;
}
return rawContent.replace(frontmatterDocstringPattern, '');
}
private async readScriptBody(filePath: string): Promise<string> {
try {
const rawContent = await fs.readFile(filePath, 'utf-8');
return this.parseScriptBody(rawContent);
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
return '';
}
}
}
let scriptEngineInstance: ScriptEngine | null = null;

View File

@@ -28,12 +28,26 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
const [slug, setSlug] = useState('');
const [kind, setKind] = useState<ScriptData['kind']>('utility');
const [entrypoint, setEntrypoint] = useState('render');
const [availableEntrypoints, setAvailableEntrypoints] = useState<string[]>([]);
const [enabled, setEnabled] = useState(true);
const [scriptContent, setScriptContent] = useState('');
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
let hash = 0;
for (let index = 0; index < content.length; index += 1) {
hash = ((hash << 5) - hash + content.charCodeAt(index)) | 0;
}
return `${scriptMeta.id}:${scriptMeta.version}:${Math.abs(hash).toString(36)}`;
};
const withMainEntrypoint = (entrypoints: string[]): string[] => {
const filtered = entrypoints.filter((name) => name !== 'main');
return ['main', ...filtered];
};
const toFunctionSlug = (value: string) => {
const normalized = value
.toLowerCase()
@@ -45,6 +59,33 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
useEffect(() => {
let cancelled = false;
const refreshEntrypoints = async (content: string, scriptMeta: ScriptData) => {
try {
const runtimeManager = getPythonRuntimeManager();
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(content, {
cacheKey: buildCacheKey(scriptMeta, content),
});
const available = withMainEntrypoint(discoveredEntrypoints);
if (cancelled) {
return;
}
setAvailableEntrypoints(available);
const preferredEntrypoint = available.includes(scriptMeta.entrypoint)
? scriptMeta.entrypoint
: 'main';
setEntrypoint(preferredEntrypoint);
} catch (error) {
if (cancelled) {
return;
}
setAvailableEntrypoints(['main']);
setEntrypoint('main');
}
};
const loadScript = async () => {
if (!scriptId) {
setScript(null);
@@ -52,6 +93,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setSlug('');
setKind('utility');
setEntrypoint('render');
setAvailableEntrypoints(['main']);
setEnabled(true);
setScriptContent('');
setIsSlugManuallyEdited(false);
@@ -65,6 +107,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setSlug('');
setKind('utility');
setEntrypoint('render');
setAvailableEntrypoints(['main']);
setEnabled(true);
setScriptContent('');
setIsSlugManuallyEdited(false);
@@ -80,6 +123,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setScriptContent(item.content || '');
const normalizedExisting = toFunctionSlug(item.slug || item.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || ''));
await refreshEntrypoints(item.content || '', item);
};
void loadScript();
@@ -117,11 +161,24 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setIsSaving(true);
try {
const runtimeManager = getPythonRuntimeManager();
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
});
const available = withMainEntrypoint(discoveredEntrypoints);
const normalizedEntrypoint = available.includes(entrypoint)
? entrypoint
: 'main';
setAvailableEntrypoints(available);
setEntrypoint(normalizedEntrypoint);
const updated = await window.electronAPI?.scripts.update(script.id, {
title,
slug,
kind,
entrypoint,
entrypoint: normalizedEntrypoint,
enabled,
content: scriptContent,
});
@@ -135,6 +192,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setSlug(toFunctionSlug(updated.slug || updated.title || ''));
setKind(updated.kind || 'utility');
setEntrypoint(updated.entrypoint || 'render');
setAvailableEntrypoints(available);
setEnabled(updated.enabled ?? true);
setScriptContent(updated.content || '');
const normalizedExisting = toFunctionSlug(updated.slug || updated.title || '');
@@ -193,7 +251,10 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
try {
const runtimeManager = getPythonRuntimeManager();
const result = await runtimeManager.execute(scriptContent);
const result = await runtimeManager.execute(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
entrypoint,
});
const now = new Date().toISOString();
if (result.result.trim().length > 0) {
@@ -307,13 +368,16 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
</div>
<div className="editor-field">
<label htmlFor="script-entrypoint">{t('scripts.field.entrypoint')}</label>
<input
<select
id="script-entrypoint"
type="text"
value={entrypoint}
onChange={(event) => setEntrypoint(event.target.value)}
disabled={!script}
/>
>
{availableEntrypoints.map((name) => (
<option key={name} value={name}>{name === 'main' ? t('scripts.entrypoint.main') : name}</option>
))}
</select>
</div>
<div className="editor-field scripts-enabled-field">
<label htmlFor="script-enabled">

View File

@@ -425,6 +425,8 @@
"scripts.content": "Skriptinhalt",
"scripts.field.kind": "Typ",
"scripts.field.entrypoint": "Einstiegspunkt",
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Keine Funktionen gefunden",
"scripts.field.enabled": "Aktiviert",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",

View File

@@ -425,6 +425,8 @@
"scripts.content": "Script Content",
"scripts.field.kind": "Kind",
"scripts.field.entrypoint": "Entrypoint",
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "No functions found",
"scripts.field.enabled": "Enabled",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",

View File

@@ -425,6 +425,8 @@
"scripts.content": "Contenido del script",
"scripts.field.kind": "Tipo",
"scripts.field.entrypoint": "Punto de entrada",
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "No se encontraron funciones",
"scripts.field.enabled": "Habilitado",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",

View File

@@ -425,6 +425,8 @@
"scripts.content": "Contenu du script",
"scripts.field.kind": "Type",
"scripts.field.entrypoint": "Point dentrée",
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Aucune fonction trouvée",
"scripts.field.enabled": "Activé",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",

View File

@@ -425,6 +425,8 @@
"scripts.content": "Contenuto script",
"scripts.field.kind": "Tipo",
"scripts.field.entrypoint": "Punto di ingresso",
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Nessuna funzione trovata",
"scripts.field.enabled": "Abilitato",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",

View File

@@ -10,9 +10,9 @@ interface InitializeDeferred {
}
interface PendingRun {
kind: 'run' | 'macro-v1';
kind: 'run' | 'macro-v1' | 'inspect-entrypoints';
stdout: string;
resolve: (value: PythonRunResult | PythonMacroV1Result) => void;
resolve: (value: PythonRunResult | PythonMacroV1Result | string[]) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
}
@@ -24,6 +24,18 @@ export interface PythonRunResult {
export interface PythonExecuteOptions {
timeoutMs?: number;
cacheKey?: string;
entrypoint?: string;
}
export interface PythonMacroSourceOptions {
kind: string;
id?: string;
}
export interface PythonMacroRenderOptions extends PythonExecuteOptions {
macroHook?: string;
macroSource?: PythonMacroSourceOptions;
}
export interface PythonMacroV1Result {
@@ -102,14 +114,17 @@ export class PythonRuntimeManager {
type: 'run',
requestId,
code,
cacheKey: options?.cacheKey,
entrypoint: options?.entrypoint,
};
this.worker!.postMessage(message);
});
}
async renderMacroV1(code: string, context: unknown, options?: PythonExecuteOptions): Promise<PythonMacroV1Result> {
const validatedContext = parseMacroContextV1(context);
async renderMacroV1(code: string, context: unknown, options?: PythonMacroRenderOptions): Promise<PythonMacroV1Result> {
const contextWithMetadata = this.withMacroEnvMetadata(context, options);
const validatedContext = parseMacroContextV1(contextWithMetadata);
await this.initialize();
if (!this.worker || !this.ready) {
@@ -139,6 +154,43 @@ export class PythonRuntimeManager {
requestId,
code,
context: validatedContext,
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
});
}
async inspectEntrypoints(code: string, options?: PythonExecuteOptions): Promise<string[]> {
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<string[]>((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: 'inspect-entrypoints',
stdout: '',
resolve: (value) => resolve(value as string[]),
reject,
timeoutId,
});
const message: PythonWorkerRequest = {
type: 'inspectEntrypoints',
requestId,
code,
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
@@ -191,6 +243,15 @@ export class PythonRuntimeManager {
return;
}
if (payload.type === 'entrypoints') {
if (pendingRun.kind !== 'inspect-entrypoints') {
pendingRun.reject(new Error('Invalid response type for pending run request'));
return;
}
pendingRun.resolve(payload.entrypoints);
return;
}
if (payload.type === 'macroResult') {
if (pendingRun.kind !== 'macro-v1') {
pendingRun.reject(new Error('Invalid response type for pending run request'));
@@ -255,4 +316,36 @@ export class PythonRuntimeManager {
this.requestCounter += 1;
return `req-${this.requestCounter}`;
}
private withMacroEnvMetadata(context: unknown, options?: PythonMacroRenderOptions): unknown {
if (!options?.macroHook && !options?.macroSource) {
return context;
}
if (!context || typeof context !== 'object' || Array.isArray(context)) {
return context;
}
const contextRecord = context as Record<string, unknown>;
const envValue = contextRecord.env;
if (!envValue || typeof envValue !== 'object' || Array.isArray(envValue)) {
return context;
}
const envRecord = envValue as Record<string, unknown>;
const nextEnv: Record<string, unknown> = { ...envRecord };
if (nextEnv.hook === undefined && options.macroHook !== undefined) {
nextEnv.hook = options.macroHook;
}
if (nextEnv.source === undefined && options.macroSource !== undefined) {
nextEnv.source = options.macroSource;
}
return {
...contextRecord,
env: nextEnv,
};
}
}

View File

@@ -17,6 +17,14 @@ export const macroContextV1Schema = z
.object({
isPreview: z.boolean(),
mainLanguage: z.string().min(1).optional(),
hook: z.string().min(1).optional(),
source: z
.object({
kind: z.string().min(1),
id: z.string().min(1).optional(),
})
.strict()
.optional(),
})
.strict(),
params: z.record(z.string(), jsonValueSchema).optional(),

View File

@@ -0,0 +1,34 @@
import type { PythonMacroRenderOptions, PythonMacroSourceOptions } from './PythonRuntimeManager';
export interface MacroRenderOptionInput {
timeoutMs?: number;
cacheKey?: string;
hook?: string;
source?: PythonMacroSourceOptions;
}
export function createMacroRenderOptions(input?: MacroRenderOptionInput): PythonMacroRenderOptions {
if (!input) {
return {};
}
const options: PythonMacroRenderOptions = {};
if (input.timeoutMs !== undefined) {
options.timeoutMs = input.timeoutMs;
}
if (input.cacheKey !== undefined) {
options.cacheKey = input.cacheKey;
}
if (input.hook !== undefined) {
options.macroHook = input.hook;
}
if (input.source !== undefined) {
options.macroSource = input.source;
}
return options;
}

View File

@@ -19,6 +19,28 @@ function toResultString(result: unknown): string {
return String(result);
}
async function runPythonCode(code: string, cacheKey?: string): Promise<unknown> {
if (!runtime) {
throw new Error('Python runtime is not ready');
}
if (!cacheKey) {
return runtime.runPythonAsync(code);
}
runtime.globals.set('__bds_source_code', code);
runtime.globals.set('__bds_cache_key', cacheKey);
return runtime.runPythonAsync(`
__bds_compiled_cache = globals().setdefault("__bds_compiled_cache", {})
__bds_compiled_code = __bds_compiled_cache.get(__bds_cache_key)
if __bds_compiled_code is None:
__bds_compiled_code = compile(__bds_source_code, f"<bds:{__bds_cache_key}>", "exec")
__bds_compiled_cache[__bds_cache_key] = __bds_compiled_code
exec(__bds_compiled_code, globals(), globals())
`);
}
async function runScript(request: PythonWorkerRequest): Promise<void> {
if (request.type !== 'run') {
return;
@@ -37,7 +59,22 @@ async function runScript(request: PythonWorkerRequest): Promise<void> {
activeRequestId = request.requestId;
try {
const result = await runtime.runPythonAsync(request.code);
let result: unknown;
if (request.entrypoint && request.entrypoint !== 'main') {
await runPythonCode(request.code, request.cacheKey);
runtime.globals.set('__bds_selected_entrypoint', request.entrypoint);
result = await runtime.runPythonAsync(`
__bds_target = globals().get(__bds_selected_entrypoint)
if __bds_target is None:
raise NameError(f"Entrypoint '{__bds_selected_entrypoint}' not found")
if not callable(__bds_target):
raise TypeError(f"Entrypoint '{__bds_selected_entrypoint}' is not callable")
__bds_target()
`);
} else {
result = await runPythonCode(request.code, request.cacheKey);
}
postRuntimeMessage({
type: 'runResult',
requestId: request.requestId,
@@ -72,7 +109,7 @@ async function runMacroV1(request: PythonWorkerRequest): Promise<void> {
const validatedContext = parseMacroContextV1(request.context);
runtime.globals.set('__bds_context_v1', validatedContext);
await runtime.runPythonAsync(request.code);
await runPythonCode(request.code, request.cacheKey);
const rawJsonResult = await runtime.runPythonAsync(`
import json
@@ -93,6 +130,56 @@ json.dumps(render(__bds_context_v1))
}
}
async function inspectEntrypoints(request: PythonWorkerRequest): Promise<void> {
if (request.type !== 'inspectEntrypoints') {
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 {
runtime.globals.set('__bds_entrypoints_source', request.code);
const rawJsonResult = await runtime.runPythonAsync(`
import ast
import json
__bds_entrypoints_tree = ast.parse(__bds_entrypoints_source)
__bds_entrypoints = []
for __bds_node in __bds_entrypoints_tree.body:
if isinstance(__bds_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not __bds_node.name.startswith('_'):
__bds_entrypoints.append(__bds_node.name)
json.dumps(__bds_entrypoints)
`);
const parsed = JSON.parse(toResultString(rawJsonResult));
const entrypoints = Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: [];
postRuntimeMessage({
type: 'entrypoints',
requestId: request.requestId,
entrypoints,
});
} 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({
@@ -122,6 +209,11 @@ self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
if (request.type === 'renderMacroV1') {
void runMacroV1(request);
return;
}
if (request.type === 'inspectEntrypoints') {
void inspectEntrypoints(request);
}
};

View File

@@ -5,12 +5,21 @@ export type PythonWorkerRequest =
type: 'run';
requestId: string;
code: string;
cacheKey?: string;
entrypoint?: string;
}
| {
type: 'renderMacroV1';
requestId: string;
code: string;
context: MacroContextV1;
cacheKey?: string;
}
| {
type: 'inspectEntrypoints';
requestId: string;
code: string;
cacheKey?: string;
};
export type PythonWorkerMessage =
@@ -18,5 +27,6 @@ export type PythonWorkerMessage =
| { type: 'error'; error: string }
| { type: 'stdout'; requestId: string; chunk: string }
| { type: 'runResult'; requestId: string; result: string }
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
| { type: 'runError'; requestId: string; error: string };