What is PyCell?

Pyodideを使ってセルでpythonスクリプトを編集・実行・描画するためのWeb Componentです。
PyodideはWebAssembly化されたpythonの実行環境で、pythonスクリプトの実行は全てブラウザ上で完結します。このページではPyCellの使用方法を解説していきますが、予めpyodideのドキュメントを一読することをお勧めします。

WebComponents/py_cell at main · arakaki-tokyo/WebComponents
Contribute to arakaki-tokyo/WebComponents development by creating an account on GitHub.

基本的な使い方

完全な例は以下の通りです。

<!DOCTYPE html>
<html>

<head>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/styles/default.min.css" integrity="sha512-3xLMEigMNYLDJLAgaGlDSxpGykyb+nQnJBzbkQy2a0gyVKL2ZpNOPIj1rD8IPFaJbwAgId/atho1+LBpWu5DhA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
    <!-- pyodide -->
    <script defer id="pyodideJs" src="https://cdn.jsdelivr.net/pyodide/v0.18.0/full/pyodide.js"></script>
    <script>
        document.getElementById("pyodideJs").onload = async e => {
            window.pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.18.0/full/' });
        }
    </script>
    <script type='module'>
        import { PyCell } from 'https://arakaki-tokyo.github.io/WebComponents/py_cell/py_cell.mjs';
        customElements.define("py-cell", PyCell);
    </script>
</head>

<body>
    <py-cell>
        for i in range(10):
            print(i)
    </py-cell>
    <p>読み込み後即時実行する</p>
    <py-cell data-execute>
        for i in range(10):
            print(i)
    </py-cell>

</body>

</html>

See the Pen PyCell_Pyodide-cdn by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

Syntax HighLighter

PyCellは内部でhighlight.jsを使用します。
ハイライトのテーマはユーザーが指定する必要があります。
埋め込むページで別のSyntax HighLighterを使用している場合には、data-highlight属性にfalseを設定することでハイライトを無効化できます。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/styles/default.min.css" integrity="sha512-3xLMEigMNYLDJLAgaGlDSxpGykyb+nQnJBzbkQy2a0gyVKL2ZpNOPIj1rD8IPFaJbwAgId/atho1+LBpWu5DhA==" crossorigin="anonymous" referrerpolicy="no-referrer" />

Pyodideのロード

Pyodideをロードします。
この例ではpythonスクリプトの実行時に、pyodideという名前で参照できるグローバルなpyodide moduleがある必要があります。
PyodideのVersion 0.18.0では自動でグローバルへの設定をしなくなったので、以下のようにユーザー自身が設定しなければなりません。

    <script defer id="pyodideJs" src="https://cdn.jsdelivr.net/pyodide/v0.18.0/full/pyodide.js"></script>
    <script>
        document.getElementById("pyodideJs").onload = async e => {
            window.pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.18.0/full/' });
        }
    </script>

また、このようにPyCellから黙示的にpyodideを参照する場合、data-execute属性は無視されます。(例の2つ目のセルは即時実行されません。)

PyCellのカスタムエレメント定義

PyCellをカスタムエレメントとして定義します。
カスタムエレメントの名前(タグ名)はユーザーが自由に設定できます。

    <script type='module'>
        import { PyCell } from 'https://arakaki-tokyo.github.io/WebComponents/py_cell/py_cell.mjs';
        customElements.define("py-cell", PyCell);
    </script>

即時実行する

ページ読み込み後にPyCellを即時実行するためには、data-execute属性とdata-pyodide属性を設定します。

上の例を以下のように変更します。

@@ -6,9 +6,12 @@
     <!-- pyodide -->
     <script defer id="pyodideJs" src="https://cdn.jsdelivr.net/pyodide/v0.18.0/full/pyodide.js"></script>
     <script>
