快轉到主要內容
  1. 教學文章/

Python watchdog 實戰:檔案變更監控與自動化完全攻略

·8 分鐘· loading · loading · ·
Python Watchdog Automation Filesystem Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 46: 本文

featured

一. 前言:有些工具不是只跑一次,而是要一直盯著變化
#

很多開發流程真正麻煩的,不是把事情做完一次,而是每次檔案有變更時,都要再做一次。

例如 Markdown 一更新就重建網站,某個資料夾多了新檔案就自動整理,設定檔改動後就重啟服務,或是圖片丟進 incoming/ 後立刻壓縮與搬移。

最直覺的做法,通常是寫個 while True:,每秒掃一次資料夾,看有沒有新東西。 但這種輪詢很快就會遇到幾個問題:不夠即時、浪費資源、邏輯容易混亂,而且很難判斷到底是哪個檔案真的發生了變化。

這時候就很適合請出 watchdog

watchdog 是 Python 裡很常用的檔案系統監控套件,可以監聽檔案或資料夾的建立、修改、刪除、重新命名等事件。 你不用自己一直輪詢,而是等作業系統在事件發生時通知你。

如果你前面看過拍拍君寫的 Python pathlib 教學Python subprocess 教學,今天這篇可以看成把兩者接起來的實戰篇。 pathlib 幫你處理路徑,subprocess 幫你在事件發生後執行其他工具,而 watchdog 則負責觀察變化本身。

這篇文章會一路帶你做到幾件事:

  • 理解 ObserverFileSystemEventHandler 的角色
  • 只監聽特定副檔名或特定目錄
  • 避免存一次檔案卻觸發好多次事件
  • 把監控接上 build、測試、轉檔等自動化 workflow
  • 寫出比較像正式專案的小工具版本

如果你平常會寫 dev tools、資料處理腳本、靜態網站工作流,這套真的很值得學起來。

二. 安裝:先把監看能力裝起來
#

安裝方式很簡單:

pip install watchdog

# 或用 uv
uv add watchdog

watchdog 最常用的核心元件大概就兩個:

  • Observer,負責在背景監聽檔案系統事件
  • FileSystemEventHandler,負責定義事件發生時要做什麼

你可以把它想成這樣:Observer 是保全系統,Handler 是收到通知後出動的人。

2.1 最小可執行範例
#

下面先來一個最基本的版本。 這個程式會監聽 ./watched 資料夾,只要有檔案建立、修改或刪除,就印出訊息。

from pathlib import Path
from time import sleep

from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer


class SimpleHandler(FileSystemEventHandler):
    def on_created(self, event):
        print(f"[created] {event.src_path}")

    def on_modified(self, event):
        print(f"[modified] {event.src_path}")

    def on_deleted(self, event):
        print(f"[deleted] {event.src_path}")


watch_path = Path("./watched")
watch_path.mkdir(exist_ok=True)

handler = SimpleHandler()
observer = Observer()
observer.schedule(handler, str(watch_path), recursive=True)
observer.start()

print(f"Watching: {watch_path.resolve()}")

try:
    while True:
        sleep(1)
except KeyboardInterrupt:
    observer.stop()

observer.join()

跑起來之後,去 watched/ 裡新增或修改檔案,就能在 terminal 看到事件輸出。

這個範例已經包含幾個重要觀念:

  • observer.start() 之後,監控會在背景進行
  • 主執行緒不能立刻結束,所以通常要維持一個簡單迴圈
  • recursive=True 代表連子資料夾也一起監聽
  • 要用 KeyboardInterrupt 收尾,最後記得 observer.join()

三. 認識事件模型:不只有 modified,還有 moved 與 directory event
#

watchdog 不只會告訴你檔案被改了。 它也會回報建立、刪除、搬移等事件,而且事件目標可能是檔案,也可能是資料夾。

最常見的第一步,就是先把資料夾事件過濾掉。

3.1 用 on_any_event() 先看清楚發生什麼事
#

當你還在摸清某個編輯器到底會觸發哪些事件時,on_any_event() 很方便。

from watchdog.events import FileSystemEventHandler


class FileOnlyHandler(FileSystemEventHandler):
    def on_any_event(self, event):
        if event.is_directory:
            return

        print(f"[{event.event_type}] {event.src_path}")

這段程式的重點不是最終設計,而是觀察。 你可以先實際存幾次檔案,看看會出現哪些 event type,再決定後面要不要拆成 on_modified()on_created()on_moved()

