From 4dca5fbf951c19bf4066c4ad34cc3448f289f0b1 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Tue, 30 Jun 2026 20:28:02 +0200 Subject: [PATCH] Crea servizio watchdog configurabile --- .gitignore | 13 +++ README.md | 60 ++++++++++++ config/config.example.toml | 65 +++++++++++++ pyproject.toml | 19 ++++ src/bakrest/__init__.py | 3 + src/bakrest/config.py | 153 +++++++++++++++++++++++++++++++ src/bakrest/config_cli.py | 43 +++++++++ src/bakrest/default_config.py | 66 +++++++++++++ src/bakrest/filters.py | 67 ++++++++++++++ src/bakrest/logging_setup.py | 34 +++++++ src/bakrest/paths.py | 26 ++++++ src/bakrest/registry.py | 71 ++++++++++++++ src/bakrest/server_check.py | 38 ++++++++ src/bakrest/service.py | 65 +++++++++++++ src/bakrest/watchdog_runner.py | 22 +++++ src/bakrest/watcher.py | 105 +++++++++++++++++++++ tests/test_config_and_filters.py | 54 +++++++++++ 17 files changed, 904 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.example.toml create mode 100644 pyproject.toml create mode 100644 src/bakrest/__init__.py create mode 100644 src/bakrest/config.py create mode 100644 src/bakrest/config_cli.py create mode 100644 src/bakrest/default_config.py create mode 100644 src/bakrest/filters.py create mode 100644 src/bakrest/logging_setup.py create mode 100644 src/bakrest/paths.py create mode 100644 src/bakrest/registry.py create mode 100644 src/bakrest/server_check.py create mode 100644 src/bakrest/service.py create mode 100644 src/bakrest/watchdog_runner.py create mode 100644 src/bakrest/watcher.py create mode 100644 tests/test_config_and_filters.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbc1914 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +dist/ +build/ +*.egg-info/ + +# Local runtime data +*.sqlite +*.log +status.json +config.local.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c0658e --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Bak&Rest + +Bak&Rest monitora cartelle configurate su Windows, registra i file creati, modificati, cancellati o spostati e prepara il backup successivo tramite tray app. + +## Componenti + +- `BakRestWatchdog`: servizio Windows di monitoraggio. +- `BakRestTray`: tray app che eseguira' il backup e lo spegnimento. +- `BakRestConfig`: GUI di configurazione. + +## Configurazione + +Alla prima esecuzione viene creato un file TOML modificabile in: + +```text +%PROGRAMDATA%\BakRest\config.toml +``` + +Per creare o trovare il file di configurazione: + +```powershell +python -m bakrest.config_cli init +python -m bakrest.config_cli path +``` + +In sviluppo si puo' usare un percorso diverso: + +```powershell +$env:BAKREST_CONFIG="C:\devel\bak&rest\config\config.toml" +python -m bakrest.watchdog_runner +``` + +## Avvio watchdog in console per test + +```powershell +python -m pip install -e . +python -m bakrest.watchdog_runner +``` + +## Installazione servizio Windows + +Da PowerShell avviato come amministratore: + +```powershell +python -m pip install -e . +python -m bakrest.service install +python -m bakrest.service start +``` + +Se `service.autostart = true`, il comando `install` configura il servizio con avvio automatico. Se e' `false`, il servizio viene installato con avvio manuale. Il valore puo' essere sempre sovrascritto passando esplicitamente le opzioni di `pywin32`. + +Comandi utili: + +```powershell +python -m bakrest.service stop +python -m bakrest.service remove +python -m bakrest.service debug +``` + +Il servizio si chiama `BakRestWatchdog`. diff --git a/config/config.example.toml b/config/config.example.toml new file mode 100644 index 0000000..229ed28 --- /dev/null +++ b/config/config.example.toml @@ -0,0 +1,65 @@ +[service] +autostart = true + +[tray] +autostart = true + +[watch] +include_dirs = [ + "%USERPROFILE%\\Desktop", + "%USERPROFILE%\\Documents", + "%USERPROFILE%\\Pictures", + "%USERPROFILE%\\Videos", +] +include_extensions = [ + ".jpg", + ".jpeg", + ".png", + ".pdf", + ".mp4", + ".mov", +] +exclude_dirs = [ + "%WINDIR%", + "%PROGRAMFILES%", + "%PROGRAMFILES(X86)%", + "%APPDATA%", + "%LOCALAPPDATA%", + "%TEMP%", + "$Recycle.Bin", + "System Volume Information", + "node_modules", + ".git", + "__pycache__", +] +exclude_patterns = [ + "~$*", + "*.tmp", + "*.bak", + "*.crdownload", + "*.part", + "*.lock", +] + +[backup] +server_host = "backup-server" +remote_destination = "utente@backup-server:/backup/bak-rest/" +rsync_path = "rsync" +shutdown_on_success = true +shutdown_command = "shutdown /s /t 0" + +[backup.server_check] +type = "tcp" +port = 22 +interval_seconds = 300 +timeout_seconds = 3 + +[stability] +min_age_seconds = 60 +unchanged_check_interval_seconds = 5 +unchanged_checks_required = 2 + +[storage] +change_registry = "%PROGRAMDATA%\\BakRest\\changes.sqlite" +logs_dir = "%PROGRAMDATA%\\BakRest\\logs" +status_file = "%PROGRAMDATA%\\BakRest\\status.json" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6ac6f10 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "bak-and-rest" +version = "0.1.0" +description = "Windows watchdog service and tray backup utility." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "watchdog>=4.0.0", + "pywin32>=306; platform_system == 'Windows'", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/bakrest/__init__.py b/src/bakrest/__init__.py new file mode 100644 index 0000000..009eeb1 --- /dev/null +++ b/src/bakrest/__init__.py @@ -0,0 +1,3 @@ +"""Bak&Rest package.""" + +__version__ = "0.1.0" diff --git a/src/bakrest/config.py b/src/bakrest/config.py new file mode 100644 index 0000000..73c647d --- /dev/null +++ b/src/bakrest/config.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import shutil +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .default_config import DEFAULT_CONFIG_TOML +from .paths import default_config_path, expand_path + + +@dataclass(frozen=True) +class ServerCheckConfig: + type: str + port: int + interval_seconds: int + timeout_seconds: int + + +@dataclass(frozen=True) +class BackupConfig: + server_host: str + remote_destination: str + rsync_path: str + shutdown_on_success: bool + shutdown_command: str + server_check: ServerCheckConfig + + +@dataclass(frozen=True) +class WatchConfig: + include_dirs: tuple[Path, ...] + include_extensions: frozenset[str] + exclude_dirs: tuple[Path | str, ...] + exclude_patterns: tuple[str, ...] + + +@dataclass(frozen=True) +class StorageConfig: + change_registry: Path + logs_dir: Path + status_file: Path + + +@dataclass(frozen=True) +class StabilityConfig: + min_age_seconds: int + unchanged_check_interval_seconds: int + unchanged_checks_required: int + + +@dataclass(frozen=True) +class AppConfig: + path: Path + service_autostart: bool + tray_autostart: bool + watch: WatchConfig + backup: BackupConfig + storage: StorageConfig + stability: StabilityConfig + + +def ensure_default_config(path: Path | None = None) -> Path: + config_path = path or default_config_path() + if config_path.exists(): + return config_path + + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8") + return config_path + + +def copy_example_config(destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(Path("config/config.example.toml"), destination) + + +def load_config(path: Path | None = None) -> AppConfig: + config_path = ensure_default_config(path) + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + return parse_config(data, config_path) + + +def parse_config(data: dict[str, Any], path: Path) -> AppConfig: + service = data.get("service", {}) + tray = data.get("tray", {}) + watch = data.get("watch", {}) + backup = data.get("backup", {}) + server_check = backup.get("server_check", {}) + storage = data.get("storage", {}) + stability = data.get("stability", {}) + + include_dirs = tuple(expand_path(item) for item in watch.get("include_dirs", [])) + include_extensions = frozenset( + _normalize_extension(item) for item in watch.get("include_extensions", []) + ) + exclude_dirs = tuple(_normalize_exclude_dir(item) for item in watch.get("exclude_dirs", [])) + + storage_config = StorageConfig( + change_registry=expand_path( + storage.get("change_registry", "%PROGRAMDATA%\\BakRest\\changes.sqlite") + ), + logs_dir=expand_path(storage.get("logs_dir", "%PROGRAMDATA%\\BakRest\\logs")), + status_file=expand_path(storage.get("status_file", "%PROGRAMDATA%\\BakRest\\status.json")), + ) + + return AppConfig( + path=path, + service_autostart=bool(service.get("autostart", True)), + tray_autostart=bool(tray.get("autostart", True)), + watch=WatchConfig( + include_dirs=include_dirs, + include_extensions=include_extensions, + exclude_dirs=exclude_dirs, + exclude_patterns=tuple(watch.get("exclude_patterns", [])), + ), + backup=BackupConfig( + server_host=str(backup.get("server_host", "backup-server")), + remote_destination=str(backup.get("remote_destination", "")), + rsync_path=str(backup.get("rsync_path", "rsync")), + shutdown_on_success=bool(backup.get("shutdown_on_success", True)), + shutdown_command=str(backup.get("shutdown_command", "shutdown /s /t 0")), + server_check=ServerCheckConfig( + type=str(server_check.get("type", "tcp")).lower(), + port=int(server_check.get("port", 22)), + interval_seconds=int(server_check.get("interval_seconds", 300)), + timeout_seconds=int(server_check.get("timeout_seconds", 3)), + ), + ), + storage=storage_config, + stability=StabilityConfig( + min_age_seconds=int(stability.get("min_age_seconds", 60)), + unchanged_check_interval_seconds=int( + stability.get("unchanged_check_interval_seconds", 5) + ), + unchanged_checks_required=int(stability.get("unchanged_checks_required", 2)), + ), + ) + + +def _normalize_extension(value: str) -> str: + value = value.strip().lower() + if not value: + return value + return value if value.startswith(".") else f".{value}" + + +def _normalize_exclude_dir(value: str) -> Path | str: + expanded = expand_path(value) + if "%" in value or not expanded.is_absolute(): + return value.lower() + return expanded diff --git a/src/bakrest/config_cli.py b/src/bakrest/config_cli.py new file mode 100644 index 0000000..9fc8505 --- /dev/null +++ b/src/bakrest/config_cli.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import argparse + +from .config import ensure_default_config, load_config +from .paths import default_config_path + + +def main() -> int: + parser = argparse.ArgumentParser(description="Bak&Rest configuration helper") + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("path", help="Print the active configuration path") + subparsers.add_parser("init", help="Create the default configuration if missing") + subparsers.add_parser("show", help="Load and print a short configuration summary") + args = parser.parse_args() + + if args.command == "path": + print(default_config_path()) + return 0 + + if args.command == "init": + print(ensure_default_config()) + return 0 + + if args.command == "show": + config = load_config() + print(f"config: {config.path}") + print(f"service_autostart: {config.service_autostart}") + print(f"tray_autostart: {config.tray_autostart}") + print("include_dirs:") + for include_dir in config.watch.include_dirs: + print(f" - {include_dir}") + print(f"include_extensions: {', '.join(sorted(config.watch.include_extensions))}") + print(f"registry: {config.storage.change_registry}") + print(f"logs_dir: {config.storage.logs_dir}") + return 0 + + parser.error("Unknown command") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bakrest/default_config.py b/src/bakrest/default_config.py new file mode 100644 index 0000000..b93d232 --- /dev/null +++ b/src/bakrest/default_config.py @@ -0,0 +1,66 @@ +DEFAULT_CONFIG_TOML = """[service] +autostart = true + +[tray] +autostart = true + +[watch] +include_dirs = [ + "%USERPROFILE%\\\\Desktop", + "%USERPROFILE%\\\\Documents", + "%USERPROFILE%\\\\Pictures", + "%USERPROFILE%\\\\Videos", +] +include_extensions = [ + ".jpg", + ".jpeg", + ".png", + ".pdf", + ".mp4", + ".mov", +] +exclude_dirs = [ + "%WINDIR%", + "%PROGRAMFILES%", + "%PROGRAMFILES(X86)%", + "%APPDATA%", + "%LOCALAPPDATA%", + "%TEMP%", + "$Recycle.Bin", + "System Volume Information", + "node_modules", + ".git", + "__pycache__", +] +exclude_patterns = [ + "~$*", + "*.tmp", + "*.bak", + "*.crdownload", + "*.part", + "*.lock", +] + +[backup] +server_host = "backup-server" +remote_destination = "utente@backup-server:/backup/bak-rest/" +rsync_path = "rsync" +shutdown_on_success = true +shutdown_command = "shutdown /s /t 0" + +[backup.server_check] +type = "tcp" +port = 22 +interval_seconds = 300 +timeout_seconds = 3 + +[stability] +min_age_seconds = 60 +unchanged_check_interval_seconds = 5 +unchanged_checks_required = 2 + +[storage] +change_registry = "%PROGRAMDATA%\\\\BakRest\\\\changes.sqlite" +logs_dir = "%PROGRAMDATA%\\\\BakRest\\\\logs" +status_file = "%PROGRAMDATA%\\\\BakRest\\\\status.json" +""" diff --git a/src/bakrest/filters.py b/src/bakrest/filters.py new file mode 100644 index 0000000..22a84c8 --- /dev/null +++ b/src/bakrest/filters.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import fnmatch +from pathlib import Path + +from .config import WatchConfig + + +class WatchFilter: + def __init__(self, config: WatchConfig) -> None: + self.config = config + + def should_track(self, path: Path, is_directory: bool = False) -> bool: + if is_directory: + return False + + if not self._is_inside_include_dir(path): + return False + + if self._is_inside_excluded_dir(path): + return False + + if self._matches_excluded_pattern(path): + return False + + if self.config.include_extensions and path.suffix.lower() not in self.config.include_extensions: + return False + + return True + + def _is_inside_include_dir(self, path: Path) -> bool: + resolved = _safe_resolve(path) + for include_dir in self.config.include_dirs: + include_resolved = _safe_resolve(include_dir) + if _is_relative_to(resolved, include_resolved): + return True + return False + + def _is_inside_excluded_dir(self, path: Path) -> bool: + resolved = _safe_resolve(path) + lower_parts = {part.lower() for part in resolved.parts} + for exclude_dir in self.config.exclude_dirs: + if isinstance(exclude_dir, Path): + if _is_relative_to(resolved, _safe_resolve(exclude_dir)): + return True + elif exclude_dir.lower() in lower_parts: + return True + return False + + def _matches_excluded_pattern(self, path: Path) -> bool: + name = path.name.lower() + return any(fnmatch.fnmatch(name, pattern.lower()) for pattern in self.config.exclude_patterns) + + +def _safe_resolve(path: Path) -> Path: + try: + return path.resolve() + except OSError: + return path.absolute() + + +def _is_relative_to(path: Path, base: Path) -> bool: + try: + path.relative_to(base) + return True + except ValueError: + return False diff --git a/src/bakrest/logging_setup.py b/src/bakrest/logging_setup.py new file mode 100644 index 0000000..73e7cab --- /dev/null +++ b/src/bakrest/logging_setup.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import logging +from datetime import date +from pathlib import Path + + +def configure_logging(logs_dir: Path, component: str) -> logging.Logger: + logs_dir.mkdir(parents=True, exist_ok=True) + logger = logging.getLogger(f"bakrest.{component}") + logger.setLevel(logging.INFO) + logger.handlers.clear() + + log_path = logs_dir / f"{date.today().isoformat()}-{component}.log" + formatter = logging.Formatter( + "%(asctime)s %(levelname)s [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.FileHandler(log_path, encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + return logger + + +def close_logger(logger: logging.Logger) -> None: + for handler in list(logger.handlers): + handler.flush() + handler.close() + logger.removeHandler(handler) diff --git a/src/bakrest/paths.py b/src/bakrest/paths.py new file mode 100644 index 0000000..147d7e8 --- /dev/null +++ b/src/bakrest/paths.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +APP_DIR_NAME = "BakRest" +CONFIG_ENV_VAR = "BAKREST_CONFIG" + + +def program_data_dir() -> Path: + base = os.environ.get("PROGRAMDATA") + if base: + return Path(base) / APP_DIR_NAME + return Path.home() / f".{APP_DIR_NAME.lower()}" + + +def default_config_path() -> Path: + configured = os.environ.get(CONFIG_ENV_VAR) + if configured: + return Path(configured).expanduser() + return program_data_dir() / "config.toml" + + +def expand_path(value: str) -> Path: + return Path(os.path.expandvars(value)).expanduser() diff --git a/src/bakrest/registry.py b/src/bakrest/registry.py new file mode 100644 index 0000000..b0b6043 --- /dev/null +++ b/src/bakrest/registry.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sqlite3 +from contextlib import closing +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass(frozen=True) +class ChangeEvent: + path: str + event_type: str + old_path: str | None = None + size: int | None = None + + +class ChangeRegistry: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def record(self, event: ChangeEvent) -> None: + timestamp = datetime.now(timezone.utc).isoformat() + with closing(self._connect()) as connection: + connection.execute( + """ + INSERT INTO changes(path, event_type, old_path, size, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?) + ON CONFLICT(path) DO UPDATE SET + event_type = excluded.event_type, + old_path = excluded.old_path, + size = excluded.size, + status = 'pending', + updated_at = excluded.updated_at + """, + (event.path, event.event_type, event.old_path, event.size, timestamp, timestamp), + ) + connection.commit() + + def pending_count(self) -> int: + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT COUNT(*) FROM changes WHERE status = 'pending'" + ).fetchone() + return int(row[0]) + + def _initialize(self) -> None: + with closing(self._connect()) as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + old_path TEXT, + size INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)" + ) + connection.commit() + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.db_path) diff --git a/src/bakrest/server_check.py b/src/bakrest/server_check.py new file mode 100644 index 0000000..8aa3f79 --- /dev/null +++ b/src/bakrest/server_check.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import socket +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +from .config import BackupConfig + + +@dataclass(frozen=True) +class ServerStatus: + reachable: bool + checked_at: str + message: str + + +def check_server(config: BackupConfig) -> ServerStatus: + checked_at = datetime.now(timezone.utc).isoformat() + if config.server_check.type != "tcp": + return ServerStatus(False, checked_at, f"Unsupported check type: {config.server_check.type}") + + try: + with socket.create_connection( + (config.server_host, config.server_check.port), + timeout=config.server_check.timeout_seconds, + ): + return ServerStatus(True, checked_at, "Server reachable") + except OSError as exc: + return ServerStatus(False, checked_at, str(exc)) + + +def write_status(path: Path, status: ServerStatus, pending_count: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = asdict(status) + payload["pending_count"] = pending_count + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") diff --git a/src/bakrest/service.py b/src/bakrest/service.py new file mode 100644 index 0000000..35b5354 --- /dev/null +++ b/src/bakrest/service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import sys + +import servicemanager +import win32event +import win32service +import win32serviceutil + +from .config import load_config +from .logging_setup import configure_logging +from .watcher import WatchdogRuntime + + +class BakRestWatchdogService(win32serviceutil.ServiceFramework): + _svc_name_ = "BakRestWatchdog" + _svc_display_name_ = "Bak&Rest Watchdog Service" + _svc_description_ = "Monitors configured folders and records file changes for Bak&Rest backups." + + def __init__(self, args: list[str]) -> None: + super().__init__(args) + self.stop_event_handle = win32event.CreateEvent(None, 0, 0, None) + self.runtime: WatchdogRuntime | None = None + + def SvcStop(self) -> None: + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + if self.runtime: + self.runtime.stop() + win32event.SetEvent(self.stop_event_handle) + + def SvcDoRun(self) -> None: + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + config = load_config() + logger = configure_logging(config.storage.logs_dir, "service") + logger.info("Windows service started with configuration: %s", config.path) + self.runtime = WatchdogRuntime(config, logger) + try: + self.runtime.run() + except Exception: + logger.exception("BakRestWatchdog failed") + raise + + +def main() -> None: + _apply_configured_startup() + win32serviceutil.HandleCommandLine(BakRestWatchdogService) + + +def _apply_configured_startup() -> None: + if len(sys.argv) < 2 or sys.argv[1].lower() != "install": + return + if "--startup" in sys.argv: + return + + config = load_config() + startup = "auto" if config.service_autostart else "manual" + sys.argv.extend(["--startup", startup]) + + +if __name__ == "__main__": + main() diff --git a/src/bakrest/watchdog_runner.py b/src/bakrest/watchdog_runner.py new file mode 100644 index 0000000..3c24924 --- /dev/null +++ b/src/bakrest/watchdog_runner.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .config import load_config +from .logging_setup import configure_logging +from .watcher import WatchdogRuntime + + +def main() -> int: + config = load_config() + logger = configure_logging(config.storage.logs_dir, "service") + logger.info("Loaded configuration: %s", config.path) + runtime = WatchdogRuntime(config, logger) + try: + runtime.run() + except KeyboardInterrupt: + logger.info("Interrupted by user") + runtime.stop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bakrest/watcher.py b/src/bakrest/watcher.py new file mode 100644 index 0000000..7c3672c --- /dev/null +++ b/src/bakrest/watcher.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path + +from .config import AppConfig +from .filters import WatchFilter +from .logging_setup import close_logger +from .registry import ChangeEvent, ChangeRegistry +from .server_check import check_server, write_status + + +class WatchdogRuntime: + def __init__(self, config: AppConfig, logger: logging.Logger) -> None: + self.config = config + self.logger = logger + self.stop_event = threading.Event() + self.registry = ChangeRegistry(config.storage.change_registry) + self.filter = WatchFilter(config.watch) + + def run(self) -> None: + try: + from watchdog.events import FileSystemEvent, FileSystemEventHandler + from watchdog.observers import Observer + except ImportError as exc: + raise RuntimeError("La libreria watchdog non e' installata") from exc + + runtime = self + + class Handler(FileSystemEventHandler): + def on_any_event(self, event: FileSystemEvent) -> None: + runtime.handle_event(event) + + observer = Observer() + watched = 0 + for include_dir in self.config.watch.include_dirs: + if include_dir.exists(): + observer.schedule(Handler(), str(include_dir), recursive=True) + self.logger.info("Monitoring directory: %s", include_dir) + watched += 1 + else: + self.logger.warning("Configured directory does not exist: %s", include_dir) + + if watched == 0: + self.logger.warning("No existing configured directories to monitor") + + observer.start() + self.logger.info("BakRestWatchdog started") + try: + self._run_health_loop() + finally: + observer.stop() + observer.join(timeout=10) + self.logger.info("BakRestWatchdog stopped") + close_logger(self.logger) + + def stop(self) -> None: + self.stop_event.set() + + def handle_event(self, event: object) -> None: + event_type = getattr(event, "event_type", "unknown") + if event_type == "opened" or event_type == "closed": + return + + src_path = Path(str(getattr(event, "src_path", ""))) + dest_path_value = getattr(event, "dest_path", None) + is_directory = bool(getattr(event, "is_directory", False)) + target_path = Path(str(dest_path_value)) if dest_path_value else src_path + + if not self.filter.should_track(target_path, is_directory=is_directory): + return + + size = _safe_size(target_path) + change = ChangeEvent( + path=str(target_path), + event_type=event_type, + old_path=str(src_path) if dest_path_value else None, + size=size, + ) + self.registry.record(change) + self.logger.info("Recorded %s: %s", event_type, target_path) + + def _run_health_loop(self) -> None: + interval = self.config.backup.server_check.interval_seconds + while not self.stop_event.wait(interval): + status = check_server(self.config.backup) + pending_count = self.registry.pending_count() + write_status(self.config.storage.status_file, status, pending_count) + if status.reachable: + self.logger.info("Backup server reachable; pending files: %s", pending_count) + else: + self.logger.warning( + "Backup server unreachable: %s; pending files: %s", + status.message, + pending_count, + ) + + +def _safe_size(path: Path) -> int | None: + try: + return path.stat().st_size + except OSError: + return None diff --git a/tests/test_config_and_filters.py b/tests/test_config_and_filters.py new file mode 100644 index 0000000..9650b3e --- /dev/null +++ b/tests/test_config_and_filters.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path + +from bakrest.config import parse_config +from bakrest.filters import WatchFilter + + +def test_parse_config_normalizes_extensions(tmp_path: Path) -> None: + config = parse_config( + { + "watch": { + "include_dirs": [str(tmp_path)], + "include_extensions": ["jpg", ".PNG"], + "exclude_dirs": [], + "exclude_patterns": [], + } + }, + tmp_path / "config.toml", + ) + + assert config.watch.include_extensions == frozenset({".jpg", ".png"}) + + +def test_filter_accepts_included_extension(tmp_path: Path) -> None: + config = parse_config( + { + "watch": { + "include_dirs": [str(tmp_path)], + "include_extensions": [".jpg"], + "exclude_dirs": [], + "exclude_patterns": [], + } + }, + tmp_path / "config.toml", + ) + + assert WatchFilter(config.watch).should_track(tmp_path / "image.jpg") + + +def test_filter_rejects_excluded_pattern(tmp_path: Path) -> None: + config = parse_config( + { + "watch": { + "include_dirs": [str(tmp_path)], + "include_extensions": [".jpg"], + "exclude_dirs": [], + "exclude_patterns": ["~$*"], + } + }, + tmp_path / "config.toml", + ) + + assert not WatchFilter(config.watch).should_track(tmp_path / "~$image.jpg")