Zum Hauptinhalt springen

pyodide-code

```py live_pyo id=b26cde2c-5b0b-4acb-814e-9f2b3bdd29a9
print("Hello from Pyodide!")
```
http://localhost:3000
print("Hello from Pyodide!")

Sleep​

from time import sleep
for i in range(10):
print(i, i*i, i**3)
sleep(1)

Ausgabe löschen​

os.system('clear') und os.system('cls') löschen die bisherige Ausgabe des aktuellen Editors. Nachfolgende Ausgaben erscheinen wieder im leeren Logfenster. Beide Befehle geben 0 zurück. Diese Unterstützung gilt für die beiden Löschbefehle; eine allgemeine Betriebssystem-Shell steht im Browser nicht zur Verfügung.

from os import system
from time import sleep

for i in range(5):
system('clear')
print(i)
sleep(1)

Es ist immer nur die aktuelle Ausgabe sichtbar.

Fibonacci​

def fibonacci(n):
if n <= 0:
return []
elif n == 1:
return [0]
elif n == 2:
return [0, 1]
else:
seq = [0, 1]
for i in range(2, n):
seq.append(seq[-1] + seq[-2])
return seq
n = int(input("fib von? "))
print(fibonacci(n))

Mit Memoization​

from functools import lru_cache

@lru_cache
def fibonacci(n):
if n in {0, 1}:
return n
return fibonacci(n-1) + fibonacci(n-2)

for n in range(1, 200):
print(f"Fibonacci({n}) = {fibonacci(n)}")

Minimierung mit SciPy​

import numpy as np
from scipy import optimize
# Function to minimize: f(x) = (x - 3)^2
def func(x):
return (x - 3) ** 2

# Minimize using scipy.optimize
result = optimize.minimize(func, x0=0)
print("Minimum at x =", result.x[0])
print("Function value at minimum =", result.fun)

Python-Pakete laden​

Imports werden mit Pythons AST ausgewertet, auch innerhalb von Funktionen oder zusammen mit weiteren Anweisungen auf einer Zeile. Pakete aus der Pyodide-Distribution werden mit deren eingebautem Loader geladen. Bereits verfügbare Module, die Standardbibliothek und lokale JavaScript-Module lösen keine zusätzlichen PyPI-Installationen aus. Fehlgeschlagene Installationen werden im Editor angezeigt.

Für zusätzliche oder versionierte Abhängigkeiten kann die Pyodide-Komponente eine Liste von Paketanforderungen erhalten:

import Pyodide from '@tdev/pyodide-code/components/Pyodide';

export const code = `from slugify import slugify\n
print(slugify("C'est déjà l'été.", algorithm='modern'))
print(slugify('影師嗎', backend='text-unidecode', algorithm='modern'))`;

<Pyodide showLineNumbers code={code} packages={['python-slugify==9.1.3']} id="32553b77-e5eb-4736-aa35-9fdaa7ce8b5d" />
http://localhost:3000
from slugify import slugify

print(slugify("C'est déjà l'été.", algorithm='modern'))
print(slugify('影師嗎', backend='text-unidecode', algorithm='modern'))

Diese Pakete werden vor der Ausführung installiert. So funktioniert auch import slugify, obwohl die Distribution python-slugify heisst. Die Liste steht ebenfalls als packages in den ModelMeta-Optionen zur Verfügung. Dynamisch zusammengesetzte Importnamen benötigen solche expliziten Paketanforderungen.

Explizite Paketanforderungen müssen erfolgreich installiert werden. Bei fehlgeschlagenen automatisch erkannten Abhängigkeiten wird eine Diagnose angezeigt und der Python-Code trotzdem ausgeführt. Dadurch können optionale Imports weiterhin mit try/except ImportError behandelt werden.

Fortschrittsmeldungen beim Laden von Paketen erscheinen ausschliesslich in der Entwicklerkonsole des Browsers. Fehlgeschlagene Installationen werden weiterhin im Editor angezeigt.

Freie Worker wiederverwenden​

Nacheinander gestartete Skripte verwenden einen freien Worker desselben Browser-Tabs wieder. Dadurch bleiben bereits installierte Pakete und importierte Bibliotheken verfügbar. Gleichzeitig laufende Skripte erhalten jeweils einen eigenen Worker. Pro Tab bleiben höchstens zwei freie Worker erhalten; verschiedene Tabs teilen keinen Python-Interpreter.

Python-Fehler behalten den geladenen Worker beim jeweiligen Editor. Beim nächsten Start wird derselbe Worker wiederverwendet.

