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」のエラーが発生しますので、余裕のある方は修正してみてください。

まとめ

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

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

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

 Canvasで箱を描いて、動かすことをやってきましたけど、箱と箱をつなぐためのコネクションのような線を描くことをやってみました。まあまあうまいことできたように思いますので、紹介させていただきます。

できあがりイメージ

 できあがりイメージは以下のとおりです。箱を描いて、まずは中心同士で直線を結ぶことにしました。箱を動かすと線がついてくる、というのが目標です。隠線処理は、次回のテーマとしたいと思います。

ソースコード

 ソースコードは以下のとおりです。先日お話したとおり、pylintでチェックしているのですけど、一部、リファクタリングのメッセージが残っています。それは次々回以降のテーマでお話したいと思います。(R0902: too-many-instance-attributesとR0913: too-many-arguments)

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 self.start_x is None:
            return
        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.make_figure()

    def make_figure(self):
        ''' コネクションを描きます '''
        start_point = self.start_e.get_center()
        end_point = self.end_e.get_center()
        self.id = self.canvas.create_line(start_point, end_point)

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


class Application(tk.Tk):
    ''' Application class '''
    def __init__(self):
        super().__init__()
        self.title("Connecter test")
        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()

説明

 箱を動かす部分については、これまでに書いてきているので省略します。今回の一つ目のポイントは、Entityクラスです。10行目で接続されたコネクションを受け取るためのリストを作成しています。50~52行で定義されたadd_listener()メソッドで、リスナーとしてコネクションを登録できるようにしています。Entityクラスのmove()メソッドの39~40行で、登録されたコネクションのmove()メソッドが呼び出されます。

 2つ目のポイントは、Connectionクラスです。55~79行目です。コネクションが登録されたときに、箱の情報を取得するようにしています。指定された箱のadd_listener()メソッドを使って自分自身を登録します。登録することで、箱が移動したときに、こちらのmove()メソッドを呼び出してもらって、箱の動作にあわせてコネクションがついていくようになります。

まとめ

 箱に連動するコネクターが作れるようになりました。次回は接続部分の隠線処理について書きたいと思います。

Pythonプログラミング Spyderのpylint設定ファイル C0103 doesn’t conform to snake_case naming styleを抑止する

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

 今回は、pylintの設定について書いてみました。急いでいる人のために、やり方は以下のとおりです。これでx、y、idはC0103の規約違反のメッセージが抑止されます。

・pylintrcファイルを作成(以下の2行を記載すれば、x、y、idではメッセージ抑制されます)
・プロジェクトフォルダ(pylint実行対象のファイルがあるフォルダ)に配置

[BASIC]
good-names=x,y,id

動機

 プログラムの保守性を高めることを考えると、コーディングルールに従ってコーディングするのは、とても良い習慣だと思います。と、いうことで、ぼくも最近はPythonの標準的なコーディングルールをチェックしてくれるツールのpylintでチェックしてからアップしているのですけど、ちょっと怪しいルールがありまして。

[C0103]14: Variable name "x" doesn't conform to snake_case naming style

 そう、「x」は、スネークケースのネーミングスタイルではありません、と言われています。pylintは3文字以下の変数については、このメッセージを出してくれて、後で読んだときにわからなくなるような変数名を回避できるように教えてくれます。そうそう、変数名に、a、b、cと付けるとか、省略形は後でわからなくなっちゃうので、避けるべきですよね。でも、GUIプログラミングにおいて、x、yは、使ってもオッケーでしょ。x座標のxですよ。これ、3文字以上にするなら、x_coordinateとかにするのでしょうかねぇ。xで完全に意味がわかるし、x_coordinateだと逆に読みにくい気がするので、良い解決方法と思えません。と、いうことで、今回はこのpylintのメッセージを抑止したいと思います。

 ちょっと調べると、pylintrcファイルに「good-names=」を記述すればよい、ということでこの設定をしたいと思います。ぼくが今使っているのはSpyderからの静的コード分析なので、こちらの設定がSpyderの中にあるかどうかを確認してみました。ツール(T)-設定(F)を開きます。

ツールの設定

 pylintは日本語環境だと「静的コード分析」というところに当たるようです。「静的コード分析」をクリックして内容を確認してみましょう。

