PyodideでブラウザPython実行環境を作る|Windows 11でゼロから構築

Pyodideで作るブラウザPython実行環境 Pyodide

PythonをインストールしていないPCでも、Pyodideを使えばWebAssembly版のPythonをブラウザ上で実行できます。本記事ではWindows 11で3つのファイルを作り、Pythonコードを入力して実行できるミニ実行環境を完成させます。Pyodideの仕組みを先に知りたい方は、「Pyodideとは?Pythonをブラウザで動かす仕組みと使い方」をご覧ください。

公式の最小例はloadPyodide()で初期化してrunPython()を呼ぶだけですが、実際に利用者が操作する画面では、初期化状態・実行結果・エラー表示・二重操作防止も必要です。今回はそこまで含めて実装します。

この記事で作るもの

  • ブラウザ上でPythonコードを入力できるエディター
  • Pyodideの初期化状態とローディング表示
  • Pythonコードを実行するボタン
  • print()などの標準出力を表示する領域
  • 構文エラーや実行時例外を表示する領域
  • 初期化中・実行中のボタン無効化と連打防止

完成後は、次のようなPythonコードを入力してブラウザだけで実行できます。

name = "Pyodide"
for i in range(3):
    print(f"{i + 1}: Hello, {name}!")

sum(range(1, 11))

Windows 11でプロジェクトを作成する

任意の場所にpyodide-runnerフォルダーを作成し、Visual Studio Codeなどのエディターで開きます。フォルダーの中に次の3ファイルを用意してください。

pyodide-runner/
├─ index.html
├─ style.css
└─ app.js

PowerShellで作る場合は、次のコマンドでも準備できます。

mkdir pyodide-runner
cd pyodide-runner
New-Item index.html, style.css, app.js -ItemType File

HTML、CSS、JavaScriptの役割

ファイル役割
index.html入力欄、実行ボタン、状態表示、結果表示を配置し、PyodideをCDNから読み込む
style.css画面のレイアウト、ローディングアニメーション、状態別の色を設定する
app.jsPyodideの初期化、Python実行、標準出力・例外の表示、ボタン制御を担当する

3つに分けることで、画面構造・見た目・処理をそれぞれ修正しやすくなります。ビルドツールやnpmは使いません。

index.htmlを作成する

index.htmlに次の内容を貼り付けます。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Browser Python Runner</title>
  <link rel="stylesheet" href="style.css">

  <script
    src="https://cdn.jsdelivr.net/pyodide/v314.0.5/full/pyodide.js"
    defer
  ></script>
  <script src="app.js" defer></script>
</head>
<body>
  <main class="app">
    <header class="app__header">
      <p class="eyebrow">Pyodide Playground</p>
      <h1>Browser Python Runner</h1>
      <p>入力したPythonコードを、このブラウザ内で実行します。</p>
    </header>

    <div id="status" class="status" data-state="loading" role="status" aria-live="polite">
      <span class="spinner" aria-hidden="true"></span>
      <span id="statusText">Pyodideを初期化しています…</span>
    </div>

    <label for="codeInput">Pythonコード</label>
    <textarea id="codeInput" spellcheck="false">name = "Pyodide"
for i in range(3):
    print(f"{i + 1}: Hello, {name}!")

sum(range(1, 11))</textarea>

    <button id="runButton" type="button" disabled>Pythonを実行</button>

    <section class="result">
      <h2>実行結果</h2>
      <pre id="output" aria-live="polite">初期化が完了すると実行できます。</pre>
    </section>
  </main>
</body>
</html>

CDNからPyodideを読み込む

次のscriptタグがPyodide本体をCDNから読み込む部分です。本記事では確認時点の安定版であるv314.0.5を明示しています。

<script
  src="https://cdn.jsdelivr.net/pyodide/v314.0.5/full/pyodide.js"
  defer
></script>

バージョン番号を固定しておくと、CDN側の更新で突然動作が変わるリスクを抑えられます。新しいプロジェクトで使うときは、Pyodide公式ドキュメントで最新の安定版を確認してください。

style.cssを作成する

style.cssに次の内容を貼り付けます。状態表示のdata-state属性を使い、初期化中・準備完了・実行中・エラーで色を切り替えます。

