高見知英の技術ログ

技術関係のログをQiitaから移行してきました。プログラミングのほか、使っているアプリの細かい仕様についてなど書いていきます。

PyWebViewのURLパラメータにHTTPサーバを指定したときの挙動について

結局ソースコードを追ってデバッグ実行してなんとか対応できた・・・。

PyWebViewというPythonのモジュールを使うと、HTMLをPythonデスクトップアプリのGUIに使うことができます。

pywebview.flowrl.com

webview#create_window()というメソッドを使って生成したウィンドウにWindow#load_html()メソッドでHTMLを流し込めば、HTMLをGUIに使うことが可能です。

最近のバージョンでは、EdgeChromiumをデフォルトのレンダリングエンジンに使うようになった。

さて、電子書籍を作成するにあたり、PyWebViewを使っているこちらのアプリを久々にいじることになりまして。

github.com

とりあえずいつもの通りモジュールを最新化して、変更されたAPIにちょこちょこ対応しても、何故か動かない。なんでだろう?と思って調べてみたところ。

  • 最近のPyWebViewのバージョンでは、Windows上でのデフォルトレンダリングエンジンとして、mshtml(Trident)からEdgeChromium(Chromium) を使うようになった
  • mshtmlはドライブパスからはじまるフルパスを指定して、HTMLファイル内からCSSJavaScriptなどのリソースを読めたが、ChromiumEdgeではできなくなった(「Not allowed to load local resource」というエラーがブラウザのコンソールに表示される)

ということで、PyWebViewに備えられているもう一つの機能、「内部HTTPサーバを使う」という機能を使ってみることになりました。

PyWebViewで、内部HTTPサーバを使う

PyWebViewのマニュアルを読むと、webview#create_window()の第二引数(URL)にWSGI互換のHTTPサーバを指定すると、ページの処理時にそれを使うという機能があることが書かれています。

pywebview.flowrl.com

ページの使用例ではFlaskのHTTPサーバ機能を使っているようですが、もちろんWSGI互換のHTTPサーバであれば、指定が可能なはずです。

今回はFlaskを使うほどの大規模なことをするわけではないので、独自にHTTPサーバを作って処理しようと思ってやってみました。

ドキュメントに書かれていないこと

ここからがだいぶPyWebViewのドキュメントに書かれていないことで、ここで指定するHTTPサーバは、以下の条件を満たしている必要があります。

  • __webview_urlというアトリビュートが宣言されている必要がある(PyWebViewから値が書き込まれるだけなので中身はどんな値でも構わない)
  • __call__メソッドを実装している。

正直わたし自身WSGI互換のHTTPサーバというものがどういうものかよくわかっていないところもあるのですが(特定のクラスを継承しておく みたいなこともないようですし)、このへんを実際にデバッグ実行してみないと分からないという仕様はこのモジュールの厳しいところだなあと。

webview#create_window()の引数にHTTPサーバを指定してから起こること

webview#create_window()の引数にHTTPサーバを指定したあと、そのウィンドウが画面に表示されると、ただちにドキュメントルート(/)へのアクセスを指定して、__call__()メソッドが呼び出されます。

class Server:
  def __init__(self) -> None:
    self.__webview_url = ""
  ###
  def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Any:
    pass  # /// 処理 ///
  ###

このとき第一引数であるenvironには呼び出し時の環境変数のリストが(リクエストのURI(パス)は、environ["PATH_INFO"]に格納されています)、第二引数のstart_response()には、実際にレスポンス処理を行なうために呼び出す関数が格納されています。

ここでパスを解析してHTMLファイルなどのリソースを読み込み、返すには、次のようなコードを書けば良い。

import mimetypes

class Server:
  def __init__(self) -> None:
    self.__webview_url = ""
  ###
  def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Any:
    path = environ["PATH_INFO"]
    filepath = self.findfilepath(path)
    content = None
    headers = []
    status = ''
    if not filepath is None and filepath.exists():
      filetype, encoding = mimetypes.guess_type(filepath)
      with open(filepath, "rb") as f:
        content = f.read()
        status = '200 OK'
        headers = [('Content-type', f'{filetype}; charset=utf-8' if filetype.startswith("text") else filetype)]
    else:
      status = '404 Not found'
      content= b'Not found'
    start_response(status, headers)
    return [content]

  def findfilepath(self, path: str) -> Path:
     pass # パスの解析処理…

mimetypesとは、Python標準モジュールの一つで、引数に指定したパスのMIMEタイプを返してくれるという便利なモジュールです。

ファイルの中身を見ないとか拡張子のないファイルに対応してないとか.jsをtext/plainとして返してしまうとかいろいろ問題はありますがとりあえずここで使うぶんには問題なし。

ここで作成したクラスをwebview#create_window()の第二引数で使用してやれば、とりあえずサーバとしての処理を行なうことが可能です。

なお、このメソッドは複数のスレッドから同時に呼び出されることになるため、複数行にまたがる処理(ログの出力など)は複数のスレッドの出力がバラバラになってしまう可能性があります。

デバッグ実行した場合も同じなので、ここのメソッドをデバッグ実行すると混乱します。あまりここでデバッグしないようにしましょう。

とりあえずこのへんを守っていれば、PyWebViewを使ったアプリケーションが作りやすくなると思います。

扱い方次第でかなり柔軟な表示もできるので、PyWebViewを使いこなすつもりであれば試してみても良いでしょう。

しかしこう、なんというか。Pythonのモジュールは後方互換性確保のためか知りませんがTypeHintがろくについてないものがおおいですね。内部APIをいじるときにどう書いたら良いか迷うので困ります…。

とくに今回みたいに、「詳細な挙動はjustMyCodeフラグをオフにしてVSCodeからデバッグしてみないと分からない」というのは正直つらいのです。

参考資料

成果物のリポジトリ

該当のコードはRe-VIEW-Preview/server.py at master · TakamiChie/Re-VIEW-Previewあたりにあります。

なおこのツール自体はRe:Viewの原稿をHTMLとしてプレビュー表示できるツールです。まだ安定版とはとても言えませんがRe:Viewを使っている方はこちらも見てみてください。