🖥️

【Ubuntu / Python】Pixiでpywebviewの煩雑なapt installは回避できるか

に公開

pywebviewのインストールは簡単になる?

そもそもGUI開発の手法には幾つか種別があり、Pythonで有名なものの一つであるtkinterは、「Tcl/Tk」というものに基づいています。Pythonのみで実装が完結する反面、画像で示す例のように、見て呉れがやや簡素になる特徴があります。

tkinterサンプル
tkinterで作る画面の例

コード

AIで適当に生成したものですが、一応コードを載せておきます。

import tkinter as tk
from io import BytesIO
from tkinter import messagebox

import requests
from PIL import Image, ImageTk


def on_click():
    label.config(text="ボタンがクリックされました!")


def show_entry():
    label.config(text=f"入力: {entry.get()}")


def show_selected():
    selected = listbox.get(listbox.curselection())
    messagebox.showinfo("選択", f"リストから選択: {selected}")


def check_action():
    label.config(text=f"チェック状態: {chk_var.get()}")


def radio_action():
    label.config(text=f"ラジオ選択: {radio_var.get()}")


def show_text():
    label.config(text=f"テキスト内容: {text.get('1.0', tk.END).strip()}")


root = tk.Tk()
root.title("Tkinter サンプル")
root.geometry("800x600")

top_frame = tk.Frame(root)
top_frame.pack(fill=tk.X, padx=10, pady=10)

label = tk.Label(top_frame, text="こんにちは、Tkinter!", font=("Meiryo", 16))
label.pack(side=tk.LEFT, padx=10)

button = tk.Button(top_frame, text="クリック", command=on_click)
button.pack(side=tk.LEFT, padx=10)

entry_frame = tk.Frame(root)
entry_frame.pack(fill=tk.X, padx=10, pady=10)

entry = tk.Entry(entry_frame, font=("Meiryo", 14))
entry.pack(side=tk.LEFT, padx=10)

show_button = tk.Button(entry_frame, text="入力を表示", command=show_entry)
show_button.pack(side=tk.LEFT, padx=10)

list_frame = tk.Frame(root)
list_frame.pack(fill=tk.X, padx=10, pady=10)

listbox = tk.Listbox(list_frame, height=5)
for item in ["りんご", "みかん", "バナナ", "ぶどう", "もも"]:
    listbox.insert(tk.END, item)
listbox.pack(side=tk.LEFT, padx=10)

list_btn = tk.Button(list_frame, text="リスト選択表示", command=show_selected)
list_btn.pack(side=tk.LEFT, padx=10)

chk_var = tk.BooleanVar()
chk = tk.Checkbutton(root, text="チェックしてみてください", variable=chk_var, command=check_action)
chk.pack(pady=10)

radio_var = tk.StringVar(value="A")
radio_frame = tk.Frame(root)
radio_frame.pack(pady=10)
tk.Label(radio_frame, text="ラジオボタン:").pack(side=tk.LEFT)
for v in ["A", "B", "C"]:
    tk.Radiobutton(radio_frame, text=v, variable=radio_var, value=v, command=radio_action).pack(side=tk.LEFT)

text_frame = tk.Frame(root)
text_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
text = tk.Text(text_frame, height=5)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
text_btn = tk.Button(text_frame, text="表示", command=show_text)
text_btn.pack(side=tk.LEFT, padx=10)

try:
    response = requests.get("https://upload.wikimedia.org/wikipedia/commons/c/c6/PwrdLogo200.gif")
    img = Image.open(BytesIO(response.content))
    img = img.resize((120, 120))
    photo = ImageTk.PhotoImage(img)
    img_label = tk.Label(root, image=photo)
    img_label.image = photo
    img_label.pack(pady=10)
except Exception:
    tk.Label(root, text="画像ファイルが見つかりません").pack(pady=10)

root.mainloop()

後ろ向きに捉えれば、tkinterは画面の結構に融通が利かないとも言えます。その中で画面を凝るには限界があるため、しばしばWeb技術、即ちHTMLCSSJavaScriptを使うことがあります。pywebviewは、そうしたものの内の一つです。

