From f4c7962847de354d53f11ebfe06fa7300b36bd5e Mon Sep 17 00:00:00 2001 From: Mario Pesch Date: Tue, 25 Aug 2026 16:54:58 +0200 Subject: [PATCH 1/2] add pyodide for code execution --- package-lock.json | 41 ++++++ package.json | 1 + src/App.css | 42 +++++- src/App.tsx | 77 +++++++++- .../blockly/blocks/generators/example.tsx | 4 +- src/hooks/usePythonRunner.ts | 135 ++++++++++++++++++ src/workers/python.worker.ts | 115 +++++++++++++++ 7 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 src/hooks/usePythonRunner.ts create mode 100644 src/workers/python.worker.ts diff --git a/package-lock.json b/package-lock.json index becc5bb..d2cb676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@radix-ui/themes": "^3.3.0", "blockly": "^13.2.1", + "pyodide": "^314.0.6", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" @@ -2397,6 +2398,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -3082,6 +3089,19 @@ "node": ">=6" } }, + "node_modules/pyodide": { + "version": "314.0.6", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-314.0.6.tgz", + "integrity": "sha512-BKDTJyIqFxC4BExLqeRS3f5xvXZIjOt8C3zGLN/Cc7tFxSwvKVhVkchQQ2AGLOtR4YrVOIFxbV8poyDOOmWwxQ==", + "license": "MPL-2.0", + "dependencies": { + "@types/emscripten": "^1.41.4", + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/radix-ui": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", @@ -3588,6 +3608,27 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index a627ac2..16f92b6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@radix-ui/themes": "^3.3.0", "blockly": "^13.2.1", + "pyodide": "^314.0.6", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" diff --git a/src/App.css b/src/App.css index bd045a2..b617d63 100644 --- a/src/App.css +++ b/src/App.css @@ -39,22 +39,36 @@ min-height: 0; } -.workspace-card, -.code-card { +.code-column { + gap: 20px; +} + +.workspace-card { height: 100%; +} + +.workspace-card, +.code-card, +.output-card { display: flex; flex-direction: column; min-height: 0; } .workspace-card-content, -.code-card-content { +.code-card-content, +.output-card-content { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.code-card, +.output-card { + flex: 1 1 0; +} + .blockly-workspace { flex: 1; min-height: 0; @@ -65,7 +79,8 @@ border: 1px solid rgba(15, 23, 42, 0.08); } -.python-preview { +.python-preview, +.python-output { margin: 0; flex: 1; min-height: 0; @@ -78,6 +93,17 @@ line-height: 1.6; } +.python-output { + background: #111827; + color: #d1fae5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.python-output-error { + color: #fecaca; +} + @media (max-width: 1080px) { .hero-panel, .workspace-layout { @@ -91,10 +117,14 @@ .workspace-column, .code-column, - .workspace-card, - .code-card { + .workspace-card { min-height: 420px; } + + .code-card, + .output-card { + min-height: 300px; + } } @media (max-width: 720px) { diff --git a/src/App.tsx b/src/App.tsx index eb7dc26..2f9ff7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,14 +10,28 @@ import { import './App.css' import { BlocklyWorkspace } from './components/BlocklyWorkspace' +import { usePythonRunner, type PythonRunStatus } from './hooks/usePythonRunner' import { useAppStore } from './store/useAppStore' +const statusLabels: Record = { + idle: 'Ready', + loading: 'Loading Python', + running: 'Running', + success: 'Finished', + error: 'Error', + stopped: 'Stopped', +} + function App() { const generatedCode = useAppStore((state) => state.generatedCode) const totalBlocks = useAppStore((state) => state.totalBlocks) const topLevelBlocks = useAppStore((state) => state.topLevelBlocks) const workspaceVersion = useAppStore((state) => state.workspaceVersion) const resetWorkspace = useAppStore((state) => state.resetWorkspace) + const { clearOutput, isBusy, output, run, status, stop } = usePythonRunner() + + const runButtonLabel = + status === 'loading' ? 'Stop loading' : status === 'running' ? 'Stop execution' : 'Run code' return ( @@ -87,7 +101,16 @@ function App() {
- Generated Python + + Generated Python + + The code preview updates from the Zustand-backed workspace state as you change the blocks. @@ -97,6 +120,58 @@ function App() {
+ + +
+ + + Output + + {statusLabels[status]} + + + + + + Standard output, return values, and Python errors appear here. + +
+                {output ||
+                  (status === 'loading'
+                    ? 'Preparing the Python runtime…'
+                    : status === 'running'
+                      ? 'Running Python…'
+                      : status === 'success'
+                        ? 'Code finished without producing output.'
+                        : 'Run the generated Python to see its output.')}
+              
+
+
diff --git a/src/components/blockly/blocks/generators/example.tsx b/src/components/blockly/blocks/generators/example.tsx index 6a4a6b9..6e6e450 100644 --- a/src/components/blockly/blocks/generators/example.tsx +++ b/src/components/blockly/blocks/generators/example.tsx @@ -1,7 +1,7 @@ import * as Blockly from "blockly"; -import { pythonGenerator } from "blockly/python"; +import { pythonGenerator, Order } from "blockly/python"; pythonGenerator.forBlock["example_block"] = function (block) { const code = "example_code"; - return [code, pythonGenerator.ORDER_ATOMIC]; + return [code, Order.ATOMIC]; }; diff --git a/src/hooks/usePythonRunner.ts b/src/hooks/usePythonRunner.ts new file mode 100644 index 0000000..4b59cef --- /dev/null +++ b/src/hooks/usePythonRunner.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type PythonRunStatus = + | "idle" + | "loading" + | "running" + | "success" + | "error" + | "stopped"; + +type WorkerResponse = + | { + type: "status"; + runId: number; + status: "loading" | "running"; + } + | { + type: "output"; + runId: number; + stream: "stdout" | "stderr"; + value: string; + } + | { + type: "complete"; + runId: number; + result?: string; + } + | { + type: "error"; + runId: number; + message: string; + }; + +export function usePythonRunner() { + const workerRef = useRef(null); + const activeRunIdRef = useRef(0); + const [output, setOutput] = useState(""); + const [status, setStatus] = useState("idle"); + + const getWorker = useCallback(() => { + if (workerRef.current) { + return workerRef.current; + } + + const worker = new Worker(new URL("../workers/python.worker.ts", import.meta.url), { + type: "module", + }); + + worker.onmessage = ({ data }: MessageEvent) => { + if (data.runId !== activeRunIdRef.current) { + return; + } + + if (data.type === "status") { + setStatus(data.status); + return; + } + + if (data.type === "output") { + setOutput((currentOutput) => currentOutput + data.value); + return; + } + + if (data.type === "complete") { + if (data.result !== undefined) { + setOutput((currentOutput) => `${currentOutput}${data.result}\n`); + } + setStatus("success"); + return; + } + + setOutput((currentOutput) => `${currentOutput}${data.message}\n`); + setStatus("error"); + }; + + worker.onerror = (event) => { + if (workerRef.current !== worker) { + return; + } + + setOutput((currentOutput) => + `${currentOutput}${event.message || "The Python worker stopped unexpectedly."}\n`, + ); + setStatus("error"); + }; + + workerRef.current = worker; + return worker; + }, []); + + const run = useCallback( + (code: string) => { + const runId = activeRunIdRef.current + 1; + activeRunIdRef.current = runId; + setOutput(""); + setStatus("loading"); + getWorker().postMessage({ type: "run", runId, code }); + }, + [getWorker], + ); + + const clearOutput = useCallback(() => { + setOutput(""); + if (status !== "loading" && status !== "running") { + setStatus("idle"); + } + }, [status]); + + const stop = useCallback(() => { + workerRef.current?.terminate(); + workerRef.current = null; + activeRunIdRef.current += 1; + setOutput((currentOutput) => + `${currentOutput}${currentOutput ? "\n" : ""}Execution stopped.\n`, + ); + setStatus("stopped"); + }, []); + + useEffect( + () => () => { + workerRef.current?.terminate(); + workerRef.current = null; + }, + [], + ); + + return { + clearOutput, + isBusy: status === "loading" || status === "running", + output, + run, + status, + stop, + }; +} diff --git a/src/workers/python.worker.ts b/src/workers/python.worker.ts new file mode 100644 index 0000000..d2668d3 --- /dev/null +++ b/src/workers/python.worker.ts @@ -0,0 +1,115 @@ +import { + loadPyodide, + version as pyodideVersion, + type PyodideInterface, +} from "pyodide"; + +type RunRequest = { + type: "run"; + runId: number; + code: string; +}; + +type WorkerResponse = + | { + type: "status"; + runId: number; + status: "loading" | "running"; + } + | { + type: "output"; + runId: number; + stream: "stdout" | "stderr"; + value: string; + } + | { + type: "complete"; + runId: number; + result?: string; + } + | { + type: "error"; + runId: number; + message: string; + }; + +type PythonWorkerScope = { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: WorkerResponse) => void; +}; + +const workerScope = self as unknown as PythonWorkerScope; +const pyodideIndexUrl = `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`; + +let pyodidePromise: Promise | null = null; + +const getPyodide = () => { + pyodidePromise ??= loadPyodide({ indexURL: pyodideIndexUrl }); + return pyodidePromise; +}; + +const formatResult = (result: unknown) => { + if (result === undefined || result === null) { + return undefined; + } + + try { + return String(result); + } finally { + if ( + typeof result === "object" && + "destroy" in result && + typeof result.destroy === "function" + ) { + result.destroy(); + } + } +}; + +workerScope.onmessage = async ({ data }) => { + if (data.type !== "run") { + return; + } + + const { code, runId } = data; + + workerScope.postMessage({ type: "status", runId, status: "loading" }); + + try { + const pyodide = await getPyodide(); + + pyodide.setStdout({ + batched: (value) => + workerScope.postMessage({ + type: "output", + runId, + stream: "stdout", + value: `${value}\n`, + }), + }); + pyodide.setStderr({ + batched: (value) => + workerScope.postMessage({ + type: "output", + runId, + stream: "stderr", + value: `${value}\n`, + }), + }); + + workerScope.postMessage({ type: "status", runId, status: "running" }); + + await pyodide.loadPackagesFromImports(code); + const result = formatResult( + await pyodide.runPythonAsync(code, { filename: "blockly_program.py" }), + ); + + workerScope.postMessage({ type: "complete", runId, result }); + } catch (error) { + workerScope.postMessage({ + type: "error", + runId, + message: error instanceof Error ? error.message : String(error), + }); + } +}; From e8821c8363a548042f98ae18af1c59c65b0ca5e3 Mon Sep 17 00:00:00 2001 From: Mario Pesch Date: Tue, 25 Aug 2026 17:00:32 +0200 Subject: [PATCH 2/2] Update example.tsx --- src/components/blockly/blocks/generators/example.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/blockly/blocks/generators/example.tsx b/src/components/blockly/blocks/generators/example.tsx index 6e6e450..befeabb 100644 --- a/src/components/blockly/blocks/generators/example.tsx +++ b/src/components/blockly/blocks/generators/example.tsx @@ -1,7 +1,6 @@ -import * as Blockly from "blockly"; import { pythonGenerator, Order } from "blockly/python"; -pythonGenerator.forBlock["example_block"] = function (block) { +pythonGenerator.forBlock["example_block"] = function () { const code = "example_code"; return [code, Order.ATOMIC]; };