From 7686c08715bdf539a3c40ab11d8c35047fde543d Mon Sep 17 00:00:00 2001 From: MatthewCaseres Date: Sun, 21 May 2023 22:10:28 -0500 Subject: [PATCH 1/2] runner is shared across editors, create context --- package-lock.json | 1 + package.json | 1 + src/components/PythonContext/Go.tsx | 25 +++++ .../PythonContext/WorkerContext.tsx | 100 ++++++++++++++++++ src/components/PythonContext/python-worker.ts | 78 ++++++++++++++ src/components/PythonContext/types.ts | 20 ++++ src/pages/f.tsx | 13 +++ 7 files changed, 238 insertions(+) create mode 100644 src/components/PythonContext/Go.tsx create mode 100644 src/components/PythonContext/WorkerContext.tsx create mode 100644 src/components/PythonContext/python-worker.ts create mode 100644 src/components/PythonContext/types.ts create mode 100644 src/pages/f.tsx diff --git a/package-lock.json b/package-lock.json index 39390c6..18ee46c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ace-builds": "^1.19.0", "autoprefixer": "^10.4.7", "clsx": "^1.2.0", + "comlink": "^4.4.1", "fast-glob": "^3.2.11", "feed": "^4.2.2", "focus-visible": "^5.2.0", diff --git a/package.json b/package.json index 65f0045..267cd3c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "ace-builds": "^1.19.0", "autoprefixer": "^10.4.7", "clsx": "^1.2.0", + "comlink": "^4.4.1", "fast-glob": "^3.2.11", "feed": "^4.2.2", "focus-visible": "^5.2.0", diff --git a/src/components/PythonContext/Go.tsx b/src/components/PythonContext/Go.tsx new file mode 100644 index 0000000..dd1fa5b --- /dev/null +++ b/src/components/PythonContext/Go.tsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react' +import { useWorker } from './WorkerContext' + +export default function Go() { + const { runner, stdout } = useWorker() + const [count, setCount] = React.useState(0) + useEffect(() => { + console.log(stdout) + }, [stdout]) + + return ( +
+

{ + setCount(count + 1) + runner.current?.run( + `print([1,2,3])\nprint(${count})\nprint(${count})` + ) + }} + > + Go +