:root {
  color-scheme: dark;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  background: #07111f;
  color: #e8eef8;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  padding: 32px 16px;
  background:
    radial-gradient(circle at top left, #193b67 0, transparent 35%),
    #07111f;
}

.app {
  width: min(900px, 100%);
  margin: 0 auto;
  padding: clamp(20px, 4vw, 40px);
  border: 1px solid #29405f;
  border-radius: 20px;
  background: rgba(9, 24, 42, 0.92);
  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
}

.app__header h1 {
  margin: 4px 0 8px;
  font-size: clamp(2rem, 6vw, 3.5rem);
}

.eyebrow {
  margin: 0;
  color: #77bdfb;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

label,
.result h2 {
  display: block;
  margin: 24px 0 8px;
  font-size: 1rem;
  font-weight: 700;
}

textarea,
#output {
  width: 100%;
  border: 1px solid #385275;
  border-radius: 12px;
  background: #030a13;
  color: #dcecff;
  font: 15px/1.7 Consolas, "Courier New", monospace;
}

textarea {
  min-height: 280px;
  padding: 16px;
  resize: vertical;
}

#output {
  min-height: 150px;
  max-height: 360px;
  margin: 0;
  padding: 16px;
  overflow: auto;
  white-space: pre-wrap;
}

#output.error {
  border-color: #ff7b8a;
  color: #ffd2d8;
}

button {
  width: 100%;
  margin-top: 16px;
  padding: 14px 20px;
  border: 0;
  border-radius: 12px;
  background: #2d8cff;
  color: #fff;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
}

button:hover:not(:disabled) {
  background: #1476e8;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.status {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 24px;
  padding: 12px 14px;
  border: 1px solid #385275;
  border-radius: 12px;
  background: #10243e;
}

.status[data-state="ready"] {
  border-color: #2fbf8f;
  background: #0e392f;
}

.status[data-state="error"] {
  border-color: #ff7b8a;
  background: #421d27;
}