-        document.getElementById("pyodideJs").onload = async e => {
-            window.pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.18.0/full/' });
-        }
+        const Pyodide = new Promise((resolve, reject) => {
+            document.getElementById("pyodideJs").onload = async e => {
+                window.pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.18.0/full/' });
+                resolve(pyodide);
+            }
+        })
     </script>
     <script type='module'>
         import { PyCell } from 'https://arakaki-tokyo.github.io/WebComponents/py_cell/py_cell.mjs';
@@ -22,7 +25,7 @@
             print(i)
     </py-cell>
     <p>読み込み後即時実行する</p>
-    <py-cell data-execute>
+    <py-cell data-execute data-pyodide='Pyodide'>
         for i in range(10):
             print(i)
     </py-cell>

See the Pen PyCell_Pyodide-cdn_immediate-execution by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

data-pyodide属性

メインスレッドでPyodideを実行している場合、data-pyodide属性にはpyodide moduleで解決するグローバルなプロミスオブジェクトを設定します。


Pyodideをワーカースレッドで実行する

Pyodideをweb workerで実行する場合、PyCellにdata-use-worker属性とdata-pyodide属性を設定します。
メインスレッドで実行する場合と異なり、data-pyodide属性は必須です。

data-use-worker属性

web workerで実行する場合にtrueを設定します。

data-pyodide属性(web worker)

Pyodideをweb workerで実行する方法については公式サイトを参照してください。
PyCellはその詳細について関知しませんが、data-pyodide属性に設定するグローバルな関数は以下のようなインターフェイスを実装している必要があります。

  • 引数:(string) pythonスクリプト
  • 戻り値:(Promise)
    • 成功時:以下のようなpython実行結果のオブジェクトで解決します。resultsの扱い方については描画の項(後述)でも詳述します。
      {log -> "stdout strings", results -> "evaluation results for last expression of the cell"}
      
    • 失敗時:pythonのエラーメッセージを理由としてリジェクトされます。

例として、このページで実行しているPyodideの上記に関連する部分は以下の通りです。

html

<py-cell data-use-worker data-pyodide="pyodide.exec">
    sum = 0
    for i in range(10):
        print(i)
        sum += i

    f'{sum=}'
</py-cell>
<script type='module'>
    import * as pyodide from '/assets/add/pyodide/py_worker.mjs';
    pyodide.load(
        ["matplotlib"], 
        ["matplotlib"]);
    globalThis.pyodide = pyodide;

    import { PyCell } from 'https://arakaki-tokyo.github.io/WebComponents/py_cell/py_cell.mjs';
    customElements.define("py-cell", PyCell);
</script>

py_worker.mjs: web workerのラッパー

