feat: more work on python scriptiong basics
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -425,6 +425,8 @@
|
||||
"scripts.content": "Contenu du script",
|
||||
"scripts.field.kind": "Type",
|
||||
"scripts.field.entrypoint": "Point d’entrée",
|
||||
"scripts.entrypoint.main": "main",
|
||||
"scripts.entrypoint.none": "Aucune fonction trouvée",
|
||||
"scripts.field.enabled": "Activé",
|
||||
"scripts.kind.utility": "utility",
|
||||
"scripts.kind.macro": "macro",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
34
src/renderer/python/macroRenderOptions.ts
Normal file
34
src/renderer/python/macroRenderOptions.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user