3.2 搬移或重新命名時,記得看 dest_path
#

如果你想處理 rename 或移動檔案,通常會用 on_moved()

from watchdog.events import FileSystemEventHandler


class MoveHandler(FileSystemEventHandler):
    def on_moved(self, event):
        if event.is_directory:
            return

        print(f"moved: {event.src_path} -> {event.dest_path}")

這在很多工作流裡都很有用,例如暫存檔改名成正式檔、下載完成後由 .part 變成正式名稱,或是某個整理程序把檔案搬到另一個子目錄。

也就是說,你監聽的不只是 bytes 有沒有變,而是工作流的狀態有沒有往下一步走。

四. 只處理你真的在乎的檔案
#

在真實專案裡,我們很少想監聽所有東西。 大部分情況下,你只想在 .md.csv.py 之類的檔案變動時處理。

4.1 用 pathlib 過濾副檔名
#

from pathlib import Path

from watchdog.events import FileSystemEventHandler


class MarkdownHandler(FileSystemEventHandler):
    def on_modified(self, event):
        if event.is_directory:
            return

        path = Path(event.src_path)
        if path.suffix != ".md":
            return

        print(f"Markdown updated: {path.name}")

這裡搭配 pathlib 的好處很直接。 你後面如果要判斷父層目錄、改副檔名、檢查檔名模式,都比手動切字串舒服很多。

4.2 把條件抽成 should_handle()
#

如果過濾規則開始變多,建議不要把判斷全部塞在事件函式裡。 把它抽成方法,程式會乾淨很多。

from pathlib import Path

from watchdog.events import FileSystemEventHandler


class FilteredHandler(FileSystemEventHandler):
    allowed_suffixes = {".md", ".txt"}

    def should_handle(self, src_path: str) -> bool:
        path = Path(src_path)
        if path.name.startswith("."):
            return False
        return path.suffix.lower() in self.allowed_suffixes

    def on_modified(self, event):
        if event.is_directory:
            return

        if not self.should_handle(event.src_path):
            return

        print(f"handle file: {event.src_path}")

這樣之後如果你要加入更多規則,例如忽略 .tmp、只處理特定子目錄、避開編輯器產生的 swap file,就能集中在一個地方維護。

五. 初學者最常遇到的坑:存一次檔案,事件卻跳很多次
#

這件事真的很常見。 你明明只按了一次儲存,結果 terminal 卻冒出兩次、三次,甚至更多 modified

通常不是你程式壞掉,而是不同編輯器與不同平台在儲存檔案時,可能真的會產生多個事件。 常見原因包括:

  • 先寫暫存檔,再 rename 成正式檔
  • 內容與 metadata 分開更新
  • 編輯器自己生成 swap file
  • 儲存後又自動跑 formatter

如果你的處理動作只是印個 log,那可能無所謂。 但如果每次觸發都會重跑 build、lint、上傳或轉檔,最好做 debounce

5.1 用時間窗避免重複處理
#

下面這個版本會記錄每個檔案上次處理的時間。 如果短時間內重複收到事件,就先跳過。

from pathlib import Path
from time import monotonic

from watchdog.events import FileSystemEventHandler


class DebouncedHandler(FileSystemEventHandler):
    def __init__(self, cooldown: float = 0.8):
        self.cooldown = cooldown
        self.last_seen: dict[str, float] = {}

    def should_skip(self, src_path: str) -> bool:
        now = monotonic()
        previous = self.last_seen.get(src_path)
        self.last_seen[src_path] = now

        if previous is None:
            return False

        return now - previous < self.cooldown

    def on_modified(self, event):
        if event.is_directory:
            return

        path = Path(event.src_path)
        if path.suffix != ".md":
            return

        if self.should_skip(event.src_path):
            return

        print(f"rebuild for: {path.name}")

這個做法的好處是簡單、直覺、在很多工作流裡也夠用。 缺點則是它只看時間,不看內容,如果你真的在很短時間內存了兩次不同內容,它也可能把第二次吞掉。

5.2 如果你要更穩,可以比較內容 hash
#

另一個思路是只在內容真的變化時才處理。 概念上可以先對檔案算 digest,和上次記錄比對後再決定要不要執行。

from hashlib import sha256
from pathlib import Path


def file_digest(path: Path) -> str:
    return sha256(path.read_bytes()).hexdigest()

