diff --git a/scripts/pathview_install.py b/scripts/pathview_install.py new file mode 100644 index 00000000..7c28e389 --- /dev/null +++ b/scripts/pathview_install.py @@ -0,0 +1,70 @@ +"""Shared package-install primitives for the Pyodide and Flask backends. + +Single source of truth for "is this importable?" + "install this package", +used by BOTH the engine-install seam (worker boot, via engineInstall.ts) and +the runtime toolbox installer (via TOOLBOX_PYTHON_HELPERS). Defined here, in +one place, so there is exactly one micropip / pip code path with one +error-classification scheme instead of three inline copies. + +The engine seam injects this before the worker snapshots ``_clean_globals``, +so these names survive a simulation reset; the toolbox layer re-injects the +same source (idempotent) on demand. +""" + +import sys as _pv_sys +import importlib as _pv_importlib + + +def _pv_already_installed(import_path): + """Return True if the given module path is already importable.""" + if not import_path: + return False + try: + _pv_importlib.import_module(import_path) + return True + except Exception: + return False + + +async def _pv_install_micropip(spec, pre=False, keep_going=True): + """Pyodide-side install via micropip (top-level await). + + micropip can only install pure-Python wheels (or packages Pyodide ships + pre-built), so toolboxes with compiled/native code fail here even though + they install fine in the standalone (pip-backed) build. On failure we + classify the error and prefix it with PV_INCOMPATIBLE (browser-runtime + limitation) or PV_INSTALL_ERROR (genuine failure) so the JS side can show + a useful hint instead of a raw traceback. + + ``pre`` allows pre-release wheels (used by the engine seam); ``keep_going`` + keeps resolving the rest of the dependency set after a single miss. + """ + import micropip + try: + await micropip.install(spec, keep_going=keep_going, pre=pre) + except Exception as e: + msg = str(e) + low = msg.lower() + incompatible = ( + "pure python" in low + or "can't find" in low + or "cannot find" in low + or "no matching distribution" in low + or "no known package" in low + ) + tag = "PV_INCOMPATIBLE" if incompatible else "PV_INSTALL_ERROR" + raise RuntimeError(tag + ": " + msg) + return {"ok": True, "spec": spec, "via": "micropip"} + + +def _pv_install_pip(spec): + """CPython-side install via subprocess pip (Flask backend).""" + import subprocess as _pv_subprocess + res = _pv_subprocess.run( + [_pv_sys.executable, "-m", "pip", "install", spec], + capture_output=True, + text=True, + ) + if res.returncode != 0: + raise RuntimeError("pip install failed:\n" + (res.stderr or res.stdout)) + return {"ok": True, "spec": spec, "via": "pip"} diff --git a/src/lib/pyodide/backend/pyodide/engineInstall.ts b/src/lib/pyodide/backend/pyodide/engineInstall.ts index cbd7f4ad..4e73f8d2 100644 --- a/src/lib/pyodide/backend/pyodide/engineInstall.ts +++ b/src/lib/pyodide/backend/pyodide/engineInstall.ts @@ -10,6 +10,7 @@ import { PYTHON_PACKAGES } from '$lib/constants/dependencies'; import { PROGRESS_MESSAGES } from '$lib/constants/messages'; +import INSTALL_PY from '../../../../../scripts/pathview_install.py?raw'; import type { PyodideInterface } from 'https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.mjs'; export interface EngineInstallContext { @@ -23,6 +24,12 @@ export async function installEngine( pyodide: PyodideInterface, ctx: EngineInstallContext ): Promise { + // Define the shared install primitive (`_pv_install_micropip`) that the + // toolbox layer also uses (via TOOLBOX_PYTHON_HELPERS). Injected here, before + // the worker snapshots `_clean_globals`, so it survives a simulation reset — + // one micropip code path with one error-classification scheme, not two. + await pyodide.runPythonAsync(INSTALL_PY); + for (const pkg of PYTHON_PACKAGES) { const progressKey = `INSTALLING_${pkg.import.toUpperCase()}` as keyof typeof PROGRESS_MESSAGES; ctx.send({ @@ -31,11 +38,9 @@ export async function installEngine( }); try { - const preFlag = pkg.pre ? ', pre=True' : ''; - await pyodide.runPythonAsync(` -import micropip -await micropip.install('${pkg.pip}'${preFlag}) - `); + await pyodide.runPythonAsync( + `await _pv_install_micropip('${pkg.pip}', pre=${pkg.pre ? 'True' : 'False'})` + ); // Verify installation await pyodide.runPythonAsync(` diff --git a/src/lib/toolbox/python.ts b/src/lib/toolbox/python.ts index 22c368fa..1749013a 100644 --- a/src/lib/toolbox/python.ts +++ b/src/lib/toolbox/python.ts @@ -13,6 +13,7 @@ */ import INTROSPECT_PY from '../../../scripts/pathview_introspect.py?raw'; +import INSTALL_PY from '../../../scripts/pathview_install.py?raw'; const RUNTIME_GLUE = ` import sys as _pv_sys @@ -22,57 +23,6 @@ import types as _pv_types _PV_INLINE_PREFIX = "pathview_inline_" -def _pv_already_installed(import_path): - """Return True if the given module path is already importable.""" - if not import_path: - return False - try: - _pv_importlib.import_module(import_path) - return True - except Exception: - return False - - -async def _pv_install_micropip(spec): - """Pyodide-side install via micropip (top-level await). - - micropip can only install pure-Python wheels (or packages Pyodide - ships pre-built), so toolboxes with compiled/native code fail here - even though they install fine in the standalone (pip-backed) build. - On failure we classify the error and prefix it with PV_INCOMPATIBLE - (browser-runtime limitation) or PV_INSTALL_ERROR (genuine failure) - so the JS side can show a useful hint instead of a raw traceback.""" - import micropip - try: - await micropip.install(spec, keep_going=True) - except Exception as e: - msg = str(e) - low = msg.lower() - incompatible = ( - "pure python" in low - or "can't find" in low - or "cannot find" in low - or "no matching distribution" in low - or "no known package" in low - ) - tag = "PV_INCOMPATIBLE" if incompatible else "PV_INSTALL_ERROR" - raise RuntimeError(tag + ": " + msg) - return {"ok": True, "spec": spec, "via": "micropip"} - - -def _pv_install_pip(spec): - """CPython-side install via subprocess pip (Flask backend).""" - import subprocess as _pv_subprocess - res = _pv_subprocess.run( - [_pv_sys.executable, "-m", "pip", "install", spec], - capture_output=True, - text=True, - ) - if res.returncode != 0: - raise RuntimeError("pip install failed:\\n" + (res.stderr or res.stdout)) - return {"ok": True, "spec": spec, "via": "pip"} - - def _pv_load_inline(module_name, code): """Exec a single-file Python module string into sys.modules.""" if not module_name.startswith(_PV_INLINE_PREFIX): @@ -178,7 +128,7 @@ def _pv_module_version(import_path): _pv_helpers_loaded = True `; -export const TOOLBOX_PYTHON_HELPERS = INTROSPECT_PY + RUNTIME_GLUE; +export const TOOLBOX_PYTHON_HELPERS = INTROSPECT_PY + INSTALL_PY + RUNTIME_GLUE; /** Sentinel expression used to check whether helpers are already loaded in the REPL. */ export const TOOLBOX_HELPERS_SENTINEL = `'_pv_helpers_loaded' in dir()`;