https://pywebview.flowrl.com/

pywebviewのサンプル
pywebviewのサンプル(WSLによる実行)

このサンプルでは、次のようにURLを指定することで、公式サイトをそのまま表示しています。

pywebview tutorial
import webview
webview.create_window('Hello world', 'https://pywebview.flowrl.com/')
webview.start()
Web技術を使う手法の例

Pythonから逸れる話題ですが、記事の内容に若干関わるため紹介します。

  • Electron

Node.jsで有名なElectronは「Chromium」、つまりWebブラウザーそのものに基づいています。技術的にはWebサイトを作ることと殆ど等しいため、「Tcl/Tk」と比べて如何様にも画面を作れる反面、Webブラウザーを動かしているようなものであるが故、容量は嵩み、動作の負荷も増えます。

  • WebView

WebViewという語は、スマートフォンのアプリ更新で見たという人もあるでしょう。Electronの代替候補として一時期名を馳せた(?)、RustTauriにも使われているものです。ElectronChromiumというブラウザーを動かすのに対し、WebViewはブラウザーを動かすのではありません。この点で、Tauriは軽量になるとして期待が寄せられていたというわけです。また「WebViewがスマートフォンで使われる」ことから、PCのみならずスマートフォンにも対応しています。

https://v2.tauri.app/ja/distribute/google-play/

https://v2.tauri.app/ja/distribute/app-store/

pywebviewは、後者のWebViewによるものというわけです。

pywebviewの不便

https://pywebview.flowrl.com/guide/installation.html

当たり前ですが、pywebviewをインストールする必要があります。この手のものにしては珍しいことに、Windowsの場合は簡単で、MacLinuxの場合は煩雑になります。

Windowsであれば、pip install pywebviewの一つで済みます。問題はLinuxです。

QtGTK

Linuxの場合、「QtGTKか選ぶ」ことになります。初見ではそもそも、このような違いがあることに気付きませんでした。

QtGTKも、GUI開発の手段と言えます。pywebviewを使うに当たり、どちらを基にするか選ぶことになっているようです。

Qt参考記事:

https://www.qt.io/ja-jp/qt-for-python

GTK参考記事:

https://www.gtk.org/

PythonでQtを使う

参考までに、(Windowsで)Qtを使った画面実装の様子についても紹介しておきます。GTKは面倒そうだったので試していません。

Python用に整理されたQtには、PyQtPySideとの別があります。どちらもQtによって画面を作るものに違いありませんが、開発元やライセンスが異なるようです。

https://programming-tango.jp/vocabulary/7175/

こちらの記事ではPyQtを使っています。

https://qiita.com/phyblas/items/d56003904c83938823f2

対してこちらのチュートリアルではPySideを使っています。

https://www.pythonguis.com/pyqt6-tutorial/

それぞれの例を示しておきます。

pyqt6

インストール
pip install pyqt6

pyqt6の例
pyqt6の実装例

import random
import sys

from PyQt6.QtCore import QSize, Qt
from PyQt6.QtWidgets import (
    QApplication,
    QLabel,
    QMainWindow,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

MESSAGES = [
    "Hello PyQt6!",
    "It's a nice day!",
    "Python and PyQt",
    "Let's enjoy the PyQt!",
    "Button clicked.",
]


def main():
    app = QApplication(sys.argv)

    window = MainWindow()
    window.show()

    app.exec()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("PyQt6 sample app")
        self.setFixedSize(QSize(300, 100))

        central_widget = QWidget()
        layout = QVBoxLayout()
        central_widget.setLayout(layout)
        self.setCentralWidget(central_widget)

        self.label = QLabel("-- Message Area --")
        self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(self.label, stretch=1)

        button = QPushButton("Press Me!")
        button.clicked.connect(self.show_random_message)
        layout.addWidget(button, stretch=1)

    def show_random_message(self):
        self.label.setText(random.choice(MESSAGES))


if __name__ == "__main__":
    main()

pyside6

インストール
pip install pyside6

pyside6の例
pyside6の実装例

import random
import sys

from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout, QWidget


def main():
    app = QApplication(sys.argv)

    widget = MyWidget()
    widget.show()

    sys.exit(app.exec_())

class MyWidget(QWidget):
    def __init__(self):
        QWidget.__init__(self)

        self.hello = [
            "Hallo Welt",
            "問天地好在",
            "Hei maailma",
            "Hola Mundo",
            "Привет мир",
        ]

        self.button = QPushButton("Click me!")
        self.message = QLabel("Hello World")
        self.message.setAlignment(Qt.AlignHCenter)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.message)
        self.layout.addWidget(self.button)

        # Connecting the signal
        self.button.clicked.connect(self.magic)

    @Slot()
    def magic(self):
        self.message.setText(random.choice(self.hello))