Abbrechen beendet den Worker sofort und initialisiert einen Ersatz. Das gilt auch beim Abbrechen einer Eingabe und stoppt Endlosschleifen zuverlässig. Andere gleichzeitig laufende Skripte behalten ihre Worker. Das abgebrochene Skript wird nicht automatisch erneut ausgeführt; seine Pakete müssen beim nächsten Start erneut geladen werden.

Skriptvariablen werden bei jeder Ausführung neu angelegt. Importierte Module, Dateien, das Arbeitsverzeichnis und anderer Interpreterzustand können zwischen Editoren erhalten bleiben. Worker mit noch laufenden asyncio-Tasks bleiben ebenfalls beim jeweiligen Editor. Erweiterungen müssen geplante asyncio-Callbacks und JavaScript-Timer vor Ende des Skripts selbst beenden; solche Hintergrundaktivitäten werden durch die Task-Prüfung nicht erkannt. Ein ausgefallener Worker wird beim nächsten Start eines Skripts ersetzt.

Interaktive Komponenten hinzufügen​

Das pyodide-code-Package kann erweitert werden - sowohl mit neuen, lokalen Python-Bibliotheken als auch mit interaktiven Komponenten, die in React geschrieben sind und mit Python kommunizieren können.

Mit 👉 webio reagiert Python auf Browser-Ereignisse dieser Komponenten und aktualisiert Spiele über @gameloop.

Interaktive Uhr​

Das 👉 Uhr-Beispiel zeigt, wie Python die Zeiger einer React-Komponente steuert.

Das 👉 Cookie-Klicker-Beispiel zeigt, wie Python mit @on_click und @on_key auf Browser-Ereignisse reagiert und mehrere Cookies unabhängig steuert.

Schneefall-Spiel​

Das 👉 Schneefall-Spiel verbindet eine Spielschleife mit zeitabhängiger Bewegung und Klick-Ereignissen.

Grid​

Das 👉 Grid-Beispiel zeigt einzelne Zellen mit Text sowie eine Animation mit einer Million Zellen und NumPy.

Erweiterungen registrieren​

Erweiterungen lassen sich als einzelner Ordner weitergeben. Voraussetzung ist das 👉 installierte Pyodide-Package. Die Beispielsseiten zur 👉 Uhr und zum 👉 Cookie-Klicker verlinken die kopierbaren Ordner.

  1. Kopiere den vollständigen Erweiterungsordner nach website/pyodide.

  2. Ergänze den Ordner im bestehenden Pyodide-Plugin:

    siteConfig.ts
    import type { PluginOptions } from '@tdev/pyodide-code/plugin';

    plugins: [
    [
    require.resolve('@tdev/pyodide-code/plugin'),
    {
    extensions: [
    './website/pyodide/meine-erweiterung'
    ]
    } satisfies PluginOptions
    ]
    ]
  3. Starte den Entwicklungsserver neu. Die Komponenten können danach wie in den Beispielen im MDX importiert werden.

Eigene Erweiterungen weitergeben​

Jeder Erweiterungsordner enthält eine extension.json mit den Einstiegspunkten:

website/pyodide/meine-erweiterung/extension.json
{
"name": "meine_erweiterung",
"worker": "./worker.ts",
"client": "./client.ts"
}
name
Eindeutiger Name der Erweiterung für die Nachrichtenübertragung. Er ist unabhängig von den Namen der bereitgestellten Python-Module.
worker
Einstiegspunkt für die Python-Module, relativ zum Erweiterungsordner.
client
Einstiegspunkt für den Browser-Store und dessen Nachrichtenverarbeitung, relativ zum Erweiterungsordner.

Ordnerpfade in extensions werden relativ zum Docusaurus-Seitenverzeichnis aufgelöst. Alternativ ist weiterhin ein Eintrag mit name, worker und client direkt in der Plugin-Konfiguration möglich; dessen Einstiegspfade gelten relativ zum Seitenverzeichnis oder können mit require.resolve() angegeben werden.

Im Erweiterungsordner liegen nur extension.json, client.ts, worker.ts, protocol.ts und README.md sowie der Ordner src. Dort sind Komponenten und Styles unter components, Modelle unter models und Stores unter store organisiert. Weitere Ordner wie worker, rendering und python kommen nach Bedarf hinzu. Innerhalb der Erweiterung werden relative Imports verwendet. Abhängigkeiten auf Teaching Dev werden über die Framework-Aliase importiert. So benötigt die empfangende Website keine Anpassung ihres SiteStore.

