Skip to content

Commit

Permalink
refactor: Disuse watchdog in favor of watchfiles. (lektor#1136)
Browse files Browse the repository at this point in the history
* refactor: use watchfiles instead of watchdog

Watchfiles has a much cleaner API.

Also, it's a flail, but I hope it might help with the hanging CI tests
on macOS (which seem to be related to watchdog.)

* fix(test): (macOS) wait a bit for pending watch event to settle

* refactor: code cleanup

* fix: watch the project file for changes
  • Loading branch information
dairiki committed Sep 11, 2023
1 parent b37b29c commit 8435b68
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 318 deletions.
61 changes: 30 additions & 31 deletions lektor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
import sys
import warnings
from contextlib import suppress
from itertools import chain

import click

Expand Down Expand Up @@ -130,38 +130,37 @@ def build_cmd(

env = ctx.get_env()

def _build():
builder = Builder(
env.new_pad(),
output_path,
buildstate_path=buildstate_path,
extra_flags=extra_flags,
)
if source_info_only:
builder.update_all_source_infos()
return True

if profile:
failures = profile_func(builder.build_all)
else:
failures = builder.build_all()
if prune:
builder.prune()
return failures == 0
with CliReporter(env, verbosity=verbosity):
builds = ["first"]
if watch:
from lektor.watcher import watch_project

reporter = CliReporter(env, verbosity=verbosity)
with reporter:
success = _build()
if not watch:
return sys.exit(0 if success else 1)

from lektor.watcher import Watcher
click.secho("Watching for file system changes", fg="cyan")
builds = chain(
builds, watch_project(env, output_path, raise_interrupt=False)
)

click.secho("Watching for file system changes", fg="cyan")
with Watcher(env) as watcher, suppress(KeyboardInterrupt):
while True:
watcher.wait()
_build()
success = False
for _ in builds:
builder = Builder(
env.new_pad(),
output_path,
buildstate_path=buildstate_path,
extra_flags=extra_flags,
)
if source_info_only:
builder.update_all_source_infos()
success = True
else:
if profile:
failures = profile_func(builder.build_all)
else:
failures = builder.build_all()
if prune:
builder.prune()
success = failures == 0

return sys.exit(0 if success else 1)


@cli.command("clean")
Expand Down
12 changes: 6 additions & 6 deletions lektor/devserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import threading
import time
import traceback
from itertools import chain

from werkzeug.serving import run_simple
from werkzeug.serving import WSGIRequestHandler
Expand All @@ -11,7 +12,7 @@
from lektor.db import Database
from lektor.reporter import CliReporter
from lektor.utils import process_extra_flags
from lektor.watcher import Watcher
from lektor.watcher import watch_project


class SilentWSGIRequestHandler(WSGIRequestHandler):
Expand Down Expand Up @@ -43,12 +44,11 @@ def build(self, update_source_info_first=False):
traceback.print_exc()

def run(self):
builds = chain(["first"], watch_project(self.env, self.output_path))
with CliReporter(self.env, verbosity=self.verbosity):
with Watcher(self.env, self.output_path) as watcher:
self.build(update_source_info_first=True)
while True:
watcher.wait()
self.build()
for n, _ in enumerate(builds):
is_first_build = n == 0
self.build(update_source_info_first=is_first_build)


def browse_to_address(addr):
Expand Down
174 changes: 24 additions & 150 deletions lektor/watcher.py
Original file line number Diff line number Diff line change
@@ -1,169 +1,43 @@
from __future__ import annotations

import os
import threading
from collections import OrderedDict
from dataclasses import dataclass
from itertools import zip_longest
from typing import Callable
from pathlib import Path
from typing import Any
from typing import Generator
from typing import TYPE_CHECKING

import click
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.api import DEFAULT_OBSERVER_TIMEOUT
from watchdog.observers.polling import PollingObserver
import watchfiles

from lektor.utils import get_cache_dir

if TYPE_CHECKING:
from lektor.environment import Environment

@dataclass(frozen=True)
class EventHandler(FileSystemEventHandler):
notify: Callable[[str], None]

def __post_init__(self):
super().__init__()
def watch_project(
env: Environment, output_path: str | Path, **kwargs: Any
) -> Generator[[watchfiles.Change, str], None, None]:
"""Watch project source files for changes.
# Generally we only care about changes (modification, creation, deletion) to files
# within the monitored tree. Changes in directories do not directly affect Lektor
# output. So, in general, we ignore directory events.
#
# However, the "efficient" (i.e. non-polling) observers do not seem to generate
# events for files contained in directories that are moved out of the watched tree.
# The only events generated in that case are for the directory — generally a
# DirDeletedEvent is generated — so we can't ignore those.
#
# (Moving/renaming a directory does not seem to reliably generate a DirMovedEvent,
# but we might as well track those, too.)
Returns an generator that yields sets of changes as they are noticed.
def on_created(self, event):
if not event.is_directory:
self.notify(event.src_path)
Changes to files within ``output_path`` are ignored, along with other files
deemed not to be Lektor source files.
def on_deleted(self, event):
self.notify(event.src_path)
"""
watch_paths = [env.root_path, env.project.project_file, *env.theme_paths]
ignore_paths = [os.path.abspath(p) for p in (get_cache_dir(), output_path)]
watch_filter = WatchFilter(env, ignore_paths=ignore_paths)

def on_modified(self, event):
if not event.is_directory:
self.notify(event.src_path)
return watchfiles.watch(*watch_paths, watch_filter=watch_filter, **kwargs)

def on_moved(self, event):
self.notify(event.src_path)
self.notify(event.dest_path)


def _fullname(cls):
"""Return the full name of a class (including the module name)."""
return f"{cls.__module__}.{cls.__qualname__}"


def _unique_everseen(seq):
"""Return the unique elements in sequence, preserving order."""
return OrderedDict.fromkeys(seq).keys()


class BasicWatcher:
def __init__(
self,
paths,
observer_classes=(Observer, PollingObserver),
observer_timeout=DEFAULT_OBSERVER_TIMEOUT, # testing
):
self.paths = paths
self.observer_classes = observer_classes
self.observer_timeout = observer_timeout
self.observer = None
self.semaphore = threading.BoundedSemaphore(1)
# pylint: disable=consider-using-with
assert self.semaphore.acquire(blocking=False)

def start(self):
# Remove duplicates since there is no point in trying a given
# observer class more than once. (This also simplifies the logic
# for presenting sensible warning messages about broken
# observers.)
observer_classes = list(_unique_everseen(self.observer_classes))
for observer_class, next_observer_class in zip_longest(
observer_classes, observer_classes[1:]
):
try:
self._start_observer(observer_class)
return
except Exception as exc:
if next_observer_class is None:
raise
click.secho(
f"Creation of {_fullname(observer_class)} failed with exception:\n"
f" {exc.__class__.__name__}: {exc!s}\n"
"This may be due to a configuration or other issue with your system.\n"
f"Falling back to {_fullname(next_observer_class)}.",
fg="red",
bold=True,
)

def stop(self):
self.observer.stop()

def __enter__(self):
self.start()
return self

def __exit__(self, ex_type, ex_value, ex_tb):
self.stop()

def _start_observer(self, observer_class=Observer):
if self.observer is not None:
raise RuntimeError("Watcher already started.")
observer = observer_class(timeout=self.observer_timeout)
event_handler = EventHandler(self._notify)
for path in self.paths:
observer.schedule(event_handler, path, recursive=True)
observer.daemon = True
observer.start()
self.observer = observer

def is_interesting(self, path: str) -> bool:
# pylint: disable=no-self-use
return True

def _notify(self, path):
"""Called by EventHandler when file change event is received."""
# pylint: disable=consider-using-with
if self.semaphore.acquire(blocking=False):
# was set (unread change pending): just put it back
self.semaphore.release()
elif self.is_interesting(path):
# was not set, but got an change event, set it
self.semaphore.release()

def wait(self, blocking: bool = True, timeout: float | None = None):
"""Wait for watched filesystem change.
This waits for a “new” non-ignored filesystem change. Here “new” means that
the change happened since the last return from ``wait``.
Waits a maximum of ``timeout`` seconds (or forever if ``timeout`` is ``None``).
Returns ``True`` if a change occurred, ``False`` on timeout.
"""
return self.semaphore.acquire(blocking, timeout)


class Watcher(BasicWatcher):
def __init__(self, env, output_path=None):
BasicWatcher.__init__(self, paths=[env.root_path] + env.theme_paths)
class WatchFilter(watchfiles.DefaultFilter):
def __init__(self, env: Environment, **kwargs: Any):
super().__init__(**kwargs)
self.env = env
self.output_path = output_path
self.cache_dir = os.path.abspath(get_cache_dir())

def is_interesting(self, path: str) -> bool:
path = os.path.abspath(path)

def __call__(self, change: watchfiles.Change, path: str) -> bool:
if self.env.is_uninteresting_source_name(os.path.basename(path)):
return False
if path.startswith(self.cache_dir):
return False
if self.output_path is not None and path.startswith(
os.path.abspath(self.output_path)
):
return False
return True
return super().__call__(change, path)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies = [
"pytz; python_version<'3.9'", # favor zoneinfo for python>=3.9
"requests",
"tzdata; python_version>='3.9' and sys_platform == 'win32'",
"watchdog",
"watchfiles",
"Werkzeug>=2.1.0,<3",
]
optional-dependencies.ipython = [
Expand Down

0 comments on commit 8435b68

Please sign in to comment.