Python ツール #4 ― GUI でコマンド実行

# Python ツール #4 ― GUI でコマンド実行

GUI からコマンドをキックする Python スクリプトを開発してみました。

## 開発するに至った動機

Python で CUI のツールやユーティリティを開発して、なかなか良いものができたら、それを GUI でラップして .exe を開発して、その .exe を配布するという考えがあります。
Python で簡単に GUI を作る方法を探していたら、たまたま Flet という GUI ライブラリがあることを知りました。
今回、Flet の使用方法を学習してみることにしました。Python 内でツールの処理が完結するものでもよかったのですが、あえて Python からコマンドをキックするものを開発してみました。[^1]

## 実行イメージ

**GUI 起動時**

[Parameter] テキストボックスにコマンドのパラメーター（繰返し回数）を入力し、[Execute] ボタンをクリックすると、コマンド（dummy_command.bat）をキックする。
[Close] ボタンをクリックすると、GUI を終了する。

![Python_tool-GuiCommandExecutor_1-Init.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3529613/855254e9-f326-196d-8b7d-c2ee1f95f4b0.jpeg)

**コマンド実行中**

[Execute] ボタン、[Close] ボタンはクリック不可にされている。
順次、コマンドの標準出力・標準エラーを表示していく。

![Python_tool-GuiCommandExecutor_2-Running.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3529613/eff8a7ab-2040-67a8-dc3d-45efc8a0b5a0.jpeg)

**右上端の [×] ボタンのクリック時**

[Confirm] ダイアログが表示される。[Yes] ボタンをクリックするまでは、コマンドは実行され続け、コマンドの標準出力・標準エラーも表示され続ける。[Close] ボタンをクリックすると、GUI を終了する。

![Python_tool-GuiCommandExecutor_3-Cancel.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3529613/4096f02e-41b5-4e36-657e-3bbf4da6f848.jpeg)

**コマンド終了時**

[Execute] ボタン、[Close] ボタンがクリック可に戻っている。
標準出力・標準エラーの行が多い場合は、画面左端にスクロールバーが表示される。

![Python_tool-GuiCommandExecutor_4-Ended.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3529613/55abdcb9-965f-49f4-420b-bba29246bf7e.jpeg)

## Flet

https://flet.dev/

Flutter ベースの Python GUI ライブラリです。
Python の基礎知識を持っている、かつ Web 開発で Vuetify や React-Boot などを使用したことがあるという人は、簡単に開発できると思います。

## ソースコードの簡単な説明

* プロジェクト構成
    ![Python_tool-GuiCommandExecutor_0-Project.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3529613/6e38aea3-4d33-6611-e6a5-e11715f3d47e.jpeg)

* dummy_command.bat（コマンド）。あえて shift-jis で作成しています。
    ```bat:dummy_command.bat
    @rem
    @rem dummy_command.bat
    @rem
    
    @rem This file's encoding is shift-jis.
    
    @echo off
    setlocal
    
    echo start of dummy_command
    
    set parameter=%1
    if "%parameter%" == "" (
        echo Parameter is not specified.
        goto :abnormal_end
    )
    
    echo parameter : %parameter%
    
    set "string=あいうえお かきくけこ さしすせそ たちつてと なにぬねの はひふへほ まみむめも やゆよ らりるれろ わをん"
    set "spacer= : "
    
    for /l %%i in (1,1,%parameter%) do (
        powershell sleep 2
        echo %%i%spacer%%string%
    )
    
    echo end of dummy_command (normal end)
    exit /b 0
    
    :abnormal_end
    echo end of dummy_command (abnormal end)
    exit /b 1
    ```
    バッチファイルに指定したパラメーター回数分、文字列 “あいうえお ...” の出力と 2秒のスリープを繰り返す。
