Google Cloud PlatformのCloud SQL(MySQL)のインスタンスをつくってみました

 今日も見に来てくださって、ありがとうございます。石川さんです。今夜は中秋の名月です。と言っても書き始めたのが遅いし、記事も長くなりそうなので、書き終わって公開するときには、中秋の名月は終わっているでしょうね。

 さて、今回は、ちょっと要望がありまして、GCPのCloud SQLを使ってみることにしました。ぼくはOracleマスターということもあり、普段データベースを使うと言えば、Oracleを使っています。そう、RDBMSシェアNo1のオラクルさまです。ただ、GCPではOracleのインスタンスが作れるようになっていないのですよね。Oracleもクラウドサービスを展開していますから、ライバル関係ということになるのかなぁ。そんなこともあり、GCPのCloud SQLのラインナップには登場しそうにありませんよね。

 所有している書籍やWeb上のGCPのドキュメントを色々と読んで、バッチリ理解してから始めようと思っていたのですが、いつまでたっても一向に始められません。こういう時は、まずやってみる、ということが大切ですよね。ということで、スクリーンショットを取りながら進めてみたいと思います。

新規プロジェクトの作成

 では、まずはGCPコンソールへ行って、プロジェクトを作成しましょう。三角形をクリックします。

Google Cloud Platformダッシュボードでプロジェクトを新規作成

 今回は、プロジェクト名を「Sample-DB」にしてみます。上記の三角形をクリックすると以下のようなウィンドウが表示されなりますので、「新しいプロジェクト」をクリックします。

新しいプロジェクトを作成します

 プロジェクト名は、「Sample-DB」に入力しなおして、あとはデフォルトのまま「作成」ボタンをクリックします。

 すると、アイコンが表示されたり、なにやら作成中の雰囲気を出したあと、プロジェクトのダッシュボードが表示されました。が、、、これは今作ったプロジェクトではありませんね。そこで下向きの三角形をクリックしてプロジェクト一覧を表示します。

作ったプロジェクトではないプロジェクトが選択されてしまった

 すると、以下のような一覧が見えますが、先ほど作ったプロジェクトが見当たりませんね。以下の「すべて」のところをクリックするか、虫眼鏡アイコンの隣の「プロジェクトとフォルダを検索」へ「Sample」と入力すれば、先ほど作成したプロジェクトが現れます。

なぜか先ほど作ったプロジェクトが見つからない

 では、「すべて」をクリックしてみましょう。

先ほどつくったSample-DBプロジェクトが見つかりました

 見つかりましたので、「Sample-DB」を選択して「開く」をクリックしてみましょう。以下の通り、プロジェクトの作成に成功しました。プロジェクトを作っておくと請求情報をプロジェクト単位でみられる等、管理しやすくなると思います。

Sample-DBプロジェクトができました

Cloud SQLのMySQLインスタンスを作成してみる

 では、次にCloude SQLのMySQLを作成してみます。ナビゲーションメニューの「データベース」カテゴリから「SQL」を選択します。左上の横線三本のハンバーガーマークをクリックするとナビゲーションメニューが表示されますので、メニューを下の方へ下げて行って、「SQL」を探してください。

「SQL」を見つけました

 「SQL」をクリックするとCloude SQL インスタンスを作成するためのウィザードが始まりました。次は、当然「インスタンスを作成」ですね。既存システムのデータを持っていれば、データの移行もできるようですね。企業システムでのリプレース案件だと結構利用するタイミングがありそうなので、気になりますね。いやいや、今回はまずインスタンスを作成しますよー。(ちょっと調べてみたら、今のところ、MySQLとPostgreSQLで利用可能で、SQL Serverは、まもなく利用可能ということです。2021年9月23日現在)

インスタンスを作成ウィザードが開始されました

 よく読んでみると、「MySQL」「PostgreSQL」「SQL Server」のインスタンスが作成できるようですね。では、「インスタンスを作成」ボタンをクリックします。以下のような表示になりました。今回は「MySQL」を選択します。

データベースエンジンの選択ができます

 「MySQL を選択」をクリックすると以下のような画面になりました。

インスタンスを作成するには、まずCompute Engine APIを有効にする必要があります。

 ふーむ、何々、「インスタンスを作成するには、まずCompute Engine APIを有効にする必要があります。」ですか。なるほど。と、いうことはMySQLインスタンスはCompute Engineを利用して実現しているのですね。いきなりMy SQLインスタンスって、どうやって起動するんだろうか、と、思っていましたが、仮想マシンを実行して、その上で動かす、ということなのでしょうね。ま、完全な想像ですけど。そして、このホームページはCompute Engineを利用しているので、この設定は不要なのでは、と、思いましたが、プロジェクトごとに設定が必要なのか、APIとは別の設定なのかのどちらかでしょうね。いずれにせよ、APIを有効にしないとどうにもならないので、「APIを有効にする」をクリックします。

APIを有効にしています。

 しばらく上記のようにくるくるしてから、以下のようになりました。

MySQLインスタンス作成の初期状態

 必須入力は、「インスタンスID」と「パスワード」のみですね。今回はお試しで作ってみるだけなので、最小構成にしたいですね。データベースのバージョンは、8.0、5.7、5.6から選べました。おそらく初期値の「5.7」は安定したバージョンなのでしょうね。今回はお試しなので、8.0を選択しておくことにします。インスタンスが作成され、常時存在することになるので、インスタンスを起動している間は使用料がかかります。金額については、概算ですが、Cloud SQL for MySQL の料金で、計算できるようです。リージョンの選択によって若干金額が変わってきます。現時点のアイオワでは、1仮想CPUあたり、1か月で$30.15、1GBあたり、1ヶ月で$5.11です。東京は$39.19、$6.64なので、やはり若干高くなっていますね。業務で利用する場合は、ネットワークの距離の影響もあるので、慎重に選ぶ必要があるかと思いますが、今回は安い方のアイオワで問題ありませんね。そして「ゾーンの可用性」ですがこちらは「シングルゾーン」に変更しましょう。複数のゾーン(高可用性)を選択すると、およそ料金が二倍になります。ま、二台用意しておいて、一台がダメになったとき切り替えてダウンタイムを極小にしよう、という取り組みですから、ま、そうなりますよね。そして、vCPU数が4に、メモリ、26GBもいらないでしょう。こちらは、「構成オプションを表示」を選択することで設定できるようになっていました。

構成オプション

 へえ、インスタンス構成は、あとから更新することもできるのですね。ま、ちゃっちゃと最小構成を目指して片づけてしまいましょう。まずは、マシンタイプですね。

マシンタイプ(ハイメモリ)

 おや、4 vCPU、26GBが最小構成なのでしょうか、、、あ、違いました。「ハイメモリ」が選択されている状態でしたね。ちょっとクリックして中身を覗いてみましょう。

マシンタイプの一覧

 なるほどねぇ、順番的には「共有コア」が一番軽量なのでしょうか。以下に、一覧にしてみました。

  • 共有コア
    • 1vCPU、0.614GB
    • 1vCPU、1.7GB
  • 軽量
    • 1vCPU、3.75GB
    • 2vCPU、3.75GB
    • 4vCPU、3.75GB
    • カスタム※
  • 標準
    • 1vCPU、3.75GB
    • 2vCPU、7.5GB
    • 4vCPU、15GB
    • カスタム※
  • ハイメモリ
    • 4vCPU、26GB
    • 8vCPU、52GB
    • 16vCPU、104GB
    • カスタム※

※カスタムは、vCPUが1~96まで入力可能(1より大きい場合は偶数)で、メモリがvCPU数(N)に応じておよそ、3.75+1.75×(N-4)/2+0.25×INT((N-2)/10)[N<6は3.75]~6.5×N GBの範囲で変更されました。

 と、いうことで、今回は「共有コア」の1vCPU、0.614GBを選択します。

 次は、ストレージですね。

ストレージのデフォルトの設定値

 SSDとHDDですね。やはりHDDの方が安価ですね。どれくらい違うのかというと、1GBあたり、アイオワリージョンでは、SSDが$0.170/月、HDDが$0.090/月、東京リージョンでは、SSDが$0.221/月、HDDが$0.117/月ということのようです。パフォーマンス要件はありませんので、ま、ここはHDDを選択しておきましょう。

 ストレージ容量は、10GB、20GB、100GB、200GB、カスタムですが、こちらも最小要件の10GBを選択します。容量を増やすほどパフォーマンスが向上する、ということが書いてありますが、どうしてでしょうね。もしかしたら、ディスクへのアクセスをうまく分散できるのかも知れませんね。ストレージの自動増量を有効にする必要は、まったくありませんが、ま、利用しなければ影響ありませんので、そのままにしておきましょう。

 次は、「接続」ですね。

「接続」の設定

 おすすめは、Cloud SQL Ploxyを使って接続してください、ということのようです。プライベートIPはVPCのみの内部(プライベート)IPアドレスで、インターネットにアクセスできるのが外部(パブリック)IPアドレスになります。Webアプリケーションの一部としてのデータベースなら、プライベートIPアドレスのみ有効にすればよいでしょうか、これは構成によりますね。今回はお試しなので、このままの設定でよいでしょう。

 次に、バックアップの設定ですね。今回のお試しではバックアップなしでよいのですが、ちょっと内容は確認しておきましょう。

バックアップオプション

「バックアップを自動化する」はデフォルトでチェックが入っていますが、時間枠で選択するようですね。マルチリージョンを選択しておけば複数のリージョンにバックアップされるのでとりあえず安心ですね。リージョンを選択すると、特定のロケーションをセットする必要があります。バックアップの数が7にセットされていますが、1週間であれば、特定のタイミングまで戻せますよ、という感じでしょうか。ポイントタイムリカバリを有効にすると、詳細オプションにログを保持する日数を設定できるようになりました。デフォルトは7日でした。
 ま、ここは、「バックアップを自動化する」はチェックを外しておきます。サンプルですから、バックアップ必要ありません。実際に稼働するシステムならもっとまじめに考えないといけませんけどね。あ、このチェック外すと「ポイントインタイムリカバリを有効にする」は触れなくなりました。普通のバックアップも取得していないのに、復元はムリですよ、ということですね。

 次は、メンテナンス項目ですね。ちょっと見てみましょう。なるほどねぇ、デフォルトだと、短期間だけど中断してしまうのですね。

