Repository: pallets/werkzeug
Pull request #2947 (2024-09-05 09:31:58)

Switch to an event allowlist for watchdog
Ignores the new closed_no_write event type fired by watchdog 5.x that is causing unnecessary reloads.  
To prevent such cases in the future, I introduced an event allow list.

fixes https://github.com/pallets/werkzeug/issues/2945

- Test case added
- Change log added

lociii: Can we please get a little speed on this one?  
`werkzeug` is currently broken when using it with `django-extensions` and not pinning `watchdog` to a `4.x` release.

https://github.com/pallets/werkzeug/issues/2945#issuecomment-2354842482

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 58b744f0..d603ed90 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,8 @@ Version 3.0.5
 
 Unreleased
 
+-   The Watchdog reloader ignores file closed no write events. :issue:`2945`
+
 
 Version 3.0.4
 -------------
diff --git a/src/werkzeug/_reloader.py b/src/werkzeug/_reloader.py
index d3507f5a..8fd50b96 100644
--- a/src/werkzeug/_reloader.py
+++ b/src/werkzeug/_reloader.py
@@ -312,7 +312,11 @@ class StatReloaderLoop(ReloaderLoop):
 
 class WatchdogReloaderLoop(ReloaderLoop):
     def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
-        from watchdog.events import EVENT_TYPE_OPENED
+        from watchdog.events import EVENT_TYPE_CLOSED
+        from watchdog.events import EVENT_TYPE_CREATED
+        from watchdog.events import EVENT_TYPE_DELETED
+        from watchdog.events import EVENT_TYPE_MODIFIED
+        from watchdog.events import EVENT_TYPE_MOVED
         from watchdog.events import FileModifiedEvent
         from watchdog.events import PatternMatchingEventHandler
         from watchdog.observers import Observer
@@ -322,7 +326,14 @@ class WatchdogReloaderLoop(ReloaderLoop):
 
         class EventHandler(PatternMatchingEventHandler):
             def on_any_event(self, event: FileModifiedEvent):  # type: ignore
-                if event.event_type == EVENT_TYPE_OPENED:
+                if event.event_type not in {
+                    EVENT_TYPE_CLOSED,
+                    EVENT_TYPE_CREATED,
+                    EVENT_TYPE_DELETED,
+                    EVENT_TYPE_MODIFIED,
+                    EVENT_TYPE_MOVED,
+                }:
+                    # skip events that don't involve changes to the file
                     return
 
                 trigger_reload(event.src_path)


Test patch:
diff --git a/tests/test_serving.py b/tests/test_serving.py
index 4abc755d..501279b9 100644
--- a/tests/test_serving.py
+++ b/tests/test_serving.py
@@ -10,6 +10,7 @@ from pathlib import Path
 from unittest.mock import patch
 
 import pytest
+from watchdog import version as watchdog_version
 from watchdog.events import EVENT_TYPE_MODIFIED
 from watchdog.events import EVENT_TYPE_OPENED
 from watchdog.events import FileModifiedEvent
@@ -136,6 +137,28 @@ def test_watchdog_reloader_ignores_opened(mock_trigger_reload):
     reloader.trigger_reload.assert_not_called()
 
 
+@pytest.mark.skipif(
+    watchdog_version.VERSION_MAJOR < 5,
+    reason="'closed no write' event introduced in watchdog 5.0",
+)
+@patch.object(WatchdogReloaderLoop, "trigger_reload")
+def test_watchdog_reloader_ignores_closed_no_write(mock_trigger_reload):
+    from watchdog.events import EVENT_TYPE_CLOSED_NO_WRITE
+
+    reloader = WatchdogReloaderLoop()
+    modified_event = FileModifiedEvent("")
+    modified_event.event_type = EVENT_TYPE_MODIFIED
+    reloader.event_handler.on_any_event(modified_event)
+    mock_trigger_reload.assert_called_once()
+
+    reloader.trigger_reload.reset_mock()
+
+    opened_event = FileModifiedEvent("")
+    opened_event.event_type = EVENT_TYPE_CLOSED_NO_WRITE
+    reloader.event_handler.on_any_event(opened_event)
+    reloader.trigger_reload.assert_not_called()
+
+
 @pytest.mark.skipif(sys.version_info >= (3, 10), reason="not needed on >= 3.10")
 def test_windows_get_args_for_reloading(monkeypatch, tmp_path):
     argv = [str(tmp_path / "test.exe"), "run"]