Coverage for gws-app/gws/lib/watcher/__init__.py: 100%
67 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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, 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: watchdog.observers.Observer
75 """The ``watchdog`` observer, created by ``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.dirEntries = {}
85 self.filePaths = set()
86 self.excludePatterns = []
88 def add_directory(self, dirname: str | os.PathLike, file_pattern: str = '', recursive: bool = False):
89 """Add a directory to watch.
91 Args:
92 dirname: Directory path.
93 file_pattern: Regular expression to search for in file names. If empty, all files match.
94 recursive: Also watch subdirectories.
95 """
96 d = str(dirname)
97 self.dirEntries[d] = _DirEntry(d, file_pattern or '.', recursive)
99 def add_file(self, filename: str | os.PathLike):
100 """Add a file to watch.
102 Args:
103 filename: File path.
104 """
105 self.filePaths.add(str(filename))
107 def exclude(self, path_pattern: str):
108 """Exclude paths from watching.
110 Args:
111 path_pattern: Regular expression to search for in the full path.
112 """
113 self.excludePatterns.append(path_pattern)
115 def start(self):
116 """Start watching the added directories and files in a background thread."""
117 self.observer = watchdog.observers.Observer()
119 h = _Handler(self)
121 for de in self.dirEntries.values():
122 gws.log.debug(f'watcher: watching {de.dirname!r}')
123 self.observer.schedule(h, de.dirname, recursive=de.recursive)
125 for f in self.filePaths:
126 gws.log.debug(f'watcher: watching {f!r}')
127 self.observer.schedule(h, os.path.dirname(f), recursive=False)
129 self.observer.start()
130 gws.log.debug(f'watcher: started with {self.observer.__class__.__name__}')
132 def stop(self):
133 """Stop watching and wait for the observer thread to finish."""
134 if self.observer is not None:
135 self.observer.stop()
136 self.observer.join()
137 gws.log.debug(f'watcher: stopped')
139 def register(self, ev: watchdog.events.FileSystemEvent):
140 """Handle a file system event, calling the callback if the path is watched.
142 Args:
143 ev: The event.
144 """
145 if self.path_matches(ev.src_path):
146 gws.log.debug(f'watcher: {ev.event_type} {ev.src_path}')
147 self.notify(ev.event_type, ev.src_path)
149 def path_matches(self, path):
150 """Check if a path is watched.
152 A path is watched if it is not excluded, and is either an added file
153 or a file in an added directory whose name matches the directory's file pattern.
155 Args:
156 path: File path.
158 Returns:
159 ``True`` if the path is watched.
160 """
161 if any(re.search(ex, path) for ex in self.excludePatterns):
162 return False
163 if path in self.filePaths:
164 return True
165 d, f = os.path.split(path)
166 for de in self.dirEntries.values():
167 if (d == de.dirname or (de.recursive and d.startswith(de.dirname + '/'))) and re.search(de.pattern, f):
168 return True
169 return False
172class _Handler(watchdog.events.FileSystemEventHandler):
173 """Event handler that passes watched event types to the watcher."""
175 def __init__(self, obj: Watcher):
176 super().__init__()
177 self.obj = obj
179 def on_any_event(self, ev):
180 if ev.event_type in _WATCH_EVENTS:
181 self.obj.register(ev)