Path:
strictdoc/server/document_watcher.py
Lines:
208
Non-empty lines:
183
Non-empty lines covered with requirements:
183 / 183 (100.0%)
Functions:
17
Functions covered by requirements:
17 / 17 (100.0%)
1
"""2
@relation(SDOC-SRS-126, scope=file)3
"""4
5
import hashlib
6
import os
7
import threading
8
from typing import Callable, Dict, List, Optional, Set, Tuple, Union
9
10
from watchdog.events import FileSystemEvent, FileSystemEventHandler
11
from watchdog.observers import Observer
12
from watchdog.observers.api import BaseObserver
13
14
from strictdoc.core.file_system.document_finder import get_document_extensions
15
from strictdoc.core.project_config import ProjectConfig
16
17
# Fallback used when no ProjectConfig is available (e.g. in unit tests18
# constructing DocumentWatcher directly). Mirrors19
# ProjectConfig.default_formats()'s read/grammar extensions; kept in sync via20
# get_watched_document_extensions() when a ProjectConfig is available.21
WATCHED_DOCUMENT_EXTENSIONS = (
22
".sdoc",
23
".gra.md",
24
".md",
25
".markdown",
26
".sgra",
27
".reqif",
28
".junit.xml",
29
".gcov.json",
30
".robot.xml",
31
)32
33
34
def get_watched_document_extensions(
35
project_config: ProjectConfig,
36
) -> Tuple[str, ...]:
37
return tuple(get_document_extensions(project_config))
38
39
40
def _as_str(path: Union[str, bytes]) -> str:
41
if isinstance(path, bytes):
42
return path.decode("utf-8", "surrogateescape")
43
return path
44
45
46
def _hash_file(path: str) -> Optional[str]:
47
try:
48
with open(path, "rb") as file:
49
return hashlib.sha256(file.read()).hexdigest()
50
except OSError:
51
return None
52
53
54
class _ChangeEventHandler(FileSystemEventHandler):
55
def __init__(
56
self,
57
*,
58
is_watched_document: Callable[[str], bool],
59
on_document_touched: Callable[[str], None],
60
) -> None:
61
self._is_watched_document = is_watched_document
62
self._on_document_touched = on_document_touched
63
64
def on_any_event(self, event: FileSystemEvent) -> None:
65
if event.is_directory:
66
return67
for path in (event.src_path, getattr(event, "dest_path", "")):
68
if path:
69
document_path = _as_str(path)
70
if self._is_watched_document(document_path):
71
self._on_document_touched(document_path)
72
73
74
class DocumentWatcher:
75
def __init__(
76
self,
77
*,
78
watch_paths: List[str],
79
output_dir_abs_path: Optional[str],
80
on_documents_changed: Callable[[], None],
81
debounce_seconds: float = 0.3,
82
watched_extensions: Tuple[str, ...] = WATCHED_DOCUMENT_EXTENSIONS,
83
) -> None:
84
self._watch_paths = [os.path.abspath(path) for path in watch_paths]
85
self._output_dir_abs_path = (
86
os.path.abspath(output_dir_abs_path)
87
if output_dir_abs_path is not None
88
else None
89
)90
self._on_documents_changed = on_documents_changed
91
self._debounce_seconds = debounce_seconds
92
self._watched_extensions = watched_extensions
93
self._observer: Optional[BaseObserver] = None
94
self._debounce_timer: Optional[threading.Timer] = None
95
self._lock = threading.Lock()
96
self._pending_paths: Set[str] = set()
97
self._inhibited_paths: Set[str] = set()
98
self._content_hashes: Dict[str, Optional[str]] = {}
99
100
def is_watched_document(self, path: str) -> bool:
101
absolute_path = os.path.abspath(path)
102
if self._output_dir_abs_path is not None and absolute_path.startswith(
103
self._output_dir_abs_path
104
):105
return False
106
if self._is_inside_hidden_directory(absolute_path):
107
return False
108
return absolute_path.endswith(self._watched_extensions)
109
110
def _is_inside_hidden_directory(self, absolute_path: str) -> bool:
111
for watch_path in self._watch_paths:
112
prefix = watch_path + os.sep
113
if absolute_path.startswith(prefix):
114
directory_parts = absolute_path[len(prefix) :].split(os.sep)[
115
:-1
116
]117
return any(part.startswith(".") for part in directory_parts)
118
return False
119
120
def _seed_content_hashes(self) -> None:
121
for watch_path in self._watch_paths:
122
for current_dir, child_dirs, file_names in os.walk(watch_path):
123
child_dirs[:] = [
124
child_dir125
for child_dir in child_dirs
126
if not child_dir.startswith(".")
127
and not self._is_output_dir(
128
os.path.join(current_dir, child_dir)
129
)130
]131
for file_name in file_names:
132
document_path = os.path.join(current_dir, file_name)
133
if self.is_watched_document(document_path):
134
absolute_path = os.path.abspath(document_path)
135
self._content_hashes[absolute_path] = _hash_file(
136
absolute_path137
)138
139
def _is_output_dir(self, path: str) -> bool:
140
return (
141
self._output_dir_abs_path is not None
142
and os.path.abspath(path) == self._output_dir_abs_path
143
)144
145
def _on_document_touched(self, path: str) -> None:
146
with self._lock:
147
self._pending_paths.add(os.path.abspath(path))
148
if self._debounce_timer is not None:
149
self._debounce_timer.cancel()
150
self._debounce_timer = threading.Timer(
151
self._debounce_seconds, self._process_pending_paths
152
)153
self._debounce_timer.daemon = True
154
self._debounce_timer.start()
155
156
def _process_pending_paths(self) -> None:
157
with self._lock:
158
pending_paths = self._pending_paths
159
self._pending_paths = set()
160
content_changed = False
161
for path in pending_paths:
162
new_hash = _hash_file(path)
163
with self._lock:
164
inhibited = path in self._inhibited_paths
165
if inhibited:
166
self._inhibited_paths.discard(path)
167
if not inhibited and new_hash != self._content_hashes.get(path):
168
content_changed = True
169
self._content_hashes[path] = new_hash
170
if content_changed:
171
self._on_documents_changed()
172
173
def start(self) -> None:
174
self._seed_content_hashes()
175
handler = _ChangeEventHandler(
176
is_watched_document=self.is_watched_document,
177
on_document_touched=self._on_document_touched,
178
)179
observer = Observer()
180
for watch_path in self._watch_paths:
181
if os.path.isdir(watch_path):
182
observer.schedule(handler, watch_path, recursive=True)
183
observer.daemon = True
184
observer.start()
185
self._observer = observer
186
187
def inhibit_next_change(self, path: str) -> None:
188
"""
189
Suppress the rebuild that would otherwise be triggered by the watchdog190
event caused by an imminent server-side write to *path*.191
192
Must be called **before** writing the file. Because the inhibition is193
registered before the write, the debounce timer always fires into an194
already-inhibited state — there is no race window between the write and195
the hash update that exists with a post-write approach.196
"""197
with self._lock:
198
self._inhibited_paths.add(os.path.abspath(path))
199
200
def stop(self) -> None:
201
with self._lock:
202
if self._debounce_timer is not None:
203
self._debounce_timer.cancel()
204
self._debounce_timer = None
205
if self._observer is not None:
206
self._observer.stop()
207
self._observer.join(timeout=5)
208
self._observer = None