feat: Add dir monitor

This commit is contained in:
2025-05-31 12:28:29 +03:00
parent be279a5f86
commit b2b6355977
2 changed files with 323 additions and 0 deletions
Executable
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.8"
# dependencies = [
# "watchdog"
# ]
# ///
import argparse
import dataclasses
import logging
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from typing import Self
from watchdog import observers
from watchdog.observers.polling import PollingObserver
from watchdog import events
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
@dataclasses.dataclass
class EventHandler:
pattern: re.Pattern
script_path: Path
@classmethod
def parse(cls, handler_spec: str) -> Self:
try:
pattern, script = handler_spec.rsplit(":", 1)
except ValueError:
raise ValueError(f"Invalid handler format: '{handler_spec}'. Expected REGEX_FILTER:/path/to/script")
if not pattern.strip():
raise ValueError(f"Empty filter regex in handler: '{handler_spec}'")
if not script.strip():
raise ValueError(f"Empty script path in handler: '{handler_spec}'")
try:
compiled = re.compile(pattern)
except re.error as e:
raise ValueError(f"Invalid regex pattern '{pattern}': {e}")
script_path = Path(script.strip()).expanduser().resolve()
return cls(pattern=compiled, script_path=script_path)
class ChangeDispatchHandler(events.FileSystemEventHandler):
def __init__(self, base_dir: Path, handlers_config: dict[str, EventHandler], ignore_patterns: list[re.Pattern]):
super().__init__()
self.base_dir = base_dir
self.handlers_config = handlers_config
self.ignore_patterns = ignore_patterns if ignore_patterns else []
def _execute_script(self, script_path: str, event_name: str, src_path: str, dest_path: str = None):
env_vars = os.environ.copy()
env_vars["MONITORED_DIR"] = str(self.base_dir)
env_vars["EVENT_TYPE"] = event_name.upper()
env_vars["SRC_PATH"] = str(Path(src_path).resolve())
args = [
event_name,
str(Path(src_path).resolve()),
*([str(Path(dest_path).resolve())] if dest_path else []),
]
if dest_path:
env_vars["DEST_PATH"] = str(Path(dest_path).resolve())
elif "DEST_PATH" in env_vars:
del env_vars["DEST_PATH"]
logging.info(f"Executing script '{script_path}' for event {event_name.upper()} on '{src_path}'" + (f" -> '{dest_path}'" if dest_path else ""))
try:
proc = subprocess.run(args=[script_path, *args], env=env_vars, capture_output=True, text=True, check=False, shell=False)
if proc.stdout:
logging.info(f"Script '{script_path}' STDOUT:\n{proc.stdout.strip()}")
if proc.stderr:
logging.warning(f"Script '{script_path}' STDERR:\n{proc.stderr.strip()}")
if proc.returncode == 0:
logging.info(f"Script '{script_path}' completed successfully.")
else:
logging.error(f"Script '{script_path}' failed with exit code {proc.returncode}.")
except FileNotFoundError:
logging.error(f"Script '{script_path}' not found. Ensure it's a valid path and in PATH if not absolute.")
except PermissionError:
logging.error(f"Permission denied for script '{script_path}'. Ensure it is executable.")
except Exception as e:
logging.error(f"An unexpected error occurred while running script '{script_path}': {e}")
def _is_ignored(self, path_to_check: Path) -> bool:
abs_path_to_check = path_to_check.resolve()
for pattern in self.ignore_patterns:
if pattern.search(str(abs_path_to_check)):
return True
# Also check relative path to monitored dir, if applicable
try:
relative_path = abs_path_to_check.relative_to(self.base_dir)
if pattern.search(str(relative_path)):
return True
except ValueError:
# Path is not inside base_dir, so relative path check is not applicable
pass
return False
def _process_event_type(self, event_name: str, path_to_filter_on: Path, src_path: Path, dest_path: Path | None = None):
if self._is_ignored(path_to_filter_on):
logging.debug(f"Path '{path_to_filter_on}' is ignored by configured patterns.")
return
handlers = self.handlers_config.get(event_name, [])
matched_handlers = [it for it in handlers if it.pattern.search(str(path_to_filter_on))]
if not matched_handlers:
logging.debug(f"No handlers matched for event '{event_name}' on path '{path_to_filter_on}'.")
return
for handler in matched_handlers:
logging.info(f"Event '{event_name}': Path '{path_to_filter_on}' matched regex '{handler.pattern.pattern}'.")
self._execute_script(
script_path=handler.script_path,
event_name=event_name,
src_path=src_path,
dest_path=dest_path,
)
def on_created(self, event: events.FileSystemEvent):
what = "directory" if event.is_directory else "file"
logging.debug(f"Watchdog event: CREATED {what} at {event.src_path}")
self._process_event_type(
event.event_type,
path_to_filter_on=Path(event.src_path),
src_path=Path(event.src_path),
)
def on_deleted(self, event: events.FileSystemEvent):
what = "directory" if event.is_directory else "file"
logging.debug(f"Watchdog event: DELETED {what} at {event.src_path}")
self._process_event_type(
event.event_type,
path_to_filter_on=Path(event.src_path),
src_path=Path(event.src_path),
)
def on_moved(self, event: events.FileSystemEvent):
what = "directory" if event.is_directory else "file"
logging.debug(f"Watchdog event: MOVED {what} from {event.src_path} to {event.dest_path}")
src_path = Path(event.src_path)
dest_path = Path(event.dest_path)
self._process_event_type(
event.event_type,
path_to_filter_on=dest_path,
src_path=src_path,
dest_path=dest_path,
)
def parse_args():
parser = argparse.ArgumentParser(
description="Monitors a folder for changes and executes handlers based on event type and regex filters.", formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("dir", type=Path, help="The directory to monitor.")
parser.add_argument("--poll", type=int, default=10, help="Polling interval in seconds.")
parser.add_argument("--recursive", action="store_true", help="Monitor the directory recursively.")
parser.add_argument(
"--created",
action="append",
dest="created_handlers",
type=EventHandler.parse,
metavar="FILTER:SCRIPT",
help="Handler for file/directory creation events.\nFormat: REGEX_FILTER:/path/to/script\nExample: '.*\\.txt$:/usr/local/bin/process_text_file.sh'",
default=[],
)
parser.add_argument(
"--deleted",
action="append",
dest="deleted_handlers",
type=EventHandler.parse,
metavar="FILTER:SCRIPT",
help="Handler for file/directory deletion events.\nFormat: REGEX_FILTER:/path/to/script",
default=[],
)
parser.add_argument(
"--moved",
action="append",
dest="moved_handlers",
type=EventHandler.parse,
metavar="FILTER:SCRIPT",
help="Handler for file/directory move events.\nThe filter applies to the destination path.\nFormat: REGEX_FILTER:/path/to/script",
default=[],
)
parser.add_argument(
"--ignore",
action="append",
dest="ignore_patterns",
type=re.compile,
metavar="REGEX_PATTERN",
help="Regex pattern for paths to ignore. Can be specified multiple times.",
default=[],
)
parser.add_argument("--debug", action="store_true", help="Enable debug logging.")
return parser.parse_args()
def main():
args = parse_args()
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.debug("Debug logging enabled.")
if not args.dir.is_dir():
logging.error(f"Monitored directory '{args.dir}' (resolved to '{args.dir.resolve()}') not found or not a directory.")
sys.exit(1)
handlers: dict[str, EventHandler] = {
"created": args.created_handlers,
"deleted": args.deleted_handlers,
"moved": args.moved_handlers,
}
if not any(lst for lst in handlers.values()):
logging.error("No valid handler scripts provided.")
sys.exit(1)
logging.info(f"Starting filesystem monitor for directory: '{args.dir}'")
for event_type, handlers_list in handlers.items():
if handlers_list:
logging.info(f"Registered {len(handlers_list)} handler(s) for '{event_type}' events.")
for handler in handlers_list:
handler: EventHandler
logging.info(f" - Filter: '{handler.pattern.pattern}', Script: '{handler.script_path}'")
if args.ignore_patterns:
logging.info("Ignoring paths matching the following patterns:")
for pattern in args.ignore_patterns:
logging.info(f" - {pattern.pattern}")
event_handler = ChangeDispatchHandler(
base_dir=args.dir.resolve(),
handlers_config=handlers,
ignore_patterns=args.ignore_patterns,
)
observer = PollingObserver(timeout=args.poll)
observer.schedule(event_handler, args.dir, recursive=args.recursive)
observer.start()
logging.info("Monitor is now running. Press Ctrl+C to stop.")
try:
while observer.is_alive():
observer.join(timeout=1)
except KeyboardInterrupt:
logging.info("Keyboard interrupt received. Shutting down monitor...")
finally:
if observer.is_alive():
observer.stop()
observer.join()
logging.info("Monitor has been shut down.")
if __name__ == "__main__":
main()