ろうとるがPythonでWindowsプログラムをつくる

# 久しぶりにWindowsプログラム
事情があって、ウン十年ぶりにWindowsプログラムをつくることになった。できるだけトレンディを身につけたく調べていたところ、Python+tkinterでWindowsプログラムを実現できることがわかったので、その記録をここに残すこととした。小生、Pythonは初心者レベル、tkinterは初めてのトライであり、基本的なことも記載。

## tkinter
Python上でGUIを実現するもの（ライブラリ）。山のように情報が見つかる。
- [tkinter --- Tcl/Tk の Python インターフェース](https://docs.python.org/ja/3/library/tkinter.html)（本家サイト）
- [PythonのTkinterを使ってみる](https://qiita.com/nnahito/items/ad1428a30738b3d93762)

## やること
sshでLinuxにログインし、"ls"にてホームディレクトリのファイルやディレクトリの一覧を取得し、"ls -ld"にて選択したファイルまたはディレクトリの詳細を表示するもの。
![全容.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634816/7e862953-9add-8cd2-0af6-07132bc78d86.png)
内容にあまり意味はなく、作成しながら学びたいこと（下記内容）を積み上げたら、このようなプログラムとなった。

#### Python
- 関数
- ログ
- 例外処理
- 文字列操作
- ssh利用
など。
#### tkinter
- GUI全般
- メニュー
- コンボボックス
- テキストボックス
- ボタン
- EXE化
など。

## ソースコード＋α
関数の関係で定義された変数が先に記載されたり、わかりにくいところがあるが、１行目から順に解説。
```py
from tkinter import *
from tkinter import ttk
import paramiko
import socket
import logging
import logging.handlers
from datetime import datetime
```
各種ライブラリのインポートで。tkinter、ログ、日付など。paramikoがssh用ライブラリで、端的な説明として、[「PythonでSSH接続する方法【初心者向け】」](https://techacademy.jp/magazine/18882)を参考にした。これがあれば、socketは不要と思われたが、後述する接続エラー検出時にこれが必要となった。

```py
import warnings 
warnings.filterwarnings('ignore')
```
Warningメッセージが処理する際に悪さをしていたため、上記により削除。[「Pythonで実行時のwarningが出ないようにする」](https://cocoinit23.com/python-warning-ignore/)を参考。

```py
### Size and Position
WIN_S = '400x350'
X_POS = 50
X_W = 100
Y_POS = 10
Y_H = 40
X_MSG = 100
Y_MSG = 300
MSG_W = 200
RES_W = 400
```
Windowサイズや表示位置の定義。

```py
### Get IP address, ID and Password from Text box
def get_conn_info():
    return srvbox.get(), usrbox.get(), pwdbox.get()
```
後述するテキストボックス（IPアドレス、ユーザ名、パスワード）の入力値を取得する関数。return文に複数の戻り値を記載できることが、ろうとるには新鮮だった。

```py
### Connect to server
def connect_srv():
    log.debug("== connect_srv ==")
    dst, id, key = get_conn_info()
    log.debug(f"  IP address:{dst}   User:{id}  Password:{key}")
    try:
        cli.connect(dst, username=id, password=key, timeout=3)
    except socket.timeout: # paramiko.SSHException does not work
        msg = Label(root, text="Connection Error", fg='red')
        msg.place(x=X_MSG, y=Y_MSG, width=MSG_W)
        log.debug('  Connection Error')
        return 1
    except paramiko.AuthenticationException:
        msg = Label(root, text="Authentication Error", fg='red')
        msg.place(x=X_MSG, y=Y_MSG, width=MSG_W)
        log.debug('  Authentication Error')
        cli.close()
        return 1
    except Exception:
        msg = Label(root, text="Other Errors", fg='red')
        msg.place(x=X_MSG, y=Y_MSG, width=MSG_W)
        log.debug('  Other Errors')
        cli.close()
        return 1
    msg = Label(root, text="Connected")
    msg.place(x=X_MSG, y=Y_MSG, width=200)
    log.debug('  Connected')
    return 0   
```
sshサーバーへの接続関数。ログ化（log.debug()）や接続（cli.connect()）実行前に、変数logおよびcliの定義が必要であるが、これらは後述するメイン部分で記載されている。
- IPアドレス、ユーザ名、パスワード取得
- サーバーへの接続および成功時の表示（Label）およびログ化
- エラー時の処理（try - except）
    - 接続エラー（各種サイトでよく見つかるparamiko.SSHExceptionではこのエラーを検出できず、大元のsocket.timeoutを利用）の表示およびログ化
    - 認証エラー（paramiko.AuthenticationException）の表示およびログ化
    - その他のエラー（Exception）の表示およびログ化
- 成功時は”0”、エラー時は”1”を返す
下記はエラー表示の例である。
![ConnectionError.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634816/b73869ee-d1d4-c6e9-e71c-91a69bcfec5f.png)

```py
### In case of "ls" click
def ls_clk():
    log.debug("==== ls_clk ====")
    ret = connect_srv()
    if ret != 0:
        return
    stdin, stdout, stderr = cli.exec_command('ls')
    data = []
    for line in stdout:                 # stdout.read() does not work
        data.append(line.splitlines())  # Also only 'line' works.
        #line.rstrip('\n')              # does not work for removal of LF
        for item in line.splitlines():  # for log
            log.debug('    ' + item)
    cbox['values'] = data               # Getting data is listed in combo box
    cbox.current(0)                     # 1st index
    cli.close()
```
”ls”ボタンクリック時にコールされる関数。サーバーへの接続後、”ls”実行結果（stdout）を行単位に処理し（splitlines()で改行コードの削除）、取得したファイル名またはディレクトリ名を、後述するコンボボックス（cbox）のリストに代入する（cbox['values']）。コンボボックスのリスト内容を、何かのきっかけにより更新する方法は、[「How To Create a Search-able Combo Box | Tkinter and Python | How To Create a Combo Box」](https://www.youtube.com/watch?v=-OAxe5LvtuM)を参考にした。
![Combobox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634816/079df076-9e40-573b-4bad-5c7b2f85d2b6.png)

```py
### In case of "ls -ld" click
def lsld_clk():
    log.debug("==== lsld_clk ====")
    item = cbox.get()
    if len(item) == 0:
        msg = Label(root, text="No selected file", fg='red')
        msg.place(x=X_MSG, y=Y_MSG, width=MSG_W)
        log.debug('  No selected file')
        return
    ret = connect_srv()
    if ret != 0:
        return
    stdin, stdout, stderr = cli.exec_command(f'ls -ld {item}')
    result = stdout.read()               # .read() is necessary.
    log.debug('    ' + result.decode())
    #log.debug(result)                   # outputs b''
    s = Label(root, text=result) 
    s.place(y=Y_POS+6*Y_H, width=RES_W)  # width is necessary to override previous result
    cli.close()
```
”ls -ld”ボタンクリック時にコールされる関数。コンボボックスにて選択されている要素を取得し、サーバーへの接続後、”ls -ld”を実行し、Labelにて表示する。なお、コンボボックスにて選択されていないときは、エラー表示する。
![No selected File.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634816/b234b5de-b6c3-ad24-86bc-e2417cb69df3.png)

```py
### For debug logging
def debug_tgl():
    tgl.set(not tgl.get())
    if tgl.get():
        log.setLevel(logging.DEBUG)
        fh = logging.FileHandler('{:Log-%Y%m%d-%H%M%S}.log'.format(datetime.now()))
        log.addHandler(fh)
    else:
        log.setLevel(logging.INFO)
```
後述する”Debug”メニュー選択時にコールされる関数。フラグ（Boolean：tgl）ON（TRUE）時、ログレベルをDEBUGにし、日時を含んだログファイルを作成する。OFF（FALSE）にはログレベルをINFOにする。本プログラムでは、log.debug()しか使われていないため、OFF時にはログなしとなる。

```py
##### Main #####
# Log
log = logging.getLogger(__name__)

# Window
root = Tk()
root.title("ls by ssh")
root.geometry(WIN_S)

# Paramiko for ssh connection
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.WarningPolicy())
```
メイン部分。ログ初期化、Windows作成、ssh接続初期化。

```py
# Menu
menubar = Menu(root)
filemenu = Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=filemenu)
filemenu.add_command(label="Exit", command=root.quit)   # only 'quit' may not work
tgl = BooleanVar()                                      # toggle for log
tgl.set(FALSE)
filemenu.add_checkbutton(label="Debug", command=debug_tgl)
root.config(menu=menubar)
```
メニューバー作成およびログ用フラグ（tgl）の初期化。ログ用メニュー”Debug”はトグルとなっており、選択（クリック）ごとにチェックが付加されたり消えたりする。”Exit”クリック時にはプログラムが終了。
![メニュー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634816/f8756a83-04fb-af6d-22ce-ba84685585b6.png)
![Debug.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634816/605a3a40-bf56-51ef-c860-a0a87b53dc7d.png)

```py
# Input item (Text box)
srv = Label(root, text='IP address')
srv.place(x=X_POS, y=Y_POS)
srvbox = Entry(root)
srvbox.place(x=X_POS+X_W, y=Y_POS)
usr = Label(root, text='User name')
usr.place(x=X_POS, y=Y_POS+Y_H)
usrbox = Entry(root)
usrbox.place(x=X_POS+X_W, y=Y_POS+Y_H)
pwd = Label(root, text='Password')
pwd.place(x=X_POS, y=Y_POS+2*Y_H)
pwdbox = Entry(root, show='*')
pwdbox.place(x=X_POS+X_W, y=Y_POS+2*Y_H)
```
IPアドレス、ユーザ名、パスワード取得用のテキストボックス。パスワードに関しては、入力内容が見えないよう”show='*'”を利用。

```py
# Button for ls
btn1 = Button(root, text='ls', command=ls_clk)
btn1.place(x=X_POS+X_W, y=Y_POS+3*Y_H)

# Box
cbox = ttk.Combobox(root, values=[])
cbox.place(x=X_POS+X_W, y=Y_POS+4*Y_H)
#cbox.bind("<<ComboboxSelected>>", sel_val)

# Button for ls -ld
btn2 = Button(root, text='ls -ld selected file', command=lsld_clk)
btn2.place(x=X_POS+X_W, y=Y_POS+5*Y_H)

# Loop
root.mainloop()
```
ボタン、コンボボックス、メインループ。ボタン、コンボボックス選択時のコールする関数を”command”で指定する。

### ログファイルサンプル
Debug ON時のログファイルのサンプルである。

```text:Log-20220320-081850.log
==== ls_clk ====
== connect_srv ==
  IP address:192.168.250.9   User:test  Password:himitsu
  Connection Error
==== ls_clk ====
== connect_srv ==
  IP address:192.168.250.1   User:test  Password:himitu
  Authentication Error
==== ls_clk ====
== connect_srv ==
  IP address:192.168.250.1   User:test  Password:himitsu
  Connected
    Desktop
    Documents
    Downloads
    Library
    Movies
    Music
    Pictures
    Public
==== lsld_clk ====
== connect_srv ==
  IP address:192.168.250.1   User:test  Password:himitsu
  Connected
    drwx------+ 3 test  staff  102 Mar 17 22:20 Downloads

==== lsld_clk ====
== connect_srv ==
  IP address:192.168.250.1   User:test  Password:himitsu
  Connected
    drwx------+ 3 test  staff  102 Mar 17 22:20 Music
```

## PythonファイルのEXE化
pyinstallerというものを使うとEXE化できる。[こちら（pyinstallerの使い方【Python】）](https://www.python-beginners.com/entry/20190306/1551873799#:~:text=pyinstaller)に詳しい説明あり。ここでは、下記のごとく実行し、コンソール出力なし、１ファイル化した。

```
> pyinstaller sshtest.py --noconsole --onefile
```

EOF