From 655337f7bf21ec239ec29fb6c2c7795e676a96f1 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:38:02 +0200 Subject: [PATCH] fix: vendor openpyxl so Excel I/O works in the Pyodide code interpreter (#30140) openpyxl has been listed as a Pyodide package since March, but it is not part of the Pyodide distribution, so the build never wrote a wheel for it and never warned about it. In the browser code interpreter every Excel operation failed with ModuleNotFoundError: import openpyxl, pd.read_excel() and DataFrame.to_excel() alike. It now takes the PyPI wheel path together with its dependency et-xmlfile, and the code interpreter installs it when a snippet imports openpyxl or calls pandas' Excel helpers, which need it without importing it by name. Injected lock entries are now keyed by the canonical dashed package name, the only spelling pyodide resolves them by. As a side effect black's mypy-extensions now resolves from the bundled wheel too, so formatting Python in the editor no longer reaches out to PyPI at runtime. Verified offline against a built static/pyodide with every network call blocked: a to_excel then read_excel roundtrip loads openpyxl and et-xmlfile from the local directory and returns the frame. Fixes #30130 --- scripts/prepare-pyodide.js | 21 ++++++------- .../components/chat/Messages/CodeBlock.svelte | 4 ++- src/routes/+layout.svelte | 4 ++- static/pyodide/pyodide-lock.json | 30 +++++++++++++++++-- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js index 64c380e3a7..577b97854f 100644 --- a/scripts/prepare-pyodide.js +++ b/scripts/prepare-pyodide.js @@ -14,15 +14,17 @@ const packages = [ 'seaborn', 'pytz', 'black', - 'openai', - 'openpyxl' + 'openai' ]; // Pure-Python packages whose wheels must be downloaded from PyPI and saved into // static/pyodide/ so that the browser can install them offline via micropip. // Packages already provided by the Pyodide distribution (click, platformdirs, // typing_extensions, etc.) do NOT need to be listed here. -const pypiPackages = ['black', 'pathspec', 'mypy_extensions', 'pytokens']; +// Spell them canonically (dashed): that is the only form pyodide resolves lock entries by. +const pypiPackages = ['black', 'pathspec', 'mypy-extensions', 'pytokens', 'openpyxl', 'et-xmlfile']; + +const pypiDepends = { openpyxl: ['et-xmlfile'] }; import { loadPyodide } from 'pyodide'; import { setGlobalDispatcher, ProxyAgent } from 'undici'; @@ -175,19 +177,18 @@ async function downloadPyPIWheels() { } // Inject into pyodide-lock.json so micropip resolves locally - const normalizedName = pkg.replace(/-/g, '_'); - if (!lockData.packages[normalizedName]) { - lockData.packages[normalizedName] = { - name: normalizedName, + if (!lockData.packages[pkg]) { + lockData.packages[pkg] = { + name: pkg, version: version, file_name: wheel.filename, install_dir: 'site', sha256: wheel.digests?.sha256 || '', package_type: 'package', - imports: [normalizedName], - depends: [] + imports: [pkg.replace(/-/g, '_')], + depends: pypiDepends[pkg] || [] }; - console.log(` Added ${normalizedName}==${version} to pyodide-lock.json`); + console.log(` Added ${pkg}==${version} to pyodide-lock.json`); } } diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index 23ef519ea5..7889592120 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -233,7 +233,9 @@ /\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null, /\bimport\s+sympy\b|\bfrom\s+sympy\b/.test(code) ? 'sympy' : null, /\bimport\s+tiktoken\b|\bfrom\s+tiktoken\b/.test(code) ? 'tiktoken' : null, - /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null + /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null, + /\bimport\s+openpyxl\b|\bfrom\s+openpyxl\b/.test(code) ? 'openpyxl' : null, + /\.(read|to)_excel\(|\.Excel(Writer|File)\(/.test(code) ? 'openpyxl' : null ].filter(Boolean); console.log(packages); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 71221fba94..d23eb89038 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -311,7 +311,9 @@ /\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null, /\bimport\s+sympy\b|\bfrom\s+sympy\b/.test(code) ? 'sympy' : null, /\bimport\s+tiktoken\b|\bfrom\s+tiktoken\b/.test(code) ? 'tiktoken' : null, - /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null + /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null, + /\bimport\s+openpyxl\b|\bfrom\s+openpyxl\b/.test(code) ? 'openpyxl' : null, + /\.(read|to)_excel\(|\.Excel(Writer|File)\(/.test(code) ? 'openpyxl' : null ].filter(Boolean); const worker = getOrCreateWorker(); diff --git a/static/pyodide/pyodide-lock.json b/static/pyodide/pyodide-lock.json index a29df9c9ce..192f49b42b 100644 --- a/static/pyodide/pyodide-lock.json +++ b/static/pyodide/pyodide-lock.json @@ -5131,8 +5131,8 @@ ], "depends": [] }, - "mypy_extensions": { - "name": "mypy_extensions", + "mypy-extensions": { + "name": "mypy-extensions", "version": "1.1.0", "file_name": "mypy_extensions-1.1.0-py3-none-any.whl", "install_dir": "site", @@ -5154,6 +5154,32 @@ "pytokens" ], "depends": [] + }, + "openpyxl": { + "name": "openpyxl", + "version": "3.1.5", + "file_name": "openpyxl-3.1.5-py2.py3-none-any.whl", + "install_dir": "site", + "sha256": "5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", + "package_type": "package", + "imports": [ + "openpyxl" + ], + "depends": [ + "et-xmlfile" + ] + }, + "et-xmlfile": { + "name": "et-xmlfile", + "version": "2.0.0", + "file_name": "et_xmlfile-2.0.0-py3-none-any.whl", + "install_dir": "site", + "sha256": "7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", + "package_type": "package", + "imports": [ + "et_xmlfile" + ], + "depends": [] } } } \ No newline at end of file