.spinner {
  width: 18px;
  height: 18px;
  border: 2px solid rgba(255, 255, 255, 0.25);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

.status[data-state="ready"] .spinner,
.status[data-state="error"] .spinner {
  display: none;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

app.jsを作成する

app.jsには初期化と実行処理をまとめます。次のコードをそのまま貼り付けてください。

const codeInput = document.getElementById("codeInput");
const runButton = document.getElementById("runButton");
const output = document.getElementById("output");
const status = document.getElementById("status");
const statusText = document.getElementById("statusText");

let pyodide = null;
let isRunning = false;

function setStatus(message, state) {
  statusText.textContent = message;
  status.dataset.state = state;
}

function clearOutput() {
  output.textContent = "";
  output.classList.remove("error");
}

function appendOutput(message, type = "stdout") {
  const text = String(message).replace(/\n?$/, "\n");
  output.textContent += text;

  if (type === "stderr") {
    output.classList.add("error");
  }

  output.scrollTop = output.scrollHeight;
}

async function initializePyodide() {
  setStatus("Pyodideを初期化しています…", "loading");
  runButton.disabled = true;

  try {
    pyodide = await loadPyodide();

    pyodide.setStdout({
      batched: (message) => appendOutput(message, "stdout"),
    });

    pyodide.setStderr({
      batched: (message) => appendOutput(message, "stderr"),
    });

    clearOutput();
    output.textContent = "準備ができました。Pythonコードを実行してください。";
    setStatus("準備完了", "ready");
    runButton.disabled = false;
  } catch (error) {
    clearOutput();
    appendOutput(`初期化に失敗しました: ${error.message ?? error}`, "stderr");
    setStatus("初期化エラー", "error");
  }
}

async function executePython() {
  if (!pyodide || isRunning) {
    return;
  }

  isRunning = true;
  runButton.disabled = true;
  clearOutput();
  setStatus("Pythonを実行しています…", "running");

  let result;

  try {
    result = await pyodide.runPythonAsync(codeInput.value, {
      filename: "editor.py",
    });

    if (result !== undefined && result !== null) {
      appendOutput(result);
    }

    setStatus("実行完了", "ready");
  } catch (error) {
    appendOutput(error.message ?? error, "stderr");
    setStatus("実行エラー", "error");
  } finally {
    if (result && typeof result.destroy === "function") {
      result.destroy();
    }

    isRunning = false;
    runButton.disabled = !pyodide;
  }
}

runButton.addEventListener("click", executePython);
initializePyodide();

loadPyodide()の使い方

pyodide.jsの読み込みが完了すると、グローバル関数loadPyodide()を呼び出せます。この関数は非同期でPyodide本体やWebAssemblyを読み込み、初期化済みのAPIを返します。

const pyodide = await loadPyodide();

初回は数MB以上のファイルを取得するため、回線やPC性能によって数秒かかります。そこで、初期化中はステータスとスピナーを表示し、実行ボタンを無効にしています。

runPython()とrunPythonAsync()の違い

API特徴向いている用途
runPython()同期的に実行し、その場で結果を返す。Python側のトップレベルawaitは使えない短い計算、単純な式、同期処理だけのコード
runPythonAsync()Promiseを返し、トップレベルawaitを含むPythonコードも実行できるUIからの実行、非同期処理を含むコード、将来の機能拡張

今回は実行前後でボタンや状態表示を切り替えやすく、将来Python側でawaitを使えるようにrunPythonAsync()を採用しました。

注意: runPythonAsync()を使っても、CPU負荷の高いPython処理が自動で別スレッドに移るわけではありません。重い計算を実行すると画面操作が固まることがあります。本格的な実行環境では、PyodideをWeb Worker内で動かす構成を検討してください。

Pythonの標準出力を画面に表示する

Pythonのprint()は標準出力へ書き込みます。PyodideのsetStdout()batchedコールバックを指定すると、出力された行をJavaScriptで受け取れます。

pyodide.setStdout({
  batched: (message) => appendOutput(message, "stdout"),
});

同様にsetStderr()も設定しているため、Pythonが標準エラー出力へ書き込んだ内容も結果欄へ表示できます。

Python実行時の例外を画面に表示する

構文エラー、ZeroDivisionErrorNameErrorなどが発生すると、runPythonAsync()が返すPromiseは失敗します。try...catchで捕捉し、例外メッセージを出力欄に表示します。

try {
  const result = await pyodide.runPythonAsync(codeInput.value, {
    filename: "editor.py",
  });
} catch (error) {
  appendOutput(error.message ?? error, "stderr");
  setStatus("実行エラー", "error");
}

filename: "editor.py"を渡しているため、トレースバック上でも入力コードをファイルとして識別しやすくなります。動作確認では、入力欄にprint(10 / 0)と書いて実行してみてください。

実行中の二重操作を防止する

実行開始時にisRunningtrueにし、ボタンも無効化します。連打や別の経路から関数が再度呼ばれても、冒頭の条件で処理を終了できます。

if (!pyodide || isRunning) {
  return;
}

isRunning = true;
runButton.disabled = true;

finallyで必ず状態を元に戻すため、正常終了でも例外発生でもボタン制御が破綻しません。

ローカルWebサーバーで起動する

index.htmlをダブルクリックしてfile://で開くより、開発中はhttp://localhostのローカルWebサーバー経由で開くのがおすすめです。ブラウザのセキュリティ制約、相対パス、外部ファイル取得、将来追加するデータファイルなどを、本番のWebサイトに近い条件で確認できるためです。

PCにPythonがインストールされている場合は、PowerShellでプロジェクトフォルダーへ移動し、次を実行します。

py -m http.server 8000

pyコマンドが使えない場合は、python -m http.server 8000も試せます。その後、ChromeまたはEdgeでhttp://localhost:8000を開いてください。PCにPythonを入れたくない場合は、Visual Studio CodeのLive Server拡張機能を使う方法もあります。

動作確認

  1. ページを開くと「Pyodideを初期化しています…」とスピナーが表示される
  2. 初期化が終わると「準備完了」に変わり、実行ボタンが有効になる
  3. サンプルコードを実行するとprint()の3行と最後の式の結果55が表示される
  4. print(10 / 0)を実行すると例外が赤い結果欄に表示される
  5. 実行中はボタンが無効になり、連打しても二重実行されない

うまく動かないときの確認ポイント

  • loadPyodide is not defined: PyodideのCDN URL、scriptタグの順番、deferの有無を確認します。
  • 初期化が終わらない: 開発者ツールのNetworkとConsoleを確認し、CDNへのアクセスがブロックされていないか調べます。
  • 外部パッケージをimportできない: 標準ライブラリ以外は別途読み込みが必要です。loadPackagesFromImports()micropipを利用します。
  • 画面が長時間固まる: 重いPython処理がメインスレッドを占有しています。Web Worker化を検討します。

まとめ

Pyodideを使うと、サーバーへPythonコードを送らず、ブラウザ内でPython実行環境を作れます。最小構成から一歩進めるときは、loadPyodide()の待ち時間、標準出力・標準エラー出力、例外処理、ボタンの無効化をセットで設計するのがポイントです。

今回の3ファイルを土台に、コード履歴、サンプル選択、外部パッケージの読み込み、Web Workerによる実行などを追加すれば、より本格的なブラウザPython環境へ発展させられます。

参考リンク

コメント

タイトルとURLをコピーしました