if __name__ == "__main__":
    main()

Qtの場合は[qt]を付けてインストールします。

pip install pywebview[qt]

GTKの場合は[gtk]を付けてインストールします。

pip install pywebview[gtk]

幾多のapt install

Qtを選ぼうとも、将GTKを選ぼうとも、Pippywebviewをインストールするだけでは動きません。QtあるいはGTKそのものをインストールしなければならないのです。

Qtを選んだ場合、更にQtWebChannelQtWebKitか選ぶことになります。QtWebChannelの方が新式、QtWebKitの方が旧式ということでしょうか。

QtWebChannelの場合
sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-pyqt5.qtwebchannel libqt5webkit5-dev
QtWebKitの場合
sudo apt install python3-pyqt5 python3-pyqt5.qtwebkit python-pyqt5 python-pyqt5.qtwebkit libqt5webkit5-dev
GTKの場合
sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-webkit2-4.1

環境への影響

大人しくaptでこれらをインストールすれば宜しく、特段これ以上の論を俟ちません。しかし本記事は、敢えてこれを回避せんと欲するのです。

そもそもPipPythonのためのパッケージマネージャーであるのに対し、aptOS全体的なパッケージマネージャーであります。OSにインストールしたものは、pywebviewとは全く関係ないところに影響する可能性や、誰か別のユーザーによって削除される可能性もあります。

また、何をインストールしたのか後から確認しづらいこともあり、不要になっても除去しないままにしてしまうことも多いのではないでしょうか。こうした整理のしづらさは嫌厭されることがあり、Pipが仮想環境ありきになったのも、こうした事情があってのことと聞きます。

環境全体への影響が少しでも低減できれば、つまりaptで何もインストールする必要がないならば、それに越したことはないのです。

本題

https://pixi.sh/latest/

偖、本記事では「Pixi」というRust製のパッケージマネージャーを使います。すっかり普及したuvRust製ですが、Pixiとは謂わばその多機能版です。私見ですが、使い始めこそ「uvでよい」と感じていたものの、徐々に「Pixiの方が便利」と意見を転じました。

インストールは次のようにします。

curlの場合
curl -fsSL https://pixi.sh/install.sh | sh
wgetの場合
wget -qO- https://pixi.sh/install.sh | sh

ターミナルを再起動することでpixiコマンドが有効になります。

conda-forgePyPI

https://anaconda.org/conda-forge

Pixiはパッケージマネージャーですが、Python標準のものであるPipとは、インストール元が異なります。

パッケージマネージャー インストール元
Pip PyPI
Pixi conda-forge(デフォルト)
PyPI

特に何も指定しなければ、Pixiconda-forgeからパッケージをインストールします。PyPIと異なって組織的に管理されているため、一定の信頼性があるとされています。

PyPIを除き」、インストール元となる「チャンネル」をprefix.devにて確認することができます。大抵の場合conda-forgeで事足りるため、特に用事が無ければ他のチャンネルを利用する必要はないでしょう。

https://prefix.dev/channels

Pixiでインストールできるものを検索する際は、このサイトを使うのが賢明です。conda-forgePyPIとでは、同じものでも名称が異なる場合があります。また、Anacondaのサイトではconda-forgeの他にもanacondaなどのownerが存在するものの、Pixiでインストールできるものはあくまでもconda-forgeartifactに限るため、他ownerartifactに検索を妨げられます。

