feat(cli): watch files with event streams
Build and Push Docker Container / build-and-push (push) Successful in 2m3s
Build and Push Docker Container / build-and-push (push) Successful in 2m3s
- Add a private SSE endpoint that emits file change events for authenticated clients. - Replace the watch sleep loop with watchdog local file events and remote SSE triggers. - Debounce local event bursts and keep watch running after transient sync failures. - Add watchdog as a standalone CLI dependency and cover watch behavior in tests. - Bump NanoShare to 1.25.0 and the standalone CLI to 0.4.0.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from .client import events_url
|
||||
from .ignore import is_ignored, load_ignore_patterns
|
||||
from .sync import relative, state_path, SYNC_DIR_NAME
|
||||
|
||||
SyncFunc = Callable[[object], int]
|
||||
|
||||
class _ChangeFlag:
|
||||
def __init__(self) -> None:
|
||||
self.event = threading.Event()
|
||||
|
||||
def notify(self) -> None:
|
||||
self.event.set()
|
||||
|
||||
def wait(self, timeout: float) -> bool:
|
||||
return self.event.wait(timeout)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.event.clear()
|
||||
|
||||
def _is_relevant_path(root: Path, path: str, ignore_patterns: list[str]) -> bool:
|
||||
try:
|
||||
candidate = Path(path).expanduser().resolve()
|
||||
rel = relative(root, candidate)
|
||||
except ValueError:
|
||||
return False
|
||||
if candidate == state_path(root) or root / SYNC_DIR_NAME in candidate.parents:
|
||||
return False
|
||||
return not is_ignored(rel, ignore_patterns)
|
||||
|
||||
def _remote_events_thread(args, flag: _ChangeFlag, stop: threading.Event) -> threading.Thread:
|
||||
def run() -> None:
|
||||
while not stop.is_set():
|
||||
client = None
|
||||
try:
|
||||
from .app import make_client
|
||||
client = make_client(args)
|
||||
headers = {'Accept': 'text/event-stream', **client.auth_headers()}
|
||||
with httpx.Client(timeout=None) as http:
|
||||
with http.stream('GET', events_url(client, args.node), headers=headers) as response:
|
||||
response.raise_for_status()
|
||||
for line in response.iter_lines():
|
||||
if stop.is_set():
|
||||
return
|
||||
if line.startswith('event: files.changed'):
|
||||
flag.notify()
|
||||
except Exception as exc:
|
||||
if not stop.is_set():
|
||||
print(f'remote event stream unavailable; retrying in 30s: {exc}', file=sys.stderr)
|
||||
stop.wait(30)
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
thread = threading.Thread(target=run, daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
def _watchdog_observer(root: Path, ignore_patterns: list[str], flag: _ChangeFlag):
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
def on_any_event(self, event):
|
||||
paths = [event.src_path]
|
||||
dest_path = getattr(event, 'dest_path', None)
|
||||
if dest_path:
|
||||
paths.append(dest_path)
|
||||
if any(_is_relevant_path(root, path, ignore_patterns) for path in paths):
|
||||
flag.notify()
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(Handler(), str(root), recursive=True)
|
||||
observer.start()
|
||||
return observer
|
||||
|
||||
def watch_loop(args, sync_func: SyncFunc) -> int:
|
||||
root = Path(args.folder).expanduser().resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None))
|
||||
interval = max(float(getattr(args, 'interval', 10.0)), 0.1)
|
||||
flag = _ChangeFlag()
|
||||
observer = None
|
||||
stop_events = threading.Event()
|
||||
events_thread = None
|
||||
|
||||
try:
|
||||
try:
|
||||
observer = _watchdog_observer(root, ignore_patterns, flag)
|
||||
print(f'watching {args.folder} for local changes; remote events with {interval}s fallback')
|
||||
except Exception as exc:
|
||||
print(f'watchdog unavailable, polling every {interval}s: {exc}', file=sys.stderr)
|
||||
print(f'watching {args.folder} every {interval}s')
|
||||
|
||||
events_thread = _remote_events_thread(args, flag, stop_events)
|
||||
|
||||
while True:
|
||||
flag.clear()
|
||||
code = sync_func(args)
|
||||
if code:
|
||||
print(f'watch sync failed; retrying in {interval}s', file=sys.stderr)
|
||||
flag.wait(interval)
|
||||
continue
|
||||
|
||||
if flag.wait(interval):
|
||||
flag.clear()
|
||||
time.sleep(1.0)
|
||||
while flag.wait(1.0):
|
||||
flag.clear()
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
stop_events.set()
|
||||
if observer:
|
||||
observer.stop()
|
||||
observer.join(timeout=5)
|
||||
if events_thread:
|
||||
events_thread.join(timeout=5)
|
||||
Reference in New Issue
Block a user