+
+ ) +} diff --git a/src/components/PythonContext/WorkerContext.tsx b/src/components/PythonContext/WorkerContext.tsx new file mode 100644 index 0000000..fb61f6d --- /dev/null +++ b/src/components/PythonContext/WorkerContext.tsx @@ -0,0 +1,100 @@ +import React, { + createContext, + useContext, + useRef, + useEffect, + ReactNode, + useState, +} from 'react' +import { proxy, Remote, wrap } from 'comlink' +import { Runner, PythonRunner } from './types' + +interface WorkerContextProps { + runner: React.MutableRefObject | undefined> + isLoading: boolean + pyodideVersion: string | undefined + stdout: string +} + +// Create the context +const WorkerContext = createContext(undefined) + +interface WorkerProviderProps { + children: ReactNode +} + +// Create a provider component +export const WorkerProvider: React.FC = ({ children }) => { + const workerRef = useRef(null) + const runnerRef = useRef>() + const [isLoading, setIsLoading] = useState(false) + const [pyodideVersion, setPyodideVersion] = useState() + const [output, setOutput] = useState([]) + const [stdout, setStdout] = useState('') + + // Immediately set stdout upon receiving new input + useEffect(() => { + if (output.length > 0) { + console.log('output', output) + setStdout(output.join('\n')) + } + }, [output]) + + useEffect(() => { + const worker = new Worker(new URL('./python-worker', import.meta.url)) + workerRef.current = worker + const init = async () => { + try { + setIsLoading(true) + const runner: Remote = wrap(workerRef.current as Worker) + runnerRef.current = runner + + await runner.init( + proxy((msg: string) => { + // Suppress messages that are not useful for the user + setOutput((prev) => [...prev, msg]) + }), + proxy(({ version }) => { + // The runner is ready once the Pyodide version has been set + setPyodideVersion(version) + console.debug('Loaded pyodide version:', version) + }), + [[], []] + ) + } catch (error) { + console.error('Error loading Pyodide:', error) + } finally { + setIsLoading(false) + } + } + init() + // Cleanup worker on unmount + return () => { + if (workerRef.current) { + workerRef.current.terminate() + workerRef.current = null + } + } + }, []) + + // now runner ref + console.log('runnerRef', runnerRef) + + // The value provided by the context includes both the worker and functions to interact with it + return ( + + {children} + + ) +} + +// Create a custom hook to access the worker +export const useWorker = (): WorkerContextProps => { + const context = useContext(WorkerContext) + if (context === undefined) { + throw new Error('useWorker must be used within a WorkerProvider') + } + return context +} diff --git a/src/components/PythonContext/python-worker.ts b/src/components/PythonContext/python-worker.ts new file mode 100644 index 0000000..f3d7e73 --- /dev/null +++ b/src/components/PythonContext/python-worker.ts @@ -0,0 +1,78 @@ +importScripts('https://cdn.jsdelivr.net/pyodide/v0.22.0/full/pyodide.js') + +interface Pyodide { + loadPackage: (packages: string[]) => Promise + pyimport: (pkg: string) => micropip + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runPythonAsync: (code: string, namespace?: any) => Promise + version: string + FS: { + readFile: (name: string, options: unknown) => void + writeFile: (name: string, data: string, options: unknown) => void + mkdir: (name: string) => void + rmdir: (name: string) => void + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globals: any + isPyProxy: (value: unknown) => boolean +} + +interface micropip { + install: (packages: string[]) => Promise +} + +declare global { + interface Window { + loadPyodide: ({ + stdout, + }: { + stdout?: (msg: string) => void + }) => Promise + pyodide: Pyodide + } +} + +// Monkey patch console.log to prevent the script from outputting logs +// eslint-disable-next-line @typescript-eslint/no-empty-function +console.log = () => {} + +import { expose } from 'comlink' + +const python = { + async init( + stdout: (msg: string) => void, + onLoad: ({ version, banner }: { version: string; banner?: string }) => void, + packages: string[][] + ) { + self.pyodide = await self.loadPyodide({ + stdout, + }) + if (packages[0].length > 0) { + await self.pyodide.loadPackage(packages[0]) + } + if (packages[1].length > 0) { + await self.pyodide.loadPackage(['micropip']) + const micropip = self.pyodide.pyimport('micropip') + await micropip.install(packages[1]) + } + const version = self.pyodide.version + onLoad({ version }) + }, + async run(code: string) { + await self.pyodide.runPythonAsync(code) + }, + readFile(name: string) { + return self.pyodide.FS.readFile(name, { encoding: 'utf8' }) + }, + writeFile(name: string, data: string) { + return self.pyodide.FS.writeFile(name, data, { encoding: 'utf8' }) + }, + mkdir(name: string) { + self.pyodide.FS.mkdir(name) + }, + rmdir(name: string) { + self.pyodide.FS.rmdir(name) + }, +} + +expose(python) diff --git a/src/components/PythonContext/types.ts b/src/components/PythonContext/types.ts new file mode 100644 index 0000000..6c245c5 --- /dev/null +++ b/src/components/PythonContext/types.ts @@ -0,0 +1,20 @@ +export interface Runner { + init: ( + stdout: (msg: string) => void, + onLoad: ({ version, banner }: { version: string; banner?: string }) => void, + packages?: string[][] + ) => Promise + interruptExecution: () => void + readFile: (name: string) => void + writeFile: (name: string, data: string) => void + mkdir: (name: string) => void + rmdir: (name: string) => void +} + +export interface PythonRunner extends Runner { + run: (code: string) => Promise +} + +export interface PythonConsoleRunner extends Runner { + run: (code: string) => Promise<{ state: string; error?: string }> +} diff --git a/src/pages/f.tsx b/src/pages/f.tsx new file mode 100644 index 0000000..100aae7 --- /dev/null +++ b/src/pages/f.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { WorkerProvider } from '../components/PythonContext/WorkerContext' +import Go from '../components/PythonContext/Go' + +export default function f() { + return ( +
+ + + +
+ ) +} From 63536ad3f548c921da101a8c9e5d358799769b16 Mon Sep 17 00:00:00 2001 From: MatthewCaseres Date: Sun, 21 May 2023 23:18:59 -0500 Subject: [PATCH 2/2] add runPython callback to context --- src/components/PythonContext/Go.tsx | 23 +++++++++---- .../PythonContext/WorkerContext.tsx | 33 ++++++++++++------- src/components/Sidebar/SidebarContext.tsx | 2 +- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/components/PythonContext/Go.tsx b/src/components/PythonContext/Go.tsx index dd1fa5b..2da421f 100644 --- a/src/components/PythonContext/Go.tsx +++ b/src/components/PythonContext/Go.tsx @@ -2,20 +2,31 @@ import React, { useEffect } from 'react' import { useWorker } from './WorkerContext' export default function Go() { - const { runner, stdout } = useWorker() + const { runner, output, runCode } = useWorker() const [count, setCount] = React.useState(0) + const [stdout, setStdout] = React.useState('') + const id = '4022iiuj' useEffect(() => { - console.log(stdout) + // check that length is greater than 0 to avoid the initial empty array + if (output.length > 0 && output[0] === id) { + // join them all with newlines except the first one + setStdout(output.slice(1).join('\n')) + } + }, [output]) + // useEffect to check the state of stdout and stderr + useEffect(() => { + if (stdout) { + console.log(stdout) + console.log(output) + } }, [stdout]) return (

{ + onClick={async () => { setCount(count + 1) - runner.current?.run( - `print([1,2,3])\nprint(${count})\nprint(${count})` - ) + await runCode('print(1+2)', '4022iiuj') }} > Go diff --git a/src/components/PythonContext/WorkerContext.tsx b/src/components/PythonContext/WorkerContext.tsx index fb61f6d..aae67a1 100644 --- a/src/components/PythonContext/WorkerContext.tsx +++ b/src/components/PythonContext/WorkerContext.tsx @@ -5,15 +5,17 @@ import React, { useEffect, ReactNode, useState, + useCallback, } from 'react' import { proxy, Remote, wrap } from 'comlink' import { Runner, PythonRunner } from './types' interface WorkerContextProps { runner: React.MutableRefObject | undefined> + runCode: (code: string, id: string) => Promise isLoading: boolean pyodideVersion: string | undefined - stdout: string + output: string[] } // Create the context @@ -30,15 +32,26 @@ export const WorkerProvider: React.FC = ({ children }) => { const [isLoading, setIsLoading] = useState(false) const [pyodideVersion, setPyodideVersion] = useState() const [output, setOutput] = useState([]) - const [stdout, setStdout] = useState('') + const [stderr, setStderr] = useState('') - // Immediately set stdout upon receiving new input + const runCode = useCallback(async (code: string, id: string) => { + if (!runnerRef.current) { + throw new Error('Pyodide is not loaded') + } + try { + setOutput([id]) + await runnerRef.current.run(code) + } catch (error) { + console.error('Error:', error) + setStderr('Traceback (most recent call): \n' + error.message) + } + }, []) + // useEffect to check on stdErr useEffect(() => { - if (output.length > 0) { - console.log('output', output) - setStdout(output.join('\n')) + if (stderr) { + console.log(stderr) } - }, [output]) + }, [stderr]) useEffect(() => { const worker = new Worker(new URL('./python-worker', import.meta.url)) @@ -51,7 +64,6 @@ export const WorkerProvider: React.FC = ({ children }) => { await runner.init( proxy((msg: string) => { - // Suppress messages that are not useful for the user setOutput((prev) => [...prev, msg]) }), proxy(({ version }) => { @@ -77,13 +89,10 @@ export const WorkerProvider: React.FC = ({ children }) => { } }, []) - // now runner ref - console.log('runnerRef', runnerRef) - // The value provided by the context includes both the worker and functions to interact with it return ( {children} diff --git a/src/components/Sidebar/SidebarContext.tsx b/src/components/Sidebar/SidebarContext.tsx index 6b67090..43876c1 100644 --- a/src/components/Sidebar/SidebarContext.tsx +++ b/src/components/Sidebar/SidebarContext.tsx @@ -1,4 +1,4 @@ -import { useReducer, createContext, useContext, Reducer } from 'react' +import React, { useReducer, createContext, useContext, Reducer } from 'react' import { produce, Draft } from 'immer' export type RawStatefulNode = {