milk_spoonのブログ

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

Boost Noteの改行をプレビューにも反映させる

markdownで改行書いても改行にならないだろjkと言われたらそれまでなんですが
たかだかメモ書くのに改行2キータッチはコスト大きいと思うんですよね。

github.com
ということで、上記ページに書いてあるとおり
以下をカスタムcssに追加すればエディターの改行もプレビューで改行されます。

p {
  white-space: pre-wrap;
} 

こんな感じでペタっとな。

f:id:milk_spoon:20201220190957p:plain

保存ボタン押し忘れに気をつけて。
以上。

PyCharm Pro版3割引(+PSF募金)

www.python.org

日頃の感謝の気持ちを込めてガッツリ入れようかなと思ったんですが
いざ金額を決めようとしたら悩み…

していたら下の協賛企業のところにJetBrainsが。
なんとPyCharmが3割引の上、売上は全部上記の募金に行くようです。
すごい。
この機会に買っちゃいました。
いやケチケチせずさっさと買っとけよって話なんですが、本当いいタイミングでした。

今回はそんなお知らせだけですが、皆さんもぜひ。

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モードを使いましょう。

boostnote(Boost Note)がリニューアルしてほぼ新アプリ

なにかって言うと、かなりリニューアルしたし、実質新しく作り直したようなので、markdownエディタとしての今までの評価を見て、これからダウンロードする人は気をつけて。
旧版もダウンロードできるよ。

という話。

公式サイトから素直にダウンロードできるのはリニューアル版のみになってた

https://boostnote.io/
ここから目ぼしいダウンロードボタンを押しても、リニューアル版しかダウンロードできない。
最近旧版を使い慣れてきていいな…と思った自分は若干裏切られた感じがしなくはない。

旧版のリンクもある

f:id:milk_spoon:20200115180759p:plain
まあその、ページ下にちゃんと旧版ダウンロードリンクがあるので。

そもそもgitHubのリリースページにいけばありますね。そりゃそうだ。
github.com
(ただのダウンロードリンクならしれっと取り下げられちゃうかと思ったけどgitHub上ならまず消えないでしょう)

boostnoteの評価が指してるのは今のところこの旧版でしょう。
もちろん時間が経てばリニューアル版の評価が出るでしょうけど。
ちなみに旧版はboostnoteって読んでたのをリニューアルを期にBoost Noteとしたようなので、これで新版旧版呼び分ける感じがする。

リニューアル版でよくなったところ

と言っても、リニューアル版使ったわけじゃないんですが
リニューアル版でクラウドスマホ対応したようなので、これは結構機能として大きいんじゃないかなと。
むしろ自分のようにローカルにメモ帳的に使えればいいや、的な方が少数派かな?

まとめ

リニューアル版はあんまりこなれてない感じがするので、アーリーアダプターさんたちによってもうちょっと磨かれるまで旧版使おうかなというただのものぐさ!
でも旧版を今の所かなり気に入ってるので、リニューアル版がいい感じになったらぜひつかいたい。なんだかんだクラウドなら安心だし。

おまけ

旧版でhtml出力する時、デフォルトだとダークテーマ的なcssはついてこない(うまく反映されてない?)けど
うまくいく方法を見つけたのでプルリクでも…と思ったらなんか新版が出ちゃったので
結局ワークアラウンドをここに雑書きする大変非社会還元的なことになりそう。

別PCからgit pushできない

結局

↓のようにremote originのurlにユーザー名とパスワードを入れた。

git remote set-url https://Hoge:Password@github.com/Hoge/repo.git

ら、push成功。
そのままは嫌だったので

git remote set-url origin https://Hoge@github.com/Hoge/repo.git

でパスワード部分は削除
でもこれでもpushうまくいったので、一回認証通すのが必要だった?
できたにはできたけどあまりスッキリ解決せず。

検索したら他の人が5億回くらい同じ問題に当たってたけど、「パスワードもurlに入れました^^」で終わってるところが多かったので
一応あとから削除してもOKよってのを書いときます。

追記

多分Windows資格情報マネージャーに別のアカウントの情報が残ってたせいかも?
コンパネから開いて、gitHub.comに対する資格情報を削除してあげれば、次回git pushであらためてユーザーIDとパスワードを聞かれる…はず。
詳しくは↓で。

詳細

メイン機で作成して色々やってgitHubにpushしたリポジトリを、サブ機でcloneしてから編集してcommitしてpushしようとした。

