/** * PyodideRuntime — browser-based Python execution via Pyodide. * * Wraps Pyodide loading, package installation, Cognite SDK setup, * and localStorage caching into a clean PythonRuntime implementation. * * The consumer owns the 'pyodide' npm package — they pass `loadPyodide` * as a config parameter so this module has no hard dependency on it. */ import type { PythonRuntime } from './types'; // ============================================================================ // Minimal Pyodide Interfaces (avoids 'pyodide' package dependency) // ============================================================================ interface PyodideGlobals { get(name: string): unknown; set(name: string, value: unknown): void; } /** Subset of PyodideInterface that this module uses. */ export interface PyodideInstance { loadPackage(packages: string[]): Promise; runPython(code: string): unknown; runPythonAsync(code: string): Promise; globals: PyodideGlobals; pyimport(name: string): unknown; } interface Micropip { install(packages: string | string[]): Promise; } // ============================================================================ // Constants // ============================================================================ const DEFAULT_CDN_URL = 'https://cdn.jsdelivr.net/pyodide/v0.29.3/full/'; const CACHE_KEY = 'dune_pyodide_initialized'; const CACHE_VERSION = 'v1'; // ============================================================================ // Config Types // ============================================================================ export interface PyodideRuntimeConfig { /** The `loadPyodide` function from the `pyodide` package. */ loadPyodide: (options: { indexURL: string }) => Promise; /** CDN URL for Pyodide files. Defaults to jsDelivr v0.29.3. */ cdnUrl?: string; /** Additional Python packages to install via micropip. */ requirements?: string[]; /** Progress callback for initialization stages. */ onProgress?: (stage: string, percent: number) => void; } export interface PyodideSDKConfig { project: string; baseUrl: string; token: string; } // ============================================================================ // Python Utility Code (injected at init) // ============================================================================ const PYTHON_UTILS = ` import json def _serialize_cognite_object(obj, depth=0): if depth > 10: return str(obj) for attr in ('dump', 'as_dict'): fn = getattr(obj, attr, None) if fn: try: return fn() except: pass if isinstance(obj, dict): return {k: _serialize_cognite_object(v, depth+1) for k, v in obj.items()} if isinstance(obj, (list, tuple)): return [_serialize_cognite_object(i, depth+1) for i in obj] if isinstance(obj, (str, int, float, bool, type(None))): return obj d = getattr(obj, '__dict__', None) if d is not None: try: return _serialize_cognite_object(d, depth+1) except: pass return str(obj) def as_json_string(value): return json.dumps(_serialize_cognite_object(value)) `; // ============================================================================ // Cache Helpers // ============================================================================ function isCacheValid(): boolean { try { return localStorage.getItem(CACHE_KEY) === CACHE_VERSION; } catch { return false; } } function markCacheValid(): void { try { localStorage.setItem(CACHE_KEY, CACHE_VERSION); } catch { /* localStorage unavailable */ } } /** Clear the Pyodide package cache — forces re-download on next init. */ export function clearPyodideCache(): void { try { localStorage.removeItem(CACHE_KEY); } catch { /* localStorage unavailable */ } } // ============================================================================ // PyProxy Detection (structural — avoids importing from 'pyodide') // ============================================================================ function isPyProxy(value: unknown): value is { destroy(): void } { return ( value != null && typeof value === 'object' && 'destroy' in value && typeof (value as Record).destroy === 'function' ); } function destroyIfPyProxy(value: unknown): void { if (isPyProxy(value)) { value.destroy(); } } // ============================================================================ // PyodideRuntime Class // ============================================================================ /** * PythonRuntime backed by Pyodide. Handles loading, package installation, * Cognite SDK credential injection, caching, and PyProxy conversion. */ export class PyodideRuntime implements PythonRuntime { private pyodide?: PyodideInstance; private micropip?: Micropip; private _initialized = false; private readonly config: PyodideRuntimeConfig; constructor(config: PyodideRuntimeConfig) { this.config = config; } get isInitialized(): boolean { return this._initialized; } /** * Load Pyodide, install packages, and set up the Cognite SDK. * Safe to call multiple times — subsequent calls are no-ops. */ async initialize(sdk: PyodideSDKConfig): Promise { if (this._initialized) return; const report = this.config.onProgress ?? (() => {}); const cdnUrl = this.config.cdnUrl ?? DEFAULT_CDN_URL; // 1. Load Pyodide report('Loading Pyodide...', 10); this.pyodide = await this.config.loadPyodide({ indexURL: cdnUrl }); report('Pyodide loaded', 30); // 2. Core packages (micropip + HTTP patching) report('Loading core packages...', 40); await this.pyodide.loadPackage(['micropip', 'pyodide-http']); await this.pyodide.runPythonAsync(` try: import pyodide_http pyodide_http.patch_all() except Exception: pass `); this.micropip = this.pyodide.pyimport('micropip') as Micropip; // 3. Cognite SDK const verb = isCacheValid() ? 'Loading' : 'Downloading'; report(`${verb} cognite-sdk...`, 60); await this.micropip.install('cognite-sdk'); if (!isCacheValid()) markCacheValid(); report('cognite-sdk ready', 80); // 4. Additional packages const reqs = this.config.requirements ?? []; if (reqs.length > 0) { report('Installing packages...', 85); await this.micropip.install(reqs); } // 5. Utility functions + Cognite client report('Setting up environment...', 90); this.pyodide.runPython(PYTHON_UTILS); report('Initializing Cognite client...', 95); this.pyodide.runPython(` import os os.environ["COGNITE_PROJECT"] = "${sdk.project}" os.environ["COGNITE_BASE_URL"] = "${sdk.baseUrl}" os.environ["COGNITE_TOKEN"] = "${sdk.token}" os.environ["COGNITE_FUSION_NOTEBOOK"] = "1" os.environ["MPLBACKEND"] = "AGG" from cognite.client import CogniteClient client = CogniteClient() `); this._initialized = true; report('Ready', 100); } /** Execute Python code asynchronously. PyProxy results are converted to JSON-safe values. */ async runCodeAsync(code: string): Promise { const pyodide = this.requirePyodide(); const raw = await pyodide.runPythonAsync(code); return this.toJsonSafe(raw); } /** Refresh the Cognite SDK token (e.g. after token rotation). */ refreshToken(token: string): void { this.requirePyodide().runPython( `import os; os.environ["COGNITE_TOKEN"] = "${token}"`, ); } private requirePyodide(): PyodideInstance { if (!this.pyodide) { throw new Error( 'PyodideRuntime not initialized — call initialize() first', ); } return this.pyodide; } /** Convert a Pyodide result to a JSON-safe JS value. */ private toJsonSafe(value: unknown): unknown { if (value == null) return undefined; if (!isPyProxy(value)) return value; const pyodide = this.requirePyodide(); const converter = pyodide.globals.get('as_json_string') as | ((obj: unknown) => string) | undefined; if (!converter) { throw new Error( 'as_json_string not available — was initialize() called?', ); } try { return JSON.parse((converter as (obj: unknown) => string)(value)); } finally { destroyIfPyProxy(converter); destroyIfPyProxy(value); } } } // ============================================================================ // Singleton // ============================================================================ let globalInstance: PyodideRuntime | undefined; /** * Get or create the global PyodideRuntime singleton. * Config is only used on first call — subsequent calls return the existing instance. */ export function getGlobalPyodideRuntime( config: PyodideRuntimeConfig, ): PyodideRuntime { if (!globalInstance) { globalInstance = new PyodideRuntime(config); } return globalInstance; } /** Reset the global runtime (e.g. on logout). */ export function resetGlobalPyodideRuntime(): void { globalInstance = undefined; }