milk_spoonのブログ

さじの情報科学的活動、考えたこと、その他を雑記するためのブログです。

LGPLライブラリとpyinstaller

免責事項

この記事は「この方法であればLGPLに準拠しながら独自コードを公開せずpyinstallerで作成したプログラムを配布できる」ことを保証するものではありません。

また、断定で書いている部分も、非常に限られた環境・ライブラリでしか確認していません。
環境によっては処理に失敗する・結果が不完全であることがあり得ます。

確認version

Python 3.8.6
以下の2パターンで確認

  • 環境をvenvで作成した直後、1パッケージ1モジュールと、Hello Worldレベルのごく簡単な処理を書いた状態
  • 下記の通りPySide2を導入し、簡単なGUIアプリを作成した状態
altgraph==0.17
future==0.18.2
pefile==2019.4.18
pyinstaller==4.1
pyinstaller-hooks-contrib==2020.10
PySide2==5.15.2
pywin32-ctypes==0.2.0
shiboken2==5.15.2

本題

LGPLのライブラリ・pythonパッケージをpyinstallerで固めると、exeの中に埋め込まれる=分離して改変できないので静的リンク扱いになり、自プログラムのソースコードを公開しなければいけない…
というところをずっと悩んでましたが、動的リンク(的)にする方法がわかりました。
ただしone-folderモードの話です。

pyinstallerの結果を動的リンク(的)にするには、pyinstallerコマンドの実行時に--debug noarchiveオプションを渡します。
または、.specファイルでビルドしている場合には.specファイルのAnalysisオブジェクトのnoarchive引数をTrueにします。

# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(['main.py'],
             pathex=['C:\\Users\\spoon\\Dev\\pyinst_prac'],
             binaries=[],
             datas=[],
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=True) #★ココ

# 以下略

これを行うと、exeの中に含まれていたファイルがexeと同じフォルダに直接出力されます。
今まで確認した限りでは、exeに含まれているのファイルはすべてpycファイルのようでした。
pycファイルについてはここで詳しく解説はしませんが、pythonソースコードから作成可能です。
そしてこの出力されたpycファイルを、自分で作成したpycファイルに差し替えても正常に動作させることができます。
つまりこれは動的リンク(的)に、exeから切り離されて、プログラム実行時にのみ参照している形であり
LGPLのエッセンスである「LGPLライブラリをユーザーが自由に改変して差し替えてプログラムを動作させることができる」という要素に準拠していると言えそうです。

ただ、「exeの中に含まれていたファイルが出力される」と書きましたが
冒頭に書いた通り結果が不完全である可能性があります。
以下の詳細セクションに従って、何がexeに含まれていたのか、それがどのように出力されたのかを確認することをおすすめします。

詳細

そもそも、上記オプションを使わない場合にexeにライブラリが本当に埋め込まれているのか?というところです。
これを確認するために、pyinstaller付属コマンドツールのpyi-archive_viewerを使います。
これに、まずはnoarchiveオプションを使わず普通にビルドしたexeを渡して、その中身が見ます。 pyi-archive_viewer .\dist\main\main.exe

 pos, length, uncompressed, iscompressed, type, name
[(0, 280, 369, 1, 'm', 'struct'),
 (280, 1103, 1847, 1, 'm', 'pyimod01_os_path'),
 (1383, 4034, 8700, 1, 'm', 'pyimod02_archive'),
 (5417, 5357, 12401, 1, 'm', 'pyimod03_importers'),
 (10774, 1818, 4025, 1, 's', 'pyiboot01_bootstrap'),
 (12592, 1153, 2099, 1, 's', 'pyi_rth_multiprocessing'),
 (13745, 270, 333, 1, 's', 'pyi_rth_pyside2'),
 (14015, 573, 882, 1, 's', 'main'),
 (14588, 1684727, 1684727, 0, 'z', 'PYZ-00.pyz')]
?

ひとまず、PYZ-00.pyz以外はpyinstallerのランタイムフックと自分のコード(main)のみである様子が確認できます。 ここからさらにO PYZ-00.pyzと打つと、このpyzファイルの中身が見れます。

 'urllib': (1, 1545339, 70),
 'urllib.error': (0, 1545409, 1316),
 'urllib.parse': (0, 1546725, 13664),
 'urllib.request': (0, 1560389, 31725),
 'urllib.response': (0, 1592114, 1398),
 'uu': (0, 1593512, 2180),
 'webbrowser': (0, 1595692, 7785),
 'xml': (1, 1603477, 422),
 'xml.parsers': (1, 1603899, 214),
 'xml.parsers.expat': (0, 1604113, 229),
 'xml.sax': (1, 1604342, 1732),
 'xml.sax._exceptions': (0, 1606074, 2204),
 'xml.sax.expatreader': (0, 1608278, 5354),

