ZZentralit
PythonbeginnerRecipeUpdated 12 Jan 2025

Folder watcher with safe triggers

Watch a directory for new files and react — move, rename, notify — with a debounce so half-written files don't trigger early.

#filesystem#watchdog#automation

The problem

A scanner drops PDFs into \\fileserver\Scans\Incoming. You want them renamed with a timestamp and moved into a dated sub-folder — but only after the scanner finishes writing. Otherwise you move a half-written file and break the downstream OCR.

This recipe uses watchdog with a small debounce window so a file has to sit quiet for N seconds before being touched.

Install

pip install watchdog

The script

import shutil
import time
from datetime import datetime
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

WATCH  = Path(r"\\fileserver\Scans\Incoming")
TARGET = Path(r"\\fileserver\Scans\Processed")
QUIET_SECONDS = 4  # how long a file must be untouched before we move it


class ScanHandler(FileSystemEventHandler):
    def __init__(self):
        self._pending: dict[Path, float] = {}

    def on_created(self, event):
        if event.is_directory:
            return
        self._pending[Path(event.src_path)] = time.time()

    def on_modified(self, event):
        if event.is_directory:
            return
        self._pending[Path(event.src_path)] = time.time()

    def tick(self):
        """Call this every second from the main loop."""
        now = time.time()
        due = [p for p, t in self._pending.items() if now - t >= QUIET_SECONDS]
        for p in due:
            self._pending.pop(p, None)
            try:
                self._process(p)
            except Exception as exc:
                print(f"!! failed {p.name}: {exc}")

    def _process(self, src: Path):
        if not src.exists():
            return
        day = datetime.now().strftime("%Y-%m-%d")
        dst_dir = TARGET / day
        dst_dir.mkdir(parents=True, exist_ok=True)
        stamp = datetime.now().strftime("%H%M%S")
        dst = dst_dir / f"{stamp}-{src.name}"
        shutil.move(str(src), str(dst))
        print(f"-> {dst}")


def main():
    handler = ScanHandler()
    obs = Observer()
    obs.schedule(handler, str(WATCH), recursive=False)
    obs.start()
    print(f"watching {WATCH} ...")
    try:
        while True:
            time.sleep(1)
            handler.tick()
    except KeyboardInterrupt:
        obs.stop()
    obs.join()


if __name__ == "__main__":
    main()

Run it as a service

On Windows, wrap it with NSSM (nssm install scan-watcher python.exe C:\scripts\watch.py). On Linux, a minimal systemd unit works:

[Service]
ExecStart=/usr/bin/python3 /srv/watch.py
Restart=on-failure

Why the debounce matters

Most “file was created” events from Windows fire before the app has finished writing. Naive scripts race the scanner and end up moving a truncated file. The quiet-window trick — only act once a file has stopped changing for a few seconds — costs you a few seconds of latency but eliminates the race.

Extensions

  • Post a Teams/Slack notification via an incoming webhook.
  • Push to Azure Blob instead of a fileshare for off-site archive.
  • Add a filter by extension (if src.suffix.lower() != ".pdf": return).