なおpywebviewconda-forgeどころか孰れのチャンネルにも存在しないため、インストールするにあたってPyPIを指定する必要があります。

プロジェクト作成

インストールの事は一度忘れて、曩にプロジェクトを作っておきます。sample_appというプロジェクト名にしましたが、今後一切顧みないので自由に決めて構いません。

pixi init sample_app

殆ど空のプロジェクトができるので、下図のような構成を作りました。なおプログラムの内容は本記事の主旨と関係ないため、特に説明していません。

ソースコード
src/main.py
import time

import webview


class QuitFlag:
    def __init__(self):
        self._flag = True

    @property
    def flag(self):
        return self._flag

    @flag.setter
    def flag(self, flag):
        self._flag = flag

class Api:
    def __init__(self, qFlag: QuitFlag):
        self._qFlag = qFlag
        self.logs = []

    def log(self, value):
        self.logs.append(value)
        print(f"log: {value}")
        return "Logged: " + value

    def get_logs(self):
        print("get_logs: called")
        return self.logs

    def exit(self):
        print("exit: called")
        self._qFlag.flag = False
        # print(f'flag: {qFlag.flag} ({qFlag})')

    @property
    def qFlag(self):
        return self._qFlag

qFlag = QuitFlag()
api = Api(qFlag)

def quit(window: webview.Window, qFlag: QuitFlag):
    while qFlag.flag:
        # print(f'flag: {qFlag.flag} ({qFlag})')
        time.sleep(2)

    print('breaked loop')
    window.destroy()
    print('quit app')

window = webview.create_window(
    'App Index',
    'statics/index.html',
    js_api = api
)

webview.start(quit, [window, qFlag])

src/statics/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>PyWebView チュートリアル</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <h1>PyWebView チュートリアル</h1>
    <button onclick="sendLog()" class="main-btn">ログを送信</button>
    <button onclick="showLogs()" class="main-btn">履歴を表示</button>
    <button onclick="exitApp()" class="exit-btn">終了</button>
    <div id="logs"></div>
    <script src="scripts.js"></script>
</body>
</html>

src/statics/styles.css
body {
    font-family: sans-serif;
    margin: 2em;
}

#logs {
    margin-top: 1em;
    background: #f4f4f4;
    padding: 1em;
    border-radius: 8px;
}

button {
    padding: 0.7em 1.5em;
    margin-right: 0.5em;
    border: none;
    border-radius: 6px;
    font-size: 1em;
    cursor: pointer;
    transition: background 0.2s;
}

.main-btn {
    background: #4caf50;
    color: white;
}

.main-btn:hover {
    background: #388e3c;
}

.exit-btn {
    background: #f44336;
    color: white;
}

.exit-btn:hover {
    background: #c62828;
}

src/statics/scripts.js
function sendLog() {
    pywebview.api.log("Let's play PyWebView!").then(response => {
        alert(response);
    });
}
function showLogs() {
    pywebview.api.get_logs().then(logs => {
        document.getElementById('logs').innerHTML =
            "<strong>ログ履歴:</strong><br>" + logs.map(l => `<div>${l}</div>`).join('');
    });
}
function exitApp() {
    pywebview.api.exit();
}

ついでに「タスク」を作って登録しておきました。

pixi.toml
[tasks]
main = "python src/main.py"
タスクとは

Pixiにはあって他のパッケージマネージャーがまず有たない機能に、「タスク」があります。

今、src/main.pyを実行するためのコマンドは次のようになります。

pixi run python src/main.py

または、仮想環境を有効にするような方法もあります。

$ pixi shell
(sample_app) $ python src/main.py

仕方ないとはいえ、どちらにせよ文字数が多く、逐一キーボードによる打鍵で入力するには煩わしく感じるでしょう。「タスク」を登録すれば、これを省略できます。

タスクを作る
# pixi task add タスク名 実行する内容
pixi task add main "python src/main.py"

ここではmainという名でタスクを作っており、その内容はpython src/main.pyです。これを実行するには次のようにします。

タスクを実行する
# pixi run タスク名
pixi run main

