file

雖然 Python 是個非常熱門的程式語言,我自己倒是很少寫它,主要是因為在過去無論是工作上還是我自己的專案都沒有用到它。一直到上週,為了要開發我新的個人專案 FontFreeze、才讓我稍微比較認真地來寫 Python 的程式。關於這個專案做的事情以及其背後的動機,我之後再用另一篇文章解說,不過總而言之它是一個用來處理 TTF 字型檔的工具,其背後至少受到了 vfitOpenType Feature Freezer 兩個專案的啟發。這兩個專案的共通點就是它們都是 Python 程式、且都引入了 fonttools 的這個專門處理字型的 Python 程式庫,以及它們都是做成命令列工具的形式(後者另外附有用 ezgooey.ez 產生的 GUI)。最初我其實也想效法它們、把 FontFreeze 做成命令列工具,但是我後來想一想不對,我明明就是 WebApp 的擁護者,不寫成網頁怎麼成?所以我就研究了一下如何在網頁裡面跑 Python 程式的方法,而很快地就找到了 Pyodide 以及更新的 PyScript 兩種作法。然而 PyScript 其實只是 Pyodide 的一個封裝、而且此刻也還在 alpha 階段,所以我就決定用 Pyodide 來寫了。整體而言,除了 Pyodide 的文件有一些小不足是我必須設法自己弄懂的、以及它的 TypeScript 定義還遠不及完備之外,串接 JavaScript 和 Python 倒還算是滿容易的,載入速度以及執行速度也都還不錯,所以我的開發體驗還算是好的。在本篇當中,我就來分享一些 Pyodide 的使用心得。

簡介

Pyodide 是將 CPython 直譯器透過 Emscripten 編譯成 WebAssembly 的網頁版 Python 執行環境。我們可以在 Pyodide 當中執行的東西包括:

  1. Python 內建的語法。
  2. Pyodide 內建的、一些常用套件的 Pyodide 建置版本。
  3. 透過 micropip 套件(Pyodide 內建)匯入任何由純 Python 寫成的 PyPI 套件(如果套件中有 C 語言的成份,則必須請求加入成內建套件)。

其中我會需要用到的 fonttools 剛好已經有了 Pyodide 的建置版本了(雖然 fonttools 其實是純 Python 套件,但總之有內建),所以用起來非常容易。要在網頁上加入 Pyodide 可以直接從 CDN 加:

<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>

這會提供一個全域的 loadPyodide() 函數,是用來初始化 Pyodide 用的:

const pyodide = await loadPyodide();

載入完成之後,我們就可以載入內建套件,例如:

await pyodide.loadPackage('fonttools');

然後我們就可以執行我們的 Python 程式碼了:

pyodide.runPython(`
    import fonttools
    ... # Python 程式碼
`);

或者,也可以把 Python 程式碼另外上傳成 .py 檔案,然後再用 fetch 去取得:

const response = await fetch('main.py');
const script = await response.text();
pyodide.runPython(script);

這邊要注意的是,用這種方式啟動的 Pyodide 在執行 Python 程式碼的時候也是用 UI 執行緒在跑的,所以要考慮到 blocking 的問題;如果會需要執行比較耗時的程式碼,應該要改成在 Worker 裡面啟動 Pyodide 較佳。不過在 FontFreeze 的應用中,處理字型最多花上兩三秒的時間,所以我就偷懶沒這麼做了。

使用檔案系統

這部份其實在 Pyodide 的 stable 版文件裡面還沒有寫到,是要切換到 latest 版的文件才找得到的;而即便是該文件其實也沒有真的寫得很清楚,尤其是在類別的部份;這我是自己嘗試才找出了正確的用法。

包括 fonttools 在內的一些套件會牽涉到檔案的讀寫,而當然在網頁上是不會有辦法碰觸到瀏覽者真正的檔案系統的。在 Pyodide 當中,檔案的讀寫預設是透過 Emscripten 預設的 MEMFS 檔案系統;這是一個純記憶體的檔案系統,所以網頁重新開啟時會遺失資料(如果需要保存資料,另有基於 IndexedDBIDBFS 檔案系統實作可用)。

