Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions scripts/pathview_install.py
Original file line number Diff line number Diff line change
@@ -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"}
15 changes: 10 additions & 5 deletions src/lib/pyodide/backend/pyodide/engineInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,12 @@ export async function installEngine(
pyodide: PyodideInterface,
ctx: EngineInstallContext
): Promise<void> {
// 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({
Expand All @@ -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(`
Expand Down
54 changes: 2 additions & 52 deletions src/lib/toolbox/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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()`;
Loading