メンテナンスオプション

 メンテナンスの時間枠は、「おまかせ」以外は「土曜日」~「金曜日」でした。曜日単位で指定するのですね。一般的な企業なら、土日が選ばれるのでしょうね。更新の順序は「任意」「後で」「早め」の三択でした。これは面白いですねぇ。バージョンアップを適用する時期を早めにするのか後でやるのか、それともお任せなのか、設定するのですね。あと、メンテナンス不要期間も設定できるのですね。今回はお試しで、すぐ削除してしまおうと思っているので、設定はこのままにしておきます。

フラグの設定

 「フラグ」では、MySQLのパラメータやオプション、インスタンス構成や調整など、様々な設定ができるようです。これはMySQLに精通しないとなかなか設定は難しそうですね。今回は不要ですが、チューニングや調査が必要な局面があるときにはここで設定可能ですね。いざというときのために覚えておきましょう。

 やっと、最後の項目、「ラベル」です。

ラベルの設定

 「ラベル」を追加すると、インスタンスを区別したり、請求料金を分析したりすることができるようです。今回は放置しておきます。

やっとひととおりの設定を眺め終わりました。

インスタンスの作成

 やっと、「インスタンスを作成」をクリックしました!ここまで読んでくれたひと、いますかねぇ。。。

 あら、インスタンスIDと、パスワードがない、と、叱られました。もうすっかり失念しておりました。

エラーが発生しました

 インスタンスIDは、何でもよいのでしょうかねぇ。「Sample-DB」を入れてみます。そして、パスワードは「生成」をクリックしてみましょう。

またもやエラーです!

 ちょっと理解に苦しみますが、、、これは、大文字がダメだった、ということでしょうか。小文字に変えてみます。予想通り、小文字なら大丈夫でした。「生成」も成功したので、今度こそ、「インスタンスを作成」をクリック!

 一瞬、「インスタンスを作成しています。」のようなメッセージが出た後、画面が切り替わり、しばらく「アップロードとSample-DBのオペレーション」の「sample-dbを作成しています」が右下に表示されます。

インスタンスを作成しています

 まだ、作成中のような気がしますが、一応、できたのかな。いや、もうしばらく待ってみましょう。。。と、いうことでインスタンス作成完了まで、11分とちょっとかかりました。できました!

データベースの作成

 次はデータベースを作成します。MySQLはデータベースを複数作成できる、ということで作成してみます。

 Cloud SQLの「データベース」メニューを選択します。

データベースを選択したところ

 「データベースの作成」をクリックしてみます。

データベースの作成

 「データベース名」は何にしましょうかねぇ。ルールがあるようですが、とりあえず「Sample-DB」と入力してみます。

Sample-DBではエラーになりました

 おお、なるほど、英数字とアンダースコアなので、ハイフンがダメなのですね。ハイフンをアンダースコアに変更してみます。

Sample_DBならOKでした

 オッケーでした。「文字セット」と「照合」を決めなければいけませんね。utf8はいいけど、「照合」って何でしょうね。選択値は「utf8_bin」とか「utf8_czech_ci」とか文字コードっぽいですね。選択した「文字セット」によって、選択値が変わりました。ま、後で変更できるようなので、デフォルトでも問題ないでしょう。「作成」をクリックします。一瞬作成中のような雰囲気になりましたが、すぐにできました。「照合」は「utf8_general_ci」が選択されたようですね。

データベース一覧に「Sample_DB」が追加されました。

テーブルの作成

 次にテーブルを作成します。まずは、MySQLに接続しなければいけないのですが、、、どうやるのでしょうか。「概要」の「このインスタンスと接続」にありました!

このインスタンスとの接続

 インスタンスに接続するさまざまな方法については、ドキュメントに書いてあるようですが「詳細」はあとにして、「CLOUD SHELLを開く」をクリックしてみました。

ターミナルが起動しました

gcloud sql connect sample-db –user=root –quiet

というコマンドで接続できるようです。Enterキーを入力したところ、、、

Cloud Shellの承認画面

 Cloud Shellの承認画面が表示されました。「承認」をクリックします。

エラー発生!

 おお、アクティブなアカウントが選択されていない、と申されましても。。。現在どのアカウントでログインしているのでしょうか。試しにもう一回実行してみます。おや、エラーが変わりました。

違うエラーが出ました!

 今度は「Cloud SQL Admin API」がこのプロジェクトではまだ使われておらず、有効になっていない、ということのようです。APIを有効にするには、表示されたURLをクリックせよ、ということでクリックしてみますと、ブラウザで別のタブが開きました。

Cloud SQL Admin APIを有効にしよう

 なるほど、「gcloud sql」コマンドを使うには、このAPIを有効にしないといけないのですね。承知いたしました。「有効にする」をクリックします。くるくるして、次の画面が表示されました。

Cloud SQL Admin APIの画面

 とりあえず、Cloud SQL Admin APIが有効になったようです。ターミナルから再度実行してみます。

5分、待ちます

 おお、コネクションのために5分必要なのでしょうか。。。パスワードを聞かれたので、先ほど自動生成したパスワードを入力して、Enterキーを押下すると、、、できました!!!

接続できました!

 先ほど作ったデータベースを確認してみましょう。「show databases;」と入力してみます。

データベースの確認結果

 次に、データベースの選択ですね。「use Sample_DB;」を実行して、テーブルが無いことを確認します。「show tables;」を入力してEnterキーを押下します。

データベースの切り替えとテーブル一覧の表示

 やっと、テーブルが作成できます!「create table sample_table ( id int auto_increment, t text, primary key(id) );」実行してみます。さらに結果を「show tables;」で表示してみます。

sample_tableを作ってみました。

 データをインサートしてみます。

insertを実行

 入りました。念のため、結果を確認します。

SELECT成功!

できました!

インスタンスの停止

 サンプルでしか使わないインスタンスですので、すぐに終了します。インスタンスが存在していると、利用していなくても放置しているだけで、課金されてしまいます。

 念のため、先ほどの接続ですが、データをコミットして、終了します。

commit;してexit;

 「概要」から「停止」をクリックします。

「停止」メニュー

 すると、以下のようなメッセージが表示されますので、「停止」をクリックします。

 停止しました。

追記(2021-09-30)

 作成直後に確認したときは、請求予定金額が0円でした。作成には請求がかからないようになっている、というのもちょっと変な話なので、タイミングを改めて請求金額を確認してみようと思っていて、確認してみました。

お支払いレポート

 およそ一週間で176円、ということでした。データベースインスタンスを停止したので、もう課金は発生しないはず、というふうに思っていたのですが、予想が外れました。一体何に課金されているのでしょうか。ということで見てみました。

  • Cloud SQL for MySQL: Zonal – IP address reservation in Americas 139.62 hour ¥154
  • Cloud SQL for MySQL: Zonal – Low cost storage in Americas 1.97 gibibyte month ¥19
  • Cloud SQL for MySQL: Zonal – Micro instance in Americas 2.19 hour ¥3

 上から青い線、赤い線、オレンジの線、の順番です。オレンジは最初だけですが、他のは安定していますね。青はIPアドレスの予約、ということなので、利用していなくても課金されてしまう、ということなのでしょうね。赤は、安いストレージ、ということなので、容量を使用している分が、課金されている、ということですね。オレンジはインスタンスなので、停止したから課金されない、ということのようです。微々たる金額ではありますが、使わなくても存在しているだけで請求が発生することがわかりました。IPアドレスは毎日およそ30円なのですね。

まとめ

 やはり、書籍やWebで調べることも大事ですが、実際に試してみてよかったです。かなり長くなってしまいましたが、ここまで読んでくださって、ありがとうございます。Cloud SQLのMySQLインスタンスの作り方がわかりました。実際の業務で利用しようと思うと、クライアントのPCから接続する方法とか、他のGoogle App Engineサービスなどから接続する方法など、もうちょっと調べる必要があると思います。とりあえず、試せる環境ができました。

骨伝導ヘッドセットAFTERSHOKZ OPENCOMMを購入しました。

 今日も見にきてくださって、ありがとうございます。石川さんです。暑い日が続きますが、みなさまいかがお過ごしでしょうか。

 ついに、買いました!そう、タイトルの通り、骨伝導のヘッドセットAFTERSHOKZ OPENCOMMです。随分前になりますが、骨伝導のヘッドフォンが発売されていたのをビックカメラの店頭で見かけて、「欲しい!」と思っていたのですけど、普通のヘッドフォンと比べるとかなりお高くて、ずっと保留にしていたのでした。この度ご縁があってウェブセミナーのようなことを始めることになりまして、お仕事で使うんだから、ヘッドセットもいいやつを買っちゃいましょう!と、ポチリとやっちゃいました。19,998円、やっぱり高いですねぇ。

 そして、今日、届きました。外観はこんな箱に入ってます。

 箱を開けてみたところ、意外にケース付きでした。

 なるほどね、ぴったりケースにはまるように作られているわけですね。これで移動中に曲げて壊したりする心配はありませんね。ただ、ケースがちょっと大きいからジャマかもなぁ。他にちょっと気になるのは、電源部分ですね。付属の専用コードがないと充電できません。マグネット式でカチッと収まるのはいいのですけど、USB-Cとかじゃないのですね。ま、仕方ありませんね。

 ペアリングは、電源をオフにした状態から開始、「+」ボタンが電源兼用になっていて、これを長押し(5秒)すると電源が入ってペアリングモードになる、と。そして、端末側で選択すれば、オッケー、と、簡単ですね。iPad ProでもAndroid端末でも、すぐに設定できました。

 ちょっと使ってみた感じでは違和感もなく、いい感じです。後頭部にワイヤーがあるので、背もたれにもたれられませんが、気になるのはそれくらいでしょうか。やっぱり最大のメリットは、耳を塞いでいない、ということですよねぇ。耳を塞いでいないのに、音が聞こえてくる、という体験は、不思議な感じです。第2の耳ができた、という感じですかね。

 また気が向いたら使用感などレポートしてみます。