おそらくこんな感じで、数々のモジュール名が出てくるかと思います。
これらがexeに埋め込まれている外部ライブラリです。つまり、LGPLライブラリが含まれている可能性があります。
本題のところで書いたやり方を行うと、これらのライブラリが外だしされます。 …が、結果が本当に問題ないのか確認したいと思います。
同じように、今度は本題に書いたnoarchiveオプションを使ってビルドしたexeの中身を見ます。 まず、直下は同じ構成かと思いますので、PYZ-00.pyzを見ます。

 Name: (ispkg, pos, len)
{}
?

こんな感じでエントリが空になっていればexeに埋め込まれていたライブラリは外だしされていることになります。

なお「noarchiveを付けたときと付けないときとで、exe直下にあるファイルが異なっていた」「noarchiveを付けてもpyz内にファイルが存在していた」場合はまだ確認していません。
この場合、「LGPLライブラリをexeに埋め込まない」ことができてない可能性がありますので確認が必要です。

なお、ここから先は若干おまけ的な話ですが…
pyzに埋め込まれるライブラリは、.spec内でも見ることができます。
参考リンクの記事に詳しくありますが、.specファイルはpyinstallerのビルド中に実際にpythonコードとして実行されます。
また、デフォルトで書かれているAnalysis等のオブジェクトは様々な情報を解析の上保持しており、その情報を変更するコードを記述することで、ビルド結果を変えたりも可能です。

pyzファイルに埋め込まれるライブラリはPYZオブジェクトに渡されているものです。
自分が確認したかぎりではa.zipped_dataは常に空でした。なので実質a.pureのみPYZに渡されています。
このaは上で作成しているAnalysisオブジェクトです。
このオブジェクトはpyinstallerに渡されたソースを解析の上、様々な情報を保持しています。
Analysisによって特定されたライブラリの情報がa.pureに格納され、PYZに渡されてexeに埋め込まれる、という流れになっていますね。
また、a.pureはこの時点ではまだビルド情報であり、pycファイルそのものではなくモジュール名と.pyソースコードの場所が格納されています。
なのでここからpyzファイルに埋め込まれる予定のソースコードを取り出すこともできます。
また、a.pureから要素を取り除くことでpyzファイルに含まれないようにできます。
実際に、a.pureからライブラリを取り除き、そのライブラリのソースコードをexeと同じ階層に配置しても正常に動作させることができました。
(ただしこれはモジュールがルートにある場合で、パッケージに入っている場合はそのパッケージを再現した配置にしないといけません)

最初はこのa.pureからライブラリ情報を取り除きPYZに含まれないようにする+ビルド情報からソースコードをコピーする
という方法でライブラリを外出ししようと思いましたが、丁度目的に合うビルドオプションが見つかり一件落着となりました。
このようにゴリゴリ書いた場合、元のモジュールのフォルダ階層も再現する必要があり、少し大変そうだったので助かりました…

おまけとは書きましたが、noarchiveライブラリの結果が万が一不完全の場合.specファイルの情報から対処するという手は残っていそうです。

参考リンク

pyinstaller公式ドキュメント、pyinstaller動作の詳細
使い方のページも今一度じっくり読んでみると、困ったときに今回のように良いコマンドを見つけられると思います。

pyinstaller使いつつGPL/LGPLに準拠するには?という記事
one-folderモードではデフォルトで外だしされているバイナリ(dll, pyd)を抽出していたりするのでone-fileモード前提?
.specファイルから取れる情報の話がとても助かりました。

終わりに

正直ライセンスの話のややこしさの割に情報が少ないところを見ると、結構皆適当にやってるんじゃないか…?と勘ぐりたくなります。
特にpyinstaller+PySide2なんてあまりによくある組み合わせのように思うのですが…(公式すらpyinstallerの使い方ページありますし)
有名所のソフトのオープンソースライブラリ表示見ても、それでApacheBSDの依存ライブラリ全部なの???って数しかないですし…
依存ライブラリの依存ライブラリは辿れるから直接表示していないとか…?
そのへんの塩梅も微妙というか、議論をあまり見ないような…

ただ、今回の結果も…exeに埋め込まれているのはpureなpythonモジュールだけのようです。
ほぼビルトインモジュールのみ…? あまり意味なかったのかな?
まあ、懸念はここだけなので問題がなければそれでOKなのですが。

ぜんぜん違う話ですが、one-fileモードはもともと色々仕様がややこしいので好きではないです。
one-folderモードではデフォルトで外出しされているバイナリも組み込まれてしまうのでさらに話は厄介になりそうです。
one-folderモードを使いましょう。