PyQt でクロスプラットフォームなデスクトップアプリケーションを

ここ何ヶ月かデスクトップアプリケーションにどっぷりな感じです。パッケージングをもっと簡単にしたい!ということで色々と試行錯誤しておりました。linux, mac はいい感じですが、Windows は・・・ py2exe でフリージングのみしかしていませんでした。配布とインストールは自動解凍書庫、アップデート、アンインストールは・・・。そこで今回 (やっと) 覚えたのが Inno SetupWiX といった Windows 用のパッケージビルダです。備忘録がてら、Python でのパッケージングをまとめてみました。

パッケージングについて

大きく 2 つのフェーズに分かれています。

  1. フリージング: Python バンドルや他の必要なライブラリーを寄せ集め、実行可能形式にまとめます。
    Windows と OS X については以下のライブラリでフリージングします。

    Linux を含むクロスプラットフォームなフリージングができる cx_Freeze というのもあります。
  2. ディストリビューションのビルド
    • Windows: Microsoft Windows Installer はアップグレード、アンインストール、トランザクション処理を使った複数パッケージの管理とかの便利機能を持っています。
      • WiX (Wikipedia): MSI 形式でビルドするための MS 製のオープンソースツールです。WXS という XML ファイルを作成し、それを元にビルドします。
      • Inno Setup (日本ドキュメント): Windows インストーラ (Setup.exe) を作成するオープンソースツールです。Delphi で書かれています。
      • NSIS (Wikipedia): 多機能。スクリプト駆動型のWindows用インストールシステム。Mozilla, Google, BitTorrent などにも使われています。
      • 他にもあるかもです。
    • OS X: hdiutil: py2app でできた *.app を *.dmg にしてくれるらしいです (使ったことありません)。

クロスプラットフォームな setup.py の書き方

Windows と OS X のフリージングは、py2exe と py2app があるので簡単です。プラットフォーム情報を取得し、各プラットフォームに対してフリージングします。いろんなコードを見ましたが、切り分けるコードは大きく 2 種類ありました。私は簡単なので前者を使っています。

import sys

if sys.platform == 'win32':
    u"""Windows 用のフリージング"""

    import py2exe

    # 処理

if sys.platform == 'darwin':
    u"""OS X 用のフリージング"""

    import py2app

    # 処理

if sys.platform == 'linux2':
    u"""Linux 用のビルド"""

    import subprocess

    u"""
    makeself 等でインストーラを作成するコマンドを実行::
    
        ret = subprocess.Popen("makeself ...")
        ret.wait()
    
    cx_Freeze でもいいと思います。
    """
import platform

if platform.system() in ['Windows', 'Microsoft']:
    u"""Windows 用のフリージング"""

    # 省略

if platform.system() == 'Darwin':
    u"""OS X 用のフリージング"""

    # 省略

if platform.system() == 'Linux':
    u"""Linux 用のビルド"""

    # 省略

Inno Setup によるディストリビューションのビルド

Inno Setup は distutils の拡張モジュールが pypi に提供されています。なので、上記の setup.py にちょっと追加すれば、Windows についてはディストリビューションのビルドがかなりお手軽になります。

環境の構築
setup.py の書き方

PyQt アプリケーションのディレクトリ構成は以下のようにしました。

  • path/to/project: プロジェクトディレクトリ
    • dist: パッケージが保存されるディレクトリ (自動的に作成されます)
    • test: PyQt アプリケーションディレクトリ
      • media: アイコンファイルなどのリソースを保存するディレクトリ
        • test.ico: アイコンファイル
      • main.py: メインスクリプト
    • setup.py: パッケージング用スクリプト
    • README: アプリケーション概要を記載したファイル (Windows の場合は cp932 に保存)
    • LICENSE: ライセンスを記載したファイル (cp932)
#!/usr/bin/env python2.6
# -*- coding: utf-8 -*-

import os
import sys, subprocess
from distutils.core import setup

########################################
# コンパイルオプション設定             #
########################################

# バイナリ名
NAME = u"test"

# バージョン情報
VERSION = "1.0.0"

# 著作権
AUTHOR = "Kosei Kitahara"

# メール
EMAIL = "mail@example.com"

# URL
u"""
AppID を生成するように用いられるため、アプリケーション毎にユニークにする
"""
URL = "http://example.com/test"

# パッケージ 概要
DESCRIPTION = u"テスト用アプリケーション"

# Python バイトコードの最適化オプション (0: None, 1: -O, 2: -OO)
OPTIMIZE = 2

# 圧縮オプション (0: 圧縮しない, 1: 圧縮する)
COMPRESSED = 1

# バンドルオプション (1: 単独, 3: 個別)
BUNDLE_FILES = 3

