0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python】watchdog×shutilで「移動先にある同名ファイル」と誤検知して無限ループする原因と対処法(Mac)

0
Posted at

はじめに

デスクトップ上のファイルを指定フォルダへ自動分類するGUIツールをPython(Tkinter + watchdog)で作成した際、移動先にファイルがないのに「すでに同じファイルが存在します」と判定されてしまう(上書き確認が無限ループする) 現象に遭遇しました。

原因の調査と、Unicode(NFD/NFC)問題を含めた解決方法を備忘録としてまとめます。

※以下の記事で記載した変数名が登場します

発生した現象

  1. デスクトップにファイルを配置するか、「手動実行」を押す。
  2. ファイルを目的のフォルダへ移動させようとする。
  3. 移動先に同名ファイルがないにもかかわらず、重複確認ダイアログ が表示される。
  4. スキップしても何度も同じダイアログが出現する。

原因

主な原因は 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()

おわりに

ファイル監視系のライブラリを使う際は、「自分の操作(移動や削除)によって発生したイベントを自分で再検出していないか」 を常に意識することが重要だと学びました。
同じような挙動でハマった方の参考になれば幸いです!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?