Pyodideとは
WebAssembly技術を用いてWEBブラウザ上でPython3を動かすことができる素晴らしいソフトウェアです。WEBブラウザでPyodideを簡単に体験できるサイトが用意されており、これは面白そうだと思いました。
WEBエディタAceとの組みあわせ
本来のPyodideの目的はWEBブラウザ上でのPythonでの科学計算だと思いますので、エディタとしてはIodideのようにグラフ表示の要求優先度が高そうです。
とはいえ、コンソール上で単純にPythonが動くだけでも応用は利きそうだと思いましたので、今回はPythonコードを記述するエディタとしてAceを利用します。
simple版コードは下記のようになります。Aceエディタの領域とPyodide実行ボタンを縦に並べて、JavaScript内で初期化、実行するだけです。実行ログはF12キーで出るブラウザのコンソールを見てください。下記コードを index.htmlにコピーして、ブラウザで開くとNumpyのサンプルが動きます。
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>pyodide test (simple)</title>
<style type="text/css" media="screen">
#editor {
width:100%;
height:500px;
margin-top:auto;
margin-bottom:auto;
}
</style>
<script src="https://cdn.jsdelivr.net/pyodide/v0.17.0/full/pyodide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js" type="text/javascript" charset="utf-8"></script>
</head>
<body style="text-align: center" >
<div id="layout">
<div id="editor">import numpy as np
a=np.array([1,2,3])
b=np.array([4,5,6])
print(a+b)
</div>
<br/>
<input class="button" id="runBtn" style="background-color: #4CAF50;" type="button" value="Run Script" onclick="RunCode();" />
</div>
<script>
// init Ace Editor
var editor = ace.edit("editor");
editor.setTheme("ace/theme/twilight");
editor.session.setMode("ace/mode/python");
// init Pyodide
async function main(){
await loadPyodide({ indexURL : 'https://cdn.jsdelivr.net/pyodide/v0.17.0/full/' });
}
let pyodideReadyPromise = main();
async function RunCode() {
await pyodideReadyPromise;
try {
code = editor.getValue();
let output = await pyodide.runPythonAsync(code);
} catch(err) {
console.log(err);
}
}
</script>
</body>
</html>
実行画面は下記です。numpyをimportするのに少し時間がかかりますが、ちゃんと答えが出ていますね。このほか、色々なライブラリをサポートしているとは思いますが、何がどこまで動くかは私もよくわかってませんのでぜひご自分でも試してみてください。
ファイルのロードセーブ
Aceを使う場合に、Ace自体はファイルのロードセーブがサポートされていないので色々調べないといけません。ロードは簡単なのですが、セーブはできないためブラウザのダウンロード機能を利用する形で実装したのが下記のコードになります。レイアウトもsimple版よりは整えてます。
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>pyodide test</title>
<style type="text/css" media="screen">
#editor {
width:100%;
height:500px;
margin-top:auto;
margin-bottom:auto;
}
#editorGroup {
position:absolute;
margin-left:810px;
margin-right:0;
margin-top:auto;
margin-bottom:auto;
height:600px;
}
.button {
width:120px;
height:60px;
background-color: #AAAAAA;
border: none;
color: white;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
border-radius: 4px;
}
#layout {
float: left;
width:800px;
background-color: #FFFFFF;
}
.clear {
clear:both;
}
table {
border-collapse: collapse; /* セルの境界線を共有 */
}
td {
border: 1px solid black; /* 表の罫線(=セルの枠線) */
padding: 0.5em 1em; /* セル内側の余白量 */
}
body {
background: #eeeeee;
font-family: Meiryo;
}
h1 {
border: none;
text-align: center;
text-decoration: none;
margin: 4px 2px;
}
</style>
<script src="https://cdn.jsdelivr.net/pyodide/v0.17.0/full/pyodide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js" type="text/javascript" charset="utf-8"></script>
</head>
<body style="text-align: center" >
<div id="layout">
<h1>Python Editor</h1>
<div id="editor">import numpy as np
a=np.array([1,2,3])
b=np.array([4,5,6])
print(a+b)
</div>
<br/>
<table id="control_panel" style="width:800px" >
<tr>
<td rowspan="2">
<input class="button" id="runBtn" style="background-color: #4CAF50;" type="button" value="Run Script" onclick="RunCode();" />
</td>
<td>Load File:
<input type="file" id="loadBtn" onchange="Load(this.files);this.value=null;return false;" value="Open" />
</td>
</tr>
<tr>
<td>
<input id="inputFileNameToSaveAs"></input>
<input class="button" id="saveasBtn" style="background-color: #728edb;height:30px;" type="button" value="Download" onclick="Save();" />
</td>
</tr>
</table>
</div>
<script>
// init Ace Editor
var editor = ace.edit("editor");
editor.setTheme("ace/theme/twilight");
editor.session.setMode("ace/mode/python");
// init Pyodide
async function main(){
await loadPyodide({ indexURL : 'https://cdn.jsdelivr.net/pyodide/v0.17.0/full/' });
}
let pyodideReadyPromise = main();
async function RunCode() {
await pyodideReadyPromise;
try {
code = editor.getValue();
let output = await pyodide.runPythonAsync(code);
} catch(err) {
console.log(err);
}
}
function Load(files) {
var file = files[0]
console.log("Load:" + file);
if (!file) return;
reader = new FileReader();
reader.onload = function() {
editor.session.setValue(reader.result)
}
reader.readAsText(file)
}
function Save()
{
var textToSaveAsBlob = new Blob([editor.getValue()], {type:"text/plain"});
var textToSaveAsURL = window.URL.createObjectURL(textToSaveAsBlob);
var fileNameToSaveAs = document.getElementById("inputFileNameToSaveAs").value;
var downloadLink = document.createElement("a");
downloadLink.download = fileNameToSaveAs;
downloadLink.innerHTML = "Download File";
downloadLink.href = textToSaveAsURL;
downloadLink.onclick = function() {
document.body.removeChild(event.target);
}
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
}
</script>
</body>
</html>
実行画面はこんな感じです。
(追記) MatplotLibの図をブラウザに出力する方法
使う予定はないもののMatplotlibの絵を出せないのは気持ち悪いので、StackOverflowの記事を参考にしてsimple版コードを改造して出せるようにしてみました。ざっくり説明すると、id="fig"に対してPython側でPNG画像をBase64に変換して書き込んでいるようです。from js import document でJavaScriptと連携できることが分かって満足。
ついでにStatusも出せるようにしてみました。id='status' を状態に応じて変えるようにしているだけですが、状況がわかりやすくなりました。
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>pyodide test (simple)</title>
<style type="text/css" media="screen">
#editor {
width:100%;
height:500px;
margin-top:auto;
margin-bottom:auto;
}
</style>
<script src="https://cdn.jsdelivr.net/pyodide/v0.17.0/full/pyodide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js" type="text/javascript" charset="utf-8"></script>
</head>
<body style="text-align: center" >
<div id="layout">
<div id="editor">from js import document
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
import io, base64
def generate_plot_img():
# get values from inputs
mu = 1
sigma = 1
# generate an interval
x = np.linspace(mu - 3*sigma, mu + 3*sigma, 100)
# calculate PDF for each value in the x given mu and sigma and plot a line
plt.plot(x, stats.norm.pdf(x, mu, sigma))
# create buffer for an image
buf = io.BytesIO()
# copy the plot into the buffer
plt.savefig(buf, format='png')
buf.seek(0)
# encode the image as Base64 string
img_str = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode('UTF-8')
# show the image
img_tag = document.getElementById('fig')
img_tag.src = img_str
buf.close()
generate_plot_img()
</div>
<br/>
<input class="button" id="runBtn" style="background-color: #4CAF50;" type="button" value="Run Script" onclick="RunCode();" />
<br/>
Status: <strong id='status'>Initializing...</strong>
<br>
<img id="fig" />
</div>
<script>
// init Ace Editor
var editor = ace.edit("editor");
editor.setTheme("ace/theme/twilight");
editor.session.setMode("ace/mode/python");
// init Pyodide
async function main(){
await loadPyodide({ indexURL : 'https://cdn.jsdelivr.net/pyodide/v0.17.0/full/' }).then(()=>document.getElementById('status').innerHTML='Start');
}
let pyodideReadyPromise = main();
async function RunCode() {
await pyodideReadyPromise;
try {
document.getElementById('status').innerHTML='Executing...';
code = editor.getValue();
let output = await pyodide.runPythonAsync(code).then(()=>document.getElementById('status').innerHTML='Done!');
} catch(err) {
console.log(err);
}
}
</script>
</body>
</html>
(追記2) sessionStorageにアクセスする方法
下記のようにすれば良いようです。
from js import sessionStorage
sessionStorage.setItem("test","123")
print(sessionStorage.getItem("test"))
(追記3) pyodide0.18.0にすると動かなくなる。
0.17.0を0.18.0に変えるだけで動くと思うじゃないですか、普通。でも動かなくなります。
ここに記載の通りに修正しても、runPythonAsyncでnumpyがインポートできません。
PythonError: Traceback (most recent call last):
File "/lib/python3.9/asyncio/futures.py", line 201, in result
raise self._exception
File "/lib/python3.9/asyncio/tasks.py", line 256, in __step
result = coro.send(None)
File "/lib/python3.9/site-packages/_pyodide/_base.py", line 494, in eval_code_async
await CodeRunner(
File "/lib/python3.9/site-packages/_pyodide/_base.py", line 345, in run_async
coroutine = eval(self.code, globals, locals)
File "<exec>", line 1, in <module>
ModuleNotFoundError: No module named 'numpy'
at new_error (pyodide.asm.js:14)
at pyodide.asm.wasm:0x1b2e8b
at pyodide.asm.wasm:0x1b6e6d
at pyodide.asm.wasm:0x7e9531
at pyodide.asm.wasm:0x1f0a48
at pyodide.asm.wasm:0x2ec5a0
at pyodide.asm.wasm:0x8078fa
at pyodide.asm.wasm:0x2383c0
at pyodide.asm.wasm:0x805ca8
at pyodide.asm.wasm:0x1f11fd
import numpyだけではダメで、await pyodide.loadPackage("numpy"); を追加すれば動くようです。ということで、下記コードであれば0.18.0でも動作しました。
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>pyodide test (simple)</title>
<style type="text/css" media="screen">
#editor {
width:100%;
height:500px;
margin-top:auto;
margin-bottom:auto;
}
</style>
<script src="https://cdn.jsdelivr.net/pyodide/v0.18.0/full/pyodide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js" type="text/javascript" charset="utf-8"></script>
</head>
<body style="text-align: center" >
<div id="layout">
<div id="editor">import numpy as np
a=np.array([1,2,3])
b=np.array([4,5,6])
print(a+b)
</div>
<br/>
<input class="button" id="runBtn" style="background-color: #4CAF50;" type="button" value="Run Script" onclick="RunCode();" />
</div>
<script>
// init Ace Editor
var editor = ace.edit("editor");
editor.setTheme("ace/theme/twilight");
editor.session.setMode("ace/mode/python");
// init Pyodide
async function main(){
return await loadPyodide({ indexURL : 'https://cdn.jsdelivr.net/pyodide/v0.18.0/full/' });
}
let pyodideReadyPromise = main();
async function RunCode() {
let pyodide = await pyodideReadyPromise;
await pyodide.loadPackage("numpy");
try {
code = editor.getValue();
let output = await pyodide.runPythonAsync(code);
} catch(err) {
console.log(err);
}
}
</script>
</body>
</html>
0.17.0のサンプルコードそのままでは基本的なところで色々動かなくなっているので、0.18.0を使う場合は注意してください。
最後に
もうちょっと苦戦するかなと思ったんですが、0.17.0に関しては意外にあっさり動いたので驚きました。最近のWEB系技術の進歩はすさまじいですね。