$ git push
remote: Permission to Hoge/repo.git denied to Fuga.
fatal: unable to access 'https://github.com/Hoge/repo.git/': The requested URL returned error: 403

実はHogeとFuga2つのgitHubアカウントを持ってる。
でもgit configの--globalも--localもついでに--systemも見たけどFugaの情報はない。
これでもかと--globalと--localでuser.nameをHogeに、user.emailをHogeのnoreplyメールアドレスに設定
あとremote originのurlにユーザー名も入れてhttps://Hoge@github.com/Hoge/repo.git
に。
それでもエラーメッセージ変わらず…
ユーザー名入れたんだから改めてパスワード聞いてくれればいいのに!
SSH使うなり、(同じ問題の回答としてよく出てくる)パスワードもremote originのurlに入れてしまえばいいのかもしれないけど
Fugaの情報をどこから持ってきてるのか謎…

でも結局冒頭のようにパスワードをurlに入れて解決。
パスワード入れておくのはイヤなのであとから削除。
削除したあともpushできたので一回認証通ればOKっぽい。

追記

ごめんこれ多分Windowsの資格情報マネージャーのせいでは。
f:id:milk_spoon:20191128063012p:plain

gitがcredential.helperとしてリモートリポジトリの認証情報を記憶管理するオプションが色々あるけど
Windowsの場合wincredという設定があって、これが資格情報マネージャーを使うらしい。
いや当然といえば当然感…
Git - 認証情報の保存

多分上の資格情報マネージャーの画面で、github.comに対する情報を消してあげれば
gitでpushする最初のときにユーザー名とパスワードを聞かれるはず。
で、以降は記憶されるのでパスワード入力しなくて済む。
でも結果として同じように解決するなら、やっぱり↑のように一回ユーザーIDとパスワード入れてpushしてからURL変えるほうがラクかも。

サブ機も前に色々開発で使ってたので、多分Fugaアカウントの情報が残っててそっちを見に行ってたっぽい。
なんでgitがFugaの情報を吐くのか(知ってるのか)怖かったけど出どころがわかって安心。

PySide2+Pillowでコード-1073740771 (0xC000041D)エラー

前の記事ではwxPythonが~PyQtが~って言ってたんですが結局PySide2やってます。
ほとんどPyQt5と違いは無いらしいけど、ライセンスがLGPLなのでちょっと安心かなと
(あまり皆気にしてないような気もする)

2020/12/20追記

PySide2で開発してて、こんな感じでエラーコードしか出なくて詳しいエラーがわからなくなったとき
もしPyCharm等のIDEから実行してる場合は一度素のpythonインタプリタから実行してみると文字列でエラーが出る時がある。

あと大抵はオブジェクトが勝手に破棄されるエラーのような気も。
PySide2関連のオブジェクトを作ったらとりあえずコンストラクタのparent(QObjectを継承してるなら必ずある)を設定して親子関係にしてあげるか、クラスのメンバとして保持してあげる。
QThreadを作ってjoinしないで実行しっぱなしだからってparentにも設定しないメンバにも持たないさようなら~だと死。

本題

で、タイトルの通り…
Pillowを使って画像をいじり、QPixmapでPySide2のGUI画面上に表示しようとしたら
コード-1073740771 (0xC000041D)でエラーになりました。
f:id:milk_spoon:20191123143653p:plain

結論から言うと、QPixmapを作るときにcopyするとOKでした。

こうしていたのを

#image: PIL.Image
qtimg = ImageQt(image)
pixmap = QPixmap.fromImage(qtimg)

こう。

#image: PIL.Image
qtimg = ImageQt(image)
pixmap = QPixmap.fromImage(qtimg).copy()  #★copy

ここの回答ほぼまんまで解決しましたが、エラー内容は違うかな…?
stackoverflow.com
下で書きますが、QGraphicsViewあたりもいじってたので原因特定がちょっと大変でした…

具体例(半分雑記)

PillowのImageGrabでスクリーンショットを撮って
PySide2のQGraphicsViewに表示させよう~としたらハマりました。
再現できるコードはコチラ。

#main_form_ui.py
#UI部分のみ
from PySide2 import QtCore, QtGui, QtWidgets

