はじめに
デスクトップ上のファイルを指定フォルダへ自動分類するGUIツールをPython(Tkinter + watchdog)で作成した際、移動先にファイルがないのに「すでに同じファイルが存在します」と判定されてしまう(上書き確認が無限ループする) 現象に遭遇しました。
原因の調査と、Unicode(NFD/NFC)問題を含めた解決方法を備忘録としてまとめます。
※以下の記事で記載した変数名が登場します
発生した現象
- デスクトップにファイルを配置するか、「手動実行」を押す。
- ファイルを目的のフォルダへ移動させようとする。
- 移動先に同名ファイルがないにもかかわらず、重複確認ダイアログ が表示される。
- スキップしても何度も同じダイアログが出現する。
原因
主な原因は watchdog のイベント監視設計 と Mac特有のファイル名(Unicode) の2点でした。
① shutil.move の移動イベントを自身で再検知(主因)
監視ハンドラ(FileSystemEventHandler)で on_moved を処理していたため、以下のループが発生していました。
[ファイル検知] ➔ [shutil.move実行] ➔ [移動イベント発生(on_moved)] ➔ [ハンドラが再度move_filesを呼び出し] ➔ [移動中のファイルを重複として検出]
② Mac特有の濁点分離(NFD形式)問題
macOSのFinderやブラウザ等で保存されたファイル名は、濁点が分離する NFD形式になっていることがあります。
Python内部のパス生成で NFC形式(合成文字)と NFD形式 が混在すると、Path.resolve() や同名ファイル判定(is_file())で予期せぬ挙動を起こします。
解決策
① on_moved 監視の削除 & 新規作成(on_created)に限定する
shutil.move による自作処理の移動イベントを拾わないよう、on_moved を削除し、新規追加のみを監視対象にします。
② ファイル名を unicodedata.normalize('NFC', ...) で統一する
入力されるファイル名をすべて NFC 形式に正規化してからパスを組み立てます。
最終的なコード
# 【文字検索】のモジュール
import re
# 【ファイル操作】のモジュール
import shutil
# 【時間制御】のモジュール
import time
# 【画面作成】のモジュール
import tkinter as tk
# 【パス指定】のモジュールをライブラリから取得
from pathlib import Path
# 【通知設定】のモジュールをライブラリから取得
from tkinter import messagebox
# 【イベント監視】のモジュールをライブラリから取得
from watchdog.events import FileSystemEventHandler
# 【監視】指定したフォルダを監視するためのモジュール
from watchdog.observers import Observer
# 【Unicode標準化】Macの濁点問題を解消するモジュール
import unicodedata
# パスの設定
desktop_path = Path.home() / "Desktop"
folder_name = desktop_path / unicodedata.normalize('NFC',"セキスペ/セキスペ過去問-問題")
# -------------------------------------------------
# メイン処理(共通)
# -------------------------------------------------
def move_files(file_path):
# 拡張子がPDFの場合は処理中断
if file_path.suffix.lower() != ".pdf":
return False
# ファイル名をNFC形式に正規化
clean_filename = unicodedata.normalize('NFC', file_path.name)
# 「R0」を含むファイル名か判定
match = re.match(r"^(R0\d+)", clean_filename, re.IGNORECASE)
if match:
# 年度ごとにフォルダ階層を作成
subFolder_name = match.group(1).upper()
target_dir = folder_name / subFolder_name
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / clean_filename
if file_path.resolve() == target_path.resolve():
return False
# 同じファイル名が存在するかチェック
if target_path.is_file():
is_overwrite = messagebox.askyesno(
"同名ファイル確認",
f"すでに同じファイル名({file_path.name})が存在します。\n上書きしますか?"
)
# 上書きしない場合は処理中断
if not is_overwrite:
messagebox.showinfo("スキップ", f"処理をスキップします")
return False
try:
# ファイルを指定階層へ移動
shutil.move(str(file_path), str(target_path))
return True
except Exception:
return False
return False
# -------------------------------------------------
# 自動処理
# -------------------------------------------------
class DesktopWatchHandler(FileSystemEventHandler):
def handle_event(self, src_path):
file_path = Path(src_path)
if not file_path.is_dir():
# ダウンロード中のファイル書き込み完了を1秒待つ
time.sleep(1)
move_files(file_path)
def on_created(self, event):
self.handle_event(event.src_path)
# def on_moved(self, event):
# self.handle_event(event.dest_path)
# -------------------------------------------------
# GUI 画面作成
# -------------------------------------------------
class MoveFileApp:
def __init__(self, root):
self.root = root
# タイトルバーの設定
self.root.title("セキスペ過去問ファイル自動格納")
# ウィンドウのサイズ設定
self.root.geometry("320x240")
self.root.resizable(False, False)
self.Observer = None
self.is_watching = False
tk.Label(
root,
text="セキスペ過去問ファイルを指定フォルダへ移動させます",
pady=15
).pack()
# 手動実行ボタン
tk.Button(
root,
text="手動実行",
command=self.manual_process,
width=20,
height=2
).pack(pady=5)
# 自動処理監視ボタン
self.watch_button = tk.Button(
root,
text="監視START",
command=self.toggle_watch,
width=20,
height=2,
)
self.watch_button.pack(pady=10)
# ステータス表示
self.status_label = tk.Label(root, text="監視ステータス:停止中", fg="gray")
self.status_label.pack()
# 手動実行ボタンの処理
def manual_process(self):
moved_count = 0
for item in desktop_path.iterdir():
if item.is_file() and move_files(item):
moved_count += 1
if moved_count > 0:
messagebox.showinfo("完了!", f"{moved_count} 件のファイルを整理しました。")
def toggle_watch(self):
if not self.is_watching:
# 監視前に、すでに置いてあるファイルを一括整理
self.manual_process()
# 自動処理スタート
self.event_handler = DesktopWatchHandler()
self.Observer = Observer()
self.Observer.schedule(self.event_handler, path=str(desktop_path), recursive=False)
self.Observer.start()
self.is_watching = True
self.watch_button.config(text="監視ストップ", bg="#f44336")
self.status_label.config(text="監視ステータス:実行中…", fg="green")
else:
# 自動処理ストップ
if self.Observer:
self.Observer.stop()
self.Observer.join()
self.is_watching = False
self.watch_button.config(text="監視スタート", bg="#2196F3")
self.status_label.config(text="監視ステータス:停止中", fg="gray")
# ツール終了時のクリーンアップ
def on_close(self):
if self.Observer and self.is_watching:
self.Observer.stop()
self.Observer.join()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = MoveFileApp(root)
root.protocol("WM_DELETE_WINDOW", app.on_close)
root.mainloop()
おわりに
ファイル監視系のライブラリを使う際は、「自分の操作(移動や削除)によって発生したイベントを自分で再検出していないか」 を常に意識することが重要だと学びました。
同じような挙動でハマった方の参考になれば幸いです!