# 依存ライブラリの解決
INCLUDES = ["sip", "ctypes", ]
EXCLUDES = ["_ssl", "tcl", "tkinter", "Tkconstants", "Tkinter", ]
DLL_EXCLUDES = ["tcl84.dll", "tk84.dll", ]

# ディレクトリ, ファイルなど
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
APPLICATION_DIR = os.path.join(BASE_DIR, "test")
DIST_DIR = os.path.join(BASE_DIR, "dist")

MAIN_SCRIPT_NAME = os.path.join(APPLICATION_DIR, "main.py")
ICON_FILE_NAME = os.path.join(APPLICATION_DIR, "media", "test.ico")
LICENSE_FILE_NAME = os.path.join(BASE_DIR, "LICENSE")

########################################
# ユーティリティ関数                   #
########################################

def get_win32ui_files():
    u"""win32ui 依存 dll の取得"""

    import win32ui
    win32ui_dir = os.path.dirname(win32ui.__file__)
    return [os.path.join(win32ui_dir, i) for i in [
        "mfc90.dll", 
        "mfc90u.dll", 
        "mfcm90.dll", 
        "mfcm90u.dll", 
        "Microsoft.VC90.MFC.manifest", ]]
WIN32UI_MFCFILES = get_win32ui_files()

def get_pyqt4_imageformats_plugin_files():
    u"""PyQt4 の画像コーデック依存 dll の取得"""

    import PyQt4
    pyqt4_dir = os.path.dirname(PyQt4.__file__)
    return [os.path.join(pyqt4_dir, "plugins", "imageformats", i) for i in [
        "qgif4.dll",
        "qico4.dll",
        "qjpeg4.dll",
        "qmng4.dll",
        "qsvg4.dll",
        "qtiff4.dll", ]]
PYQT4_IMAGEFORMATS = get_pyqt4_imageformats_plugin_files()

########################################
# ビルドスクリプト                     #
########################################

# Linux, OS X は省略
if sys.platform == 'win32':
    u"""Windows 用のフリージング"""

    import py2exe, innosetup

    # py2exe, innosetup 共通ビルドオプション
    DATA_FILES = [
            ("Microsoft.VC90.MFC", WIN32UI_MFCFILES), 
            ("imageformats", PYQT4_IMAGEFORMATS), ]
    ICON_RESOURCES = [(1, ICON_FILE_NAME), ]
    PY2EXE_OPTIONS = {
            "includes": INCLUDES,
            "excludes": EXCLUDES,
            "dll_excludes": DLL_EXCLUDES,
            "compressed": COMPRESSED,
            "optimize": OPTIMIZE,
            "bundle_files": BUNDLE_FILES, }

    # innosetup ビルドオプション
    u"""
    'inno_script' は、.iss ファイル名を指定することもできますが、
    デフォルトでも用意されているので、大変便利です。
    """
    INNOSETUP_OPTIONS = {
            "inno_script": innosetup.DEFAULT_ISS, 
            "bundle_vcr": True, 
            "zip": False, }

    setup(
            name=NAME,
            version=VERSION,
            license=LICENSE_FILE_NAME,
            author=AUTHOR,
            author_email=EMAIL,
            description=DESCRIPTION,
            url=URL,
            data_files=DATA_FILES,
            options={
                    "py2exe" : PY2EXE_OPTIONS,
                    "innosetup": INNOSETUP_OPTIONS},
            windows=[{
                    "script" : MAIN_SCRIPT_NAME, 
                    "icon_resources": ICON_RESOURCES}], 
            zipfile="test.lib", )

py2exe の設定を継承してくれるので、大変便利ですね。

パッケージング処理の実行

以下のコマンドを実行すると、パッケージングしてくれます。

python setup.py innosetup

もちろん setup.py py2exeも動作します。innosetup の run メソッドは py2exe を継承しており、dist ディレクトリに作成するライブラリや実行ファイルは py2exe と同様です。それとは別に以下のファイルが作成されます。

  • Microsoft.VC90.CRT.manifest
  • distutils.iss: インストーラ作成用 ISS ファイル
  • test-1.0.0-setup.exe: インストーラファイル。ファイル名は settings.py で設定した [アプリケーション名]-[バージョン]-setup.exe になります。

実行すると Inno Setup Compiler が自動的に起動し、作成した distutils.iss ファイルを元にインストーラーが作成されます。途中でエラーが出たら、innosetup.py を修正し、出力する ISS ファイルを変更しましょうw

ちなみに、私の環境では以下を修正しました。

218c218
<             return win32api.LoadResource(handle, restype, name).decode('utf_8')
---
>             return win32api.LoadResource(handle, restype, name).decode('utf-8')
525c525
<             iss_metadata['MinVersion'] = '5.0,4.0'
---
>             iss_metadata['MinVersion'] = '0,5.0'
546c546
<             fp.write('%s=%s\n' % (k, iss_metadata[k], ))
---
>             fp.write(unicode('%s=%s\n' % (k, iss_metadata[k], )).encode("utf-8"))
779c779
<             fp.write('#define %s "%s"\n' % (k, consts[k], ))
---
>             fp.write(unicode('#define %s "%s"\n' % (k, consts[k], )).encode("utf-8"))

