StrictDoc Documentation
strictdoc/server/document_watcher.py
Source file coverage
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 tests
18
# constructing DocumentWatcher directly). Mirrors
19
# ProjectConfig.default_formats()'s read/grammar extensions; kept in sync via
20
# 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
            return
67
        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_dir
125
                    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_path
137
                        )
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 watchdog
190
        event caused by an imminent server-side write to *path*.
191
 
192
        Must be called **before** writing the file.  Because the inhibition is
193
        registered before the write, the debounce timer always fires into an
194
        already-inhibited state — there is no race window between the write and
195
        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