這種作法比較穩,但成本也比較高,尤其是大檔案。 所以拍拍君通常會先從 debounce 開始,除非你的觸發成本真的很高,才會再升級成 hash-based 判斷。

六. 把 watchdog 接上自動化 workflow
#

很多人用 watchdog,其實不是想知道檔案變了。 真正想做的是在變動後 自動做事

像是:

  • .md 更新後重建靜態網站
  • .py 更新後跑測試
  • incoming/ 新增圖片後壓縮並搬移
  • config.yaml 變動後 reload 本地服務

這時候就很適合搭配 subprocess.run()

6.1 Markdown 變更後自動 build
#

from pathlib import Path
from subprocess import run
from time import monotonic

from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer


class BuildHandler(FileSystemEventHandler):
    def __init__(self, cooldown: float = 1.0):
        self.cooldown = cooldown
        self.last_seen: dict[str, float] = {}

    def should_skip(self, src_path: str) -> bool:
        now = monotonic()
        previous = self.last_seen.get(src_path)
        self.last_seen[src_path] = now
        return previous is not None and now - previous < self.cooldown

    def on_modified(self, event):
        if event.is_directory:
            return

        path = Path(event.src_path)
        if path.suffix != ".md":
            return

        if self.should_skip(event.src_path):
            return

        print(f"[build] detected update: {path.name}")
        result = run(["hugo"], cwd="/Users/maho/code/my-site")
        print(f"[build] exit code: {result.returncode}")


observer = Observer()
handler = BuildHandler()
observer.schedule(handler, "/Users/maho/code/my-site/content", recursive=True)
observer.start()

try:
    observer.join()
except KeyboardInterrupt:
    observer.stop()
    observer.join()

這裡的重點很簡單。 watchdog 負責感知事件,subprocess 負責執行外部命令,兩者搭起來,就能讓很多手動流程自動化。

如果你對 subprocess 還不熟,建議補一下 Python subprocess 教學,跟這種工作流真的很搭。

6.2 指令執行時,最好把錯誤也收好
#

正式一點的版本,通常不會只印 return code。 你會希望把 stdout、stderr 接住,失敗時能留下線索。

from subprocess import CalledProcessError, run


def run_build() -> None:
    try:
        result = run(
            ["hugo"],
            cwd="/Users/maho/code/my-site",
            capture_output=True,
            text=True,
            check=True,
        )
        print("build success")
        print(result.stdout)
    except CalledProcessError as exc:
        print("build failed")
        print(exc.stdout)
        print(exc.stderr)

如果你的外部流程偶爾會因為短暫狀況失敗,也可以再搭配 Python tenacity 教學,讓整條自動化管線更耐打一點。

七. 如果事件處理很重,先丟進 queue 比較穩
#

如果你的事件處理本身很重,不建議直接在 handler 裡做完所有事。 回呼最好維持短小,避免一個長任務把後續事件全部卡住。

這時候,一個很常見的設計是把事件先丟進 queue,再由背景 worker 慢慢處理。

from pathlib import Path
from queue import Queue
from time import monotonic

from watchdog.events import FileSystemEventHandler


class QueueHandler(FileSystemEventHandler):
    def __init__(self, queue: Queue, cooldown: float = 0.5):
        self.queue = queue
        self.cooldown = cooldown
        self.last_seen: dict[str, float] = {}

    def on_modified(self, event):
        if event.is_directory:
            return

        path = Path(event.src_path)
        if path.suffix != ".py":
            return

        now = monotonic()
        previous = self.last_seen.get(event.src_path)
        self.last_seen[event.src_path] = now

        if previous is not None and now - previous < self.cooldown:
            return

        self.queue.put(path)


def worker(queue: Queue) -> None:
    while True:
        path = queue.get()
        print(f"run checks for: {path.name}")
        queue.task_done()

這種設計的好處有幾個:

  • handler 本身會變得很薄
  • 你可以很容易擴充成多個 worker
  • 後續若要做失敗重試或批次處理,也比較好加
  • 測試時可以把事件過濾邏輯和真正工作邏輯拆開驗證

如果你喜歡把工具慢慢工程化,這一步很值得。

八. 一個比較像正式專案的版本
#

下面整理一個稍微完整一點的範例。 功能包括:監聽指定資料夾、只處理 .md.txt、忽略資料夾事件、使用 debounce,最後在事件發生時呼叫自訂 callback。

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from time import monotonic, sleep
from typing import Callable