これでデスクトップアプリケーションの配布がかなり楽になりましたよ!ユニコードインストーラなどについてはもうちょっと調べる必要がありますね・・・。

追記 (2010-08-18)

試してないですが、bdist_nsi という NSS I 用の distutils 拡張モジュールが pypi に登録されていました。ソースを見ると、Python 3 にも対応しており (Python 2 は 3 へ変換)、py2exe は使ってないようですね。これはよさげ!

PyQt を py2exe でフリージング

標準的なフリージング

# setup.py

import py2exe
from distutils.core import setup

py2exe_options = {
    "compressed": 1, # 圧縮する
    "optimize": 2,
    "bundle_files": 3,
    "includes" : ["sip",]
}
setup(
    options={"py2exe" : py2exe_options},
    windows=[{"script" : "main.py"}], # PyQt ファイル
    zipfile="[zipped.lib ファイル名]",
)

私の環境 (XP, Py2.6.4) では、 py2exe_optionsbundle_files は 3 (分割ファイル) じゃないと動作しませんでした。

jpeg や gif 等の画像を取り扱う場合

PyQt は bmp や png 以外の画像形式を標準でサポートしていません。その他のフォーマットはプラグインとして提供されています。プラグイン (dll) をフリージングしたファイル配下のディレクトリーに保存する必要があります。

# setup.py

import os
import py2exe
from distutils.core import setup

# 任意のフォーマットの dll を追加。特にアイコンは必要かも
IMAGELIB_DIR = r"C:\path\to\python\Lib\site-packages\PyQt4\plugins\imageformats"
imgfiles = [os.path.join(IMAGELIB_DIR, i) for i in [
    "qgif4.dll",
    "qico4.dll",
    "qjpeg4.dll",
    "qmng4.dll",
    "qsvg4.dll",
    "qtiff4.dll",]]

data_files = [
    ("imageformats", imgfiles), 
]

py2exe_options = {
    "compressed": 1,
    "optimize": 2,
    "bundle_files": 3,
    "includes" : ["sip",]
}

setup(
    data_files = data_files,
    options={"py2exe" : py2exe_options},
    windows=[{"script" : "main.py"}], # PyQt ファイル
    zipfile="[zipped.lib ファイル名]",
)

win32ui を利用する場合

MFC (Microsoft Foundation Classes) の dll が必要 (本家ドキュメント)。

# setup.py

import os
import py2exe
from distutils.core import setup

WIN32UI_DIR = r"C:\path\to\python\Lib\site-packages\pythonwin"
mfcfiles = [os.path.join(WIN32UI_DIR, i) for i in [
    "mfc90.dll", 
    "mfc90u.dll", 
    "mfcm90.dll", 
    "mfcm90u.dll", 
    "Microsoft.VC90.MFC.manifest"]]

IMAGELIB_DIR = r"C:\path\to\python\Lib\site-packages\PyQt4\plugins\imageformats"
imgfiles = [os.path.join(IMAGELIB_DIR, i) for i in [
    "qgif4.dll",
    "qico4.dll",
    "qjpeg4.dll",
    "qmng4.dll",
    "qsvg4.dll",
    "qtiff4.dll",]]

data_files = [
    ("Microsoft.VC90.MFC", mfcfiles), 
    ("imageformats", imgfiles), 
]

py2exe_options = {
    "compressed": 1,
    "optimize": 2,
    "bundle_files": 3,
    "includes" : ["sip", "ctypes",] # ctypes を利用している場合は追加する
}

setup(
    data_files = data_files,
    options={"py2exe" : py2exe_options},
    windows=[{"script" : "main.py"}], # PyQt ファイル
    zipfile="[zipped.lib ファイル名]",
)

py2exe は利用しないライブラリーなども取り込まれるらしく、ちょっとファイルが大きくなってしまいます。私の環境では簡単なウィジェットでも 11 M ぐらいになりました。

Python から Win32 API 経由で印刷する

Win32 API から印刷をするためには、プリンタデバイスコンテキスト (Printer DC) を利用します。具体的には以下のような順序で印刷します。

  1. プリンタドライバからハンドルを取得する
  2. プリンタドライバのステータス API から印刷可能か同かを取得する
  3. プリンタデバイスコンテキストを作成する
  4. ドキュメントを開始する
  5. 必要であればフォント等を設定し、印字、改ページ処理などをする
  6. ドキュメントを終了する (この時点でプリンタスプールサービスに登録されるようです)
  7. プリンタデバイスコンテキストを開放する
  8. プリンタドライバのステータス API を監視し、印刷が正常に終了したかを取得する
  9. プリンタドライバのハンドルを解放する