Das Plugin erzeugt getrennte Registries für Worker und Browser. worker.ts darf keine React-, DOM- oder Store-Abhängigkeiten importieren; gemeinsame Payload-Typen werden mit import type eingebunden. Beim Laden wird geprüft, ob die Worker-Einträge createModules() und die Browser-Einträge createStore() sowie handleMessage() bereitstellen. Ein ungültiger Export verhindert die Initialisierung und nennt die betroffene Erweiterung.

Python-Module im Worker​

defineWorkerExtension<Payload>() erhält eine Factory. Diese liefert ein Objekt, dessen Schlüssel die Python-Modulnamen sind. Eine Erweiterung kann mehrere Module bereitstellen; Modulnamen und Nachrichtentypen sind unabhängig voneinander. Doppelte Modulnamen und die reservierten Namen browser_input und webio werden mit einer Fehlermeldung abgelehnt.

Worker und Browser-Handler verwenden denselben Payload-Typ aus einer gemeinsamen protocol.ts. Die Typen prüfen den selbst geschriebenen Worker und Handler; es gibt keine automatische Laufzeitvalidierung des Payloads. Eine vollständige Umsetzung zeigt der 👉 Worker der Uhr-Erweiterung.

Der Factory-Kontext bietet:

editorId
ID des ausführenden Editors.
send(targetId, payload)
Sendet eine Nachricht an den Browser-Handler dieser Erweiterung. Das Framework ergänzt Editor-ID, Erweiterungsname und Zeitstempel. Der Payload muss durch strukturiertes Klonen übertragbar sein.
await sendAndWait(targetId, payload)
Sendet eine Nachricht und wartet, bis der synchrone Browser-Handler sie verarbeitet hat. Verwende dies für grosse Animationszustände, damit sich keine ausstehenden Bilder ansammeln. Der Aufruf aus einer beendeten Ausführung wird abgelehnt.
events.source(targetId)
Erzeugt eine Ereignisquelle für die aktuelle Ausführung. Ein Python-Handle stellt sie als events bereit, damit es mit den 👉 Handlern aus webio verwendet werden kann.

Für den Rückweg ruft der Browser-Store context.events.emit({ extension: context.name, targetId }, { type, payload }) auf. webio.run() meldet die Quellen der registrierten Python-Handler an; Ereignisse gehen an das dafür laufende Programm.

Die Factory wird bei jeder Ausführung aufgerufen und ihre Module werden vor dem Python-Code registriert. Damit funktionieren auch dynamische Imports. Der Worker wartet vor Abschluss auf ausstehende Nachrichten an die Browser-Handler. Ein Handler-Fehler wird als Ausführungsfehler im Editor angezeigt. Bereits zugestellte Änderungen werden nicht zurückgerollt.

Schlagen sowohl die Ausführung als auch deren Abschluss fehl, erscheint der ursprüngliche Ausführungsfehler zuerst. Zusätzliche Fehler beim Zustellen von Nachrichten oder beim Aufräumen werden darunter angezeigt. Die weiteren Aufräumschritte werden auch nach einem Fehler ausgeführt.

defineWorkerExtension(factory, pythonModules)
Registriert zusätzlich Python-Quelltexte aus einem Dictionary mit Modulnamen und Quelltext. Das Framework-Modul webio und die JavaScript-Module aller Erweiterungen stehen vor den Python-Quelltexten bereit. Python-Module werden in der angegebenen Reihenfolge registriert; benötigte Python-Module müssen davor stehen. Die Quellen werden vor der Paketinstallation und dem Benutzerprogramm ausgeführt; optionale Pakete wie NumPy sollten deshalb erst in den Funktionen importiert werden, die sie benötigen. Der 👉 Grid-Worker zeigt die Verbindung einer Python-Klasse mit einer JavaScript-Bridge.

Browser-Store und React​

defineClientExtension<Payload, Store>() verbindet die Store-Factory mit einem synchronen Nachrichten-Handler:

createStore(context)
Erzeugt den Browser-Store einmal beim Initialisieren des PyodideStore. Die Factory darf weder einen Python-Worker starten noch DOM-Zugriffe voraussetzen, da sie auch beim Rendern auf dem Server aufgerufen wird.
handleMessage(store, message)
Verarbeitet Nachrichten mit derselben Store-Instanz, die auch die React-Komponente verwendet. Der Handler läuft innerhalb einer MobX-Action.

Der Factory-Kontext enthält:

root
Root-Store der Website für benötigte Framework-Dienste.
name
Konfigurierter Erweiterungsname. Verwende ihn beim Erzeugen von Browser-Ereignisquellen, damit die Zuordnung auch bei einem geänderten Namen stimmt.
events
Ereignisverwaltung mit isConnected(source) und emit(source, { type, payload }). Eine Browser-Ereignisquelle hat die Form { extension: context.name, targetId }.

Die Nachricht enthält:

editorId
ID des ausführenden Editors.
targetId
Ziel-ID aus dem Aufruf von send(targetId, payload).
timeStamp
Zeitpunkt der Nachricht in Millisekunden seit der Unix-Epoche.
payload
Daten der Erweiterung mit dem gemeinsam definierten Payload-Typ.
useExtensionStore(extension)
Liefert der React-Komponente die Store-Instanz ihrer exportierten Client-Definition. Eine nicht konfigurierte Erweiterung führt zu einer Fehlermeldung.

Die 👉 Uhr-Erweiterung zeigt, wie Browser-Handler und React-Komponente dieselbe Store-Instanz verwenden.

Zustand und Lebensdauer​

Der PyodideStore besitzt pro konfigurierter Erweiterung eine Browser-Store-Instanz. Diese lebt mit der Anwendung und bleibt auch beim Navigieren sowie nach Ende oder Abbruch eines Skripts erhalten. Wie neue Python-Handles mit diesem Zustand umgehen, legt die Erweiterung fest; siehe 👉 Zustand der Uhr.

Worker können zwischen Editoren wiederverwendet werden. Die registrierten JavaScript-Module erhalten pro Ausführung einen neuen Kontext. Bereits importierte Python-Hilfsmodule können jedoch alte Referenzen auf Module oder Handles behalten; deren Cache wird nicht rekursiv geleert. Solche Hilfsmodule sollen das Modul oder die Factory innerhalb der pro Ausführung aufgerufenen Funktion neu importieren und erst dann Handles anfordern. Auf Modulebene gespeicherte Factory-Referenzen bleiben sonst an den alten, bereits geschlossenen Kontext gebunden.

Erweiterungen müssen eigene Timer und Hintergrundaufgaben vor Ende des Skripts beenden. Nachrichten nach Abschluss werden verworfen. Abbrechen beendet den Worker; ausstehende Nachrichten einer abgebrochenen oder ersetzten Ausführung werden durch die bestehende Ausführungsprüfung ignoriert. Das Framework stellt keine automatische Ressourcenverwaltung oder Hintergrunddienste für Erweiterungen bereit.

Installation​

packages/tdev/pyodide-code