class Ui_MainForm(object):
    def setupUi(self, MainForm):
        MainForm.setObjectName("MainForm")
        MainForm.resize(400, 300)
        self.horizontalLayout = QtWidgets.QHBoxLayout(MainForm)
        self.horizontalLayout.setObjectName("horizontalLayout")
        #QGraphicsViewを配置
        self.viewMain = QtWidgets.QGraphicsView(MainForm)
        self.viewMain.setObjectName("viewMain")
        self.horizontalLayout.addWidget(self.viewMain)

        self.retranslateUi(MainForm)
        QtCore.QMetaObject.connectSlotsByName(MainForm)

    def retranslateUi(self, MainForm):
        MainForm.setWindowTitle(QtWidgets.QApplication.translate("MainForm", "Dialog", None, -1))
#main_form.py
#フォームの実装とmain
from PySide2.QtWidgets import QApplication, QMainWindow, QDialog, QGraphicsScene,QGraphicsPixmapItem
from PySide2.QtGui import QPixmap
from PIL import Image, ImageGrab
from PIL.ImageQt import ImageQt
from main_form_ui import Ui_MainForm
import sys

class MainForm(QDialog):
    def __init__(self,parent=None):
        super().__init__(parent)
        self.ui = Ui_MainForm()
        self.ui.setupUi(self)

        scene = QGraphicsScene(None)
        #スクリーンショットを取得、QGraphicsPixmapItemになるまで変換してsceneに追加
        image = ImageGrab.grab()
        imgqt = ImageQt(image)
        #★ココ!
        pixmap = QPixmap.fromImage(imgqt)#.copy()
        pixmap_item = QGraphicsPixmapItem(pixmap)
        scene.addItem(pixmap_item)

        self.ui.viewMain.setScene(scene)

if __name__ == "__main__":
    app = QApplication(sys.argv)

    window = MainForm()
    window.show()

    sys.exit(app.exec_())

ウィンドウにQGraphicsViewが貼ってあって、そこに起動時にImageGrabで撮ったスクショを貼るだけのコードです。

問題の箇所は★ココ!の部分で、ImageGrabで取得したスクショ画像をQPixmapにするところですね。
ちなみにImageGrabではなく、Image.openで画像ファイルを開いたものを渡したときはQPixmapをcopyしなくても動作していました。
StackOverflowの回答にあるように、特定状況でリソースが解放されてしまう感じなんでしょうか。

こんな感じで。
PySide2楽しいです。皆どんどんGUIpythonで書こう~

wxPythonで画像が切れたり余分に表示されてしまうときの対処

PythonGUI書きたいよね、と思ってwxPythonやってます。
QtはC++で書いたことあるし、PyQtのほうが良いんじゃ…と思いつつ。
正直書きやすく分かりやすくはないので人にオススメするかと思うとうーん
定数でレイアウト指定とかして、Pythonっぽくない…

でもまあ頑張ればそれなりに書けるので簡単なツールを手元で作ってます。
画像ビューア+αみたいな感じだけど、一つ詰まったことが…

下のようなサイズ違いの2つの画像を切り替えてGUIに表示すると
f:id:milk_spoon:20180108221240p:plain
f:id:milk_spoon:20180108221244p:plain

こんな風にめちゃくちゃな表示に(´;ω;`)
f:id:milk_spoon:20180108224707p:plain
f:id:milk_spoon:20180108224713p:plain

原因は、画像データを更新した後に画面のRefreshメソッドを呼んでいなかったことでした。
実際使うものを作ったときには画像が欠けてたりもして結構解決に時間をとりましたが、分かってみるとそりゃそうか、という感じです…
抜き出しでわかりづらいですがGUI上の画像変更のコードを以下のようにするとうまくいきます。

    #wx.Frameを継承したメイン画面クラスのメンバ
    def UpdateDisplay(self):
        #画像ファイルパスを指定して読み込み
        img = wx.Image(self.imagePaths[self.index])
        #StaticBitmapコントロールにセットする
        self.image.SetBitmap(img.ConvertToBitmap())
        #画面を更新する←これが抜けていた
        self.Refresh()

実際作ってるものはメインのwx.Frame継承クラスの子にさらにwx.Panelが何重かネストしてその中に画像があったりするんですが、一番上のself.Refreshだけでちゃんとなるので
あまりコントロール間の事は考えずself.Refreshが楽そうです
(wx.Window継承クラスならRefresh呼べるので一部Panelだけ更新ももちろんできるはず)

wxPythonで画像が欠けたり変な表示になってたらself.Refresh()!ということで
同じところで詰まる人がいませんよーにと祈ります。