feat: add Python support with Pyodide integration

GitOrigin-RevId: 382ce102c43050aace691dd89d825a94abf347a8
This commit is contained in:
Domagoj Kriskovic
2026-03-09 09:05:34 +00:00
committed by Copybot
parent bb5d90a332
commit 138f7f8023
12 changed files with 920 additions and 1 deletions
@@ -0,0 +1,148 @@
import type {
ProjectFileData,
PyodideWorkerRequest,
PyodideWorkerResponse,
} from './pyodide-worker-messages'
export type OutputCallback = (
stream: 'stdout' | 'stderr',
line: string,
requestId?: string
) => void
export type LifecycleCallback = (
event:
| { type: 'loaded' }
| { type: 'loading-failed'; error: string }
| { type: 'run-finished'; requestId: string }
) => void
export class PyodideWorkerClient {
private worker: Worker
private listening = false
private loaded = false
private destroyed = false
private loadingError: string | null = null
private pendingMessages: PyodideWorkerRequest[] = []
private outputCallback: OutputCallback | null = null
private lifecycleCallback: LifecycleCallback | null = null
constructor(options: { baseAssetPath: string }) {
const { baseAssetPath } = options
this.worker = new Worker(
/* webpackChunkName: "pyodide-worker" */
new URL('./pyodide.worker.ts', import.meta.url),
{ type: 'module' }
)
this.worker.addEventListener('message', this.receive.bind(this))
this.queueMessage({
type: 'init',
baseAssetPath,
})
}
setOutputCallback(callback: OutputCallback) {
this.outputCallback = callback
}
setLifecycleCallback(handler: LifecycleCallback) {
this.lifecycleCallback = handler
if (this.loaded) {
handler({ type: 'loaded' })
return
}
if (this.loadingError) {
handler({ type: 'loading-failed', error: this.loadingError })
}
}
runCode(
code: string,
options: { requestId: string; files: ProjectFileData[] }
): void {
if (this.destroyed) {
throw new Error('Pyodide worker client has been destroyed')
}
if (this.loadingError) {
throw new Error(this.loadingError)
}
this.queueMessage({
type: 'run-code',
code,
id: options.requestId,
files: options.files,
})
}
destroy() {
if (this.destroyed) {
return
}
this.destroyed = true
this.pendingMessages.length = 0
if (!this.loaded && !this.loadingError) {
this.loadingError = 'Pyodide worker was destroyed before loading finished'
}
this.worker.terminate()
}
private queueMessage(message: PyodideWorkerRequest) {
if (this.listening) {
this.worker.postMessage(message)
} else {
this.pendingMessages.push(message)
}
}
private receive(event: MessageEvent<PyodideWorkerResponse>) {
const response = event.data
switch (response.type) {
case 'listening':
this.listening = true
for (const message of this.pendingMessages) {
this.worker.postMessage(message)
}
this.pendingMessages.length = 0
return
case 'loaded':
this.loaded = true
this.lifecycleCallback?.({ type: 'loaded' })
return
case 'loading-failed':
this.loadingError = response.error
this.pendingMessages.length = 0
this.lifecycleCallback?.({
type: 'loading-failed',
error: response.error,
})
return
case 'output-line':
this.outputCallback?.(
response.stream,
response.line,
response.requestId
)
break
case 'run-code-result':
this.lifecycleCallback?.({
type: 'run-finished',
requestId: response.id,
})
break
}
}
}
@@ -0,0 +1,48 @@
export type ProjectFileData = {
relativePath: string
content: string
}
// Main thread -> Worker messages
export type InitRequest = {
type: 'init'
baseAssetPath: string
}
export type RunCodeRequest = {
type: 'run-code'
id: string
code: string
files: ProjectFileData[]
}
export type PyodideWorkerRequest = InitRequest | RunCodeRequest
// Worker -> Main thread lifecycle and streaming events
export type ListeningEvent = { type: 'listening' }
export type LoadedEvent = { type: 'loaded' }
export type LoadingFailedEvent = { type: 'loading-failed'; error: string }
export type OutputLineEvent = {
type: 'output-line'
stream: 'stdout' | 'stderr'
line: string
requestId?: string
}
export type PyodideWorkerEvent =
| ListeningEvent
| LoadedEvent
| LoadingFailedEvent
| OutputLineEvent
// Worker -> Main thread ID responses
export type RunCodeResult = {
type: 'run-code-result'
id: string
}
export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult
@@ -0,0 +1,216 @@
import path from 'path-browserify'
import type {
ProjectFileData,
PyodideWorkerRequest,
RunCodeRequest,
} from './pyodide-worker-messages'
type PyodideRuntimeModule = {
loadPyodide: (options: {
indexURL: string
packageBaseUrl?: string
env?: Record<string, string>
packages?: string[]
}) => Promise<PyodideInstance>
}
type PyodideInstance = {
FS: unknown
runPythonAsync: (code: string) => Promise<unknown>
setStdout: (options: { batched: (line: string) => void }) => void
setStderr: (options: { batched: (line: string) => void }) => void
}
type PyodideFs = {
analyzePath: (filePath: string) => { exists: boolean }
mkdir: (filePath: string) => void
writeFile: (
filePath: string,
content: string | ArrayBuffer | Uint8Array
) => void
chdir: (filePath: string) => void
}
const PROJECT_FS_ROOT = '/project'
const PYODIDE_INDEX_PATH = 'js/libs/pyodide/'
function getPyodideIndexUrl(baseAssetPath: string): string {
return new URL(PYODIDE_INDEX_PATH, baseAssetPath).toString()
}
function toRuntimeProjectPath(relativePath: string): string {
return path.posix.join(PROJECT_FS_ROOT, path.posix.normalize(relativePath))
}
function ensureProjectRootExists(fs: PyodideFs) {
try {
const projectRootAnalysis = fs.analyzePath(PROJECT_FS_ROOT)
if (!projectRootAnalysis.exists) {
fs.mkdir(PROJECT_FS_ROOT)
}
} catch {
fs.mkdir(PROJECT_FS_ROOT)
}
}
function ensureDirectoryExists(fs: PyodideFs, filePath: string) {
const directory = path.dirname(filePath)
if (directory === '.' || directory === '/') {
return
}
let currentPath = directory.startsWith('/') ? '/' : ''
for (const part of directory.split('/').filter(Boolean)) {
currentPath = path.posix.join(currentPath, part)
try {
const analysis = fs.analyzePath(currentPath)
if (!analysis.exists) {
fs.mkdir(currentPath)
}
} catch {
// Ignore failures when a directory already exists.
}
}
}
function syncProjectFiles(fs: PyodideFs, files: ProjectFileData[]) {
ensureProjectRootExists(fs)
for (const file of files) {
const runtimePath = toRuntimeProjectPath(file.relativePath)
ensureDirectoryExists(fs, runtimePath)
fs.writeFile(runtimePath, file.content)
}
fs.chdir(PROJECT_FS_ROOT)
}
let pyodideInstance: PyodideInstance | null = null
let activeRunRequestId: string | null = null
async function loadPyodideModule(
pyodideIndexUrl: string
): Promise<PyodideRuntimeModule> {
const runtimeModuleUrl = `${pyodideIndexUrl}pyodide.mjs`
try {
return (await import(
/* webpackIgnore: true */ runtimeModuleUrl
)) as PyodideRuntimeModule
} catch (loadError) {
const loadErrorMessage =
loadError instanceof Error ? loadError.message : String(loadError)
throw new Error(
`Unable to load Pyodide module from ${runtimeModuleUrl}. Original error: ${loadErrorMessage}`
)
}
}
async function handleInit(msg: { baseAssetPath: string }) {
const pyodideIndexUrl = getPyodideIndexUrl(msg.baseAssetPath)
try {
const pyodideModule = await loadPyodideModule(pyodideIndexUrl)
const instance = await pyodideModule.loadPyodide({
indexURL: pyodideIndexUrl,
packageBaseUrl: pyodideIndexUrl,
env: { MPLBACKEND: 'Agg' },
})
instance.setStdout({
batched: (line: string) => {
self.postMessage({
type: 'output-line',
stream: 'stdout',
line,
requestId: activeRunRequestId ?? undefined,
})
},
})
instance.setStderr({
batched: (line: string) => {
self.postMessage({
type: 'output-line',
stream: 'stderr',
line,
requestId: activeRunRequestId ?? undefined,
})
},
})
pyodideInstance = instance
self.postMessage({ type: 'loaded' })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Pyodide initialization failed', error)
self.postMessage({
type: 'loading-failed',
error: errorMessage,
})
}
}
async function handleRunCode(msg: RunCodeRequest) {
if (!pyodideInstance) {
const error = 'Pyodide is not initialized'
self.postMessage({
type: 'output-line',
stream: 'stderr',
line: error,
requestId: msg.id,
})
self.postMessage({
type: 'run-code-result',
id: msg.id,
})
return
}
activeRunRequestId = msg.id
try {
if (msg.files.length > 0) {
const fs = pyodideInstance.FS as PyodideFs
syncProjectFiles(fs, msg.files)
}
const result = await pyodideInstance.runPythonAsync(msg.code)
if (result !== undefined) {
self.postMessage({
type: 'output-line',
stream: 'stdout',
line: String(result),
requestId: activeRunRequestId ?? undefined,
})
}
} catch (runError) {
const errorMessage =
runError instanceof Error ? runError.message : String(runError)
self.postMessage({
type: 'output-line',
stream: 'stderr',
line: errorMessage,
requestId: activeRunRequestId ?? undefined,
})
} finally {
activeRunRequestId = null
}
self.postMessage({
type: 'run-code-result',
id: msg.id,
})
}
self.addEventListener('message', async event => {
const msg = event.data as PyodideWorkerRequest
switch (msg.type) {
case 'init':
await handleInit(msg)
break
case 'run-code':
await handleRunCode(msg)
break
}
})
self.postMessage({ type: 'listening' })
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import path from 'path-browserify'
import OLButton from '@/shared/components/ol/ol-button'
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
import MaterialIcon from '@/shared/components/material-icon'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { debugConsole } from '@/utils/debugging'
import getMeta from '@/utils/meta'
import { PyodideWorkerClient } from './pyodide-worker-client'
export default function PythonOutputPane() {
const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
const { pathInFolder } = useFileTreePathContext()
const clientRef = useRef<PyodideWorkerClient | null>(null)
const currentRequestIdRef = useRef<string | null>(null)
const [isReady, setIsReady] = useState(false)
const [output, setOutput] = useState<string[]>([])
const [isRunning, setIsRunning] = useState(false)
const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
const [error, setError] = useState<string | null>(null)
const appendOutput = useCallback((line: string) => {
setOutput(previousOutput => [...previousOutput, line])
}, [])
useEffect(() => {
const baseAssetPath = new URL(
getMeta('ol-baseAssetPath'),
window.location.href
).toString()
const client = new PyodideWorkerClient({ baseAssetPath })
clientRef.current = client
let cancelled = false
client.setLifecycleCallback(event => {
if (cancelled) {
return
}
switch (event.type) {
case 'loaded':
setIsReady(true)
setIsLoadingPyodide(false)
return
case 'loading-failed':
debugConsole.error('Failed to load Python runtime', event.error)
setError(formatError(event.error))
setIsLoadingPyodide(false)
setIsRunning(false)
return
case 'run-finished':
if (event.requestId !== currentRequestIdRef.current) {
return
}
currentRequestIdRef.current = null
setIsRunning(false)
break
}
})
client.setOutputCallback((_stream, line, requestId) => {
if (!requestId || requestId !== currentRequestIdRef.current) {
return
}
appendOutput(line)
})
return () => {
cancelled = true
currentRequestIdRef.current = null
client.destroy()
clientRef.current = null
}
}, [appendOutput])
useEffect(() => {
currentRequestIdRef.current = null
setIsRunning(false)
setOutput([])
setError(null)
}, [currentDocumentId])
const buildCurrentDocumentSyncFile = useCallback(() => {
if (!currentDocument || !currentDocumentId) {
return null
}
const content = currentDocument.getSnapshot()
if (typeof content !== 'string') {
return null
}
const currentPath = pathInFolder(currentDocumentId)
if (!currentPath) {
throw new Error(
'Unable to resolve current document path for Python sync.'
)
}
return {
relativePath: path.posix.normalize(currentPath),
content,
}
}, [currentDocument, currentDocumentId, pathInFolder])
const handleRun = useCallback(() => {
const client = clientRef.current
if (!client || !isReady) {
return
}
const syncFile = buildCurrentDocumentSyncFile()
if (!syncFile) {
return
}
setOutput([])
setError(null)
const requestId = syncFile.relativePath
currentRequestIdRef.current = requestId
setIsRunning(true)
try {
client.runCode(syncFile.content, { requestId, files: [syncFile] })
} catch (runError) {
if (currentRequestIdRef.current !== requestId) {
return
}
currentRequestIdRef.current = null
setIsRunning(false)
setError(formatError(runError))
}
}, [buildCurrentDocumentSyncFile, isReady])
return (
<div className="ide-redesign-python-output-pane">
<OLButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
<div className="toolbar-pdf-left">
<div className="compile-button-group">
<OLButton
onClick={handleRun}
variant="primary"
className="compile-button align-items-center py-0 px-3"
disabled={!isReady || isLoadingPyodide || isRunning}
aria-label="Run Python Code"
style={{ borderRadius: '12px' }}
>
{isRunning ? 'Running...' : 'Run'}
<MaterialIcon type="play_arrow" className="ml-2" />
</OLButton>
</div>
</div>
</OLButtonToolbar>
<div className="ide-redesign-python-output-pane-body">
{isLoadingPyodide && (
<div className="ide-redesign-python-output-pane-placeholder">
Loading Python runtime...
</div>
)}
{!isLoadingPyodide && !error && output.length === 0 && (
<div className="ide-redesign-python-output-pane-placeholder">
Run the current script to see output.
</div>
)}
{error && (
<div className="ide-redesign-python-output-pane-error">{error}</div>
)}
{output.map((line, index) => (
<div className="ide-redesign-python-output-pane-line" key={index}>
{line}
</div>
))}
</div>
</div>
)
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}
@@ -9,12 +9,16 @@ import { Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { PythonEditorSplit } from '@/features/ide-react/components/layout/python-editor-split'
export const Editor = () => {
const { opening, errorState, showSymbolPalette } =
useEditorPropertiesContext()
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const { currentDocumentId, currentDocument } = useEditorOpenDocContext()
const isPythonDocument =
openEntity?.type === 'doc' &&
openEntity.entity.name.toLowerCase().endsWith('.py')
if (!currentDocumentId) {
return null
@@ -39,7 +43,7 @@ export const Editor = () => {
order={1}
className="ide-redesign-editor-panel"
>
<SourceEditor />
{isPythonDocument ? <PythonEditorSplit /> : <SourceEditor />}
{isLoading && <EditorLoadingPane />}
</Panel>
{showSymbolPalette && (
@@ -0,0 +1,27 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
import SourceEditor from '@/features/source-editor/components/source-editor'
export const PythonEditorSplit = () => {
return (
<PanelGroup
autoSaveId="ide-redesign-editor-python-output"
direction="vertical"
className="ide-redesign-python-editor-split"
>
<Panel id="ide-redesign-panel-source-editor-content" order={1}>
<SourceEditor />
</Panel>
<VerticalResizeHandle id="ide-redesign-editor-python-output" />
<Panel
id="ide-redesign-panel-python-output"
order={2}
defaultSize={35}
minSize={10}
>
<PythonOutputPane />
</Panel>
</PanelGroup>
)
}