This commit is contained in:
Ole
2026-05-31 20:25:41 +00:00
commit 0a07ab8593
275 changed files with 52660 additions and 0 deletions
@@ -0,0 +1,295 @@
/**
* 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<void>;
runPython(code: string): unknown;
runPythonAsync(code: string): Promise<unknown>;
globals: PyodideGlobals;
pyimport(name: string): unknown;
}
interface Micropip {
install(packages: string | string[]): Promise<void>;
}
// ============================================================================
// 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<PyodideInstance>;
/** 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<string, unknown>).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<void> {
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<unknown> {
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;
}