インストール無しにWEBブラウザで動作するPython環境"Pyodide"を使ってみた

# Pyodideとは

https://pyodide.org/en/stable/

WebAssembly技術を用いてWEBブラウザ上でPython3を動かすことができる素晴らしいソフトウェアです。[WEBブラウザでPyodideを簡単に体験できるサイト](https://pyodide.org/en/stable/console.html)が用意されており、これは面白そうだと思いました。

# WEBエディタAceとの組みあわせ

本来のPyodideの目的はWEBブラウザ上でのPythonでの科学計算だと思いますので、エディタとしては[Iodide](https://alpha.iodide.io/)のようにグラフ表示の要求優先度が高そうです。

とはいえ、コンソール上で単純にPythonが動くだけでも応用は利きそうだと思いましたので、今回はPythonコードを記述するエディタとして[Ace](https://ace.c9.io/)を利用します。

simple版コードは下記のようになります。Aceエディタの領域とPyodide実行ボタンを縦に並べて、JavaScript内で初期化、実行するだけです。実行ログはF12キーで出るブラウザのコンソールを見てください。下記コードを index.htmlにコピーして、ブラウザで開くとNumpyのサンプルが動きます。

``` html
<!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するのに少し時間がかかりますが、ちゃんと答えが出ていますね。このほか、色々なライブラリをサポートしているとは思いますが、何がどこまで動くかは私もよくわかってませんのでぜひご自分でも試してみてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/c73977cc-ce2b-79dd-f5ee-9f4f571884c2.png)


# ファイルのロードセーブ

Aceを使う場合に、Ace自体はファイルのロードセーブがサポートされていないので色々調べないといけません。ロードは簡単なのですが、セーブはできないためブラウザのダウンロード機能を利用する形で実装したのが下記のコードになります。レイアウトもsimple版よりは整えてます。


``` html
<!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>
```

実行画面はこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/7cc19961-fc18-5334-13ee-a3f085ba5b3c.png)

# (追記) MatplotLibの図をブラウザに出力する方法

使う予定はないもののMatplotlibの絵を出せないのは気持ち悪いので、[StackOverflowの記事](https://stackoverflow.com/questions/56583696/how-to-redirect-render-pyodide-output-in-browser)を参考にしてsimple版コードを改造して出せるようにしてみました。ざっくり説明すると、id="fig"に対してPython側でPNG画像をBase64に変換して書き込んでいるようです。from js import document でJavaScriptと連携できることが分かって満足。
ついでにStatusも出せるようにしてみました。id='status' を状態に応じて変えるようにしているだけですが、状況がわかりやすくなりました。

``` html
<!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>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/63ac6c77-cdc9-8818-50d2-b91e0aa9515b.png)


# (追記2) sessionStorageにアクセスする方法

　下記のようにすれば良いようです。

``` python 
from js import sessionStorage
sessionStorage.setItem("test","123")
print(sessionStorage.getItem("test"))
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/3b00ac8e-e99c-734b-0d92-ff82cd4a10c5.png)

# (追記3) pyodide0.18.0にすると動かなくなる。

0.17.0を0.18.0に変えるだけで動くと思うじゃないですか、普通。でも動かなくなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1804036/8115b821-d11e-0c09-0a46-be85ac707fe9.png)

https://pyodide.org/en/stable/usage/quickstart.html#alternative-example

ここに記載の通りに修正しても、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でも動作しました。

``` html
<!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系技術の進歩はすさまじいですね。