from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer


@dataclass
class WatchConfig:
    root: Path
    suffixes: set[str] = field(default_factory=lambda: {".md", ".txt"})
    recursive: bool = True
    cooldown: float = 0.8


class SmartHandler(FileSystemEventHandler):
    def __init__(self, config: WatchConfig, callback: Callable[[Path], None]):
        self.config = config
        self.callback = callback
        self.last_seen: dict[Path, float] = {}

    def should_handle(self, event: FileSystemEvent) -> bool:
        if event.is_directory:
            return False

        path = Path(event.src_path)
        return path.suffix.lower() in self.config.suffixes

    def is_debounced(self, path: Path) -> bool:
        now = monotonic()
        previous = self.last_seen.get(path)
        self.last_seen[path] = now
        return previous is not None and now - previous < self.config.cooldown

    def on_modified(self, event: FileSystemEvent) -> None:
        if not self.should_handle(event):
            return

        path = Path(event.src_path)
        if self.is_debounced(path):
            return

        self.callback(path)


def on_change(path: Path) -> None:
    print(f"processing: {path.name}")


def main() -> None:
    config = WatchConfig(root=Path("./notes"))
    config.root.mkdir(exist_ok=True)

    observer = Observer()
    handler = SmartHandler(config, on_change)
    observer.schedule(handler, str(config.root), recursive=config.recursive)
    observer.start()

    print(f"watching {config.root.resolve()}")

    try:
        while True:
            sleep(1)
    except KeyboardInterrupt:
        observer.stop()

    observer.join()


if __name__ == "__main__":
    main()

這個版本已經有幾分可以放進正式工具的味道了。 你可以再往下加 CLI 參數、logging、設定檔、更多 callback 策略,或接到更完整的工作流中。

如果之後想把它包成命令列工具,可以再搭配 Python argparse 教學Python Typer 入門

九. 開發時幾個很實際的小提醒
#

9.1 不同平台的事件細節可能會不太一樣
#

watchdog 底層會依照作業系統使用不同的檔案監控機制,所以事件順序、事件數量、編輯器行為在不同平台上可能略有差異。

這不是它特別怪,而是檔案系統事件本來就很貼近作業系統。 所以拍拍君很建議,剛開始先用 on_any_event() 觀察自己的工作流,再決定最後要吃哪些事件。

9.2 不要把很重的工作直接塞進 handler
#

如果你的處理包含下載、整站 build、遠端同步或大型轉檔,最好還是丟 queue 或另外交給 worker。 不然出問題時很難 debug,而且後續事件也容易塞住。

9.3 記得做優雅關閉
#

監控程式通常會跑很久,所以 shutdown 很重要。 至少要確保 KeyboardInterrupt 時會呼叫 observer.stop(),最後 observer.join(),若有 queue 或 thread 也要一起收尾。

9.4 需要更穩時,可以組合 retry
#

如果事件發生後要做的是呼叫 API、上傳檔案或寫資料庫,那失敗不一定是邏輯錯,有時只是暫時性問題。 這時候把 watchdog、queue 與 retry 策略組合起來,整條 pipeline 會穩很多。

十. 結語:watchdog 的價值,是把重複工作變成自動反應
#

watchdog 很容易被誤會成一個只會印事件的小工具。 但它真正的價值,是讓你把很多「某個檔案一變,就做某件事」的需求,變成乾淨而可維護的自動化流程。

今天我們一路看了:

  • ObserverFileSystemEventHandler 的基本組合
  • 如何只處理特定檔案類型
  • 如何面對多重事件與 debounce
  • 如何接上 subprocess 形成 workflow
  • 如何整理成比較工程化的版本

如果你原本都用輪詢資料夾硬撐,學會 watchdog 之後,很多工具會乾淨非常多。 而且它跟 Python 生態系其他工具很好接,拿去接 CLI、靜態網站、測試流程、ETL 前處理,甚至本地 AI workflow 都很順。

說穿了,它就是一個很適合拿來「偷懶,但偷得很優雅」的套件。 這種偷懶,拍拍君完全支持。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 46: 本文

相關文章

Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具
Python match/case:結構化模式匹配完全攻略
·6 分鐘· loading · loading
Python Match Pattern-Matching Python 3.10
Python enum:打造型別安全的常數管理
·5 分鐘· loading · loading
Python Enum 型別安全 設計模式