lib.watcher
Observador reactivo de ficheros YAML.
Usa watchdog (inotify en Linux, FSEvents en macOS, ReadDirectoryChanges en Windows) para detectar cambios en tiempo real sin polling.
Uso:
from lib.watcher import start_watch, WatchdogNotAvailable
def on_change(yaml_path): rebuild(yaml_path)
try: start_watch([Path('courses/java/content')], on_change) except WatchdogNotAvailable: print("instala watchdog: uv sync --group watch")
1# Copyright (C) 2026 David Vaquero <pepesan@gmail.com> 2# 3# This program is free software: you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation, either version 3 of the License, or 6# (at your option) any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16""" 17Observador reactivo de ficheros YAML. 18 19Usa watchdog (inotify en Linux, FSEvents en macOS, ReadDirectoryChanges en Windows) 20para detectar cambios en tiempo real sin polling. 21 22Uso: 23 from lib.watcher import start_watch, WatchdogNotAvailable 24 25 def on_change(yaml_path): 26 rebuild(yaml_path) 27 28 try: 29 start_watch([Path('courses/java/content')], on_change) 30 except WatchdogNotAvailable: 31 print("instala watchdog: uv sync --group watch") 32""" 33 34from __future__ import annotations 35 36import threading 37from pathlib import Path 38from typing import Callable 39 40try: 41 from watchdog.observers import Observer 42 from watchdog.events import FileSystemEventHandler, FileModifiedEvent 43 WATCHDOG_AVAILABLE = True 44except ImportError: 45 WATCHDOG_AVAILABLE = False 46 Observer = None # type: ignore[assignment,misc] 47 FileSystemEventHandler = object # type: ignore[assignment,misc] 48 49 50class WatchdogNotAvailable(RuntimeError): 51 """Se lanza si watchdog no está instalado.""" 52 53 54class _YamlChangeHandler(FileSystemEventHandler): 55 """Dispara un callback con debounce cuando cambia un YAML vigilado.""" 56 57 def __init__( 58 self, 59 yaml_set: set[Path], 60 on_change: Callable[[Path], None], 61 debounce_s: float = 0.3, 62 ) -> None: 63 super().__init__() 64 self._yaml_set = yaml_set 65 self._on_change = on_change 66 self._debounce = debounce_s 67 self._pending: set[Path] = set() 68 self._lock = threading.Lock() 69 self._timer: threading.Timer | None = None 70 71 def _check(self, path: str) -> None: 72 p = Path(path).resolve() 73 if p in self._yaml_set: 74 self._schedule(p) 75 76 def on_modified(self, event) -> None: 77 if not getattr(event, 'is_directory', False): 78 self._check(event.src_path) 79 80 def on_created(self, event) -> None: 81 # Algunos editores guardan escribiendo un fichero nuevo 82 if not getattr(event, 'is_directory', False): 83 self._check(event.src_path) 84 85 def on_moved(self, event) -> None: 86 # Guardado atómico: escribe en tmp y renombra al path original (vim, gedit, PyCharm…) 87 if not getattr(event, 'is_directory', False): 88 self._check(event.dest_path) 89 90 def _schedule(self, path: Path) -> None: 91 with self._lock: 92 self._pending.add(path) 93 if self._timer is not None: 94 self._timer.cancel() 95 self._timer = threading.Timer(self._debounce, self._flush) 96 self._timer.start() 97 98 def _flush(self) -> None: 99 with self._lock: 100 to_build = list(self._pending) 101 self._pending.clear() 102 for p in to_build: 103 try: 104 self._on_change(p) 105 except Exception as exc: 106 import sys 107 print(f'[watch] Error en {p.name}: {exc}', file=sys.stderr) 108 109 def cancel_pending(self) -> None: 110 with self._lock: 111 if self._timer is not None: 112 self._timer.cancel() 113 114 115def start_watch( 116 yaml_paths: list[Path], 117 on_change: Callable[[Path], None], 118 debounce_s: float = 0.3, 119) -> None: 120 """Inicia el observer y bloquea hasta Ctrl+C. 121 122 Args: 123 yaml_paths: Lista de ficheros YAML a vigilar. 124 on_change: Callback llamado con el Path del YAML modificado. 125 debounce_s: Segundos de espera para agrupar cambios rápidos. 126 127 Raises: 128 WatchdogNotAvailable: si watchdog no está instalado. 129 """ 130 if not WATCHDOG_AVAILABLE: 131 raise WatchdogNotAvailable( 132 "watchdog no está instalado.\n" 133 "Instalar con: uv sync --group watch" 134 ) 135 136 resolved = [p.resolve() for p in yaml_paths] 137 yaml_set = set(resolved) 138 dirs = {p.parent for p in resolved} 139 140 handler = _YamlChangeHandler(yaml_set, on_change, debounce_s) 141 observer = Observer() 142 for d in dirs: 143 observer.schedule(handler, str(d), recursive=False) 144 145 observer.start() 146 try: 147 while observer.is_alive(): 148 observer.join(timeout=1) 149 except KeyboardInterrupt: 150 pass 151 finally: 152 observer.stop() 153 handler.cancel_pending() 154 observer.join()
class
WatchdogNotAvailable(builtins.RuntimeError):
Se lanza si watchdog no está instalado.
def
start_watch( yaml_paths: list[pathlib.Path], on_change: Callable[[pathlib.Path], NoneType], debounce_s: float = 0.3) -> None:
116def start_watch( 117 yaml_paths: list[Path], 118 on_change: Callable[[Path], None], 119 debounce_s: float = 0.3, 120) -> None: 121 """Inicia el observer y bloquea hasta Ctrl+C. 122 123 Args: 124 yaml_paths: Lista de ficheros YAML a vigilar. 125 on_change: Callback llamado con el Path del YAML modificado. 126 debounce_s: Segundos de espera para agrupar cambios rápidos. 127 128 Raises: 129 WatchdogNotAvailable: si watchdog no está instalado. 130 """ 131 if not WATCHDOG_AVAILABLE: 132 raise WatchdogNotAvailable( 133 "watchdog no está instalado.\n" 134 "Instalar con: uv sync --group watch" 135 ) 136 137 resolved = [p.resolve() for p in yaml_paths] 138 yaml_set = set(resolved) 139 dirs = {p.parent for p in resolved} 140 141 handler = _YamlChangeHandler(yaml_set, on_change, debounce_s) 142 observer = Observer() 143 for d in dirs: 144 observer.schedule(handler, str(d), recursive=False) 145 146 observer.start() 147 try: 148 while observer.is_alive(): 149 observer.join(timeout=1) 150 except KeyboardInterrupt: 151 pass 152 finally: 153 observer.stop() 154 handler.cancel_pending() 155 observer.join()
Inicia el observer y bloquea hasta Ctrl+C.
Arguments:
- yaml_paths: Lista de ficheros YAML a vigilar.
- on_change: Callback llamado con el Path del YAML modificado.
- debounce_s: Segundos de espera para agrupar cambios rápidos.
Raises:
- WatchdogNotAvailable: si watchdog no está instalado.