Python プログラミング Anaconda3 再起動後、なぜか Spyder が起動できない!

 いつも見にきてくれてありがとうございます。石川さんです。スイカの季節ですね。いや、今ならオリンピックの話題が優先でしょうか。オリンピック、あんまり興味が持てないんだよねぇ。せっかく東京でやるのにね。

概要

 Anacondaに付属してくるSpyder IDEを使っていたのですが、パソコン再起動後、突然起動しなくなりました。原因はプロジェクトを利用するときに、日本語のファイル名を使ったことでした。Bugのようですが、対応方法について記載しておきました。

経緯

 最近、Pythonのスクリプトの編集、実行には、AnacondaをインストールするとついてくるSpyderを使っています。しかし、今日は、パソコンを再起動したあとに、Spyderを起動するとなぜか立ち上がらなくなってしまいました。えっ、どうした、Spyderよ。立ち上がって来いよ~。そんなに貧弱じゃないだろ?と、言いつつ、Anacondaプロンプトですらヒストリが読めなくて起動時にエラーを出力していたことを思い出しました。何か、入力しちゃいけない文字コードを入力してしまったのでしょうか。しかし、起動した後に、スプラッシュ画像が出て、そのままお亡くなりになるなんて、聞いてないよ~。少なくともエラーメッセージを出して欲しいよ。どうしようもないじゃない、Spyderさん。

まずはSpyderを開始できるようにします

 さて。気を取り直して、実行プログラムのありかを探して、まずはそこからでしょうか、と、思って実行プログラムを見ると、おや、今まで気にもならなかったプログラムがあるじゃないですか!そう、「Reset Spyder Settings (anaconda3)」です。こんなの、今まで、気にしたこともなかったなぁ。

Reset Spyder Settings(anaconda3)

 さて、実行したところ、何やら設定がリセットされたような気がしますので、再度実行してみます。心なしかスプラッシュ画像が小さくなったような気がします。何とか立ち上がりました。アイコンも心なしか小さくなったような気がしますねぇ。。。ま、それはいいか。

アイコンが小さくなったような気がする

原因を追究します

 直前に作業していたのは、、、と、記憶をたどると、そうそう、新しくプロジェクトを作成して、そこにドキュメントフォルダをつくって、Sphinxのドキュメントを構築しようとしていたんだった、と、いうことを思い出しました。ということで、その新しいプロジェクトを再度開いてみると、、、おお、エラーか?。。。エラーですね。「Issue reporter」なる画面が起動されました。ふむふむ「Spyder has encountered an internal problem!」ということですね。内部の問題に直面しました、ということなのですが、問題レポートを送ればいいのかな?

Issue reporter

 いや、送る前に、包括的なトラブルシューティングガイドで相談してください、と、書いてありますね。ほとんどの問題が解決されるらしいです。あとは、分かっているバグ、を検索してということだけど、検索しようにも何のメッセージも出てないしなぁ。。。そもそもこの「Issue report」は出てきたんだけど、エラーメッセージは何にも出力されていないのですよね。だからレポートを書くとしたら、「プロジェクトを開こうとしたら、内部的な問題に出会いました」くらいしか書きようがないですよね。とりあえず、「Close」でよいのかな?あ、「Close」ボタンの隣に「Show details」ボタンを発見しました!このボタンを押して詳細を見てみよう。と、ポチリとした結果、下部にエラーメッセージが出てまいりました。

Show detailsを押した結果

テキストは一部分だけが見える状態でしたので、以下に全文を張り付けておきます。これだけ情報があれば、なんとかなりそうですね。

Traceback (most recent call last):
  File "C:\Users\mitsu\anaconda3\lib\site-packages\spyder\plugins\projects\plugin.py", line 129, in <lambda>
    triggered=lambda v: self.open_project())
  File "C:\Users\mitsu\anaconda3\lib\site-packages\spyder\plugins\projects\plugin.py", line 402, in open_project
    project_type_class = self._load_project_type_class(path)
  File "C:\Users\mitsu\anaconda3\lib\site-packages\spyder\plugins\projects\plugin.py", line 813, in _load_project_type_class
    config.read(fpath)
  File "C:\Users\mitsu\anaconda3\lib\configparser.py", line 697, in read
    self._read(fp, filename)
  File "C:\Users\mitsu\anaconda3\lib\configparser.py", line 1017, in _read
    for lineno, line in enumerate(fp, start=1):
UnicodeDecodeError: 'cp932' codec can't decode byte 0x81 in position 269: illegal multibyte sequence

 想定の範囲内ですが、UnicodeDecodeErrorですね!記憶にないけど、プロジェクト作るときに、日本語を使っちゃったのかなぁ。configparser.pyでエラーが発生しているのと、起動時は問題なくてプロジェクトを開くときにエラーが発生したので、プロジェクトのフォルダを見に行ってみます。ありましたね「.spyproject」フォルダが怪しいです。ちょっと覗いてみます。

エラーになったプロジェクトのフォルダ

 なるほど、「config」フォルダがあって、その中に複数の「.ini」ファイルがあります。この中をひとつずつ見ていきましょう。

エラーになったconfigフォルダ

 見つけました。「workspace.ini」ファイルですね。ここにマルチバイト文字がありました。そうそう、Sphinxで使う「.rst」のファイル名を日本語にしたのを思い出しました。このファイル自体はUTF-8で保存されていたので、うまく開けないのはどうしてでしょうね。

[workspace]
restore_data_on_startup = True
save_data_on_exit = True
save_history = True
save_non_project_files = False
project_type = empty-project-type
recent_files = ['..\\..\\..\\Users\\mitsu\\.spyder-py3\\temp.py', 'doc\\source\\index.rst', 'doc\\source\\このドキュメントについて.rst', 'doc\\source\\conf.py']

[main]
version = 0.2.0
recent_files = []

さらに原因を追究します

 configparser.pyのソースファイルを覗いてみたところ、ファイルをencoding=Noneとしてオープンしていることがわかりました。encoding=Noneとした場合は、ロケールのデフォルトエンコーディングが使用されます。つまり、日本語のWindows10環境では「cp932」が使われる、ということになります。configparser.pyの中にはファイルへの書き込みのためのオープンが見当たらなかったので、どのような設定になっているのかはちょっと不明だったのですが、上述のとおりファイルの内容はUTF-8で書き込まれていたので、書き込み時はUTF-8でオープンされているのだと思います。

では、修正しましょう

 さて、どう修正するのがよいでしょうか。気持ちとしてはconfigparser.pyのencoding=”utf-8″としたいところですが、影響範囲がはかり知れないので、Spyderに限って考えたいと思います。そうすると、エラーメッセージで指摘されている、C:\Users\mitsu\anaconda3\lib\site-packages\spyder\plugins\projects\plugin.pyの813行目に、encoding=”utf-8″を追加するのがよいでしょうか。

        config.read(fpath)  # 修正前
        config.read(fpath,encoding="utf-8")  # 修正後

 さて、修正してみたまではよかったのですが、果たして反映されるのでしょうか。Spyderって、実行形式で配布されている訳じゃないのかな?と、迷っている間に実行できそうなので、ささっとSpyderを再起動してみます。おっ、できました。エラーは発生しませんでした。

エラーなく日本語ファイルが開けました

まとめ

 結論としては、UTF-8で保存したファイルをローカルロケールでオープンして読み込んだためにエラーが発生した、ということでしたね。なので、パソコン再起動が原因ではなくて、Spyderの再起動が原因、ということでした。Pythonはバージョン3から完全UTF-8対応したはずなのに、どうしてこんなことが起きるのかというと、デフォルトエンコーディング、大混乱しているようですね。「Fluent Python Pythonicな思考とコーディング手法」を参考に、Pythonのデフォルトエンコーディングに影響を与える設定を確認してみました。

Python 3.8.8 (default, Apr 13 2021, 15:08:03) [MSC v.1916 64 bit (AMD64)]
Type "copyright", "credits" or "license" for more information.

IPython 7.22.0 -- An enhanced Interactive Python.

In [1]: import sys, locale

In [2]: locale.getpreferredencoding()
Out[2]: 'cp932'

In [3]: my_file = open("dummy","w")

In [4]: type(my_file)
Out[4]: _io.TextIOWrapper

In [5]: my_file.encoding
Out[5]: 'cp932'

In [6]: sys.stdout.isatty()
Out[6]: False

In [7]: sys.stdout.encoding
Out[7]: 'UTF-8'

In [8]: sys.stdin.isatty()
Out[8]: False

In [9]: sys.stdin.encoding
Out[9]: 'cp932'

In [10]: sys.stderr.isatty()
Out[10]: False

In [11]: sys.stderr.encoding
Out[11]: 'UTF-8'

In [12]: sys.getdefaultencoding()
Out[12]: 'utf-8'

In [13]: sys.getfilesystemencoding()
Out[13]: 'utf-8'

In [14]: my_file.close()

 書籍はブラジル向けにローカライズされたWindows 7の結果が示されており、結果はなんと4種類のエンコーディングが出力されていました。それに比べると日本語のWindows10環境は「cp932」と「utf-8」の二つしかないので、まだマシな方かも知れませんね。(苦笑)

Python プログラミング Anaconda3でのpytestの警告を回避、iPad Proの a-Shell で pytest 始めてみました

 今日も見にきてくださってありがとうございます。石川さんです。梅雨入りしてジメジメが続きますね。

 さて、先日翔泳社の『テスト駆動Python』という書籍を購入しまして、さ〜て、やりますか、と張り切っていたのですが、1章の1ページ目からうまく動かず。実行はできたのですけど、何故か警告が出力されてしまうのですよね。ぼくの環境は、Windows 10 & Anaconda3です。

