PyCell example
What is PyCell?
Pyodideを使ってセルでpythonスクリプトを編集・実行・描画するためのWeb Componentです。
PyodideはWebAssembly化されたpythonの実行環境で、pythonスクリプトの実行は全てブラウザ上で完結します。このページではPyCellの使用方法を解説していきますが、予めpyodideのドキュメントを一読することをお勧めします。
基本的な使い方
完全な例は以下の通りです。
<!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のエラーメッセージを理由としてリジェクトされます。
- 成功時:以下のような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('<', '<')
}
}
self.postMessage({
results: results ? String(results) : "",
log: out.log
});
}
catch (error) {
console.dir(error)
self.postMessage(
{ error }
);
}
}
...
以下の例では、pythonスクリプトの標準出力(print(...)
)と、最後に評価された式の結果(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.
描画(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('<', '<')
} 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
関数を使用することができます。
ところがchrome系のブラウザでPyodideのVersion 0.18.0を使う場合、この関数では2,300kbを超えるサイズのファイルをダウンロードすることができません。
→Version 0.18.1で修正されました。
以下エラーなく実行できているはずですが、文章は修正していません。
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 await js.Function(f''' return 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