2626from typing import Any , BinaryIO
2727
2828import watchdog .events
29- import watchdog .observers . polling
29+ import watchdog .observers
3030
3131_SCRIPT_TEMPLATE_STR = """
3232var livereload = function(epoch, requestId) {
@@ -132,15 +132,17 @@ def __init__(
132132 self ._rebuild_cond = threading .Condition () # Must be held when accessing _want_rebuild.
133133
134134 self ._shutdown = False
135+ self ._building = False
135136 self .serve_thread = threading .Thread (target = lambda : self .serve_forever (shutdown_delay ))
136- self .observer = watchdog .observers .polling . PollingObserver (timeout = polling_interval )
137+ self .observer = watchdog .observers .Observer (timeout = polling_interval )
137138
138139 self ._watched_paths : dict [str , int ] = {}
139140 self ._watch_refs : dict [str , Any ] = {}
141+ self ._extra_watch_refs : dict [str , list [Any ]] = {}
140142
141143 def watch (self , path : str , func : None = None , * , recursive : bool = True ) -> None :
142144 """Add the 'path' to watched paths, call the function and reload when any file changes under it."""
143- path = os .path .abspath (path )
145+ path = os .path .realpath (path )
144146 if not (func is None or func is self .builder ): # type: ignore[unreachable]
145147 raise TypeError ("Plugins can no longer pass a 'func' parameter to watch()." )
146148
@@ -152,6 +154,8 @@ def watch(self, path: str, func: None = None, *, recursive: bool = True) -> None
152154 def callback (event ):
153155 if event .is_directory :
154156 return
157+ if self ._building :
158+ return
155159 log .debug (str (event ))
156160 with self ._rebuild_cond :
157161 self ._want_rebuild = True
@@ -162,14 +166,79 @@ def callback(event):
162166 log .debug (f"Watching '{ path } '" )
163167 self ._watch_refs [path ] = self .observer .schedule (handler , path , recursive = recursive )
164168
169+ # Watch symlink targets outside the watched tree so that native file system
170+ # observers (inotify, FSEvents) detect changes to symlinked files.
171+ if recursive and os .path .isdir (path ):
172+ self ._watch_symlink_targets (path , callback )
173+
174+ def _watch_symlink_targets (self , root : str , callback : Callable ) -> None :
175+ file_targets : set [str ] = set ()
176+ dir_targets : set [str ] = set ()
177+ self ._collect_symlink_targets (root , root , file_targets , dir_targets )
178+ if not file_targets and not dir_targets :
179+ return
180+
181+ def filtered_callback (event : Any ) -> None :
182+ if event .is_directory :
183+ return None
184+ src = os .path .realpath (event .src_path )
185+ if src in file_targets :
186+ return callback (event )
187+ for d in dir_targets :
188+ if src .startswith (d + os .sep ):
189+ return callback (event )
190+ return None
191+
192+ filtered_handler = watchdog .events .FileSystemEventHandler ()
193+ filtered_handler .on_any_event = filtered_callback # type: ignore[method-assign]
194+
195+ extra_refs : list [Any ] = []
196+ watched_dirs : set [str ] = set ()
197+ for target in file_targets :
198+ parent = os .path .dirname (target )
199+ if parent not in watched_dirs :
200+ watched_dirs .add (parent )
201+ extra_refs .append (self .observer .schedule (filtered_handler , parent , recursive = False ))
202+ for d in dir_targets :
203+ if d not in watched_dirs :
204+ watched_dirs .add (d )
205+ extra_refs .append (self .observer .schedule (filtered_handler , d , recursive = True ))
206+ if extra_refs :
207+ self ._extra_watch_refs [root ] = extra_refs
208+
209+ def _collect_symlink_targets (
210+ self , scan_dir : str , root : str , file_targets : set [str ], dir_targets : set [str ]
211+ ) -> None :
212+ try :
213+ entries = list (os .scandir (scan_dir ))
214+ except OSError :
215+ return
216+ for entry in entries :
217+ if entry .is_symlink ():
218+ try :
219+ target = os .path .realpath (entry .path )
220+ except OSError :
221+ continue
222+ if target == root or target .startswith (root + os .sep ):
223+ continue
224+ if os .path .isdir (target ):
225+ dir_targets .add (target )
226+ self ._collect_symlink_targets (target , root , file_targets , dir_targets )
227+ elif os .path .isfile (target ):
228+ file_targets .add (target )
229+ elif entry .is_dir (follow_symlinks = False ):
230+ self ._collect_symlink_targets (entry .path , root , file_targets , dir_targets )
231+
165232 def unwatch (self , path : str ) -> None :
166233 """Stop watching file changes for path. Raises if there was no corresponding `watch` call."""
167- path = os .path .abspath (path )
234+ path = os .path .realpath (path )
168235
169236 self ._watched_paths [path ] -= 1
170237 if self ._watched_paths [path ] <= 0 :
171238 self ._watched_paths .pop (path )
172239 self .observer .unschedule (self ._watch_refs .pop (path ))
240+ for ref in self ._extra_watch_refs .pop (path , []):
241+ self .observer .unschedule (ref )
173242
174243 def serve (self , * , open_in_browser = False ):
175244 self .server_bind ()
@@ -210,6 +279,7 @@ def _build_loop(self):
210279 self ._want_rebuild = False
211280
212281 try :
282+ self ._building = True
213283 self .builder ()
214284 except Exception as e :
215285 if isinstance (e , SystemExit ):
@@ -220,6 +290,12 @@ def _build_loop(self):
220290 "An error happened during the rebuild. The server will appear stuck until build errors are resolved."
221291 )
222292 continue
293+ finally :
294+ self ._building = False
295+ # Discard any file change events generated by the build itself
296+ # (e.g. from plugins that write into the docs directory).
297+ with self ._rebuild_cond :
298+ self ._want_rebuild = False
223299
224300 with self ._epoch_cond :
225301 log .info ("Reloading browsers" )
0 commit comments