Pyhtonで自作ツールをデスクトップアプリ化

 ## はじめに
 この記事は前回の記事[PythonでGoogleフォーム自動入力&自動反映](https://qiita.com/shunki0001/items/ec020726dafed0673b40)の続きです。

 ## 目的
 自動化を実行する際、
 
 - ターミナルを開く
 - 指定のフォルダに移動
 - コマンドで特定のファイルを実行
 
といった作業が必要になります。正直、毎回コマンドを打つのは面倒です。

そのためには、スマホアプリのように

 - アプリを開く
 - ボタンを押して実行

この２ステップで完結させたいと考えました。

## 準備
### ファイル構成
```text
/app
├── main.py             # GUIでボタンを作り、他2つの.pyを起動
├── create_spread.py      # スプレッドシートへの自動入力
├── input.py          # Googleフォームに反映
```

前回作成したコードを使用します。
 ```python:main.py
import tkinter as tk
from tkinter import messagebox
import subprocess
import sys
import os

# ボタン1: スプレッドシートを作成して入力
try:
    script_path = resource_path("create_spread")
    subprocess.run([sys.executable, script_path], check=True)
    subprocess.run(["python3", "create_spread.py"], check=True)
    messagebox.showinfo("エラー", "スプレッドシートの作成が完了しました。")

except Exception as e:
    messagebox.showerror("エラー", f"エラーが発生しました:\n{e}")

# ボタン2: Googleフォームに反映
try:
     script_path = resource_path("input.py")
     subprocess.run([sys.executable, script_path], check=True)
     subprocess.run(["python3", "input.py"], check=True)
     messagebox.showinfo("完了", "フォームへの反映が完了しました。")
except Exception as e:
    messagebox.showerror("エラー", f"エラーが発生しました\n{e}")

# GUIウィンドウ作成
root = tk.Tk()
root.title("スプレッドシート&フォーム作成")
root.geometry("300x150")

create_spread_btn = tk.Button(root, text="スプレッドシート作成", command=run_create_sheet)
create_spread_btn.pack(pady=10)

input_btn = tk.Button(root, text="Googleフォームへ反映", command=run_send_to_form)
input_btn.pack(pady=10)

root.mainloop()
```

```python:input.py
import gspread
from google.oauth2.service_account import Credentials
from oauth2client.service_account import ServiceAccountCredentials
from datetime import datetime
import webbrowser
import urllib.parse
import os
import sys

 # スプレッドシートに接続
scopes = ['https://spreadsheets.google.com/feeds','https://www.googleapis.com/auth/drive',]
# creds = ServiceAccountCredentials.from_json_keyfile_name("still-summit-xxxx.json", scope)
creds = Credentials.from_service_account_file(get_credential_path(), scopes=scopes)
client = gspread.authorize(creds)

# 今日の日付を取得
today = datetime.today()
today_str = today.strftime("%Y-%m-%d")
sheet = client.open("スプレッドシート").worksheet(today_str) # シート名を今日の日付に設定

# B列の値を辞書として取得
items = sheet.col_values(1)
values = sheet.col_values(2)

# A列:B列を辞書に(空の項目はスキップ)
form_data = {key: value for key, value in zip(items, values) if key and value}

# フォームのentryIDマッピング
entry_map = {
    "名前": 'entry.0000000001',
    "メールアドレス": 'emailAddress' ,
    # 今日の日付
    "西暦": 'entry.000000002_year',
    "月": 'entry.0000000003_month',
    "日": 'entry.0000000004_day',
    "開始(時間)": 'entry.0000000005_hour',
    "開始(分)": 'entry.0000000006_minute',
    "終了(時間)": 'entry.0000000007_hour',
    "終了(分)": 'entry.0000000008_minute',
    "今日やったこと": 'entry.0000000009',
    "所感": 'entry.0000000010',
    "達成率": 'entry.0000000011',
    "明日やること": 'entry.0000000012',
}

# entry.xxxxにマッピングしてURL用のパラメータ辞書に変換
params = {
    entry_map[key]: value
    for key, value in form_data.items()
    if key in entry_map
}

# URL生成&ブラウザで開く
form_url = "https://docs.google.com/forms/d/e/1FAIpQLSfEwTnCBxf7Ekrwom5qhuwx84l8mp6kDnqKKCH2cLaMei33KA/viewform"
url_with_params = form_url + "?" + urllib.parse.urlencode(params)
webbrowser.open(url_with_params)
```
```python:create_spread.py
import gspread
from google.oauth2.service_account import Credentials
from oauth2client.service_account import ServiceAccountCredentials
from datetime import datetime
from gspread_formatting import DataValidationRule, BooleanCondition, set_data_validation_for_cell_range
import os
import sys

# 認証と接続
def authorize():
    scopes= [
        'https://spreadsheets.google.com/feeds',
        'https://www.googleapis.com/auth/drive',
    ]
    creds = Credentials.from_service_account_file("still-summit-xxxx.json", scopes=scopes)
    return gspread.authorize(creds)

client = authorize()
# https://docs.google.com/spreadsheets/d/スプレッドシートID/edit?gid=0#gid=0
# スプレッドシートIDはURLにあります↑
SPREADSHEET_ID = "スプレッドシート ID"
spreadsheet = client.open_by_key(SPREADSHEET_ID)

# 日付の取得とシート作成
today = datetime.today()
today_str =today.strftime("%Y-%m-%d")
worksheet = spreadsheet.add_worksheet(title=today_str, rows="20", cols="10")

# 値をシートに書き込む関数
def update_cell_value(ws, cell, value):
    ws.update([[value]], cell)

# 項目リスト一括設定
def add_items_to_worksheet(ws):
    year = today.strftime("%Y")
    month = today.strftime("%m")
    day = today.strftime("%d")

    items = [
        ("項目", "内容"),
        ("メールアドレス", ""),
        ("名前", ""),
        ("西暦", year),
        ("月", month),
        ("日", day),
        ("開始(時間)", "10"),
        ("開始(分)", "00"),
        ("終了(時間)", "18"),
        ("終了(分)", "00"),
        ("", ""), # 今日やったこと
        ("所感", ""),
        ("達成率", "5"),
        ("", "") # 明日やること
    ]

    for i, (label, value) in enumerate(items, start=1):
        ws.update_cell(i, 1, label)
        ws.update_cell(i, 2, value)

# テンプレート挿入
def insert_templates(ws):
    training_template = "\n".join([
        "【やること】", "【予定時間】", "【実際にかかった時間】", "【残り時間】", "",
        "【やること】", "【予定時間】", "【実際にかかった時間】", "【残り時間】"
    ])
    next_plan_template = "\n".join([
        "【やること】", "【予定時間】", "",
        "【やること】", "【予定時間】"
    ])
    update_cell_value(ws, 'A11', '今日やること')
    update_cell_value(ws, 'B11', training_template)
    update_cell_value(ws, 'A14', '明日やること')
    update_cell_value(ws, 'B14', next_plan_template)

# ドロップダウン設定
def add_dropdown_rule(ws, cell, options):
    rule = DataValidationRule(
        condition=BooleanCondition('ONE_OF_LIST', options),
        showCustomUi=True,
        strict=True
    )
    set_data_validation_for_cell_range(ws, cell, rule)

# 実行処理
add_items_to_worksheet(worksheet)
insert_templates(worksheet)

# ドロップダウンを設定
add_dropdown_rule(worksheet, 'B14', ['1', '2', '3', '4', '5'])

# 数値の「00」対策(文字列として再設定)
update_cell_value(worksheet, 'B8', '00')
update_cell_value(worksheet, 'B10', '00')

print(f"{today_str}シートを作成し、項目を追加しました。")

```

## アプリ化
PyInstallerをインストール
```bash
pip install pyinstaller
```
アプリ化
```bash
pyinstaller --onefile --windowed main.py
```
これで`dis/main`にデスクトップアプリが生成されます

### 補足
- subprocess.run(...)を使って、ボタンクリックで外部スクリプトを実行できます
- 認証ファイル（client_secret.jsonなど）は同じフォルダに配置してください
- check=Trueを指定すると、エラーがあれば例外で拾えます

この手順のあとアプリを立ち上げ、ボタンを押したが実行されなかった。

## エラーの内容
```bash
 エラーが発生しました:
Command '['python3', 'create_spread.py']' returned non-zero exit status 2.
```
### 原因: `--onefile`オプションでは、Pythonスクリプトは一括には「自動でまとめられない」
`create_psread.py`と`input.py`は`main.py`の外部にあるため、明示的に含めないと実行時に見つからずエラーになります

### 解決方法: ビルド時に明示的に追加する
コマンド
```bash
pyinstaller --onefile --windowed main.py \
    --add-data "create_spread.py:." \
    --add-data "input.py:."
```
`:`区切りを忘れずに！

再度ビルドして実行したら、
アプリが開く->ボタンを押す->アプリが開く.....
と無限ループに入った...

### 対策方法: モジュールとして呼び出すように修正する
「スクリプト」ではなく「モジュール」としてインポートして関数を実行できるか試してみる

#### (1)`create_spread.py`を関数化:
```python:create_spread.py
def run():
    # ここにスプレッドシート自動入力の処理
    print("スプレッドシート処理を実行しました")
```

#### (2)`input.py`を関数化:
```python:input.py
def run():
    # Googleフォームに反映する処理
    print("フォーム反映処理を実行しました")
```

#### (3)`main.py`からモジュールとして呼び出す:
```python:main.py
import tkinter as tk
from tkinter import messagebox
import sys
import os

# spread_test と input をインポート
import spread_test
import input

def run_create_sheet():
    try:
        spread_test.run()
        messagebox.showinfo("完了", "スプレッドシートの作成が完了しました。")
    except Exception as e:
        messagebox.showerror("エラー", f"エラーが発生しました:\n{e}")

def run_send_to_form():
    try:
        input.run()
        messagebox.showinfo("完了", "フォームへの反映が完了しました。")
    except Exception as e:
        messagebox.showerror("エラー", f"エラーが発生しました:\n{e}")

def create_gui():
    root = tk.Tk()
    root.title("スプレッドシート&フォーム操作")
    root.geometry("300x150")

    btn1 = tk.Button(root, text="スプレッドシート作成", command=run_create_sheet)
    btn1.pack(pady=10)

    btn2 = tk.Button(root, text="Googleフォームへ反映", command=run_send_to_form)
    btn2.pack(pady=10)

    root.mainloop()

if __name__ == "__main__":
    create_gui()
```

#### 修正後にビルド
```bash
pyinstaller --onefile --windowed main.py
```

実行をしたらまたエラーが出て、動作しなかった...

#### エラーの内容
```bash
エラーが発生しました:
[Errno 2] No such file or directory: 'still-summit-xxxx.json'
```

#### 原因: 認証JSONファイルが見つからないため動かない
スプレッドシートにアクセスする際、必要な認証JSONファイルがアプリに入っていないのでエラーになる

#### 対策方法
PyInstallerでビルドする際に、認証ファイルも一緒にパッケージする必要があります

##### 手順1: ファイルを埋め込む + 実行時に使えるようにする
1. `main.py`と同じフォルダに`still-summit-xxxx.json`を置いておく
1. PyInstallerにファイルを含めるように指定:
    ```bash
    pyinstaller --onefile --noconsole --add-data "認証JSONファイル:." main.py
    ```

##### 手順2: コードの修正
Step1: ３つのコードにresource_path()を追加
```python:
# 先頭付近
import os
import sys

def resource_path(relative_path):
    """PyInstallerでバンドルされたデータのパスを解決"""
    if hasattr(sys, '_MEIPASS'):
        return os.path.join(sys._MEIPASS, relative_path)
    return os.path.join(os.path.abspath("."), relative_path)
```

Step2: `create_spread.py`と`input.py`に認証ファイルのパス解決を追加
認証ファイルの読み込み部分を以下のように書き換えます
修正前:
```python
creds = Credentials.from_service_account_file("still-summit-xxxxx.json", scopes=scopes)
```
修正後:
```python
import os
import sys
from google.oauth2.service_account import Credentials

def get_credential_path():
    if hasattr(sys, '_MEIPASS'):
        return os.path.join(sys._MEIPASS, "still-summit-xxxx.json")
    return "still-summit-xxxx.json"

# 認証処理
scopes = ['https://www.googleapis.com/auth/spreadsheets']
creds = Credentials.from_service_account_file(get_credential_path(), scopes=scopes)
gc = gspread.authorize(creds)
```

Step3: PyInstallerビルドコマンドを変更
```bash
pyinstaller --onefile --noconsole --add-data "still-summit-xxxx.json:." main.py
```

これで問題なく動作した。

### アイコンを追加&アプリ名を変更
最後に見た目をアプリっぽくするためにアイコンと名前を変更します

1. アイコンファイルを用意
    - 画像の拡張子は`.icns`に変換する必要がある
1. PyInstallerコマンド
    ```bash
    	pyinstaller main.py \
		--onefile \
		--noconsole \
		--icon=myapp.icns \ 
		--name=MyApp \ 
		--add-data=still-summit-xxxx.json:.
    ```
#### 補足:各オプションの意味:
| オプション | 意味 |
|:-:|:-:|
| --onefile  | １つの`.exe`にまとめる  |
| --noconsole  | 黒いコンソール画面を非表示(GUI向け)  |
| --icon=myapp.icns  | アプリのアイコンを設定  |
| --name=MyApp  | 出力ファイル名に変更  |
| --add-date=...  | JSONファイルをアプリに同梱  |

### まとめ
- `create_spread.py`と`input.py`の処理は全て関数としてまとめてモジュールとして扱い、`main.py`で呼び出せるようにする
- 認証JSONキーも`main.py`に読み込めるようにまとめる
- アプリと同じように操作ができるので今後はコマンド入力は不要に

### 最後に
ビルドの作業の際にスクリプトを１つにまとめることが難しく、最初から１つのファイルに全ての処理を書く方法もあった。
処理をまとめた方法と分ける方法、どちらが良いのかをまとめます。

スクリプトを分けるべきパターン
- 処理の役割がはっきり異なる時
- 再利用性を高いめたい時
- テストしやすくしたい時
- 処理ごとにエラー管理をしたい時

1つにまとめた方が良いパターン
- 全処理が連続的かつ一体となっている
- スクリプト間でデータの受け渡しが多い

それぞれメリット、デメリットがあった。
むしろ分けることで、**テスト・再利用・保守性**が上がるので
基本的には処理ごとに分けるのが良いことが多い。