* gui_command_executor.py（Python スクリプト）。
    ```python:gui_command_executor.py
    #!/usr/bin/env python3
    
    ・・・
    
    # Import Libraries
    import os
    import subprocess
    import flet as ft
    
    # Constants
    CMD_DIR = '.\\bin'
    CMD_NAME = 'dummy_command.bat'
    CMD_ENCODING = 'shift-jis'
    DEBUG = False
    
    # Windows Size
    WINDOW_RATIO = 60
    WINDOW_WIDTH = 16 * WINDOW_RATIO
    WINDOW_HEIGHT = 10 * WINDOW_RATIO
    LIST_SPACING = 5
    LIST_PADDING = 10
    TEXTBOX_LENGTH = 250
    
    
    def write_message(page: ft.Page, list_view: ft.ListView, message: str):
        list_view.controls.append(ft.Text(message))
        page.update()
        return
    
    
    # Execute Command
    def execute_command(page: ft.Page, list_view: ft.ListView, text_value: str) -> None:
    
        list_view.clean()
    
        param1 = text_value.strip()
        if param1 == '':
            write_message(page, list_view, 'Parameter is not specified.')
            return
    
        write_message(page, list_view, 'call')
    
        proc = subprocess.Popen(
            [CMD_NAME, param1], cwd=CMD_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            encoding=CMD_ENCODING, text=True, shell=True)
    
        if DEBUG:
            print('start of read stdout')
    
        while proc.poll() is None:
    
            line_string = proc.stdout.readline()
            if not line_string:
                break
    
            if DEBUG:
                print('stdout > %s' % line_string.strip())
            write_message(page, list_view, line_string.strip())
    
        if DEBUG:
            print('end of read stdout')
    
        proc.wait()
        ret = proc.returncode
        proc.terminate()
    
        if DEBUG:
            print('return : %d' % ret)
        write_message(page, list_view, 'exit : %d' % ret)
    
        return
    
    
    # Main
    def main(page: ft.Page) -> None:
    
        os.environ['PATH'] += ';' + CMD_DIR
    
        def yes_click(e):  # noqa
            page.window_destroy()
    
        def no_click(e):  # noqa
            confirm_dialog.open = False
            page.update()
    
        confirm_dialog = ft.AlertDialog(
            modal=True,
            title=ft.Text('Confirm'),
            content=ft.Text('Are you sure?'),
            actions=[
                ft.ElevatedButton('Yes', on_click=yes_click),
                ft.OutlinedButton('No', on_click=no_click),
            ],
            actions_alignment=ft.MainAxisAlignment.END,
        )
    
        def window_event(e):
            if e.data == 'close':
                page.dialog = confirm_dialog
                confirm_dialog.open = True
                page.update()
                return
    
        def click_execute_button(e):  # noqa
            text_value = str(compo_text.value)
            compo_execute_button.disabled = True
            compo_close_button.disabled = True
            execute_command(page, compo_list, text_value)
            compo_execute_button.disabled = False
            compo_close_button.disabled = False
            page.update()
            return
    
        def click_close_button(e):  # noqa
            page.window_destroy()
            return
    
        page.title = 'Command Executor'
        page.scroll = ft.ScrollMode.ALWAYS
        page.window_width = WINDOW_WIDTH
        page.window_height = WINDOW_HEIGHT
        page.window_prevent_close = True
        page.on_window_event = window_event
    
        compo_text = ft.TextField(label='Parameter', hint_text="Input command parameter",
                                  value='', text_align=ft.TextAlign.LEFT, width=TEXTBOX_LENGTH)
        compo_execute_button = ft.FilledButton(text="Execute", on_click=click_execute_button)
        compo_close_button = ft.OutlinedButton(text="Close", on_click=click_close_button)
        compo_list = ft.ListView(expand=1, spacing=LIST_SPACING, padding=LIST_PADDING)
    
        page.add(
            ft.Row(
                [
                    compo_text,
                    compo_execute_button,
                    compo_close_button,
                ]
            ),
            ft.Row(
                [
                    compo_list,
                ]
            )
        )
    
    
    # Goto Main
    ft.app(target=main)
    ```
    **main()**
    GUI を描画し、テキストボックスでバッチファイルに渡すパラメーターを取得し、[Execute] ボタンのクリックで execute_command() を実行する。
    **execute_command()**
    バッチファイルを実行し、shift-jis で標準出力・標準エラーを読み込み、画面に表示する。
    * 後述の .exe の実行にて DOS プロンプト画面が開かれないようにするために、“subprocess.Popen()” のオプションに “shell=True” を指定している。
    * 今回は、“os.environ['PATH']” で、コマンドへの相対パスを指定する方法を採用したが、“subprocess.Popen()” でコマンドへの絶対パスを指定する方法でもよい。

## .py のコマンド化（.exe 化）

pyinstaller をインストールし、pyinstaller を実行する。“.\dist” 配下に “.exe” が作成される。

```
> D:
> cd D:\path\of\python_project
> pip install pyinstaller
> pyinstaller gui_command_executor.py --noconsole --onefile
> copy .\dist\gui_command_executor.exe .\
```

難点：
.exe のファイルサイズが大きい。
.exe を実行したとき、GUI が立ち上がってくるのがめちゃくちゃ遅い（2 ～ 5秒くらい？、フリーズしたかと思ってしまうことあり）。

## ソースコードの置き場所

https://github.com/tengbu-haoshou/Python_tool-gui_command_executor

## 参考

**Flet とは**

https://qiita.com/NasuPanda/items/48849d7f925784d6b6a0

**Flet 一通り**

https://resanaplaza.com/2023/10/08/%E3%80%90%E5%9B%B3%E8%A7%A3%E3%80%91pyton%E3%81%A8flet%E3%81%AB%E3%82%88%E3%82%8Bui%E9%96%8B%E7%99%BA%E3%81%A7%E7%9F%A5%E3%81%A3%E3%81%A6%E3%81%8A%E3%81%8D%E3%81%9F%E3%81%84%E5%9F%BA%E7%A4%8E%E7%9F%A5/

##

[^1]:GUI コマンド・キックを開発する際、よくハマるのが GUI 実行時に DOS プロンプト画面が開かれてしまうことかと思います。
約 20年前、Win32 API の CreateProcess() でハマったことがあります。製品リリース日が迫っていたので会社に二晩泊り込みでした。最終的な修正方法は、たった一行のビット・フラグ指定でした。
なので、あえてコマンド・キックにトライしてみました。案の定、ハマってしまいました。上述の通りフラグ  “shell=True”  によって対応できました。