Coverage for gws-app/gws/lib/watcher/__init__.py: 100%
68 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""File system watcher.
3Monitors file system events (creation, deletion, modification and movement) using ``watchdog``,
4and calls a notification callback when an event occurs on a watched path.
6A ``Watcher`` can watch whole directories, optionally recursively and restricted to file names
7matching a regular expression, and individual files. Paths matching an exclude pattern are ignored.
8The callback is called from the observer thread, not from the thread that started the watcher.
10Example::
12 import gws.lib.watcher
14 def on_change(event_type, path):
15 print(event_type, path)
17 w = gws.lib.watcher.new(on_change)
18 w.add_directory('/data/config', file_pattern=r'[.]json$', recursive=True)
19 w.add_file('/data/config.cx')
20 w.exclude(r'/[.]git/')
21 w.start()
22 ...
23 w.stop()
24"""
26from typing import Callable, Optional, TypeAlias
27import os
28import re
30import watchdog.events
31import watchdog.observers
33import gws
35_WATCH_EVENTS = {
36 watchdog.events.EVENT_TYPE_MOVED,
37 watchdog.events.EVENT_TYPE_DELETED,
38 watchdog.events.EVENT_TYPE_CREATED,
39 watchdog.events.EVENT_TYPE_MODIFIED,
40}
42_EVENTS = []
45class _DirEntry:
46 """A watched directory."""
48 def __init__(self, dirname, pattern, recursive):
49 self.dirname = dirname
50 self.pattern = pattern
51 self.recursive = recursive
54_NotifyFn: TypeAlias = Callable[[str, str], None]
57def new(notify: _NotifyFn):
58 """Create a new watcher.
60 Args:
61 notify: A callback function that will be called when an event occurs.
62 It accepts two arguments: the event type and the path of the file.
63 The callback is called from a different thread than the one that created the watcher.
65 Returns:
66 The watcher, not started yet.
67 """
68 return Watcher(notify)
71class Watcher:
72 """File system watcher."""
74 observer: Optional[watchdog.observers.Observer]
75 """The ``watchdog`` observer, created by ``start``. ``None`` before ``start``."""
77 def __init__(self, notify: _NotifyFn):
78 """Create a watcher.
80 Args:
81 notify: Callback, called with the event type and the path.
82 """
83 self.notify = notify
84 self.observer = None
85 self.dirEntries = {}
86 self.filePaths = set()
87 self.excludePatterns = []
89 def add_directory(self, dirname: str | os.PathLike, file_pattern: str = '', recursive: bool = False):
90 """Add a directory to watch.
92 Args:
93 dirname: Directory path.
94 file_pattern: Regular expression to search for in file names. If empty, all files match.
95 recursive: Also watch subdirectories.
96 """
97 d = str(dirname)
98 self.dirEntries[d] = _DirEntry(d, file_pattern or '.', recursive)
100 def add_file(self, filename: str | os.PathLike):
101 """Add a file to watch.
103 Args:
104 filename: File path.
105 """
106 self.filePaths.add(str(filename))
108 def exclude(self, path_pattern: str):
109 """Exclude paths from watching.
111 Args:
112 path_pattern: Regular expression to search for in the full path.
113 """
114 self.excludePatterns.append(path_pattern)
116 def start(self):
117 """Start watching the added directories and files in a background thread."""
118 self.observer = watchdog.observers.Observer()
120 h = _Handler(self)
122 for de in self.dirEntries.values():
123 gws.log.debug(f'watcher: watching {de.dirname!r}')
124 self.observer.schedule(h, de.dirname, recursive=de.recursive)
126 for f in self.filePaths:
127 gws.log.debug(f'watcher: watching {f!r}')
128 self.observer.schedule(h, os.path.dirname(f), recursive=False)
130 self.observer.start()
131 gws.log.debug(f'watcher: started with {self.observer.__class__.__name__}')
133 def stop(self):
134 """Stop watching and wait for the observer thread to finish."""
135 if self.observer is not None:
136 self.observer.stop()
137 self.observer.join()
138 gws.log.debug(f'watcher: stopped')
140 def register(self, ev: watchdog.events.FileSystemEvent):
141 """Handle a file system event, calling the callback if the path is watched.
143 Args:
144 ev: The event.
145 """
146 if self.path_matches(ev.src_path):
147 gws.log.debug(f'watcher: {ev.event_type} {ev.src_path}')
148 self.notify(ev.event_type, ev.src_path)
150 def path_matches(self, path):
151 """Check if a path is watched.
153 A path is watched if it is not excluded, and is either an added file
154 or a file in an added directory whose name matches the directory's file pattern.
156 Args:
157 path: File path.
159 Returns:
160 ``True`` if the path is watched.
161 """
162 if any(re.search(ex, path) for ex in self.excludePatterns):
163 return False
164 if path in self.filePaths:
165 return True
166 d, f = os.path.split(path)
167 for de in self.dirEntries.values():
168 if (d == de.dirname or (de.recursive and d.startswith(de.dirname + '/'))) and re.search(de.pattern, f):
169 return True
170 return False
173class _Handler(watchdog.events.FileSystemEventHandler):
174 """Event handler that passes watched event types to the watcher."""
176 def __init__(self, obj: Watcher):
177 super().__init__()
178 self.obj = obj
180 def on_any_event(self, ev):
181 if ev.event_type in _WATCH_EVENTS:
182 self.obj.register(ev)