(base) C:\work\pytest\ch1>pytest test_one.py
================================================= test session starts =================================================
platform win32 -- Python 3.8.8, pytest-6.2.3, py-1.10.0, pluggy-0.13.1
rootdir: C:\work\pytest\ch1
plugins: anyio-2.2.0
collected 1 item

test_one.py .                                                                                                    [100%]

================================================== warnings summary ===================================================
..\..\..\ProgramData\Anaconda3\lib\site-packages\pyreadline\py3k_compat.py:8
  C:\ProgramData\Anaconda3\lib\site-packages\pyreadline\py3k_compat.py:8: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.9 it will stop working
    return isinstance(x, collections.Callable)

-- Docs: https://docs.pytest.org/en/stable/warnings.html
============================================ 1 passed, 1 warning in 0.08s =============================================

(base) C:\work\pytest\ch1>
警告が出力される

 なるほど、Anaconda3のpyreadlineモジュールで警告ですね。これは問題だと言われているファイル、py3k_compat.pyの8行目、「return isinstance(x, collections.Callable)」のところの「collections.Callable」を「collections.abc.Callable」に修正することで回避できました。そういえば、ちょっと前にもこちらの記事でpyreadlineのモジュール、修正していましたね。警告が出なくなってスッキリしたのですが、ふと、最近購入した、iPad Proでもできるんじゃないかな、と、やってみました。

実行環境のセットアップ

 iPad Proでのpythonというと、大体は「Pythonista 3」か、jupyter notebook系だと「Juno」「Carnets」がおすすめされることがほとんどだと思います。でも、今回使ったのは、「a-Shell」というアプリで、iOSで簡単なUnixコマンドが使えるシェルです。ここから「python」や「ipython 」を実行することができます。App Storeの検索で「a-Shell」を入力してぐるぐるのアイコンを探してインストールします。

a-Shellのアイコン

 インストールできたら、実行します。黒いコマンドラインがフルスクリーンで登場します。pytestを実行してみましたが、コマンドが存在しない(command not found)、と、エラーになります。pytestを使えるようにするためには、インストールが必要ですね。pipコマンド「pip install pytest」で簡単に実行できるようになりました。

$ pytest
pytest: command not found
$ pip install pytest
Defaulting to user installation because normal site-packages is not writeable
Collecting pytest
  Downloading pytest-6.2.4-py3-none-any.whl (280 kB)
     |████████████████████████████████| 280 kB 4.0 MB/s 
Requirement already satisfied: packaging in /private/var/containers/Bundle/Application/10B03820-2240-4A08-AA93-CE205988D553/a-Shell.app/Library/lib/python3.9/site-packages (from pytest) (20.9)
Requirement already satisfied: attrs>=19.2.0 in /private/var/containers/Bundle/Application/10B03820-2240-4A08-AA93-CE205988D553/a-Shell.app/Library/lib/
python3.9/site-packages (from pytest) (20.3.0)
Requirement already satisfied: toml in /private/var/containers/Bundle/Application/10B03820-2240-4A08-AA93-CE205988D553/a-Shell.app/Library/lib/python3.9/site-packages (from pytest) (0.10.2)
Collecting py>=1.8.2
  Downloading py-1.10.0-py2.py3-none-any.whl (97 kB)
     |████████████████████████████████| 97 kB 10.5 MB/s 
Collecting iniconfig
  Downloading iniconfig-1.1.1-py2.py3-none-any.whl (5.0 kB)
Collecting pluggy<1.0.0a1,>=0.12
  Downloading pluggy-0.13.1-py2.py3-none-any.whl (18 kB)
Requirement already satisfied: pyparsing>=2.0.2 in /private/var/containers/Bundle/Application/10B03820-2240-4A08-AA93-CE205988D553/a-Shell.app/Library/lib/python3.9/site-packages (from packaging->pytest) (2.4.7)
Installing collected packages: py, pluggy, iniconfig, pytest
Successfully installed iniconfig-1.1.1 pluggy-0.13.1 py-1.10.0 pytest-6.2.4

実行してみます

  書籍の内容を元に、test_one.pyファイルを作成して、以下の通り実行できました。

$ pytest test_one.py
================================================================= test session starts ==================================================================
platform darwin -- Python 3.9.2+, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /private/var/mobile/Containers/Data/Application/117A9320-8100-4E66-A829-2B31D0457140/Documents/work/pytest/ch1
collected 1 item                                                                                                                                       
test_one.py .                                                                                                                                    [100%]
================================================================== 1 passed in 0.01s ===================================================================
$ 
実行結果

はい、見事に成功しましたね。pytestでテスト対象として自動的に実行されるためには、

  • ファイル名が test_*.py または、*_test.py という名前であること
  • 関数名やメソッド名は、test_* という名前であること
  • クラス名は Test* という名前であること

ということのようです。最初、テスト関数名を「est_passing」と「t」を欠落して作ってしまっていました。その欠落が原因で実行されなかったのですが、「t」の欠落に気づくまでは、警告があると実行できないのか〜、と勘違いしてしばらくウロウロしちゃいました。無事、解決できてよかったです。

お気に入り

 最近は、iPad Pro がお気に入りで、色々と遊んでいます。手書きの性能がとっても良いのが特にお気に入りで、近頃は手帳の替わりにノートアプリの「Goodnotes5」を使うようになりました。手書きのいいところと、電子のいいところの両方が享受できていて、大満足です。
 で、せっかく持ち歩けるのだから、pythonを使ったプログラミングもできないかなぁ、と、色々と検索してたどり着いたのが、今回紹介した「a-Shell」です。ただ、こちら tkinter が使えないので、しばらくは使い道がないなぁ、と思っていたのですが、GUIを利用しない今回のようなケースであれば活用できるはずだ、と、今回張り切って記事にしてみました。

まとめ

 Anaconda3 で pytest を実行するときにでた警告の修正と、iPad Proで pythonを使って pytest を実行する方法について記載しました。これでしばらくは気軽に pytest で遊ぶことができそうです。
 ちなみに、今回の記事は、iPad Pro を使って書いてみました。って、どこ見てもそんなこと分かりませんね。失礼しました!

Python プログラミング Windowsショートカットの参照先サーバ名の変更

 見に来てくださって、ありがとうございます。石川さんです。

 たくさんあったWindowsのショートカットの参照先をスクリプトを使って一気に変えたくなったのですが、やり方がすぐに見つけられなかったので記録しておきます。サーバのリプレースやフォルダの整理等により、ショートカットの参照先が変わってしまって対応が必要になったときに使えます。どなたかのお役に立てば、うれしいです。

 すぐに見つけられなかったのは、「ショートカット」という名称がよくなかったようです。キーボードショートカットが検索結果の上位に登場してきます。試行錯誤して探し出しましたが、まずは参考になったのがこちらです。ここでショートカットの拡張子は「.lnk」らしい、ということと、win32com.clientのDispatchで”WScript.Shell”を使えばショートカットが作成できるらしい、と、アタリをつけました。
 今回は、ショートカットのプロパティの「リンク先(T)」「作業フォルダー(S)」を修正したかったのですが、上記で見つけたリンクでは「リンク先(T)」が「TargetPath」だ、ということしかわからなかったので、追加で「WScript.Shell」+「shortcut」+「property」で検索してこちらのページを見つけました。こちらで「作業フォルダー(S)」は「WorkingDirectory」ということがわかりました。

ショートカットのプロパティ

ソースコード

 ここまでわかればなんとかなる、ということでスクリプトを作成しました。Pythonです。

#!python
# -*- coding: cp932 -*-
# このスクリプトは指定したフォルダの中をくまなく操作して、
# ショートカットを見つけたときに、「置き換え前」を「置き換え後」に置き換えるためにつくりました。
# 変数を変更することで他の用途にも対応可能です。

import os
import win32com.client 

指定フォルダ = "Z:" + os.sep + "テストフォルダ"
置き換え前 = r"\\old_server" + os.sep + "shared_folder$"
置き換え後 = r"\\new_server" + os.sep + "shared_folder$"

shell = win32com.client.Dispatch("WScript.Shell")

for root, dirs, files in os.walk(指定フォルダ):
    for file in files:
        if file[-4:] == ".lnk":
            shortcut = shell.CreateShortCut(root + os.sep + file)
            if shortcut.Targetpath[:len(置き換え前)].lower() == 置き換え前.lower():
                changed_targetpath = 置き換え後 + shortcut.Targetpath[len(置き換え前):]
                shortcut.Targetpath = changed_targetpath
                shortcut.save()
            if shortcut.Workingdirectory[:len(置き換え前)].lower() == 置き換え前.lower():
                changed_workingdirectory = 置き換え後 + shortcut.Workingdirectory[len(置き換え前):]
                shortcut.Workingdirectory = changed_workingdirectory
                shortcut.save()

 まずは、一番のポイント、14行目のWScript.Shellですね。ここではCOMオブジェクトをディスパッチしています。これによりWindowsのWshShellオブジェクトの機能が利用可能になります。
 次は16行目ですが、os.walkを利用して、「指定フォルダ」配下のファイルをすべてチェックします。ここのforループのrootに、現在処理対象のフォルダ名、filesにそのフォルダ内にあるファイルのリストが取得できます。この取得したファイルリストをすべて処理し始めるのが、17行目のforループです。
 18行目では、ファイル名の最後の4文字を取り出して「.lnk」かどうかチェックしています。ショートカットは拡張子が「.lnk」になっています。そして、ショートカットの場合、19行目で「CreateShortCut」を使ってショートカットのオブジェクトを作成しています。作成する、と言っても元々がショートカットですので、ショートカットのショートカットが新しく作成されるわけではなく、現在の設定のショートカットを参照した形になります。ちなみに、os.sepは、セパレート文字列で、Windowsの場合「\」、Linuxの場合は「/」のように適切な区切り文字にセットされます。
 20行目で置き換え対象かどうかチェックして、対象なら「Targetpath」をセットすることで「リンク先」を更新して23行目の「save」メソッドで変更を確定する、ということをやっています。24~27行は同様ですが、「WorkingDirectory」をセットすることで「作業フォルダー」を更新しています。