太字の手順 (3 - 7) は Win32 API で共通ですが、それ以外の手順 (1, 2, 8, 9) はプリンタドライバに依存しているため、Python から制御したい場合はライブラリを作成する必要があります。これについては、ドライバマニュアルとにらめっこしながら ctypes.windll でがんばります・・・。プリンタ接続、用紙切れ、ミスフィード等のプリントエラーを制御する必要がなければ、書く必要はありません (排他制御が必要なプリンタを除く)。プリンタドライバ毎に変わる部分は置いといて、太字の Win32 API の部分の覚書です。

プリンタデバイスコンテキストを作成する

import win32ui
import win32con

PRINTER_NAME = '[コンパネ -> プリンタとFAX -> に登録しているプリンター名]'
PIXELS_PER_INCH = 1440 # 1 インチ毎のピクセル数
INCH_PER_POINT = 72 # 1 インチ毎のポイント数
SCALE_FACTOR = int(PIXELS_PER_INCH / INCH_PER_POINT) # わざわざ計算せず 20 と指定する場合が多い

dc = win32ui.CreateDC()
dc.CreatePrinterDC(PRINTER_NAME) # 指定したプリンタ名のプリンタデバイスコンテキストを作成する
self.dc.SetMapMode(win32con.MM_TWIPS) # デバイスコンテキストのマッピングモード (座標単位) を設定する

SetMapModewin32con.MM_TWIPS を指定しているため、1 インチ毎のポイント数 20 を計算しています。その他の単位で印字したい場合は、msdn のサイトで詳しく解説されています。

ドキュメントを開始する

dc.StartDoc(u'[ドキュメント名]') # 印刷ジョブを区別するためにユニークなドキュメント名を設定する

必要であればフォント等を設定し、印字、改ページ処理などをする

u"""
フォントは以下が指定可能::
    name -- フォント名
    width -- 平均文字幅
    height -- フォントの高さ
    weight -- フォントの太さ
        0 (win32con.FW_DONTCARE) - 1000 (win32con.FW_BLACK) までで指定する。
        標準は 400 (win32con.FW_REGULAR)。
    italic -- 斜体にするかどうか
    underline -- 下線を付けるかどうか
    pitch and family -- ピッチとファミリ
    charset -- 文字セット
        標準は Windows 文字セット (win32con.ANSI_CHARSET)
        日本語の場合はシフト JIS 文字セット (win32con.SHIFTJIS_CHARSET)
詳細: http://msdn.microsoft.com/ja-jp/library/cc428368.aspx
"""
fontdict = {
    "height": SCALE_FACTOR * 10, # 10 ポイント
    "name": u"[フォント名]", # "MS ゴシック" 等のフォント名
    "charset": win32con.SHIFTJIS_CHARSET, # シフト JIS 文字セット
}
font = win32ui.CreateFont(fontdict) # CFont インスタンスを作成

# フォントオブジェクトのセット: http://msdn.microsoft.com/ja-jp/library/cc410576.aspx
# フォントをセットすると今までセットされてたフォントを返してくれるので保持しとく。
oldfont = dc.SelectObject(font) 

# 描画: http://msdn.microsoft.com/ja-jp/library/cc428775.aspx
dc.TextOut(0, SCALE_FACTOR * 10 * -1, u"印字") # 1 行目
dc.TextOut(0, SCALE_FACTOR * 10 * -2, u"印字") # 2 行目
dc.TextOut(0, SCALE_FACTOR * 10 * -3, u"印字") # 3 行目

# フォントを元に戻す
dc.SelectObject(oldfont)

# 改ページ処理
dc.EndPage() 

改行処理は手動でする必要があります。私は 1 文字ずつ印字していき、指定した横幅に達したら改行するように制御しています。

# 行の幅と高さを計算: http://msdn.microsoft.com/ja-jp/library/z7e878zz%28VS.80%29.aspx
x, y = dc.GetTextExtent(u"長文 ...")

アライン設定は SetTextAlign を使います。

# テキスト配置フラグ設定: http://msdn.microsoft.com/ja-jp/library/cc428723.aspx
dc.SetTextAlign(win32con.TA_LEFT | win32con.TA_TOP | win32con.TA_NOUPDATECP) # 左寄せ
dc.SetTextAlign(win32con.TA_CENTER | win32con.TA_TOP | win32con.TA_NOUPDATECP) # センタリング
dc.SetTextAlign(win32con.TA_RIGHT | win32con.TA_TOP | win32con.TA_NOUPDATECP) # 右寄せ

ドキュメントを終了する

dc.EndDoc()

プリンタデバイスコンテキストを開放する

dc.DeleteDC()