const dir = import.meta.url.match(/^.*\//g)[0];
const pyodideWorker = new Worker(`${dir}py_worker.js`);
let jobs = Promise.resolve();
export function load(packages = [], init = []) {
    pyodideWorker.postMessage({ function: "load", args: { packages, init } })
}

export async function exec(code) {
    jobs = jobs.catch(() => undefined).then(() => new Promise((resolve, reject) => {
        pyodideWorker.onmessage = e => {
            if (e.data.error) {
                reject(e.data.error);
            } else {
                resolve(e.data);
            }
        }
        pyodideWorker.postMessage({ function: "exec", args: { code } })
    }))
    return jobs
}

py_worker.js: web worker

...
async function exec({ code }) {
    const pyodide = "Pyodide" in self ? await Pyodide : null;
    if (!pyodide) {
        self.postMessage({
            error: "pyodide has not been loaded"
        });
        return;
    }

    try {
        await pyodide.runPythonAsync('if not "sys" in dir(): import sys');
        const sys = pyodide.globals.get('sys');
        const out = new OutBuffer()
        sys.stdout = out;

        let results = await pyodide.runPythonAsync(code);
        if (pyodide.isPyProxy(results)) {
            if ("_repr_html_" in results) {
                results = results._repr_html_()
            } else if ("__repr__" in results) {
                results = results.__repr__().replaceAll('<', '&lt;')
            }
        }
        self.postMessage({
            results: results ? String(results) : "",
            log: out.log
        });
    }
    catch (error) {
        console.dir(error)
        self.postMessage(
            { error }
        );
    }
}
...

以下の例では、pythonスクリプトの標準出力(print(...))と、最後に評価された式の結果(f'{sum=}')が実行結果として表示されます。
エラーの場合はエラーメッセージが表示されるので試してみてください。

sum = 0 for i in range(10): print(i) sum += i f'{sum=}'

描画(Pyodideをメインスレッドで使用する場合)

PyCellにはJupyterのように包括的かつシームレスな描画の仕組みはありませんが、以下の方法で描画パッケージを使用することができます。

matplotlib

PyodideのVersion 0.18.0ではPyodide用のmatplotlibレンダラーが追加されました

有効化するにはimport前に環境変数で設定します。

import os
os.environ['MPLBACKEND'] = 'module://matplotlib.backends.html5_canvas_backend

See the Pen PyCell_matplotlib by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

_repr_html_メソッドを持つオブジェクト

PyCellは、pythonスクリプトの最後に評価される式の結果が_repr_html_メソッドを持つオブジェクトだった場合、_repr_html_メソッドを実行して結果のHTMLをレンダリングします。

下の例では、

  • plotly.graph_objects.Figure_repr_html_を実行して描画しています。showメソッドを実行しても描画されません。
  • pandas.DataFrame_repr_html_を実行してtableタグを描画しています。DataFrameのtable用スタイルはPyCellに組み込まれていますが、利用するページのCSSによって上書きされる可能性があります。

See the Pen PyCell__repr_html_ by Arakaki Tokyo (@arakaki-tokyo) on CodePen.

最後に評価された文字列をHTMLとしてレンダリング

PyCellは、pythonスクリプトの最後に評価される式の結果が文字列だった場合、その文字列をHTMLとしてレンダリングしようとします。
これはユーザーにとって意図しない結果となる可能性がありますが、_repr_html_メソッドを持たない描画ライブラリなどでも使用することができます。

下の例では、

  • bokeh.plotting.Figure_repr_html_メソッドを持ちますが、グラフではなくオブジェクトのプロパティを一覧するHTMLを出力します[1]。代わりにbokeh.embed.file_htmlを使用してグラフのHTML文字列を出力させます。
  • plotlyの描画は前述の通りですが、HTMLとして出力することでより細かい設定をすることができます。

See the Pen PyCell__render-html by Arakaki Tokyo (@arakaki-tokyo) on CodePen.


  1. Add support for _repr_html_ to HasProps and Model · Issue #5164 · bokeh/bokeh ↩︎


描画(Pyodideをworkerスレッドで使用する場合)

前述のmatplotlibレンダラーは直接documentのDOMを操作しているため、Pyodideをweb workerで動かしている場合には使用することができません。
また、PyCellはweb workerで実行されたPyodideの結果{log, results}を文字列として受け取り、resultsをすべてHTMLとしてレンダリングしようとします。

matplotlib

matplotlibで描画するための回避策として、matplotlib.pyplot.showメソッドを上書きしてHTML文字列を返すようにします。

参考:Matplotlib backend in a web worker · Issue #1518 · pyodide/pyodide

import os

os.environ['MPLBACKEND'] = 'AGG'

import matplotlib.pyplot
import base64
from io import BytesIO

def ensure_matplotlib_patch():

    def show():
        buf = BytesIO()
        matplotlib.pyplot.savefig(buf, format='png')
        buf.seek(0)
        # encode to a base64 str
        img = base64.b64encode(buf.read()).decode('utf-8')
        matplotlib.pyplot.clf()

        return f'<img src="data:image/png;base64,{img}">'

    matplotlib.pyplot.show = show

ensure_matplotlib_patch()  
from matplotlib import pyplot as plt plt.figure() plt.plot([1,2,3]) plt.show()

web workerからHTML文字列を返す

最後に評価した式の結果が_repr_html_メソッドを持つ場合に、Pyodideをメインスレッドで動かす場合と同様の結果を表示するためには、web worker側で以下のような処理が必要です。(再掲)

await pyodide.loadPackagesFromImports(code);
let results = await pyodide.runPythonAsync(code);
if (pyodide.isPyProxy(results)) {
    if ("_repr_html_" in results) {
        results = results._repr_html_()
    } else if ("__repr__" in results) {
        results = results.__repr__().replaceAll('<', '&lt;')
    } else {
        results = results;
    }
}
self.postMessage({
    results: results ? String(results) : "",
    log: out.log
});
import micropip micropip.install('plotly==5.0.0') # スクリプトの最後にFigureオブジェクトを評価する際に必要 import webbrowser webbrowser.get = lambda:None import pandas as pd import plotly.express as px xs = range(-30, 30) ys = list(map(lambda x: x**3, xs)) fig1 = px.scatter(x=xs, y=ys) fig1.update_layout( margin={'l':10, 'r':20, 'b':10, 't':20,'pad':0} ) print(type(fig1)) fig1 import plotly.data as data df = data.iris() print(type(df)) df fig1.to_html(config={'scrollZoom': True}) from bokeh.plotting import figure, show from bokeh.resources import CDN from bokeh.embed import file_html # prepare some data x = [1, 2, 3, 4, 5] y = [6, 7, 2, 4, 5] # create a new plot with a title and axis labels p = figure(title="Simple line example", x_axis_label="x", y_axis_label="y") # add a line renderer with legend and line thickness p.line(x, y, legend_label="Temp.", line_width=2) # show the results # show(p) file_html(p, CDN)

【余談】PyodideでのHTTP(S)リクエスト

現時点では、Pyodideから直接urllibなどを使ってHTTP通信を行うことはできません。
参考:Roadmap — Version 0.18.0

代わりにPyodideのpython側APIとしてpyodide.open_url関数を使用することができます。

import pyodide import pandas as pd iris_url = 'https://raw.githubusercontent.com/plotly/datasets/master/iris.csv' pd.read_csv(pyodide.open_url(iris_url))

ところがchrome系のブラウザでPyodideのVersion 0.18.0を使う場合、この関数では2,300kbを超えるサイズのファイルをダウンロードすることができません。

earthquake_url = 'https://raw.githubusercontent.com/plotly/datasets/master/earthquakes-23k.csv' # JsException: RangeError: Maximum call stack size exceeded pyodide.open_url(earthquake_url)

open_url関数の内部では単にJSのxhrを呼んでいるだけなのですが、どうもレスポンスをJS側からpython側に持ってくる際の型変換でエラーが生じているようです。

参考:Type translations — Version 0.18.0

Javascript to Python translations occur:

  • when importing from the js module
  • when passing arguments to a Python function called from Javascript
  • when returning the result of a Javascript function called from Python
  • when accessing an attribute of a JsProxy

そのため、以下のようにレスポンスを一旦JSでグローバル変数に設定してpython側からアクセスしようとしても、同様のエラーが生じます。

earthquake_url = 'https://raw.githubusercontent.com/plotly/datasets/master/earthquakes-23k.csv' import js js.Function(f''' fetch("{earthquake_url }") .then(res => res.text()) .then(text => globalThis.text = text) ''')()

js.text

前のバージョンではこのようなエラーは生じないので0.18.0でのバグだと思われますが、差し当たってchromeでファイルをダウンロードする際にはbufferを使って回避することができます。

async def urlopen2(url): import js import io await js.Function(f''' return fetch("{url}") .then(res => res.text()) .then(text => {{ globalThis.resBody = Int16Array.from(text, char => char.charCodeAt(0)) }}) ''')() return io.StringIO(js.resBody.to_py().tobytes().decode('utf-16')) earthquake_url = 'https://raw.githubusercontent.com/plotly/datasets/master/earthquakes-23k.csv' import pandas as pd df = pd.read_csv(await urlopen2(earthquake_url)) import plotly.express as px fig = px.density_mapbox(df, lat='Latitude', lon='Longitude', z='Magnitude', radius=10, center=dict(lat=0, lon=180), zoom=0, mapbox_style="stamen-terrain") fig