Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion PQEnalyzer/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,10 @@ def __auto_refresh_control_event(self):

if self.auto_refresh.get():
if self.__start_file_watcher():
self.__set_auto_refresh_status("Watching for file changes")
status = "Watching for file changes"
if getattr(self.__file_watcher, "mode", None) == "polling":
status += " (polling)"
self.__set_auto_refresh_status(status)
else:
self.__stop_file_watcher()
self.__set_auto_refresh_status("Auto-refresh paused")
Expand Down
65 changes: 55 additions & 10 deletions PQEnalyzer/apps/file_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@
File-change watcher used by the GUI auto-refresh flow.
"""

import contextlib
from pathlib import Path

from .._logging import get_logger

try:
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
except ImportError: # pragma: no cover - dependency is installed at runtime
FileSystemEventHandler = object
Observer = None
PollingObserver = None


logger = get_logger(__name__)
Expand Down Expand Up @@ -45,6 +48,7 @@ def __init__(self, filenames, callback):
self.filenames = {Path(filename).resolve() for filename in filenames}
self.callback = callback
self.observer = None
self.mode = None

def start(self) -> bool:
"""
Expand All @@ -55,25 +59,41 @@ def start(self) -> bool:
logger.warning("Auto-refresh unavailable: watchdog is not installed.")
return False

self.observer = Observer()
handler = _InputFileEventHandler(self)
for directory in sorted({filename.parent for filename in self.filenames}):
self.observer.schedule(handler, str(directory), recursive=False)
directories = sorted({
filename.parent for filename in self.filenames
})
try:
self.__start_observer(Observer, handler, directories)
except OSError as error:
self.__stop_observer()
logger.warning(
"Native auto-refresh unavailable (%s); using polling.",
error,
)
else:
self.mode = "native"
return True

if PollingObserver is None:
return False

self.observer.start()
try:
self.__start_observer(PollingObserver, handler, directories)
except OSError as error:
self.__stop_observer()
logger.warning("Auto-refresh unavailable: %s", error)
return False

self.mode = "polling"
return True

def stop(self) -> None:
"""
Stop the background observer if it is running.
"""

if self.observer is None:
return

self.observer.stop()
self.observer.join(timeout=1.0)
self.observer = None
self.__stop_observer()

def notify(self, event) -> None:
"""
Expand Down Expand Up @@ -103,3 +123,28 @@ def __matches_loaded_file(self, event) -> bool:
return True

return False

def __start_observer(self, observer_type, handler, directories):
"""
Start one watchdog observer implementation.
"""

self.observer = observer_type()
for directory in directories:
self.observer.schedule(handler, str(directory), recursive=False)
self.observer.start()

def __stop_observer(self):
"""
Stop a complete or partially started observer.
"""

observer = self.observer
self.observer = None
self.mode = None
if observer is None:
return

observer.stop()
with contextlib.suppress(RuntimeError):
observer.join(timeout=1.0)
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ overview with one panel per parameter.

`Auto-Refresh` is enabled by default. It watches the loaded input files and
refreshes open plots when new simulation output is written. Disable it to pause
file watching. Plot controls apply to the selected focused plot, so different
plot windows can use different statistics and overlays at the same time.
file watching. If the native file watcher is unavailable, PQEnalyzer falls back
to polling and marks that mode in the GUI status. Plot controls apply to the
selected focused plot, so different plot windows can use different statistics
and overlays at the same time.

Available statistics and overlays are:

Expand Down
23 changes: 23 additions & 0 deletions tests/apps/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,29 @@ def start(self):
assert status.configure_kwargs["text"] == "Auto-refresh unavailable"


def test_auto_refresh_control_reports_polling_fallback(monkeypatch):
app = make_app(auto_refresh=True)
status = FakeWidget()
app.auto_refresh_status_label = status

class FakeWatcher:
mode = "polling"

def __init__(self, filenames, callback):
pass

def start(self):
return True

monkeypatch.setattr(app_module, "FileChangeWatcher", FakeWatcher)

app_module.App._App__auto_refresh_control_event(app)

assert status.configure_kwargs["text"] == (
"Watching for file changes (polling)"
)


def test_auto_refresh_debounces_file_events():
app = make_app(auto_refresh=True)
calls = []
Expand Down
92 changes: 92 additions & 0 deletions tests/apps/test_file_watcher.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import errno
from types import SimpleNamespace

from PQEnalyzer.apps import file_watcher
Expand Down Expand Up @@ -78,6 +79,7 @@ def join(self, timeout=None):
assert watcher.start() is True
watcher.stop()

assert watcher.mode is None
assert [entry[1] for entry in scheduled[:2]] == [
str(tmp_path / "a"),
str(tmp_path / "b"),
Expand All @@ -94,3 +96,93 @@ def test_file_change_watcher_reports_unavailable_observer(monkeypatch):
watcher = FileChangeWatcher(["run.en"], lambda: None)

assert watcher.start() is False


def test_file_change_watcher_falls_back_after_native_resource_error(
tmp_path, monkeypatch, caplog):
energy_file = tmp_path / "run.en"
calls = []

class FailingNativeObserver:

def schedule(self, handler, directory, recursive):
calls.append(("native-schedule", directory, recursive))

def start(self):
raise OSError(errno.EMFILE, "inotify instance limit reached")

def stop(self):
calls.append(("native-stop",))

def join(self, timeout=None):
calls.append(("native-join", timeout))
raise RuntimeError("observer thread was not started")

class FakePollingObserver:

def schedule(self, handler, directory, recursive):
calls.append(("polling-schedule", directory, recursive))

def start(self):
calls.append(("polling-start",))

def stop(self):
calls.append(("polling-stop",))

def join(self, timeout=None):
calls.append(("polling-join", timeout))

monkeypatch.setattr(
file_watcher,
"Observer",
FailingNativeObserver,
)
monkeypatch.setattr(
file_watcher,
"PollingObserver",
FakePollingObserver,
)
watcher = FileChangeWatcher([energy_file], lambda: None)

assert watcher.start() is True
assert watcher.mode == "polling"
assert ("native-stop",) in calls
assert ("native-join", 1.0) in calls
assert ("polling-start",) in calls
assert "inotify instance limit reached" in caplog.text
assert "using polling" in caplog.text

watcher.stop()

assert watcher.mode is None
assert calls[-2:] == [
("polling-stop",),
("polling-join", 1.0),
]


def test_file_change_watcher_reports_failed_polling_fallback(
tmp_path, monkeypatch, caplog):

class FailingObserver:

def schedule(self, handler, directory, recursive):
return None

def start(self):
raise OSError(errno.EMFILE, "watch limit reached")

def stop(self):
return None

def join(self, timeout=None):
return None

monkeypatch.setattr(file_watcher, "Observer", FailingObserver)
monkeypatch.setattr(file_watcher, "PollingObserver", FailingObserver)
watcher = FileChangeWatcher([tmp_path / "run.en"], lambda: None)

assert watcher.start() is False
assert watcher.observer is None
assert watcher.mode is None
assert "Auto-refresh unavailable" in caplog.text
Loading