設定-静的コード分析

 どうやら、good-namesの設定も、pylintrcファイルに関する設定もできるところはなさそうですね。なので、pylintrcファイルをつくって、結果が出力されるフォルダへ配置してみましょう。

pylintrcファイルに以下のように記述しました。

good-names=x,y,id

 pylint実行してみましたが、特に反応なしでした。と、いうことでSpyderにおけるpylintrcファイルの保存先調べてみましたら、プロジェクトフォルダへ配置せよ、ということでした。おっと、ここまで書いておいてSpyderからのpylint実行方法を書いていなかったことに気づきました。「ソース(C)」-「静的コード分析を実行」です。ショートカットキーは「F8」ですね。

Spyderでpylintを実行する

 他のpylint実行のやり方は、「静的コード分析」ペインを表示して、

「静的コード分析」ペインの表示

 そこの右上にある「分析」ボタンをクリックすると、実行結果が表示されます。こんな感じで出力されます。

pylintの実行結果

 さて、pylintrcファイルをプロジェクトフォルダに配置して、再度実行します。おっと、エラーがでました。

エラー内容

 「File contains no section headers.」ということなので、ファイルの中にセクションヘッダーを書かないといけないのでしょうね。「OK」ボタンを押してみますと、Issueレポーターなどというものが起動されました。おお~、Spyderの内部エラーを回収する仕組みがあるのですねぇ。

Issueレポーター

 でも、今回は、おそらくpylintrcファイルの書き方の問題だと思いますので、送信は不要ですね。とりあえず「閉じる」を押して、この画面を閉じましょう。

 Pylintrc exampleで検索してみたところ、すぐに見つかりました。はい、セクションヘッダーとして、[BASIC]を入れればよさそうです。入れて、保存して、実行してみたところ、無事、x、y、idのメッセージが出力されなくなりました。

まとめ

 pylintをつかって、警告をひとつずつ潰していくと、色々と勉強になります。みなさんもなるべく使うようにしましょう。そして、昔やっていたC言語のコンパイルエラーとか警告を潰していく作業を思い出してなんだかなつかしいです。

Python GUIプログラミング tkinter 自動で消えるスクロールバー

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

 今日は、先日書いた記事のスクロールバーが必要のないときに消える方法について書きました。

出来上がりイメージ

項目の位置によってスクロールバーが出たり消えたりする

ソースコード

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

import tkinter as tk


class AutoScrollbar(tk.Scrollbar):  # pylint: disable=too-many-ancestors
    ''' 必要に応じて自動で消えるスクロールバー
        grid と pack geometry manager で利用可能です'''
    def __init__(self, master=None, **kw):
        super().__init__(master, **kw)
        self.geo_mgr = self.key_word = None

    def set(self, first, last):
        self.geo_mgr = self.geo_mgr if self.geo_mgr else self.winfo_manager()
        if float(first) <= 0.0 and float(last) >= 1.0:
            if self.geo_mgr == "grid":
                self.grid_remove()
            elif self.geo_mgr == "pack":
                self.pack_forget()
        else:
            if self.geo_mgr == "grid":
                self.grid()
            elif self.geo_mgr == "pack":
                self.pack(**self.key_word)
        super().set(first, last)

    def pack(self, **kw):  # pylint: disable=arguments-differ
        ''' pack geometry managerのキーワード引数を保持 '''
        self.key_word = kw
        super().pack(**kw)

    def place(self, **kw):  # pylint: disable=no-self-use
        ''' place geometry manager を使ったときのための警告 '''
        raise tk.TclError('placeは使えません')


class App(tk.Tk):
    ''' メインアプリケーションクラス '''
    def __init__(self):
        super().__init__()
        self.title("Canvas move test")
        self.geometry("400x200")

        # gridバージョン
        self.canvas = can = tk.Canvas(self, background="white")
        sbx = AutoScrollbar(self, orient=tk.HORIZONTAL, command=can.xview)
        sby = AutoScrollbar(self, orient=tk.VERTICAL, command=can.yview)
        can.config(xscrollcommand=sbx.set, yscrollcommand=sby.set)
        self.info = tk.Label(self)
        can.grid(row=0, column=0, sticky=tk.NSEW)
        sbx.grid(row=1, column=0, sticky=tk.EW)
        sby.grid(row=0, column=1, sticky=tk.NS)
        self.info.grid(row=2, columnspan=2)
