|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a QFileSystemWatcher replacement based on the 'watchdog' package. |
|
8 """ |
|
9 |
|
10 import os |
|
11 |
|
12 from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot |
|
13 from watchdog.events import EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED, FileSystemEventHandler |
|
14 from watchdog.observers import Observer |
|
15 |
|
16 |
|
17 class _EricFileSystemEventHandler(QObject, FileSystemEventHandler): |
|
18 """ |
|
19 Class implementing a QObject based file system event handler for watchdog. |
|
20 |
|
21 @signal directoryChanged(path: str) emitted when the directory at a given path is |
|
22 modified or removed from disk |
|
23 @signal directoryCreated(path: str) emitted when a directory is created |
|
24 @signal directoryDeleted(path: str) emitted when a directory is removed from disk |
|
25 @signal directoryModified(path: str) emitted when a directory is modified |
|
26 @signal directoryMoved(srcPath: str, dstPath: str) emitted when the directory at a |
|
27 given source path is renamed or moved to destination path |
|
28 @signal fileChanged(path: str) emitted when the file at a given path is modified |
|
29 or removed from disk |
|
30 @signal fileCreated(path: str) emitted when a file is created |
|
31 @signal fileDeleted(path: str) emitted when a file is removed from disk |
|
32 @signal fileModified(path: str) emitted when a file is modified |
|
33 @signal fileMoved(srcPath: str, dstPath: str) emitted when the file at a |
|
34 given source path is renamed or moved to destination path |
|
35 """ |
|
36 |
|
37 directoryChanged = pyqtSignal(str) # compatibility with QFileSystemWatcher |
|
38 directoryCreated = pyqtSignal(str) |
|
39 directoryDeleted = pyqtSignal(str) |
|
40 directoryModified = pyqtSignal(str) |
|
41 directoryMoved = pyqtSignal(str, str) |
|
42 |
|
43 fileChanged = pyqtSignal(str) # compatibility with QFileSystemWatcher |
|
44 fileCreated = pyqtSignal(str) |
|
45 fileDeleted = pyqtSignal(str) |
|
46 fileModified = pyqtSignal(str) |
|
47 fileMoved = pyqtSignal(str, str) |
|
48 |
|
49 def __init__(self, parent=None): |
|
50 """ |
|
51 Constructor |
|
52 |
|
53 @param parent reference to the parent object (defaults to None) |
|
54 @type QObject (optional) |
|
55 """ |
|
56 super().__init__(parent=parent) |
|
57 |
|
58 def on_any_event(self, event): |
|
59 """ |
|
60 Private method handling any file system event. |
|
61 |
|
62 @param event event to be handled |
|
63 @type watchdog.events.FileSystemEvent |
|
64 """ |
|
65 super().on_any_event(event) |
|
66 |
|
67 if event.event_type not in (EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED): |
|
68 if event.is_directory: |
|
69 self.directoryChanged.emit(event.src_path) |
|
70 else: |
|
71 self.fileChanged.emit(event.src_path) |
|
72 |
|
73 def on_created(self, event): |
|
74 """ |
|
75 Private method to handle a creation event. |
|
76 |
|
77 @param event event to be handled |
|
78 @type watchdog.events.FileCreatedEvent or watchdog.event.DirCreatedEvent |
|
79 """ |
|
80 super().on_created(event) |
|
81 |
|
82 if event.is_directory: |
|
83 self.directoryCreated.emit(event.src_path) |
|
84 else: |
|
85 self.fileCreated.emit(event.src_path) |
|
86 |
|
87 def on_deleted(self, event): |
|
88 """ |
|
89 Private method to handle a deletion event. |
|
90 |
|
91 @param event event to be handled |
|
92 @type watchdog.events.FileDeletedEvent or watchdog.event.DirDeletedEvent |
|
93 """ |
|
94 super().on_deleted(event) |
|
95 |
|
96 if event.is_directory: |
|
97 self.directoryDeleted.emit(event.src_path) |
|
98 else: |
|
99 self.fileDeleted.emit(event.src_path) |
|
100 |
|
101 def on_modified(self, event): |
|
102 """ |
|
103 Private method to handle a modification event. |
|
104 |
|
105 @param event event to be handled |
|
106 @type watchdog.events.FileModifiedEvent or watchdog.event.DirModifiedEvent |
|
107 """ |
|
108 super().on_modified(event) |
|
109 |
|
110 if event.is_directory: |
|
111 self.directoryModified.emit(event.src_path) |
|
112 else: |
|
113 self.fileModified.emit(event.src_path) |
|
114 |
|
115 def on_moved(self, event): |
|
116 """ |
|
117 Private method to handle a move or rename event. |
|
118 |
|
119 @param event event to be handled |
|
120 @type watchdog.events.FileMovedEvent or watchdog.event.DirMovedEvent |
|
121 """ |
|
122 super().on_moved(event) |
|
123 |
|
124 if event.is_directory: |
|
125 self.directoryMoved.emit(event.src_path, event.dest_path) |
|
126 else: |
|
127 self.fileMoved.emit(event.src_path, event.dest_path) |
|
128 |
|
129 |
|
130 class EricFileSystemWatcher(QObject): |
|
131 """ |
|
132 Class implementing a file system monitor based on the 'watchdog' package as a |
|
133 replacement for 'QFileSystemWatcher'. |
|
134 |
|
135 This class has more fine grained signaling capability compared to |
|
136 QFileSystemWatcher. The 'directoryChanged' and 'fileChanged' signals are emitted |
|
137 to keep the API compatible with QFileSystemWatcher. |
|
138 |
|
139 @signal directoryChanged(path: str) emitted when the directory at a given path is |
|
140 modified or removed from disk |
|
141 @signal directoryCreated(path: str) emitted when a directory is created |
|
142 @signal directoryDeleted(path: str) emitted when a directory is removed from disk |
|
143 @signal directoryModified(path: str) emitted when a directory is modified |
|
144 @signal directoryMoved(srcPath: str, dstPath: str) emitted when the directory at a |
|
145 given source path is renamed or moved to destination path |
|
146 @signal fileChanged(path: str) emitted when the file at a given path is modified |
|
147 or removed from disk |
|
148 @signal fileCreated(path: str) emitted when a file is created |
|
149 @signal fileDeleted(path: str) emitted when a file is removed from disk |
|
150 @signal fileModified(path: str) emitted when a file is modified |
|
151 @signal fileMoved(srcPath: str, dstPath: str) emitted when the file at a |
|
152 given source path is renamed or moved to destination path |
|
153 """ |
|
154 |
|
155 directoryChanged = pyqtSignal(str) # compatibility with QFileSystemWatcher |
|
156 directoryCreated = pyqtSignal(str) |
|
157 directoryDeleted = pyqtSignal(str) |
|
158 directoryModified = pyqtSignal(str) |
|
159 directoryMoved = pyqtSignal(str, str) |
|
160 |
|
161 fileChanged = pyqtSignal(str) # compatibility with QFileSystemWatcher |
|
162 fileCreated = pyqtSignal(str) |
|
163 fileDeleted = pyqtSignal(str) |
|
164 fileModified = pyqtSignal(str) |
|
165 fileMoved = pyqtSignal(str, str) |
|
166 |
|
167 def __init__(self, parent=None): |
|
168 """ |
|
169 Constructor |
|
170 |
|
171 @param parent reference to the parent object (defaults to None) |
|
172 @type QObject (optional) |
|
173 """ |
|
174 super().__init__(parent=parent) |
|
175 |
|
176 self.__paths = {} |
|
177 # key: file/directory path, value: tuple with created watch and monitor count |
|
178 |
|
179 self.__eventHandler = _EricFileSystemEventHandler(parent=self) |
|
180 self.__eventHandler.directoryChanged.connect(self.__directoryChanged) |
|
181 self.__eventHandler.directoryCreated.connect(self.directoryCreated) |
|
182 self.__eventHandler.directoryDeleted.connect(self.directoryDeleted) |
|
183 self.__eventHandler.directoryModified.connect(self.directoryModified) |
|
184 self.__eventHandler.directoryMoved.connect(self.directoryMoved) |
|
185 self.__eventHandler.fileChanged.connect(self.__fileChanged) |
|
186 self.__eventHandler.fileCreated.connect(self.fileCreated) |
|
187 self.__eventHandler.fileDeleted.connect(self.fileDeleted) |
|
188 self.__eventHandler.fileModified.connect(self.fileModified) |
|
189 self.__eventHandler.fileMoved.connect(self.fileMoved) |
|
190 |
|
191 self.__observer = Observer() |
|
192 self.__observer.start() |
|
193 |
|
194 def __del__(self): |
|
195 """ |
|
196 Special method called when an instance object is about to be destroyed. |
|
197 """ |
|
198 self.shutdown() |
|
199 |
|
200 @pyqtSlot(str) |
|
201 def __directoryChanged(self, path): |
|
202 """ |
|
203 Private slot handling any change of a directory at a given path. |
|
204 |
|
205 It emits the signal 'directoryChanged', if the path is in the list of |
|
206 watched paths. This behavior is compatible with the QFileSystemWatcher signal |
|
207 of identical name. |
|
208 |
|
209 @param path path name of the changed directory |
|
210 @type str |
|
211 """ |
|
212 if path in self.__paths: |
|
213 self.directoryChanged.emit(path) |
|
214 |
|
215 @pyqtSlot(str) |
|
216 def __fileChanged(self, path): |
|
217 """ |
|
218 Private slot handling any change of a file at a given path. |
|
219 |
|
220 It emits the signal 'fileChanged', if the path is in the list of |
|
221 watched paths. This behavior is compatible with the QFileSystemWatcher signal |
|
222 of identical name. |
|
223 |
|
224 @param path path name of the changed file |
|
225 @type str |
|
226 """ |
|
227 if path in self.__paths: |
|
228 self.fileChanged.emit(path) |
|
229 |
|
230 def addPath(self, path, recursive=False): |
|
231 """ |
|
232 Public method to add the given path to the list of watched paths. |
|
233 |
|
234 If the given path is a directory, a recursive watch may be requested. |
|
235 Otherwise only the given directory is watched for changes but none of its |
|
236 subdirectories. |
|
237 |
|
238 The path is not added, if it does not exist or if it is already being monitored |
|
239 by the file system watcher. |
|
240 |
|
241 @param path file or directory path to be added to the watched paths |
|
242 @type str |
|
243 @param recursive flag indicating a watch for the complete tree rooted at the |
|
244 given path (for directory paths only) (defaults to False) |
|
245 @type bool (optional) |
|
246 @return flag indicating a successful creation of a watch for the given path |
|
247 @rtype bool |
|
248 """ |
|
249 if os.path.exists(path): |
|
250 try: |
|
251 self.__paths[path][1] += 1 |
|
252 return True |
|
253 except KeyError: |
|
254 watch = self.__observer.schedule( |
|
255 self.__eventHandler, |
|
256 path, |
|
257 recursive=recursive and os.path.isdir(path), |
|
258 ) |
|
259 if watch is not None: |
|
260 self.__paths[watch.path] = [watch, 1] |
|
261 return True |
|
262 |
|
263 return False |
|
264 |
|
265 def addPaths(self, paths, recursive=False): |
|
266 """ |
|
267 Public method to add each path of the given list to the file system watched. |
|
268 |
|
269 If a path of the given paths list is a directory, a recursive watch may be |
|
270 requested. Otherwise only the given directory is watched for changes but none |
|
271 of its subdirectories. This applies to all directory paths of the given list. |
|
272 |
|
273 A path of the list is not added, if it does not exist or if it is already being |
|
274 monitored by the file system watcher. |
|
275 |
|
276 @param paths list of file or directory paths to be added to the watched paths |
|
277 @type list of str |
|
278 @param recursive flag indicating a watch for the complete tree rooted at the |
|
279 given path (for directory paths only) (defaults to False) |
|
280 @type bool (optional) |
|
281 @return list of paths that could not be added to the list of monitored paths |
|
282 @rtype list of str |
|
283 """ |
|
284 failedPaths = [] |
|
285 |
|
286 for path in paths: |
|
287 ok = self.addPath(path, recursive=recursive) |
|
288 if not ok: |
|
289 failedPaths.append(path) |
|
290 |
|
291 return failedPaths |
|
292 |
|
293 def removePath(self, path): |
|
294 """ |
|
295 Public method to remove a given path from the list of monitored paths. |
|
296 |
|
297 @param path directory or file path to be removed |
|
298 @type str |
|
299 @return flag indicating a successful removal. The only reason for a failure is, |
|
300 if the given path is not currently being monitored. |
|
301 @rtype bool |
|
302 """ |
|
303 try: |
|
304 self.__paths[path][1] -= 1 |
|
305 if self.__paths[path][1] == 0: |
|
306 watch, _ = self.__paths.pop(path) |
|
307 self.__observer.unschedule(watch) |
|
308 return True |
|
309 except KeyError: |
|
310 return False |
|
311 |
|
312 def removePaths(self, paths): |
|
313 """ |
|
314 Public method to remove the specified paths from the list of monitored paths. |
|
315 |
|
316 @param paths list of directory or file paths to be removed |
|
317 @type list of str |
|
318 @return list of paths that could not be removed from the list of monitored paths |
|
319 @rtype list of str |
|
320 """ |
|
321 failedPaths = [] |
|
322 |
|
323 for path in paths: |
|
324 ok = self.removePath(path) |
|
325 if not ok: |
|
326 failedPaths.append(path) |
|
327 |
|
328 return failedPaths |
|
329 |
|
330 def directories(self): |
|
331 """ |
|
332 Public method to return a list of paths to directories that are being watched. |
|
333 |
|
334 @return list of watched directory paths |
|
335 @rtype list of str |
|
336 """ |
|
337 return [p for p in self.__paths if os.path.isdir(p)] |
|
338 |
|
339 def files(self): |
|
340 """ |
|
341 Public method to return a list of paths to files that are being watched. |
|
342 |
|
343 @return list of watched file paths |
|
344 @rtype list of str |
|
345 """ |
|
346 return [p for p in self.__paths if os.path.isfile(p)] |
|
347 |
|
348 def paths(self): |
|
349 """ |
|
350 Public method to return a list of paths that are being watched. |
|
351 |
|
352 @return list of all watched paths |
|
353 @rtype list of str |
|
354 """ |
|
355 return list(self.__paths.keys()) |
|
356 |
|
357 def shutdown(self): |
|
358 """ |
|
359 Public method to shut down the file system watcher instance. |
|
360 |
|
361 This needs to be done in order to stop the monitoring threads doing their |
|
362 work in the background. If this method was not called explicitly when the |
|
363 instance is about to be destroyed, the special method '__del__' will do that. |
|
364 """ |
|
365 if self.__observer is not None: |
|
366 self.__observer.stop() |
|
367 self.__observer.join() |
|
368 self.__observer = None |
|
369 |
|
370 |
|
371 _GlobalFileSystemWatcher = None |
|
372 |
|
373 |
|
374 def instance(): |
|
375 """ |
|
376 Function to get a reference to the global file system monitor object. |
|
377 |
|
378 If the global file system monitor does not exist yet, it will be created |
|
379 automatically. |
|
380 |
|
381 @return reference to the global file system monitor object |
|
382 @rtype EricFileSystemWatcher |
|
383 """ |
|
384 global _GlobalFileSystemWatcher |
|
385 |
|
386 if _GlobalFileSystemWatcher is None: |
|
387 _GlobalFileSystemWatcher = EricFileSystemWatcher() |
|
388 |
|
389 return _GlobalFileSystemWatcher |