main
py 422 lines 13.1 KB
Raw
1 from __future__ import annotations
2
3 import os
4 import threading
5 from dataclasses import dataclass
6 from pathlib import PurePosixPath
7 from typing import Any, Callable, Iterable, Literal, cast
8 from watchdog.observers import Observer as _WatchdogObserver
9
10
11 class _DispatchHandler:
12 def __init__(self, registry: "_WatchRegistry", scheduled_root: str):
13 self.registry = registry
14 self.scheduled_root = scheduled_root
15
16 def dispatch(self, event: Any):
17 self.registry.dispatch(self.scheduled_root, event)
18
19
20 WatchEvent = Literal["create", "modify", "delete", "move"]
21 WatchEvents = Literal["all"] | list[WatchEvent | str] | set[WatchEvent | str]
22 WatchItem = list[str]
23 WatchHandler = Callable[[list[WatchItem]], None]
24 PatternMatcher = Callable[[str], bool]
25
26 _DEFAULT_PATTERNS = ["**/*"]
27 _DEFAULT_IGNORE_PATTERNS = [
28 "**/__pycache__",
29 "**/__pycache__/*",
30 "**/*.pyc",
31 "**/*.pyo",
32 ]
33 _VALID_EVENTS: frozenset[WatchEvent] = frozenset(["create", "modify", "delete", "move"])
34 _EVENT_ALIASES: dict[str, WatchEvent] = {
35 "create": "create",
36 "created": "create",
37 "modify": "modify",
38 "modified": "modify",
39 "delete": "delete",
40 "deleted": "delete",
41 "move": "move",
42 "moved": "move",
43 }
44
45
46 @dataclass(frozen=True)
47 class _Watch:
48 id: str
49 root: str
50 root_with_sep: str
51 patterns: list[str]
52 ignore_patterns: list[str]
53 matcher: PatternMatcher
54 events: frozenset[WatchEvent]
55 debounce: float
56 handler: WatchHandler
57
58
59 @dataclass
60 class _PendingBatch:
61 items_by_path: dict[str, WatchItem]
62 timer: threading.Timer | None = None
63
64
65 class _WatchRegistry:
66 def __init__(self):
67 self._lock = threading.RLock()
68 self._observer: Any = None
69 self._watches: dict[str, _Watch] = {}
70 self._watch_ids_by_group: dict[str, set[str]] = {}
71 self._scheduled_roots: set[str] = set()
72 self._pending_batches: dict[str, _PendingBatch] = {}
73
74 def add(
75 self,
76 id: str,
77 roots: list[str],
78 patterns: list[str] | None,
79 ignore_patterns: list[str] | None,
80 events: WatchEvents,
81 debounce: float,
82 handler: WatchHandler,
83 ) -> None:
84 self._ensure_watchdog_available()
85 normalized_roots = _normalize_roots(roots)
86 normalized_patterns = _normalize_patterns(patterns)
87 normalized_ignore_patterns = _normalize_patterns(
88 ignore_patterns, default=_DEFAULT_IGNORE_PATTERNS
89 )
90 normalized_events = _normalize_events(events)
91 normalized_debounce = _normalize_debounce(debounce)
92 watch_ids = [id] if len(normalized_roots) == 1 else [f"{id}:{index}" for index in range(len(normalized_roots))]
93 watches = {
94 watch_id: _Watch(
95 id=watch_id,
96 root=normalized_root,
97 root_with_sep=normalized_root + os.sep,
98 patterns=normalized_patterns,
99 ignore_patterns=normalized_ignore_patterns,
100 matcher=_compile_matcher(
101 normalized_root,
102 normalized_patterns,
103 normalized_ignore_patterns,
104 ),
105 events=normalized_events,
106 debounce=normalized_debounce,
107 handler=handler,
108 )
109 for watch_id, normalized_root in zip(watch_ids, normalized_roots)
110 }
111 with self._lock:
112 previous_watch_ids = self._watch_ids_by_group.pop(id, set())
113 for watch_id in previous_watch_ids:
114 self._watches.pop(watch_id, None)
115 pending = self._pending_batches.pop(watch_id, None)
116 if pending and pending.timer:
117 pending.timer.cancel()
118 self._watches.update(watches)
119 self._watch_ids_by_group[id] = set(watches)
120 self._refresh_observer()
121
122 def remove(self, id: str) -> bool:
123 with self._lock:
124 watch_ids = self._watch_ids_by_group.pop(id, {id})
125 removed = False
126 for watch_id in watch_ids:
127 removed = self._watches.pop(watch_id, None) is not None or removed
128 pending = self._pending_batches.pop(watch_id, None)
129 if pending and pending.timer:
130 pending.timer.cancel()
131 if removed:
132 self._refresh_observer()
133 return removed
134
135 def clear(self) -> None:
136 with self._lock:
137 self._watches.clear()
138 self._watch_ids_by_group.clear()
139 pending_batches = list(self._pending_batches.values())
140 self._pending_batches.clear()
141 self._refresh_observer()
142 for pending in pending_batches:
143 if pending.timer:
144 pending.timer.cancel()
145
146 def start(self) -> None:
147 with self._lock:
148 observer = self._observer
149 if observer is None:
150 observer = self._create_observer()
151 self._observer = observer
152 if observer.is_alive():
153 return
154 observer.start()
155
156 def stop(self) -> None:
157 self._stop_observer()
158
159 def dispatch(self, scheduled_root: str, event: Any) -> None:
160 event_type = _map_event_type(str(getattr(event, "event_type", "")))
161 if event_type is None:
162 return
163 if bool(getattr(event, "is_synthetic", False)):
164 return
165 paths: list[str] = []
166 src_path = getattr(event, "src_path", None)
167 if isinstance(src_path, str) and src_path:
168 paths.append(os.path.abspath(src_path))
169 dest_path = getattr(event, "dest_path", None)
170 if event_type == "move" and isinstance(dest_path, str) and dest_path:
171 paths.append(os.path.abspath(dest_path))
172 with self._lock:
173 watches = list(self._watches.values())
174 for path in paths:
175 if not _is_same_or_nested(path, scheduled_root):
176 continue
177 for watch in watches:
178 if event_type not in watch.events:
179 continue
180 if not _is_under_watch(path, watch):
181 continue
182 if not watch.matcher(path):
183 continue
184 self._queue_event(watch, path, event_type)
185
186 def _ensure_watchdog_available(self) -> None:
187 return None
188
189 def _queue_event(self, watch: _Watch, path: str, event_type: WatchEvent) -> None:
190 item: WatchItem = [path, event_type]
191 if watch.debounce <= 0:
192 watch.handler([item])
193 return
194 with self._lock:
195 pending = self._pending_batches.get(watch.id)
196 if pending is None:
197 pending = _PendingBatch(items_by_path={})
198 self._pending_batches[watch.id] = pending
199 pending.items_by_path[path] = item
200 timer = pending.timer
201 if timer:
202 timer.cancel()
203 pending.timer = threading.Timer(watch.debounce, self._flush_watch_batch, args=(watch.id,))
204 pending.timer.daemon = True
205 pending.timer.start()
206
207 def _flush_watch_batch(self, watch_id: str) -> None:
208 items: list[WatchItem] = []
209 handler: WatchHandler | None = None
210 with self._lock:
211 watch = self._watches.get(watch_id)
212 pending = self._pending_batches.pop(watch_id, None)
213 if watch is None or pending is None:
214 return
215 if pending.timer:
216 pending.timer.cancel()
217 items = list(pending.items_by_path.values())
218 handler = watch.handler
219 if items:
220 handler(items)
221
222 def _refresh_observer(self) -> None:
223 target_roots = _covering_roots(watch.root for watch in self._watches.values())
224 if not target_roots:
225 self._stop_observer()
226 return
227 observer = self._observer
228 if observer is None:
229 observer = self._create_observer()
230 self._observer = observer
231 observer.start()
232 if target_roots == self._scheduled_roots:
233 return
234 observer = cast(Any, observer)
235 observer.unschedule_all()
236 for root in target_roots:
237 observer.schedule(_DispatchHandler(self, root), root, recursive=True)
238 self._scheduled_roots = target_roots
239
240 def _stop_observer(self) -> None:
241 with self._lock:
242 observer = self._observer
243 self._observer = None
244 self._scheduled_roots = set()
245 if observer is None:
246 return
247 observer.unschedule_all()
248 observer.stop()
249 observer.join()
250
251 def _create_observer(self) -> Any:
252 observer = cast(Any, _WatchdogObserver())
253 return observer
254
255
256 def _normalize_root(root: str) -> str:
257 normalized = os.path.abspath(os.path.normpath(root))
258 if not os.path.exists(normalized):
259 os.makedirs(normalized, exist_ok=True)
260 if not os.path.isdir(normalized):
261 raise NotADirectoryError(normalized)
262 return normalized
263
264
265 def _normalize_roots(roots: list[str]) -> list[str]:
266 normalized = list(dict.fromkeys(_normalize_root(item) for item in roots))
267 if not normalized:
268 raise ValueError("roots must not be empty")
269 return normalized
270
271
272 def _normalize_patterns(
273 patterns: list[str] | None,
274 default: list[str] | None = None,
275 ) -> list[str]:
276 default = default or _DEFAULT_PATTERNS
277 if not patterns:
278 return list(default)
279 normalized = [pattern.strip().replace("\\", "/") for pattern in patterns if pattern and pattern.strip()]
280 return normalized or default
281
282
283 def _normalize_events(events: WatchEvents) -> frozenset[WatchEvent]:
284 if events == "all":
285 return _VALID_EVENTS
286 normalized: set[WatchEvent] = set()
287 for event in events:
288 mapped = _map_event_type(str(event))
289 if mapped is None:
290 raise ValueError(f"Unsupported watch event: {event}")
291 normalized.add(mapped)
292 return frozenset(normalized) if normalized else _VALID_EVENTS
293
294
295 def _map_event_type(event_type: str) -> WatchEvent | None:
296 return _EVENT_ALIASES.get(event_type.lower())
297
298
299 def _normalize_debounce(debounce: float) -> float:
300 if debounce < 0:
301 raise ValueError("debounce must be >= 0")
302 return debounce
303
304
305 def _covering_roots(roots: Iterable[str]) -> set[str]:
306 ordered = sorted(set(roots), key=lambda root: (len(root), root))
307 covered: set[str] = set()
308 for root in ordered:
309 if any(_is_same_or_nested(root, parent) for parent in covered):
310 continue
311 covered.add(root)
312 return covered
313
314
315 def _is_same_or_nested(path: str, root: str) -> bool:
316 return path == root or path.startswith(root + os.sep)
317
318
319 def _is_under_watch(path: str, watch: _Watch) -> bool:
320 return path == watch.root or path.startswith(watch.root_with_sep)
321
322
323 def _compile_matcher(
324 root: str,
325 patterns: list[str],
326 ignore_patterns: list[str],
327 ) -> PatternMatcher:
328 include_matcher = _compile_single_matcher(root, patterns)
329 ignore_matcher = _compile_single_matcher(root, ignore_patterns)
330
331 def matches(path: str) -> bool:
332 return include_matcher(path) and not ignore_matcher(path)
333
334 return matches
335
336
337 def _compile_single_matcher(root: str, patterns: list[str]) -> PatternMatcher:
338 if not patterns or patterns == _DEFAULT_PATTERNS:
339 return lambda path: True
340
341 if any(pattern in {"**", "**/*", "*"} for pattern in patterns):
342 return lambda path: True
343
344 relative_patterns = [pattern for pattern in patterns if "/" in pattern]
345 name_patterns = [
346 pattern for pattern in patterns if "/" not in pattern and pattern not in {"**", "**/*", "*"}
347 ]
348
349 def matches(path: str) -> bool:
350 relative = os.path.relpath(path, root).replace("\\", "/")
351 if relative == ".":
352 relative = ""
353 relative_path = PurePosixPath(relative) if relative else PurePosixPath("")
354 name_path = PurePosixPath(os.path.basename(path))
355
356 for pattern in relative_patterns:
357 if relative and relative_path.match(pattern):
358 return True
359 for pattern in name_patterns:
360 if name_path.match(pattern):
361 return True
362 if relative and relative_path.match(pattern):
363 return True
364 return False
365
366 return matches
367
368
369 _registry = _WatchRegistry()
370 _registry.start()
371
372
373 def add_watchdog(
374 id: str,
375 roots: list[str],
376 patterns: list[str] | None = None,
377 ignore_patterns: list[str] | None = None,
378 events: WatchEvents = "all",
379 debounce: float = 0.01,
380 handler: WatchHandler | None = None,
381 ) -> None:
382 if handler is None:
383 raise ValueError("handler is required")
384 _registry.add(
385 id=id,
386 roots=roots,
387 patterns=patterns,
388 ignore_patterns=ignore_patterns,
389 events=events,
390 debounce=debounce,
391 handler=handler,
392 )
393
394
395 def remove_watchdog(id: str) -> bool:
396 return _registry.remove(id)
397
398
399 def clear_watchdogs() -> None:
400 _registry.clear()
401
402
403 def start_watchdog_daemon() -> None:
404 _registry.start()
405
406
407 def stop_watchdog_daemon() -> None:
408 _registry.stop()
409
410
411 __all__ = [
412 "WatchEvent",
413 "WatchEvents",
414 "WatchItem",
415 "WatchHandler",
416 "add_watchdog",
417 "remove_watchdog",
418 "clear_watchdogs",
419 "start_watchdog_daemon",
420 "stop_watchdog_daemon",
421 ]
422