まとめ

  やりたいことは難しいことでなかったのですが、既存のショートカットを更新したいのに「CreateShortCut」メソッドを使う、というところがちょっと馴染めない感じがしました。ま、慣れれば大丈夫ですね。

「LEARN LIKE A PRO 学び方の学び方」読了しました

今日も見にきてくださってありがとうございます。石川さんです。
久しぶりの更新です。

 「LEARN LIKE A PRO 学び方の学び方」バーバラ・オークレー/オラフ・シーヴェ/宮本喜一訳 アチーブメント出版を読了いたしました。

 ぼくは他の人と比べて読書量がまあまあ多い方じゃないかな、と思っています。ただ、最近は読み終わった本を見ても、ホントにこの本読んだんだっけ、という気持ちになることが増えてきました。このままではよくないなぁ、ということで読んだ書籍の記録も兼ねて感想などを書くことにしました。

 一番の収穫は、自分の学び方が結構ダメだった、ということがわかったという点ですね(笑)。もっとイケてると思っていたのですけど。内容は以下のような感じでしょうか。大きく分類すると、学習において考えなければいけない要素について、と、その取り組み方について述べられていたように思います。

 最初にポモドーロテクニックが紹介されていました。結構有名なテクニックなので、ご存知の方もいらっしゃると思います。25分間集中して、その後5分間休憩する、というサイクルを繰り返して勉強や仕事をするやり方です。集中して取り組むために必要なことを教えてもらえます。ちなみに、このポモドーロ、イタリア語でトマトのことだったのですね。

 次に行き詰まりを克服するという内容で集中モードと拡散モードについて教えてくれます。ぼくにとってはとても馴染みのある話でした。集中して考えたあとに、ふとひらめく、あの感じのことをわかりやすく教えてくれます。

 そして、学習の深さについて、教えてくれます。脳のシナプスが繋がっていく様と、その繋がりが太くなっていく様から、学習するということは脳の中のニューロンの結合を強く長くしていくことだ、というふうに教えてくれます。そしてその結合を強く長くしていく具体的な方法について教えてくれます。

 作業記憶という概念についても詳しく説明してくれています。最大限に活用する方法について具体的な方法が書かれており、その延長線上として、ノートの取り方について解説してくれています。「何よりも、肝心なことはノートをとることではない。大切な内容を頭に入れることだ」と言われて、目から鱗が落ちた気分です。

 記憶することについてもそのテクニックと重要性が述べられていました。ぼくはこの章を読むまでは、記憶することの重要性を軽んじていました。単に記憶するという概念にとどまらず、「内在化する」という表現でその重要性について語られていました。記憶することによって考える力の負担が軽くなり、より難しい概念について理解したり、問題を解決できるようになる、ということでした。言われてみれば当たり前のことなのですけど、そこまで掘り下げて考えていなかったので、ちょっぴりショックでした。

 ここまでが学習の要素なのかなぁ、というところなのですけど、これだけでもお腹いっぱいで、その後の取り組み方についてはまだ未消化です。何度も読み返して、いや、回収の実践をして、内在化したいと思う一冊でした。

 

Python GUIプログラミング tkinterのクラス

 今日も見に来てくださってありがとうございます。石川さんです。久しぶりの更新です。

ちょうど一年くらい前にPythonでGUIという記事を書きました。いくつかサンプルをつくって勉強していくうちに色々とできるようなって、tkinterで使っているクラスの継承関係をいっちょ描いてみるか、ということでプログラミングしてみました。早く結論を知りたい方のために、まずはできあがりイメージの画像です。

できあがりイメージ

tkinter承継関係

ソースコード

 ソースコードは以下の通りです。

import tkinter as tk
import inspect