#        # packバージョン
#        self.canvas = can = tk.Canvas(self, background="white")
#        sbx = AutoScrollbar(can, orient=tk.HORIZONTAL, command=can.xview)
#        sby = AutoScrollbar(can, orient=tk.VERTICAL, command=can.yview)
#        can.config(xscrollcommand=sbx.set, yscrollcommand=sby.set)
#        self.info = tk.Label(self)
#        can.grid(row=0, column=0, sticky=tk.NSEW)
#        sbx.pack(side=tk.BOTTOM, fill=tk.X)
#        sby.pack(sid=tk.RIGHT, fill=tk.Y)
#        self.info.grid(row=1, columnspan=2)

        self.start = self.item = None
        can.bind("<Motion>", self.move)
        can.bind("<ButtonPress>", self.button_press)
        can.bind("<ButtonRelease>", self.button_release)
        can.bind("<MouseWheel>", self.mouse_wheel)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind("<Configure>", self.resize)

        can.create_rectangle(40, 40, 60, 60)

    def move(self, event):
        ''' マウスが動いたときの処理 '''
        x = self.canvas.canvasx(event.x)  # pylint: disable=invalid-name
        y = self.canvas.canvasy(event.y)  # pylint: disable=invalid-name
        text = f"mouse position = ({event.x}, {event.y}) ({x},{y})"
        self.info.configure(text=text)
        if self.start and self.item:
            original_x, original_y = self.start
            self.canvas.move(self.item, x - original_x, y - original_y)
            self.start = x, y
            self.resize(event)

        elif event.state == 256:  # Button1
            self.canvas.scan_dragto(event.x, event.y, gain=1)

    def button_press(self, event):
        ''' マウスのボタンを押されたときの処理 '''
        x = self.canvas.canvasx(event.x)  # pylint: disable=invalid-name
        y = self.canvas.canvasy(event.y)  # pylint: disable=invalid-name
        self.start = x, y
        self.item = self.canvas.find_overlapping(x-10, y-10, x+10, y+10)
        if self.item:
            self.item = self.item[0]
        else:
            self.canvas.scan_mark(event.x, event.y)
        if event.num == 3:
            self.item = self.canvas.create_rectangle(x-10, y-10, x+10, y+10)

    def button_release(self, event):  # pylint: disable=unused-argument
        ''' ボタンが離されたときの処理 '''
        self.start = None

    def resize(self, event):  # pylint: disable=unused-argument
        ''' ウィンドウサイズが変わった時の処理 '''
        region = self.canvas.bbox(tk.ALL)
        if region[0] > 0:
            region = (0, *region[1:])
        if region[1] > 0:
            region = (region[0], 0, *region[2:])
        self.canvas.configure(scrollregion=region)

    def mouse_wheel(self, event):
        ''' ホイールが操作されたときの処理 '''
        if event.state == 5:  # Shift|Control
            self.canvas.scan_mark(event.x, event.y)
            self.canvas.scan_dragto(event.x + event.delta // 120,
                                    event.y, gain=10)
        elif event.state == 4:  # Control
            scale = 1 + event.delta / 1200
            self.canvas.scale(tk.ALL, event.x, event.y, scale, scale)
        elif event.state == 1:  # Shift
            pass
        elif event.state == 0:  # None
            self.canvas.scan_mark(event.x, event.y)
            self.canvas.scan_dragto(event.x,
                                    event.y + event.delta // 120, gain=10)
        self.resize(event)


def main():
    ''' メインプログラム '''
    app = App()
    app.mainloop()


if __name__ == "__main__":
    main()

説明

 今回は、Scrollbarを継承して、AutoScrollbarを作成しました。ま、基本的には、ここで記載のあった方法を踏襲しています。違いはpackでも利用できるようにしたことくらいでしょうか。ポイントはScrollbarのsetが呼び出されたときに、必要がなければ、grid_remove(pack_forget)を使って見えなくする、というところでしょうか。あと、packでも利用可能にするために、winfo_managerでセットされているgeometry managerを取得してインスタンス変数に保持するようにしました。packでの利用方法は、コメントアウトしてあるところです。
 その他の部分は前回とおんなじです、、、と思いましたが、一部、pylintでチェックして規約違反やら警告やらリファクタと言われたところを修正いたしました。スッキリしました!

まとめ

 スクロールバーが自動で消えるようになりました。

Python GUIプログラミング tkinter Canvas state Mod3

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

 先日書いた記事に修正を加えようと思って、ソースコードを探し出して実行してみたら、なぜかうまく動かなくてずいぶんと悩んでしまいました。今回はそのお悩みを解決した話です。

 修正自体はスクロールバーが必要な時には表示されて、不要な時には非表示になる、という修正をしようと思いたちました(その記事は次回の予定に。。。)。スクロールバー使ったソースはどれかしら、と、探し出して、実行してみたところなんと、ホイールを使ったスクロールがなぜか動作しません。先日、少なくともブログを書いた今年の4月頃までは動作していたのに!
 これはもしや知らないうちにPythonの仕様が変更されたのでは、時々バージョンアップしてるからねぇ、と、Pythonの仕様変更を疑ってしまいました。でも、いやいや待て待て、そんな影響が起きるような仕様変更がそんなに頻繁にあるわけはなかろう、と、気を取り直しまして、、、とりあえず、ソースコードをよく読みこんでみました。
 ええと、ちゃんとホイールが利用されたときのコーディングもされていますね。と、よく見ると、if文の条件が「event.state == 5」となっていますね。このstateは何かのキーが同時に押されると特定のビットを立てていくようになっていたはずです。ちょっと怪しいのでprintして確認してみました。

mouse_wheel: <MouseWheel event state=Mod3 delta=-120 x=231 y=103> 32

 なんと、stateに「Mod3(32 = 2**5)」がセットされている。。。何だこりゃ?!そりゃ、動きませんなぁ。しかし、何でしょうこのMod3は。Google先生に聞いてもなかなか見つけられなかったのですが、、、ありました。ここです。なるほど、ScrollLockですね。値としては、2**5でした。他にもCapsLockでは、2**1が確認できました。ScrollLockとCapsLockが同時にセットされた場合は、2**1 | 2**5となるわけですね。(2進数だと、00100010というように下位から1番目と5番目のビットがセットされてくる。(0オリジンです。))こんな風に、いろいろとセットされてくる可能性があるので、「event.state == 5」でShiftとCtrlが押されていることを判断するのではちょっと足りなかった、ということだったのですね。
 と、いうことでstateをチェックしているところはすべて以下のように修正しました。

if event.state & (2**0 | 2**2) == 5: # Shift|Control
    ...
elif event.state & 2**2 == 4: # Control
    ...
elif event.state & 2**0 == 1: # Shift
    ...
elif event.state & (2**0 | 2**2) == 0: # None
    ...

 ちょっと美しくないので、なんとかしたいところですが、ま、そのうち、おいおいと。

 調べた結果をまとめますと、以下の通りです。マウスクリックしたときの<ButtonPress>イベントについて記載します。

state数値WindowsLinuxMac OSX
Shift2**0 = 1Shiftキー
Lock2**1 = 2Caps Lock
Control2**2 = 4Ctrlキー
Mod12**3 = 8NumLockAltCommand(=Meta)
Mod22**4 = 16NumLockOption(=Alt)
Mod32**5 = 32ScrollLockNumLock
Mod42**6 = 64WinFn
Mod52**7 = 128
0x200002**17 = 131,072Alt
マウスクリックとキー入力状態によるstateの違い(Windows10は実験結果、Linux、MaOSXは未検証、※は不明)

 わからないところが結構あって、埋められていません。どなたかわかる方、教えていただけるとうれしいです。以下のスクリプトを実行すると、イベントがprintされますので、確認してみてください。

import tkinter
c = tkinter.Canvas()
c.pack()
c.bind("<ButtonPress>", print)
c.mainloop()

まとめ

 CtrlキーやShiftキーが押された状態を扱う処理を作る場合は、bit演算が必要です。