├── .gitignore ├── LICENSE ├── README.md ├── assets └── demo.png ├── package.json ├── pnpm-lock.yaml ├── setup └── code-runners.ts ├── slides.md └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | *.local 5 | .vite-inspect 6 | .remote-assets 7 | components.d.ts 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 _Kerman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slidev-addon-python-runner 2 | 3 | Python runner for the [Monaco Runner feature](https://sli.dev/features/monaco-run) in [Slidev](https://sli.dev/). Code executed in browser using [Pyodide](https://pyodide.org/). 4 | 5 | ![Demo](https://cdn.jsdelivr.net/gh/KermanX/slidev-addon-python-runner/assets/demo.png) 6 | 7 | ## Usage 8 | 9 | Firstly, install the package: 10 | 11 | ```bash 12 | npm install slidev-addon-python-runner 13 | ``` 14 | 15 | Then, add it as an addon in your headmatter in `slides.md`: 16 | 17 | ```md 18 | --- 19 | addons: 20 | - slidev-addon-python-runner 21 | 22 | # Optional configuration for this runner 23 | python: 24 | # Install packages from PyPI. Default: [] 25 | installs: ["cowsay"] 26 | 27 | # Code executed to set up the environment. Default: "" 28 | prelude: | 29 | GREETING_FROM_PRELUDE = "Hello, Slidev!" 30 | 31 | # Automatically load the imported builtin packages. Default: true 32 | loadPackagesFromImports: true 33 | 34 | # Disable annoying warning from `pandas`. Default: true 35 | suppressDeprecationWarnings: true 36 | 37 | # Always reload the Python environment when the code changes. Default: false 38 | alwaysReload: false 39 | 40 | # Options passed to `loadPyodide`. Default: {} 41 | loadPyodideOptions: {} 42 | --- 43 | ``` 44 | 45 | To add an interactive Python code runner, use the `monaco-run` directive: 46 | 47 | ````md 48 | ```py {monaco-run} 49 | from termcolor import colored 50 | 51 | print(colored("Hello, Slidev!", "blue")) 52 | ``` 53 | ```` 54 | 55 | ## Bundle `pyodide` 56 | 57 | By default, when building slides (i.e. `slidev build`), the `pyodide` package will be replaced with the CDN version. This is because of https://github.com/pyodide/pyodide/issues/1949, which causes the imported python packages to be lost when using the bundled version. 58 | 59 | To bundle the local version of `pyodide`, set the `PYODIDE_BUNDLE` environment variable to `true`. Note that in this way you can't import python packages in the static build. 60 | -------------------------------------------------------------------------------- /assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kermanx/slidev-addon-python-runner/16e28d207a09fe1bf16434ec028d8779ecb08ae3/assets/demo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slidev-addon-python-runner", 3 | "version": "0.1.3", 4 | "type": "module", 5 | "scripts": { 6 | "build": "slidev build", 7 | "dev": "slidev --open", 8 | "export": "slidev export" 9 | }, 10 | "license": "MIT", 11 | "keywords": [ 12 | "slidev-addon", 13 | "python", 14 | "pyodide" 15 | ], 16 | "description": "A Slidev addon that allows you to run Python code in your slides.", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/KermanX/slidev-addon-python-runner.git" 20 | }, 21 | "author": { 22 | "name": "_Kerman", 23 | "email": "kermanx@qq.com", 24 | "url": "https://github.com/KermanX" 25 | }, 26 | "files": [ 27 | "setup", 28 | "README.md", 29 | "LICENSE", 30 | "vite.config.ts" 31 | ], 32 | "devDependencies": { 33 | "@slidev/cli": "latest", 34 | "@slidev/client": "latest", 35 | "@slidev/theme-default": "latest", 36 | "@slidev/types": "latest", 37 | "@types/node": "latest", 38 | "vite": "5", 39 | "vue": "latest" 40 | }, 41 | "packageManager": "pnpm@9.14.3", 42 | "dependencies": { 43 | "pyodide": "^0.26.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /setup/code-runners.ts: -------------------------------------------------------------------------------- 1 | import { CodeRunnerOutput, defineCodeRunnersSetup } from '@slidev/types' 2 | import { loadPyodide, PyodideInterface } from 'pyodide' 3 | import { ref } from 'vue' 4 | import { useNav } from '@slidev/client' 5 | 6 | let pyodideCache: PyodideInterface | null = null 7 | let pyodideOptionCache = "{}" 8 | async function setupPyodide(options = {}, code) { 9 | const { 10 | installs = [], 11 | prelude = "", 12 | loadPackagesFromImports = true, 13 | suppressDeprecationWarnings = true, 14 | alwaysReload = false, 15 | loadOptions = {}, 16 | } = options as any 17 | 18 | if (alwaysReload || pyodideOptionCache !== JSON.stringify(options)) { 19 | pyodideCache = null 20 | pyodideOptionCache = JSON.stringify(options) 21 | } 22 | 23 | if (pyodideCache) { 24 | if (loadPackagesFromImports) { 25 | await pyodideCache.loadPackagesFromImports(code) 26 | } 27 | return pyodideCache 28 | } 29 | 30 | pyodideCache = await loadPyodide(loadOptions); 31 | 32 | if (prelude) { 33 | if (loadPackagesFromImports) { 34 | await pyodideCache.loadPackagesFromImports(prelude) 35 | } 36 | await pyodideCache.runPythonAsync(prelude) 37 | } 38 | 39 | // Pandas always throws a DeprecationWarning 40 | if (suppressDeprecationWarnings) 41 | await pyodideCache.runPythonAsync(` 42 | import warnings 43 | warnings.filterwarnings("ignore", category=DeprecationWarning) 44 | `) 45 | 46 | if (installs.length) { 47 | await pyodideCache.loadPackage('micropip') 48 | await pyodideCache.runPythonAsync([ 49 | 'import micropip', 50 | 'await micropip.install(' + JSON.stringify(installs) + ')', 51 | ].join('\n')) 52 | } 53 | 54 | if (loadPackagesFromImports) { 55 | await pyodideCache.loadPackagesFromImports(code) 56 | } 57 | 58 | return pyodideCache 59 | } 60 | 61 | export default defineCodeRunnersSetup(() => { 62 | const { slides } = useNav() 63 | async function run(code: string) { 64 | // @ts-expect-error 65 | const pyodide = await setupPyodide(slides.value[0].meta.slide.frontmatter?.python, code) 66 | const texts = ref(['']) 67 | const extras = ref([]) 68 | const decoder = new TextDecoder('utf-8'); 69 | function write(buffer: Uint8Array) { 70 | const text = decoder.decode(buffer) 71 | for (const line of text.split('\n')) { 72 | texts.value[texts.value.length - 1] += line 73 | texts.value.push('') 74 | } 75 | return buffer.length 76 | } 77 | pyodide.setStdout({ 78 | write: write, 79 | isatty: true, 80 | }) 81 | pyodide.setStderr({ 82 | write: write, 83 | isatty: true, 84 | }) 85 | pyodide.runPythonAsync(code).catch(err => { 86 | console.error(err) 87 | const str = err.toString() 88 | const matchNotFoundError = str.match(/ModuleNotFoundError: No module named '(.*)'/) 89 | if (matchNotFoundError) { 90 | extras.value.push({ 91 | html: [ 92 | `
${matchNotFoundError[0]}
`, 93 | `
Tip: This may because of this package is not a Pyodide builtin package.`, 94 | "
You may need to install it by adding the package name to the `python.installs` array in your headmatter.", 95 | `
` 96 | ].join('') 97 | }) 98 | } else { 99 | for (const line of str.split('\n')) { 100 | extras.value.push({ 101 | text: line, 102 | class: 'text-red' 103 | }) 104 | } 105 | } 106 | }); 107 | return () => [ 108 | ...texts.value.map(text => ({ text, highlightLang: 'ansi' })), 109 | ...extras.value, 110 | ] 111 | } 112 | return { 113 | python: run, 114 | py: run, 115 | } 116 | }) 117 | -------------------------------------------------------------------------------- /slides.md: -------------------------------------------------------------------------------- 1 | --- 2 | theme: default 3 | layout: default 4 | python: 5 | # Install packages from PyPI. Default: [] 6 | installs: ["cowsay"] 7 | 8 | # Code executed to set up the environment. Default: "" 9 | prelude: | 10 | GREETING_FROM_PRELUDE = "Hello, Slidev!" 11 | 12 | # Automatically load the imported builtin packages. Default: true 13 | loadPackagesFromImports: true 14 | 15 | # Disable annoying warning from `pandas`. Default: true 16 | suppressDeprecationWarnings: true 17 | 18 | # Always reload the Python environment when the code changes. Default: false 19 | alwaysReload: false 20 | 21 | # Options passed to `loadPyodide`. Default: {} 22 | loadPyodideOptions: {} 23 | --- 24 | 25 | # Python Runner for Slidev 26 | 27 | ```py {monaco-run} 28 | from termcolor import colored 29 | import pandas as pd 30 | import numpy as np 31 | 32 | print(colored(GREETING_FROM_PRELUDE, "blue")) 33 | 34 | df = pd.DataFrame({ 35 | "A": 1.0, 36 | "B": pd.Timestamp("20130102"), 37 | "C": pd.Series(1, index=list(range(4)), dtype="float32"), 38 | "D": np.array([3] * 4, dtype="int32"), 39 | "E": pd.Categorical(["test", "train", "test", "train"]), 40 | "F": "foo" 41 | }) 42 | 43 | print(df) 44 | ``` 45 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { copyFile, mkdir } from "fs/promises"; 2 | import { dirname, join } from "path"; 3 | import { version } from "pyodide"; 4 | import { fileURLToPath } from "url"; 5 | import { defineConfig } from 'vite'; 6 | 7 | function getCdnUrl(filename: string) { 8 | return `https://cdn.jsdelivr.net/pyodide/v${version}/full/${filename}`; 9 | } 10 | 11 | const bundlePyodide = !!process.env.BUNDLE_PYODIDE; 12 | 13 | export default defineConfig(({ command }) => ({ 14 | optimizeDeps: { exclude: ["pyodide"] }, 15 | build: bundlePyodide ? { 16 | rollupOptions: { 17 | external(source, importer, isResolved) { 18 | if (!isResolved && importer?.endsWith('pyodide/pyodide.mjs') && source.startsWith('node:')) { 19 | return true; 20 | } 21 | }, 22 | } 23 | } : undefined, 24 | resolve: !bundlePyodide && command === 'build' ? { 25 | alias: { 26 | 'pyodide': `https://cdn.jsdelivr.net/pyodide/v${version}/full/pyodide.mjs`, 27 | } 28 | } : undefined, 29 | plugins: [ 30 | { 31 | name: "slidev-addon-python-runner", 32 | configureServer(server) { 33 | server.middlewares.use(async (req, res, next) => { 34 | let match = req.url?.match(/\/node_modules\/pyodide\/(.+)$/); 35 | if (!match || !match[1].endsWith(".whl")) { 36 | return next(); 37 | } 38 | const url = getCdnUrl(match[1]); 39 | res.writeHead(302, { Location: url }); 40 | res.end(); 41 | }) 42 | }, 43 | 44 | async generateBundle(options, bundle, isWrite) { 45 | if (!bundlePyodide) { 46 | return; 47 | } 48 | 49 | if (!isWrite) { 50 | console.log("Skipping pyodide assets copying"); 51 | return; 52 | } 53 | 54 | const modulePath = fileURLToPath(import.meta.resolve("pyodide")) 55 | 56 | // Get where the "pyodide" module is located 57 | let path: string | null = null 58 | for (const file in bundle) { 59 | const chunk = bundle[file] 60 | if (chunk.type === "chunk" && chunk.modules) { 61 | for (const module of Object.keys(chunk.modules)) { 62 | if (module.includes("pyodide")) { 63 | if (path) { 64 | throw new Error("Found multiple pyodide modules") 65 | } 66 | path = file 67 | break 68 | } 69 | } 70 | } 71 | } 72 | if (!path) { 73 | throw new Error("Could not find the pyodide module") 74 | } 75 | 76 | const assetsDir = dirname(join(options.dir!, path)); 77 | await mkdir(assetsDir, { recursive: true }); 78 | const files = [ 79 | "pyodide-lock.json", 80 | "pyodide.asm.js", 81 | "pyodide.asm.wasm", 82 | "python_stdlib.zip", 83 | ]; 84 | for (const file of files) { 85 | await copyFile( 86 | join(dirname(modulePath), file), 87 | join(assetsDir, file), 88 | ); 89 | } 90 | }, 91 | }, 92 | ], 93 | })) 94 | --------------------------------------------------------------------------------