Kopiere des packages/tdev/pyodide-code-Verzeichnis in das tdev-website/website/packages-Verzeichnis oder über updateTdev.config.yaml hinzufügen.

  1. Pyodide-Plugin und Dokument-Provider im siteConfig.ts registrieren:
    siteConfig.ts
    const getSiteConfig: SiteConfigProvider = () => {
    return {
    plugins: [
    require.resolve('@tdev/pyodide-code/plugin'),
    ],
    apiDocumentProviders: [
    require.resolve('@tdev/pyodide-code/register'),
    ]
    };
    };
  2. ServiceWorker hinzufügen:
    Damit Eingaben von Benutzer:innen abgewartet werden können, braucht es einen Servie-Worker. Dieser muss direkt in den statischen Ordner 👉 static/pyodide.sw.js kopiert werden. Bei Paket-Updates muss auch diese Datei aktualisiert werden, damit Frontend und ServiceWorker dasselbe Eingabeprotokoll verwenden.

    static/pyodide.sw.js

    pyodide.sw.js
    const PY_INPUT = 'PY_INPUT';
    const PY_CLIENT_ID = 'PY_CLIENT_ID';
    const PY_AWAIT_INPUT = 'PY_AWAIT_INPUT';
    const PY_STDIN_ROUTE = '/py-get-input/';
    const PY_CANCEL_INPUT = 'PY_CANCEL_INPUT';
    const PY_CANCEL_ALL = 'PY_CANCEL_ALL';
    const INPUT_POLL_TIMEOUT = 10000;
    const INPUT_RECONNECT_TIMEOUT = 60000;

    self.addEventListener('install', (event) => {
    event.waitUntil(self.skipWaiting());
    });

    self.addEventListener('activate', (event) => {
    event.waitUntil(self.clients.claim());
    });

    const requests = new Map();

    const completeRequest = (requestId, response) => {
    const request = requests.get(requestId);
    if (!request || request.response) {
    return;
    }
    if (!request.resolve) {
    // Preserve replies and cancellation received between polls.
    request.response = response;
    return;
    }
    clearTimeout(request.timer);
    requests.delete(requestId);
    request.resolve(response);
    };

    const pollInput = (requestId, request) => {
    clearTimeout(request.timer);
    if (request.response) {
    requests.delete(requestId);
    return request.response;
    }
    return new Promise((resolve) => {
    request.resolve = resolve;
    // Keep each fetch below browser service-worker event time limits.
    request.timer = setTimeout(() => {
    request.resolve = null;
    request.timer = setTimeout(() => requests.delete(requestId), INPUT_RECONNECT_TIMEOUT);
    resolve(new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }));
    }, INPUT_POLL_TIMEOUT);
    });
    };

    self.addEventListener('message', (event) => {
    const clientId = event.source?.id;
    if (!clientId) {
    return;
    }
    if (event.data.type === PY_CLIENT_ID) {
    event.ports[0]?.postMessage({ clientId });
    return;
    }
    if (event.data.type === PY_CANCEL_ALL) {
    for (const [requestId, request] of requests) {
    if (request.clientId === clientId) {
    completeRequest(requestId, new Response('Run cancelled', { status: 410 }));
    }
    }
    return;
    }
    const request = requests.get(event.data.requestId);
    if (!request || request.clientId !== clientId) {
    return;
    }
    if (event.data.type === PY_INPUT) {
    completeRequest(
    event.data.requestId,
    new Response(event.data.value, { status: 200, headers: { 'Cache-Control': 'no-store' } })
    );
    } else if (event.data.type === PY_CANCEL_INPUT) {
    completeRequest(event.data.requestId, new Response('Run cancelled', { status: 410 }));
    }
    });

    self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);
    if (url.origin !== self.location.origin || url.pathname !== PY_STDIN_ROUTE) {
    return;
    }
    const id = url.searchParams.get('id');
    const clientId = url.searchParams.get('clientId');
    const requestId = url.searchParams.get('requestId');
    const executionId = Number(url.searchParams.get('executionId'));
    if (!id || !clientId || !requestId || !Number.isSafeInteger(executionId) || executionId < 1) {
    event.respondWith(new Response('Invalid input request', { status: 400 }));
    return;
    }
    const existingRequest = requests.get(requestId);
    if (existingRequest) {
    if (
    existingRequest.clientId !== clientId ||
    existingRequest.id !== id ||
    existingRequest.executionId !== executionId ||
    existingRequest.resolve
    ) {
    event.respondWith(new Response('Conflicting input request', { status: 409 }));
    return;
    }
    event.respondWith(pollInput(requestId, existingRequest));
    return;
    }
    const request = { clientId, id, executionId, resolve: null, response: null, timer: null };
    requests.set(requestId, request);
    event.respondWith(pollInput(requestId, request));
    event.waitUntil(
    self.clients
    .get(clientId)
    .then((client) => {
    if (!client || client.type !== 'window') {
    completeRequest(requestId, new Response('Input owner closed', { status: 410 }));
    return;
    }
    client.postMessage({
    type: PY_AWAIT_INPUT,
    id,
    requestId,
    executionId,
    prompt: url.searchParams.get('prompt') || ''
    });
    })
    .catch(() => {
    completeRequest(requestId, new Response('Input owner unavailable', { status: 410 }));
    })
    );
    });

Danach muss erneut installiert werden:

yarn install

Pyodide-Version konfigurieren​

Die exakte pyodide-Abhängigkeit in package.json legt die Laufzeitversion fest. config.ts leitet daraus PYODIDE_VERSION und PYODIDE_INDEX_URL ab. Worker und Main-Thread-Hook verwenden denselben ES-Modul-Loader und dieselbe CDN-Adresse. Das Docusaurus-Plugin prüft beim Start, dass die Version exakt angegeben ist, und konfiguriert den für Pyodide erforderlichen Modul-Worker.

Zum Aktualisieren auf eine neue stabile Version:

yarn workspace @tdev/pyodide-code add pyodide@<version> --exact

Die geänderten Dateien package.json und yarn.lock müssen gemeinsam übernommen werden.

Docusaurus- und Rspack-Updates prüfen​

Der ModuleWorker-Konstruktor, der Chunk-Name pyodide-worker und die Parser- und SplitChunks-Konfiguration in plugin.ts gehören zusammen. Sie erhalten den ES-Modul-Worker und verhindern gemeinsame Chunks, die Rspack sonst über importScripts() laden würde. Nach Updates von Docusaurus, Rspack oder Pyodide muss ein Produktions-Build im Browser geprüft werden: Der Worker muss als Modul starten und Python einschliesslich Paketimporten und Eingaben ausführen können.