假設我們在網頁上今天用 <input type="file"> 元件讀取了一個檔案,那麼我們可以用如下的方式將它寫入到 Pyodide 的檔案系統中。首先定義:

function readFile(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = e => resolve(e.target.result);
        reader.onerror = e => reject(e);
        reader.readAsArrayBuffer(file);
    });
}

然後主程式如下:

const file = inputElement.files[0]; // inputElement 為 <input> 元件
const buffer = await readFile(file);
const array = new Uint8Array(buffer);
pyodide.FS.writeFile('filename', array);

如此一來,任何 Python 程式碼只要讀取名為 filename 的檔案,讀到的就會是我們開啟的檔案了。反過來,如果要用 JavaScript 讀取 Python 程式碼寫入的檔案,則是這樣做:

const content = pyodide.FS.readFile('filename'); // 會是 Uint8Array

然後我們可以再進一步把這個 Uint8Array 轉換成 Blob 或是 ObjectURL 以便從網頁上面儲存或下載起來。

JavaScript 和 Python 之間溝通

如果我們要從 JavaScript 呼叫一個 Python 中的全域函數 func,我們可以這樣寫:

const func = pyodide.globals.get('func'); // 會傳回一個 PyProxy 物件
func(...args); // 要傳入的參數

傳入的參數在 Python 當中都會被自動轉換成 JsProxy 物件,但其行為跟 Python 的一些原生型別(例如 dict)有點差距。如果我們想要轉換成原生型別,可以執行 JsProxy 的 .to_py() 方法:

def func(args):
    args = args.to_py()
    ... # 然後 args 就會是原生型別了

反過來,Python 程式碼傳回到 JavaScript 的東西也都會是 PyProxy 物件,然後也是可以轉成原生 JavaScript 物件:

const proxy = pyodide.runPython("...");
const result = proxy.toJs();

其中要注意的是,Python 當中的 dict 物件預設是會被轉換成 JavaScript 當中的 Map,這對很多人來說應該會覺得用起來反而麻煩;若要轉成 object literal,可以改成這樣寫:

const result = proxy.toJs({ dict_converter: Object.fromEntries });

請求加入非純 Python 的套件

如同前面提到的,如果一個 PyPI 套件裡面全部都只有 Python 程式碼(而沒有例如 C 語言的部份),那可以直接透過 micropip 來匯入 Pyodide 之中,但如果一個套件並非純 Python,那就必須先建置成 Pyodide 專用的版本才能用。這部份當然可以丟 issue 請他們加入,但是如果自己可以先建置成功然後純粹只是丟 PR,那上去的速度自然會比較快一點。

由於 Pyodide 的開發者顯然是在 Linux 的環境下開發的,而且其開發相依的東西非常多,所以最好是用 Pyodide 提供的 Docker 來進行建置。首先我們把 Pyodide 從 GitHub 上面 clone 下來,然後在其根目錄中照著最新版的文件去執行

./run_docker --pre-built

的指令(這個 run_docker 的是一個 bash 腳本,不能直接在 Windows 的命令列裡面跑,不過可以例如開 WSL2 去跑;若要這樣做,也建議 clone 的時候就直接在 WSL2 裡面去做),這會把開發用的 Docker 映像做出來(會需要幾分鐘的時間),然後繼續照著文件去建置你想要的套件即可。以我的情況來說,我想要的是 brotli 這個套件(fonttools 在輸出 WOFF2 格式的時候需要),而這個套件建置起來倒是直接就成功了,不需要做任何的 patch,所以 patch 的部份我暫時沒有經驗可以分享了。

結語

Pyodide 提供了在前端執行 Python 程式的有效方案,讓 WebApp 在程式庫的選項上比過去更豐富,期待未來 Pyodide 可以內建更多的套件。


分享此頁至:
最後修改日期: 2022/08/03

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。