Repository: mkdocs/mkdocs
Pull request #2777 (2022-02-23 12:25:16)

Allow to unwatch files with `LiveReloadServer`
I'm working on a project that needs to watch files being served based in the content on other files. I've not found a way to stop watching files for changes. As far as I know, I can't access the watch object returned by `observer.schedule` needed to unwatch them, so crafted this `unwatch` method for the server.

Is working as expected in my local environment (Ubuntu 20.04). Mentioning @oprypin as creator and maintainer of the new livereload server.

oprypin: Hmm by looking at the dependent pull request, I finally understand the problem that this tries to solve.
One might want to pick up new files to watch throughout the `serve`, and subsequently "un"-pick them up if they are no longer relevant.
Maybe there should be a better solution, such as building up the list of paths to watch from scratch every time (and calling `on_serve` every time).
But hm, this certainly can work, without many unnecessary changes.

To not give false hope that this can skip sub-paths of existing directories (the method doesn't even error in case it finds no match!), I think we should instead directly expose the result of `observer.schedule` and then operate based on it.
oprypin: I looked into it more, and actually found it a bad experience to use those watch references directly. And it didn't even solve what I was trying to solve.

The main problem I was trying to avoid is for a plugin to *accidentally* unwatch paths that it didn't watch in the first place.
I solved it with a counter instead.
I pushed my changes directly. Sorry if you already started work.
oprypin: Try it out if it works for you
mondeja: It works, thank you! 👍🏼 

Patch:
diff --git a/mkdocs/commands/babel.py b/mkdocs/commands/babel.py
index 2ccadc28..9de5be8f 100644
--- a/mkdocs/commands/babel.py
+++ b/mkdocs/commands/babel.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import sys
 from distutils.errors import DistutilsOptionError
 from os import path
 
@@ -31,6 +32,12 @@ class compile_catalog(babel.compile_catalog, ThemeMixin):
         ("theme=", "t", "theme name to work on"),
     ]
 
+    def run(self):
+        # Possible bug in Babel - produces unused return value:
+        # https://github.com/python-babel/babel/blob/v2.10.3/babel/messages/frontend.py#L194
+        if super().run():
+            sys.exit(1)
+
     def initialize_options(self):
         super().initialize_options()
         self.theme = None
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
index c0c99bdf..540c1755 100644
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -17,7 +17,7 @@ import threading
 import time
 import warnings
 import wsgiref.simple_server
-from typing import Callable, Dict, Optional, Tuple
+from typing import Any, Callable, Dict, Optional, Tuple
 
 import watchdog.events
 import watchdog.observers.polling
@@ -100,7 +100,8 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
         self.serve_thread = threading.Thread(target=lambda: self.serve_forever(shutdown_delay))
         self.observer = watchdog.observers.polling.PollingObserver(timeout=polling_interval)
 
-        self._watched_paths: Dict[str, bool] = {}  # Used as an ordered set.
+        self._watched_paths: Dict[str, int] = {}
+        self._watch_refs: Dict[str, Any] = {}
 
     def watch(self, path: str, func: Optional[Callable] = None, recursive: bool = True) -> None:
         """Add the 'path' to watched paths, call the function and reload when any file changes under it."""
@@ -116,7 +117,9 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
             )
 
         if path in self._watched_paths:
+            self._watched_paths[path] += 1
             return
+        self._watched_paths[path] = 1
 
         def callback(event):
             if event.is_directory:
@@ -129,9 +132,16 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
         handler = watchdog.events.FileSystemEventHandler()
         handler.on_any_event = callback
         log.debug(f"Watching '{path}'")
-        self.observer.schedule(handler, path, recursive=recursive)
+        self._watch_refs[path] = self.observer.schedule(handler, path, recursive=recursive)
 
-        self._watched_paths[path] = True
+    def unwatch(self, path: str) -> None:
+        """Stop watching file changes for path. Raises if there was no corresponding `watch` call."""
+        path = os.path.abspath(path)
+
+        self._watched_paths[path] -= 1
+        if self._watched_paths[path] <= 0:
+            self._watched_paths.pop(path)
+            self.observer.unschedule(self._watch_refs.pop(path))
 
     def serve(self):
         self.observer.start()


Test patch:
diff --git a/mkdocs/tests/livereload_tests.py b/mkdocs/tests/livereload_tests.py
index 6a56a784..7c2d762d 100644
--- a/mkdocs/tests/livereload_tests.py
+++ b/mkdocs/tests/livereload_tests.py
@@ -158,6 +158,30 @@ class BuildTests(unittest.TestCase):
 
                 self.assertTrue(started_building.wait(timeout=10))
 
+    @tempdir()
+    def test_unwatch(self, site_dir):
+        started_building = threading.Event()
+
+        with testing_server(site_dir, started_building.set) as server:
+            with self.assertRaises(KeyError):
+                server.unwatch(site_dir)
+
+            server.watch(site_dir)
+            server.watch(site_dir)
+            server.unwatch(site_dir)
+            time.sleep(0.01)
+
+            Path(site_dir, "foo").write_text("foo")
+            self.assertTrue(started_building.wait(timeout=10))
+            started_building.clear()
+
+            server.unwatch(site_dir)
+            Path(site_dir, "foo").write_text("bar")
+            self.assertFalse(started_building.wait(timeout=0.5))
+
+            with self.assertRaises(KeyError):
+                server.unwatch(site_dir)
+
     @tempdir({"foo.docs": "a"})
     @tempdir({"foo.site": "original"})
     def test_custom_action_warns(self, site_dir, docs_dir):