Skip to content

Commit 1026eee

Browse files
ericchansenCopilot
andcommitted
Use native file system observer for livereload
Replace the hardcoded PollingObserver with watchdog's platform-default Observer, which automatically selects the best native backend per platform (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows). This fixes live reload on Linux and WSL2, where PollingObserver silently fails to detect file changes on ext4 filesystems. To preserve symlink support that PollingObserver provided via stat(), the watch() method now resolves symlink paths with os.path.realpath() and adds filtered watches for symlink targets outside the watched tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cf1d532 commit 1026eee

File tree

1 file changed

+70
-4
lines changed

1 file changed

+70
-4
lines changed

properdocs/livereload/__init__.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from typing import Any, BinaryIO
2727

2828
import watchdog.events
29-
import watchdog.observers.polling
29+
import watchdog.observers
3030

3131
_SCRIPT_TEMPLATE_STR = """
3232
var livereload = function(epoch, requestId) {
@@ -133,14 +133,15 @@ def __init__(
133133

134134
self._shutdown = False
135135
self.serve_thread = threading.Thread(target=lambda: self.serve_forever(shutdown_delay))
136-
self.observer = watchdog.observers.polling.PollingObserver(timeout=polling_interval)
136+
self.observer = watchdog.observers.Observer(timeout=polling_interval)
137137

138138
self._watched_paths: dict[str, int] = {}
139139
self._watch_refs: dict[str, Any] = {}
140+
self._extra_watch_refs: dict[str, list[Any]] = {}
140141

141142
def watch(self, path: str, func: None = None, *, recursive: bool = True) -> None:
142143
"""Add the 'path' to watched paths, call the function and reload when any file changes under it."""
143-
path = os.path.abspath(path)
144+
path = os.path.realpath(path)
144145
if not (func is None or func is self.builder): # type: ignore[unreachable]
145146
raise TypeError("Plugins can no longer pass a 'func' parameter to watch().")
146147

@@ -162,14 +163,79 @@ def callback(event):
162163
log.debug(f"Watching '{path}'")
163164
self._watch_refs[path] = self.observer.schedule(handler, path, recursive=recursive)
164165

166+
# Watch symlink targets outside the watched tree so that native file system
167+
# observers (inotify, FSEvents) detect changes to symlinked files.
168+
if recursive and os.path.isdir(path):
169+
self._watch_symlink_targets(path, callback)
170+
171+
def _watch_symlink_targets(self, root: str, callback: Callable) -> None:
172+
file_targets: set[str] = set()
173+
dir_targets: set[str] = set()
174+
self._collect_symlink_targets(root, root, file_targets, dir_targets)
175+
if not file_targets and not dir_targets:
176+
return
177+
178+
def filtered_callback(event: Any) -> None:
179+
if event.is_directory:
180+
return None
181+
src = os.path.realpath(event.src_path)
182+
if src in file_targets:
183+
return callback(event)
184+
for d in dir_targets:
185+
if src.startswith(d + os.sep):
186+
return callback(event)
187+
return None
188+
189+
filtered_handler = watchdog.events.FileSystemEventHandler()
190+
filtered_handler.on_any_event = filtered_callback # type: ignore[method-assign]
191+
192+
extra_refs: list[Any] = []
193+
watched_dirs: set[str] = set()
194+
for target in file_targets:
195+
parent = os.path.dirname(target)
196+
if parent not in watched_dirs:
197+
watched_dirs.add(parent)
198+
extra_refs.append(self.observer.schedule(filtered_handler, parent, recursive=False))
199+
for d in dir_targets:
200+
if d not in watched_dirs:
201+
watched_dirs.add(d)
202+
extra_refs.append(self.observer.schedule(filtered_handler, d, recursive=True))
203+
if extra_refs:
204+
self._extra_watch_refs[root] = extra_refs
205+
206+
def _collect_symlink_targets(
207+
self, scan_dir: str, root: str, file_targets: set[str], dir_targets: set[str]
208+
) -> None:
209+
try:
210+
entries = list(os.scandir(scan_dir))
211+
except OSError:
212+
return
213+
for entry in entries:
214+
if entry.is_symlink():
215+
try:
216+
target = os.path.realpath(entry.path)
217+
except OSError:
218+
continue
219+
if target == root or target.startswith(root + os.sep):
220+
continue
221+
if os.path.isdir(target):
222+
dir_targets.add(target)
223+
self._collect_symlink_targets(target, root, file_targets, dir_targets)
224+
elif os.path.isfile(target):
225+
file_targets.add(target)
226+
elif entry.is_dir(follow_symlinks=False):
227+
self._collect_symlink_targets(entry.path, root, file_targets, dir_targets)
228+
165229
def unwatch(self, path: str) -> None:
166230
"""Stop watching file changes for path. Raises if there was no corresponding `watch` call."""
167-
path = os.path.abspath(path)
231+
path = os.path.realpath(path)
168232

169233
self._watched_paths[path] -= 1
170234
if self._watched_paths[path] <= 0:
171235
self._watched_paths.pop(path)
172236
self.observer.unschedule(self._watch_refs.pop(path))
237+
for ref in self._extra_watch_refs.pop(path, []):
238+
self.observer.unschedule(ref)
173239

174240
def serve(self, *, open_in_browser=False):
175241
self.server_bind()

0 commit comments

Comments
 (0)