オフラインのスマホ(iOS)でPythonを動かす

## 概要

個人情報をサーバーに送らずにデータ分析したいといった要望はままあります。
また、分析するコードをPythonで開発後に、Swiftに移植するのはかなり手間です。
ソリューションとしてオフラインでPythonを実行できないか以前に調べた内容を紹介します。

https://github.com/pyodide/pyodide/discussions/2188

これはPyodideをWebkitから実行する方法です。
将来的には[wasmer/python](https://wasmer.io/python/python)で直接実行できることでしょう。
ちなみに上記ディスカッションのotmbは私の個人アカウントです。

## 環境構築

適当にPyodideの最新版を引っ張ってきます。

```
$ curl -LO https://github.com/pyodide/pyodide/releases/download/0.26.0a2/pyodide-core-0.26.0a2.tar.bz2
$ tar xvjf pyodide-core-0.26.0a2.tar.bz2
```

解凍したpyodideフォルダをXcodeプロジェクト内に配置した上でReferenceになるように取り込みます。

- 実際の配置
```
PyodideExample
├── Assets.xcassets
│   ├── AccentColor.colorset
│   │   └── Contents.json
│   ├── AppIcon.appiconset
│   │   └── Contents.json
│   └── Contents.json
├── ContentView.swift
├── Preview Content
│   └── Preview Assets.xcassets
│       └── Contents.json
├── PyodideExampleApp.swift
├── index.html
└── pyodide
    ├── package.json
    ├── pyodide-lock.json
    ├── pyodide.asm.js
    ├── pyodide.asm.wasm
    ├── pyodide.js
    ├── pyodide.mjs
    └── python_stdlib.zip
```

- 青いフォルダ（Reference)になるように取り込む

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35973/e7b602f7-7a97-5466-56de-b30528a2460b.png" width="500" />
<br />

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35973/a57b9030-21f0-6879-ab5e-83f1ae200a6f.png" width="200" />

## コード

```ContentView.swift
import SwiftUI
import UIKit
import WebKit

struct ContentView: View {
  var body: some View {
    VStack {
      WebView()
    }
    .padding()
  }
}

struct WebView: UIViewControllerRepresentable {
  typealias UIViewControllerType = WebViewController
  
  func makeUIViewController(context: Context) -> WebViewController {
    return .init()
  }
  
  func updateUIViewController(_ uiViewController: WebViewController, context: Context) {}
}

class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler {
  var webView: WKWebView!
  
  override func loadView() {
    let config = WKWebViewConfiguration()
    config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
    let controller = WKUserContentController()
    controller.add(self, name: "logging")
    config.userContentController = controller
    webView = WKWebView(frame: .zero, configuration: config)
    webView.uiDelegate = self
    webView.isInspectable = true
    
    view = webView
  }
  
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    print(message.name, message.body)
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    let htmlFileName = "index"
    do {
      guard let filePath = Bundle.main.path(forResource: htmlFileName, ofType: "html")
      else {
        throw NSError(domain: "Error: The file path is invalid.", code: -1, userInfo: nil)
      }
      let htmlString = try String(contentsOfFile: filePath, encoding: .utf8)
      webView.loadHTMLString(htmlString, baseURL: URL(fileURLWithPath: filePath))
    } catch let error {
      print(error.localizedDescription)
    }
  }
}
```

```index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>Hello WKWebView</title>
      <script type="text/javascript">
        function jsLog (msg){
          window.webkit.messageHandlers.logging.postMessage(msg);
        }
        const console = {
          log: jsLog,
          info: jsLog,
          warn: jsLog,
          error: jsLog,
        }
        
        async function fetch(filename) {
          const promise = new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open('GET', filename);
            xhr.responseType = 'arraybuffer';
            xhr.addEventListener('load', (e) => resolve(xhr.response));
            xhr.send();
          });
          
          const response = new Response(await promise.then(), {headers: new Headers([['Content-Type', 'application/wasm']])});
          
          return response;
        }
      </script>
      <script src="pyodide/pyodide.js"></script>
  </head>
  <body>
    Pyodide test page <br>
    Open your browser console to see Pyodide output

    <script type="text/javascript">
      async function main(){
        let pyodide = await loadPyodide({
          stdin: jsLog,
          stdout: jsLog,
          stderr: jsLog,
        });
        console.log(pyodide.runPython(`
              import sys
              sys.version
          `));
          pyodide.runPython("print(1 + 2)");
      }
      main();
    </script>

  </body>
</html>
```

### 実行結果
```
logging 3.12.1 (main, Feb 15 2024, 01:00:57) [Clang 18.0.0git (https://github.com/llvm/llvm-project 0a3a0ea5914cb4633f4f4c14f
logging 3
```

## コードの説明

PyodideはJSの`fetch`を利用してファイルを取得します。
ところが`WKWebView`はローカルファイルの取得に`XMLHttpRequest`は機能しますが`fetch`は機能しません。
なので`fetch`を上書きします。

```javascript
async function fetch(filename) {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', filename);
    xhr.responseType = 'arraybuffer';
    xhr.addEventListener('load', (e) => resolve(xhr.response));
    xhr.send();
  });
  
  const response = new Response(await promise.then(), {headers: new Headers([['Content-Type', 'application/wasm']])});
  
  return response;
}
```

以上。簡単なハックでオフラインで動作します。

## おまけ

機械学習ライブラリを使う場合は、フルサイズのPyodideに置き換えると良い。
```
$ curl -LO https://github.com/pyodide/pyodide/releases/download/0.26.0a2/pyodide-0.26.0a2.tar.bz2
$ tar xvjf pyodide-0.26.0a2.tar.bz2
```

```index.html
<script type="text/javascript">
      async function main(){
      ...
          await pyodide.loadPackage(["numpy", "scikit-learn"]);
          await pyodide.runPython(`
              import numpy as np
              a = np.array([1.0, 2.0, 3.0])
              b = np.array([a, (10, 20, 30)])
              print(b)
          `);
      }
      main();
</script>

`);
```

### 実行結果

指定したライブラリの依存関係含めて読み込みます。
```
logging Loading numpy, scikit-learn, scipy, openblas, joblib, threadpoolctl
logging Loaded joblib, numpy, openblas, scikit-learn, scipy, threadpoolctl
logging [[ 1.  2.  3.]
logging  [10. 20. 30.]]
```