class Class:
    '''クラスを描画するためのクラス'''
    CLASS_COUNTER = 0

    def __init__(self, canvas, name):
        self.canvas = canvas
        self.name = name
        self.start_pos = None
        self.connections = []
        Class.CLASS_COUNTER += 1

        x = 150 + 200 * (Class.CLASS_COUNTER % 5)
        y = 150 + 50 * (Class.CLASS_COUNTER // 5)
        text = canvas.create_text(x, y, text=name, tag=name)
        rect = canvas.create_rectangle(canvas.bbox(name), tag=name, fill="lightyellow")
        canvas.tag_lower(rect, text)
        canvas.tag_bind(name, "<ButtonPress>", self.start)
        canvas.tag_bind(name, "<Motion>", self.move)
        canvas.tag_bind(name, "<ButtonRelease>", self.end)

    def start(self, event):
        '''クラスをクリックされたときのメソッド'''
        self.start_pos = (self.canvas.canvasx(event.x),
                          self.canvas.canvasy(event.y))

    def move(self, event):
        '''クラスを動かすためのメソッド'''
        if self.start_pos is None:
            return
        x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        dx, dy = x - self.start_pos[0], y - self.start_pos[1]
        self.canvas.move(self.name, dx, dy)
        self.start_pos = x, y
        for connection in self.connections:
            connection.move(self)

    def end(self, event):  # pylint: disable=unused-argument
        '''クラスの選択が終わった時のメソッド'''
        self.start_pos = None

    def get_center(self):
        '''中心点を戻す'''
        bbox = self.canvas.bbox(self.name)
        return (bbox[0] + bbox[2]) // 2, (bbox[1] + bbox[3]) // 2

    @property
    def x(self):
        '''左上のx座標を戻す'''
        bbox = self.canvas.bbox(self.name)
        return bbox[0]

    @property
    def y(self):
        '''左上のy座標を戻す'''
        bbox = self.canvas.bbox(self.name)
        return bbox[1]

    @property
    def height(self):
        '''高さを戻す'''
        bbox = self.canvas.bbox(self.name)
        return abs(bbox[1] - bbox[3])

    @property
    def width(self):
        '''幅を戻す'''
        bbox = self.canvas.bbox(self.name)
        return abs(bbox[0] - bbox[2])

    def add_listener(self, connection):
        ''' コネクションのリスナーを登録します '''
        self.connections.append(connection)


class Inheritance:
    '''継承関係の線を表示するためのクラス'''
    def __init__(self, canvas, parent, child):
        self.canvas = canvas
        self.parent = parent
        self.child = child
        parent.add_listener(self)
        child.add_listener(self)

        p_coords = self.get_intersection(parent)
        c_coords = self.get_intersection(child)
        self.id = self.canvas.create_line(*p_coords, *c_coords, arrow=tk.FIRST)

    def get_intersection(self, box):
        ''' 矩形との接点を求める '''
        x, y = box.get_center()
        height, width = box.height // 2, box.width // 2
        dx, dy = self.child.x - self.parent.x, self.child.y - self.parent.y
        if box == self.child:
            dx, dy = -dx, -dy
        if dx == 0:
            dx = 0.001
        if abs(dy / dx) < (height / width):  # 垂直側
            x_pos = x + width if dx > 0 else x - width
            y_pos = y + dy * width / abs(dx)
        else:  # 水平側
            x_pos = x + dx * height / abs(dy)
            y_pos = y + height if dy > 0 else y - height
        return x_pos, y_pos

    def move(self, box):
        ''' エンティティが移動したときの処理(エンティティから呼び出される)'''
        coords = self.canvas.coords(self.id)
        if box == self.parent:
            coords[0:2] = self.get_intersection(box)
            coords[2:4] = self.get_intersection(self.child)
        elif box == self.child:
            coords[0:2] = self.get_intersection(self.parent)
            coords[2:4] = self.get_intersection(box)
        self.canvas.coords(self.id, coords)


class Application:
    '''アプリケーションクラス'''
    def __init__(self, root):
        self.root = root
        root.title("tkinter classes")
        root.geometry("1200x800")
        self.start_pos = None
        self.item = None

        self.canvas = tk.Canvas(root, background="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)
        self.canvas.bind("<Motion>", self.move)

        self.classes = {}
        self.init()

    def init(self):
        '''初期化'''
        for name, obj in inspect.getmembers(tk):
            if inspect.isclass(obj):
                if name in ('getdouble', 'getint', '_setit'):
                    continue
                if name not in self.classes.keys():
                    self.classes[name] = Class(self.canvas, name)
                for parent in obj.__bases__:
                    p_name = parent.__name__
                    if p_name == 'object':
                        continue
                    if p_name not in self.classes.keys():
                        self.classes[p_name] = Class(self.canvas, p_name)
                    print(p_name, "<--", name)
                    Inheritance(self.canvas, self.classes[p_name], self.classes[name])

    def move(self, event):
        '''マウスが動いたときにタイトルに座標を表示します'''
        title = "tkinter class[" + str(event.x) + "," + str(event.y) + "]"
        self.root.title(title)


def main():
    '''主処理'''
    root = tk.Tk()
    application = Application(root)
    application.root.mainloop()


if __name__ == "__main__":
    main()

詳細説明

 最初のポイントは、inspectモジュールを使って、tkinterモジュールの中身を検査しているところでしょうか(139行~)。メンバーを順番に取り出してクラスを抜き出します。実行してみてから小文字のクラスがあるのに気づいたのですが、これらはint、floatに別名を付けていたものと内部的なクラス(Internal class)とコメントに書いてあったので、出力されないようスキップしました。
 一度も出力していないクラスを出力して、その親クラスも同様に出力、その後承継を作成するようにしています。

 その他の部分は、これまでに記事にした内容を参照すれば、できそうですね。

まとめ

 tkinterのクラス図ができあがりました。本当は、レイアウトもある程度自動でやりたかったのですが、とりあえず、今回は、手で移動しました。もうちょっと、勉強してきます。

Python GUIプログラミング Windows実行形式ファイルをつくる

 今日も見に来てくださって、ありがとうございます。石川さんです。

 今回は、Pythonスクリプトから実行形式の「.exe」ファイルをつくる、というのを書きたいと思います。ずいぶん昔にもお試しでやったことがあったのですけど、どうやるのか、すっかり忘れてしまったので、備忘録的なアレですね。

迷いどころ

 実行形式のファイルをつくる方法、ちょっと検索しただけで、山のようにあるのですね。どれを使ったらよいのか、迷います。手元の書籍には、cx_Freezeが紹介されていましたので、それで進めるのがいいのかな、と思っていましたが、最終的には、pyInstallerを使うことに決めました。今はAnaconda3を使っているので、そこに入っているやつがいいなぁ、と調べてみたところ、pyinstallerならcondaコマンドでインストールできそうだったのです。これが決め手ですね。

(base) C:\work>conda search pyinstaller
Loading channels: done
# Name                       Version           Build  Channel
pyinstaller                      3.4  py27h7a46e7a_1  pkgs/main
pyinstaller                      3.4  py36h2a8f88b_1  pkgs/main
pyinstaller                      3.4  py37h2a8f88b_1  pkgs/main
pyinstaller                      3.5  py27h7a46e7a_0  pkgs/main
pyinstaller                      3.5  py36h2a8f88b_0  pkgs/main
pyinstaller                      3.5  py37h2a8f88b_0  pkgs/main
pyinstaller                      3.6  py36h2a8f88b_1  pkgs/main
pyinstaller                      3.6  py36h2a8f88b_2  pkgs/main
pyinstaller                      3.6  py36h62dcd97_4  pkgs/main
pyinstaller                      3.6  py36h62dcd97_5  pkgs/main
pyinstaller                      3.6  py37h2a8f88b_1  pkgs/main
pyinstaller                      3.6  py37h2a8f88b_2  pkgs/main
pyinstaller                      3.6  py37h62dcd97_4  pkgs/main
pyinstaller                      3.6  py37h62dcd97_5  pkgs/main
pyinstaller                      3.6  py38h2a8f88b_1  pkgs/main
pyinstaller                      3.6  py38h2a8f88b_2  pkgs/main
pyinstaller                      3.6  py38h62dcd97_4  pkgs/main
pyinstaller                      3.6  py38h62dcd97_5  pkgs/main

(base) C:\work>

 そう、他の、py2exe、cx_Freeze(cx-Freeze)、pyoxidizer、bbFreeze、py2app、Shiv、PyRun、pynsist、は、ことごとくダメだったのですよねぇ。ま、condaのオプションを変更して登録先を変更すれば見つけられそうでしたけど、デフォルトで入っていない、ということはちょっぴり信頼性が足りないのかな、という理由で候補から外すことにしました。いろいろとウロウロしている中で、Nuitka、Bazelというのはconda searchコマンドで見つけることができたのですけど、あんまりメジャーじゃなさそう、ということで、今回はパスすることにしました。はい、ぜんぜん検証まではしていませんので、ご了承くださいね。

(base) C:\work>conda search py2exe
Loading channels: done
No match found for: py2exe. Search: *py2exe*

PackagesNotFoundError: The following packages are not available from current channels:

  - py2exe

Current channels:

  - https://repo.anaconda.com/pkgs/main/win-64
  - https://repo.anaconda.com/pkgs/main/noarch
  - https://repo.anaconda.com/pkgs/r/win-64
  - https://repo.anaconda.com/pkgs/r/noarch
  - https://repo.anaconda.com/pkgs/msys2/win-64
  - https://repo.anaconda.com/pkgs/msys2/noarch

To search for alternate channels that may provide the conda package you're
looking for, navigate to

    https://anaconda.org

and use the search bar at the top of the page.



(base) C:\work>

セットアップ

 早速ですが、PyInstallerをセットアップしていきます。公式ホームページには、以下のコマンドでインストールできる、と書いてありました。

pip install pyinstaller

 が、ぼくの使用している環境はAnaconda3ですので、condaを使ってセットアップします。コマンドは以下の通りです。

conda install pyinstaller

condaも調べていくと「-c」オプションでチャンネルを追加すれば、いろんなところからインストールできることがわかりました。conda-forgeチャンネルを指定すればもう少し新しいPyInstallerがインストールできることも分かったのですが、ここはあえてシンプルなやり方を使うことにしました。(condaにはconfigコマンドもあって、どのチャンネルからインストールするかの設定も変えられるのですね。)

(base) C:\work>conda install pyinstaller
Collecting package metadata (repodata.json): done
Solving environment: done

## Package Plan ##

  environment location: C:\ProgramData\Anaconda3

  added / updated specs:
    - pyinstaller


The following packages will be downloaded:

    package                    |            build
    ---------------------------|-----------------
    altgraph-0.17              |             py_0          21 KB
    ca-certificates-2020.10.14 |                0         159 KB
    certifi-2020.6.20          |     pyhd3eb1b0_3         159 KB
    conda-4.9.2                |   py37haa95532_0         3.1 MB
    macholib-1.14              |             py_1          36 KB
    openssl-1.1.1c             |       he774522_1         5.7 MB
    pefile-2019.4.18           |             py_0          54 KB
    pycryptodome-3.7.3         |   py37he774522_0         5.9 MB
    pyinstaller-3.6            |   py37h62dcd97_5         2.4 MB
    pywin32-ctypes-0.2.0       |        py37_1001          38 KB
    ------------------------------------------------------------
                                           Total:        17.7 MB

The following NEW packages will be INSTALLED:

  altgraph           pkgs/main/noarch::altgraph-0.17-py_0
  macholib           pkgs/main/noarch::macholib-1.14-py_1
  pefile             pkgs/main/noarch::pefile-2019.4.18-py_0
  pycryptodome       pkgs/main/win-64::pycryptodome-3.7.3-py37he774522_0
  pyinstaller        pkgs/main/win-64::pyinstaller-3.6-py37h62dcd97_5
  pywin32-ctypes     pkgs/main/win-64::pywin32-ctypes-0.2.0-py37_1001

The following packages will be UPDATED:

  ca-certificates      anaconda::ca-certificates-2020.1.1-0 --> pkgs/main::ca-certificates-2020.10.14-0
  certifi            anaconda/win-64::certifi-2019.11.28-p~ --> pkgs/main/noarch::certifi-2020.6.20-pyhd3eb1b0_3
  conda                        anaconda::conda-4.8.3-py37_0 --> pkgs/main::conda-4.9.2-py37haa95532_0

The following packages will be SUPERSEDED by a higher-priority channel:

  openssl                anaconda::openssl-1.1.1-he774522_0 --> pkgs/main::openssl-1.1.1c-he774522_1


Proceed ([y]/n)?

 と、いうことでセットアップ完了です。あ、、、完了してませんでした。「y」を押して、エンターキーで続行します。

Proceed ([y]/n)? y


Downloading and Extracting Packages
macholib-1.14        | 36 KB     | ########################################### | 100%
pywin32-ctypes-0.2.0 | 38 KB     | ########################################### | 100%
ca-certificates-2020 | 159 KB    | ########################################### | 100%
pyinstaller-3.6      | 2.4 MB    | ########################################### | 100%
conda-4.9.2          | 3.1 MB    | ########################################### | 100%
openssl-1.1.1c       | 5.7 MB    | ########################################### | 100%
pycryptodome-3.7.3   | 5.9 MB    | ########################################### | 100%
pefile-2019.4.18     | 54 KB     | ########################################### | 100%
certifi-2020.6.20    | 159 KB    | ########################################### | 100%
altgraph-0.17        | 21 KB     | ########################################### | 100%
Preparing transaction: done
Verifying transaction: failed

EnvironmentNotWritableError: The current user does not have write permissions to the target environment.
  environment location: C:\ProgramData\Anaconda3



(base) C:\work>

 ということで今度こそ、完了、、、と、思いましが「EnvironmentNotWritableError」が出ていました。これは、Anaconda3を実行したユーザーに権限がなくて、書き込めていない、ということのようです。ということで、気を取り直して、Anaconda Promptを管理者権限で実行して、再度やり直しです。ダウンロードまでは済んでいたので、スキップされました。もう一度Proceed ([y]/n)? で「y」を入力すると、4~5行ほど何かが表示された後クリアされ、

done

(base) C:\work> 

と出力されました。「done」だから、完了、ですよね?

お試し

 では、さっそくお試しです。お試しコマンドは公式ホームページの下の方にありました。

pyinstaller yourprogram.py

 前回つくったスクリプト「connecter_test3.py」でお試ししてみました。実行結果は以下の通りですね。

(base) C:\work\tkinter_example>pyinstaller connecter_test3.py
61 INFO: PyInstaller: 3.6
62 INFO: Python: 3.7.3 (conda)
64 INFO: Platform: Windows-10-10.0.18362-SP0
65 INFO: wrote C:\work\tkinter_example\connecter_test3.spec
68 INFO: UPX is not available.
70 INFO: Extending PYTHONPATH with paths
['C:\\work\\tkinter_example', 'C:\\work\\tkinter_example']
70 INFO: checking Analysis
71 INFO: Building Analysis because Analysis-00.toc is non existent
72 INFO: Initializing module dependency graph...
84 INFO: Caching module graph hooks...
96 INFO: Analyzing base_library.zip ...
5632 INFO: Caching module dependency graph...
5744 INFO: running Analysis Analysis-00.toc
5769 INFO: Adding Microsoft.Windows.Common-Controls to dependent assemblies of final executable
  required by C:\ProgramData\Anaconda3\python.exe
6120 INFO: Analyzing C:\work\tkinter_example\connecter_test3.py
6308 INFO: Processing module hooks...
6309 INFO: Loading module hook "hook-encodings.py"...
6411 INFO: Loading module hook "hook-pydoc.py"...
6413 INFO: Loading module hook "hook-xml.py"...
6886 INFO: Loading module hook "hook-_tkinter.py"...
7253 INFO: checking Tree
7253 INFO: Building Tree because Tree-00.toc is non existent
7259 INFO: Building Tree Tree-00.toc
7355 INFO: checking Tree
7356 INFO: Building Tree because Tree-01.toc is non existent
7357 INFO: Building Tree Tree-01.toc
7396 INFO: Looking for ctypes DLLs
7418 INFO: Analyzing run-time hooks ...
7423 INFO: Including run-time hook 'pyi_rth__tkinter.py'
7429 INFO: Looking for dynamic libraries
7698 INFO: Looking for eggs
7699 INFO: Using Python library C:\ProgramData\Anaconda3\python37.dll
7700 INFO: Found binding redirects:
[]
7703 INFO: Warnings written to C:\work\tkinter_example\build\connecter_test3\warn-connecter_test3.txt
7742 INFO: Graph cross-reference written to C:\work\tkinter_example\build\connecter_test3\xref-connecter_test3.html
7784 INFO: checking PYZ
7784 INFO: Building PYZ because PYZ-00.toc is non existent
7786 INFO: Building PYZ (ZlibArchive) C:\work\tkinter_example\build\connecter_test3\PYZ-00.pyz
8370 INFO: Building PYZ (ZlibArchive) C:\work\tkinter_example\build\connecter_test3\PYZ-00.pyz completed successfully.
8378 INFO: checking PKG
8379 INFO: Building PKG because PKG-00.toc is non existent
8379 INFO: Building PKG (CArchive) PKG-00.pkg
8398 INFO: Building PKG (CArchive) PKG-00.pkg completed successfully.
8400 INFO: Bootloader C:\ProgramData\Anaconda3\lib\site-packages\PyInstaller\bootloader\Windows-64bit\run.exe
8400 INFO: checking EXE
8401 INFO: Building EXE because EXE-00.toc is non existent
8401 INFO: Building EXE from EXE-00.toc
8402 INFO: Appending archive to EXE C:\work\tkinter_example\build\connecter_test3\connecter_test3.exe
8406 INFO: Building EXE from EXE-00.toc completed successfully.
8410 INFO: checking COLLECT
8411 INFO: Building COLLECT because COLLECT-00.toc is non existent
8412 INFO: Building COLLECT COLLECT-00.toc
10094 INFO: Building COLLECT COLLECT-00.toc completed successfully.

(base) C:\work\tkinter_example>

 配下に「build」「dist」サブフォルダができていました。本家のページに書いてありましたが「dist」というサブディレクトリにバンドルが生成される、ということですので「dist」の方が最終的な出力先なのでしょう。両方のディレクトリに「connecter_test3.exe」が生成されていましたので、実行してみました。「dist」は動きましたが、「build」は動きませんでした。ただ、「dist」の動作結果も以下の通り、コマンドプロンプトのウィンドウが余分に表示されていましたので、GUIプログラムのバンドルを生成する場合は、オプションでなんとかしなければいけません。

distへできあがったconnecter_test3.exeをエクスプローラからダブルクリックで実行したところ。

 ちなみに、「build」の方ですが、コマンドプロンプトから実行して確認してみたところ、メッセージが出力されていました。

(base) C:\work\tkinter_example\build\connecter_test3>connecter_test3.exe
Error loading Python DLL 'C:\work\tkinter_example\build\connecter_test3\python37.dll'.
LoadLibrary: 指定されたモジュールが見つかりません。

(base) C:\work\tkinter_example\build\connecter_test3>

 「dist」のディレクトリには「python37.dll」がありましたので、「build」は途中生成物か、実行環境上での共通ではない変更されるもの、ということなのかな?

 そういえば、色々見ている中で、PyInstallerには、–onefileオプションがあったなぁ、ということで使い方を見てみました。で、出力される実行ファイルを一つに、コンソールなしで、というコマンドは以下の通りでした。

pyinstaller --onefile --windowed connecter_test3.py

 実行結果は以下の通りです。

(base) C:\work\tkinter_example>pyinstaller --onefile --windowed connecter_test3.py
79 INFO: PyInstaller: 3.6
80 INFO: Python: 3.7.3 (conda)
81 INFO: Platform: Windows-10-10.0.18362-SP0
82 INFO: wrote C:\work\tkinter_example\connecter_test3.spec
85 INFO: UPX is not available.
86 INFO: Extending PYTHONPATH with paths
['C:\\work\\tkinter_example', 'C:\\work\\tkinter_example']
86 INFO: checking Analysis
160 INFO: checking PYZ
182 INFO: checking PKG
235 INFO: Building because C:\work\tkinter_example\build\connecter_test3\connecter_test3.exe.manifest changed
235 INFO: Building PKG (CArchive) PKG-00.pkg
2640 INFO: Building PKG (CArchive) PKG-00.pkg completed successfully.
2663 INFO: Bootloader C:\ProgramData\Anaconda3\lib\site-packages\PyInstaller\bootloader\Windows-64bit\runw.exe
2663 INFO: checking EXE
2673 INFO: Building because console changed
2673 INFO: Building EXE from EXE-00.toc
2676 INFO: Appending archive to EXE C:\work\tkinter_example\dist\connecter_test3.exe
2686 INFO: Building EXE from EXE-00.toc completed successfully.

(base) C:\work\tkinter_example>

 実行したら、引数の.pyファイルを分析して、.specファイルができるとのこと。おお、確かに、できてました。

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

block_cipher = None


a = Analysis(['connecter_test3.py'],
             pathex=['C:\\work\\tkinter_example'],
             binaries=[],
             datas=[],
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
          a.scripts,
          a.binaries,
          a.zipfiles,
          a.datas,
          [],
          name='connecter_test3',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          upx_exclude=[],
          runtime_tmpdir=None,
          console=False )

 specファイルは、pyinstallerコマンドに指定して実行することができるファイルで、コマンドラインで指定したオプションのかわりになるファイルです。specファイルの書き方の詳細は、ここにありました。まずは、コマンドラインで実行してspecファイルを生成してから、以降はこのspecファイルで生成するのがよいでしょうか。

まとめ

 いろいろと実験してみましたが、アイコンファイルや画像ファイル等の別ファイルを使うとコマンドラインオプションでいろいろと指定する必要が出てきそうです。そんなときは、コマンドラインの指定をバッチスクリプトにしておくか、specファイルを利用する、というのが推奨されたやり方のようです。

Python tkinter GUIプログラミング Canvasで箱をつなぐ線を描くその3

 今日も見に来てくださって、ありがとうございます。石川さんです。

 さて、その3ですが、その1を書いたときに宣言した通り、リファクタリングをやります。pylintさんから、こんなリファクタリングをした方がいいんじゃない?、と、提案がありました。

************* Module connecter_test2
R0902: 11,0: Entity: Too many instance attributes (9/7)
R0913: 13,4: Entity.__init__: Too many arguments (6/5)

------------------------------------------------------------------

Your code has been rated at 9.75/10 (previous run: 9.75/10, +0.00)

 はい、Entityのインスタンスのアトリビュートが多すぎますよ、初期化のメソッドの引数が多すぎますよ、と、申しております。インスタンスのアトリビュートは上限7個が適当だとpylint設計者は考えているようですね。確かに、TMで分析してアトリビュートはだいたい5個未満にならないとおかしいもんね。たまにアトリビュートが多いヤツもありますけどねぇ。7個までというのは、直観的にもいい数字ですね。そして、引数の上限は5個、まあ、妥当でしょうね。あんまり引数が多すぎると、間違いのもとですからね。

 アトリビュートを見てみますと、canvas、x、y、width、height、id、start_x、start_y、connectionsと、、、まあまあ多いですね。Pointをつくるとx、yは一つになりますね。どうしましょうか。今回は箱をつくるということで、Rectangleを用意してみることにしました。そして、前からずっと使いたかった、namedtupleを使ってみることにしました。

ソースコード

 ソースコードは以下の通りです。

import tkinter as tk
from collections import namedtuple

Rectangle = namedtuple('Rectangle', ['x', 'y', 'width', 'height'],
                       defaults=(60, 40))


class Entity():
    ''' Entity class '''
    def __init__(self, canvas, rectangle):
        self.canvas = canvas
        self.rectangle = rectangle
        self.start_x = self.start_y = None
        self.connections = []

        self.id = self.canvas.create_rectangle(rectangle.x, rectangle.y,
                                               rectangle.x + rectangle.width,
                                               rectangle.y + rectangle.height,
                                               fill="lightblue", width=3)
        self.canvas.tag_bind(self.id, "<ButtonPress>", self.button_press)
        self.canvas.tag_bind(self.id, "<Motion>", self.move)
        self.canvas.tag_bind(self.id, "<ButtonRelease>", self.button_release)

    def button_press(self, event):
        ''' マウスのボタンが押されたときの処理 '''
        self.start_x = self.canvas.canvasx(event.x)
        self.start_y = self.canvas.canvasy(event.y)

    def move(self, event):
        ''' マウスが移動したときの処理 '''
        if event.state & 256:  # マウスボタン1が押されているときだけ(ドラッグ中のみ)
            can_x = self.canvas.canvasx(event.x)
            can_y = self.canvas.canvasy(event.y)
            coords = self.canvas.coords(self.id)
            coords[0] -= self.start_x - can_x
            coords[1] -= self.start_y - can_y
            coords[2] -= self.start_x - can_x
            coords[3] -= self.start_y - can_y
            self.canvas.coords(self.id, coords)
            self.start_x = can_x
            self.start_y = can_y
            self.rectangle = Rectangle(*coords[0:2], *self.rectangle[2:4])
            for connection in self.connections:
                connection.move(self)

    def button_release(self, event):  # pylint: disable=unused-argument
        ''' マウスのボタンが離されたとき '''
        self.start_x = self.start_y = None

    def get_center(self):
        ''' 中心座標を戻します '''
        center_x = self.rectangle.x + self.rectangle.width//2
        center_y = self.rectangle.y + self.rectangle.height//2
        return center_x, center_y

    def add_listener(self, connection):
        ''' コネクションのリスナーを登録します '''
        self.connections.append(connection)


class Connection():
    ''' Connection class '''
    def __init__(self, canvas, start_entity, end_entity):
        self.canvas = canvas
        self.start_e = start_entity
        self.end_e = end_entity
        start_entity.add_listener(self)
        end_entity.add_listener(self)

        self.id = self.canvas.create_line(self.get_intersection(start_entity),
                                          self.get_intersection(end_entity))

    def move(self, entity):
        ''' エンティティが移動したときの処理(エンティティから呼び出される)'''
        coords = self.canvas.coords(self.id)
        if entity == self.start_e:
            coords[0:2] = self.get_intersection(entity)
            coords[2:4] = self.get_intersection(self.end_e)
        elif entity == self.end_e:
            coords[0:2] = self.get_intersection(self.start_e)
            coords[2:4] = self.get_intersection(entity)
        self.canvas.coords(self.id, coords)

    def get_intersection(self, entity):
        ''' 矩形の接点を求める '''
        x, y = entity.get_center()
        height, width = entity.rectangle.height//2, entity.rectangle.width//2
        dx = self.end_e.rectangle.x - self.start_e.rectangle.x
        dy = self.end_e.rectangle.y - self.start_e.rectangle.y
        if entity == self.end_e:
            dx, dy = -dx, -dy
        if abs(dy / dx) < (height / width):  # 垂直側
            x_pos = x + width if dx > 0 else x - width
            y_pos = y + dy * width / abs(dx)
        else:  # 水平側
            x_pos = x + dx * height / abs(dy)
            y_pos = y + height if dy > 0 else y - height
        return x_pos, y_pos


class Application(tk.Tk):
    ''' Application class '''
    def __init__(self):
        super().__init__()
        self.title("Connecter test 3")
        self.geometry("640x320")

        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)

        entity1 = Entity(self.canvas, Rectangle(40, 80))
        entity2 = Entity(self.canvas, Rectangle(240, 160))
        Connection(self.canvas, entity1, entity2)
        entity3 = Entity(self.canvas, Rectangle(420, 60))
        Connection(self.canvas, entity2, entity3)


def main():
    ''' main function '''
    application = Application()
    application.mainloop()


if __name__ == "__main__":
    main()

説明

 まずは、2行目です。namedtupleはcollectionsモジュールに含まれていますので、まずはimportしておきましょう。これでnamedtupleを使えるようになりました。4行目でさっそく使っています。これだけで、x、y、width、heightの4つのアトリビュートをもつRectangleクラスと同等のものができあがりました。defaultsオプションは後ろの方の初期値になります。

Rectangle = namedtuple('Rectangle', ['x', 'y', 'width', 'height'],
                       defaults=(60, 40))

 今回の場合は、widthとheightが省略された場合、これらに対し、60と40がセットされます。

 これで、Entityクラスの__init__()メソッドの4つのパラメータが一つにまとまりますので、これだけで「Too many arguments」のリファクタリングは完了です。あとは、インスタンスアトリビュートも地道に変更すれば完了です。self.xをself.retangle.x、self.yをself.rectangle.y、self.widthをself.rectangle.widht、self.heightをself.rectangle.heightといった具合にすべて変更しました。あと、111行目あたりのEntity作成時の初期値部分をRectangle(40, 80)というふうに変更しました。

 実行していたわかったことですが、namedtupleは名前がタプルということだけあって、不変なのですね。箱を動かしたときに、座標をセットしていたのですけど、self.rectangle.xは値を変えられませんでした。ま、名前から想像すれば、当たり前のことだったのですけどね。と、いうことで、唯一の座標を保持するための場所は、Rectangleクラスを再作成することにしました。42行目です。「Rectangle(*coords[0:2], *self.rectangle[2:4])」というふうに初期化しているのですが、namedtupleで生成したタプルは、属性が名称で参照できるだけでなく、タプルと同じように位置でアクセスすることもできるのですね。すばらしいです!

まとめ

 インスタンスアトリビュートの数は7個までにおさえましょう。属性をまとめるのには、namedtupleが便利です。

Python tkinter GUIプログラミング Canvasで箱をつなぐ線を描くその2

 今日も見に来てくださって、ありがとうございます。石川さんです。

 前回の宣言通り、今回は陰線処理です。箱の中心から中心へ線を引くところまでは同じなのですが、箱にかぶる部分は線を引かないようにしましょう。このページを参考にさせていただきました。

できあがりイメージ

 できあがりイメージは前回とほぼ変化なしです。中心から箱までの線が描かれなくなっただけです。

箱と箱を線で結ぶ。ただし、箱の中の線を取り除く。

ソースコード

 ソースコードは以下の通りです。変更点はConnectionクラスだけです。

import tkinter as tk


class Entity():
    ''' Entity class '''
    def __init__(self, canvas, x, y, width=60, height=40):
        self.canvas = canvas
        self.x, self.y, self.width, self.height = x, y, width, height
        self.start_x = self.start_y = None
        self.connections = []

        self.id = self.canvas.create_rectangle(x, y, x + width, y + height,
                                               fill="lightblue", width=3)
        self.canvas.tag_bind(self.id, "<ButtonPress>", self.button_press)
        self.canvas.tag_bind(self.id, "<Motion>", self.move)
        self.canvas.tag_bind(self.id, "<ButtonRelease>", self.button_release)

    def button_press(self, event):
        ''' マウスのボタンが押されたときの処理 '''
        self.start_x = self.canvas.canvasx(event.x)
        self.start_y = self.canvas.canvasy(event.y)

    def move(self, event):
        ''' マウスが移動したときの処理 '''
        if event.state & 256:  # マウスボタン1が押されているときだけ(ドラッグ中のみ)
            can_x = self.canvas.canvasx(event.x)
            can_y = self.canvas.canvasy(event.y)
            coords = self.canvas.coords(self.id)
            coords[0] -= self.start_x - can_x
            coords[1] -= self.start_y - can_y
            coords[2] -= self.start_x - can_x
            coords[3] -= self.start_y - can_y
            self.canvas.coords(self.id, coords)
            self.start_x = can_x
            self.start_y = can_y
            self.x, self.y = coords[0:2]
            for connection in self.connections:
                connection.move(self)

    def button_release(self, event):  # pylint: disable=unused-argument
        ''' マウスのボタンが離されたとき '''
        self.start_x = self.start_y = None

    def get_center(self):
        ''' 中心座標を戻します '''
        return self.x + self.width//2, self.y + self.height//2

    def add_listener(self, connection):
        ''' コネクションのリスナーを登録します '''
        self.connections.append(connection)


class Connection():
    ''' Connection class '''
    def __init__(self, canvas, start_entity, end_entity):
        self.canvas = canvas
        self.start_e = start_entity
        self.end_e = end_entity
        start_entity.add_listener(self)
        end_entity.add_listener(self)

        self.id = self.canvas.create_line(self.get_intersection(start_entity),
                                          self.get_intersection(end_entity))

    def move(self, entity):
        ''' エンティティが移動したときの処理(エンティティから呼び出される)'''
        coords = self.canvas.coords(self.id)
        if entity == self.start_e:
            coords[0:2] = self.get_intersection(entity)
            coords[2:4] = self.get_intersection(self.end_e)
        elif entity == self.end_e:
            coords[0:2] = self.get_intersection(self.start_e)
            coords[2:4] = self.get_intersection(entity)
        self.canvas.coords(self.id, coords)

    def get_intersection(self, entity):
        ''' 矩形との接点を求める '''
        x, y = entity.get_center()
        height, width = entity.height // 2, entity.width // 2
        dx, dy = self.end_e.x - self.start_e.x, self.end_e.y - self.start_e.y
        if entity == self.end_e:
            dx, dy = -dx, -dy
        if abs(dy / dx) < (height / width):  # 垂直側
            x_pos = x + width if dx > 0 else x - width
            y_pos = y + dy * width / abs(dx)
        else:  # 水平側
            x_pos = x + dx * height / abs(dy)
            y_pos = y + height if dy > 0 else y - height
        return x_pos, y_pos


class Application(tk.Tk):
    ''' Application class '''
    def __init__(self):
        super().__init__()
        self.title("Connecter test 2")
        self.geometry("640x320")

        self.canvas = tk.Canvas(self, background="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)

        entity1 = Entity(self.canvas, 40, 80)
        entity2 = Entity(self.canvas, 240, 160)
        Connection(self.canvas, entity1, entity2)
        entity3 = Entity(self.canvas, 420, 60)
        Connection(self.canvas, entity2, entity3)


def main():
    ''' main function '''
    application = Application()
    application.mainloop()


if __name__ == "__main__":
    main()

解説

 今回は、上述したように、Connectionクラスを変更しただけです。主な変更点は、get_intersection()メソッドの追加と、これまで箱側のget_center()メソッドを使って開始点、終点を算出していたところ、この追加したget_intersection()メソッドに変更したところです。このメソッドをどこに持つのがいいのかなぁ、というのはちょっと悩みましたが、箱と箱の関係によって線との交点が変わってくるので、Connectionクラスの方へ実装することにしました。

 get_intersection()メソッドでの計算のポイントは、箱と箱の中心間を結ぶ直線の傾き(dx / dy)と、箱そのものの縦横比(height / width)を比較することで、縦の線と交わるのか、横の線と交わるのか、というのを決めているところですね。縦の線と交わるときはx座標はそのまま戻す、横の線の場合はy座標をそのまま戻す感じになります。実際には上下、左右と二種類あるので、dx、dyの正負を条件にして切り替えるようにしています。座標の位置がそのままじゃない方については、縦線または横線の長さに直線の傾きの比率を掛けることで、求めるようにしています。

 Connectionクラスのmove()メソッドの変更点としては、これまで移動した方の箱の中心点だけ変更していればよかったところを、開始点と終了点の両方を計算する必要がでてきたので、68~73行目で対応しました。

 あと、実は箱が重なった時に変なところに線が引かれてしまうのと、dxが0になったときに「ZeroDivisionError: float division by zero」のエラーが発生しますので、余裕のある方は修正してみてください。

まとめ

 出来上がってみるとなんと言うことはないのですけど、作るのは結構難しいですね。参考になればうれしいです。