これでpixi run python src/main.pyを実行したと同等になります。

インストール

愈愈インストールに取り掛かりますが、先んじてpixi.tomlを示しておきます。着目すべきは[dependencies][pypi-dependencies]の箇所です。

pixi.toml
[workspace]
channels = ["conda-forge"]
name = "sample_app"
platforms = ["linux-64"]
version = "0.1.0"

[tasks]
main = "python src/main.py"

[dependencies]
python = ">=3.13.5,<3.14"
pyqt = ">=5.15.11,<6"
pyqtwebengine = ">=5.15.11,<6"
pyqtwebkit = ">=5.15.11,<6"
pygobject = ">=3.50.0,<4"
gtk3 = ">=3.24.43,<4"

[pypi-dependencies]
pywebview = { version = ">=6.0, <7", extras = ["qt"] }

pixi.tomlを直接編集してこれらを記載したら、次のコマンドから一挙にインストールできます。

pixi install
コマンドの場合
# pywebview
pixi add --pypi pywebview[qt]
# python
pixi add python
# pyqt
pixi add pyqt
# pyqtwebengine
pixi add pyqtwebengine
# pyqtwebkit
pixi add pyqtwebkit
# pygobject
pixi add pygobject
# gtk3
pixi add gtk3
指摘

QtGTK

[pypi-dependencies]
pywebview = { version = ">=6.0, <7", extras = ["qt"] }

Linuxの場合、pywebviewをインストールするにはQtGTKか選ぶとは既に述べた通りです。この箇所を見て分かるように、本記事ではQtを選びました。

[dependencies]pygobject = ">=3.50.0,<4"
gtk3 = ">=3.24.43,<4"

しかしこの箇所に連なる名は、全てGTKに関わるものです。どうやら、これらを抜きにしても動きはするようでした。

これらはエラーメッセージにて

[pywebview] GTK cannot be loaded
︙
ModuleNotFoundError: No module named 'gi'

と言われてpygobjectを入れ、

[pywebview] GTK cannot be loaded
Traceback (most recent call last):
︙
    gi.require_version('Gtk', '3.0')
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
︙
ValueError: Namespace Gtk not available

と言われてgtk3を入れ、

[pywebview] GTK cannot be loaded
Traceback (most recent call last):
︙
    gi.require_version('WebKit2', '4.1')
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
︙
ValueError: Namespace WebKit2 not available

と言われてWebKit2を調べたところ、webkit2gtk4.1があったため入れようとしたものの、

pixi add webkit2gtk4.1
Error:   × failed to solve requirements of environment 'default' for platform 'linux-64'
  ├─▶ failed to solve the environment
  ╰─▶ Cannot solve the request because of: webkit2gtk4.1 * cannot be installed because there are no viable
      options:
      └─ webkit2gtk4.1 2.48.4 | 2.48.4 | 2.48.5 would require
         └─ __glibc >=2.34,<3.0.a0, for which no candidates were found.

この通り失敗したという経緯があってのものです。このエラーメッセージからは進展することができませんでした。

結局、アプリケーション自体は動いているものの、この通りエラーは解消しなかったため、完全な状態を整えることには失敗していると言えます。

QtWebChannelQtWebKit

Qtを選んだ場合、更にQtWebChannelQtWebKitかを選ぶ必要がありました。
しかしconda-forgeQtWebChannelが無かったため、消去法でQtWebKitを選んでいます。

なお、anacondaにはあるようです。

https://anaconda.org/anaconda/qtwebchannel

実行の様子

アプリ起動直後
アプリ起動直後

ログ出力
Pythonへログ出力

ログ取得
Pythonからログ取得

画面が動くだけでなく、JavaScriptPythonとの連携もできています。

  1. 未解消のエラーがあること
  2. GTKの場合を試していないこと
  3. QtWebChannelをインストールできないこと

といった問題・課題が残るものの、辛くも動作するところまで漕ぎ着けました。先にも述べたように、aptを使っていればこのような苦慮はありません。しかし新たな選択肢を提示するに至ったことは一つ、漂着点として申し分ないだろうと思います。

Discussion