From 3367eeb80b4343987c308dd6db7f68bb2eef806d Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Mon, 22 Jun 2026 18:57:17 +0300 Subject: [PATCH] feat: asyncio log file watcher with offline detection --- log_watcher.py | 55 ++++++++++++++++++++++++++++++ pytest.ini | 2 ++ tests/test_log_watcher.py | 72 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 log_watcher.py create mode 100644 pytest.ini create mode 100644 tests/test_log_watcher.py diff --git a/log_watcher.py b/log_watcher.py new file mode 100644 index 0000000..d5c9ae7 --- /dev/null +++ b/log_watcher.py @@ -0,0 +1,55 @@ +import asyncio +import os +from pathlib import Path +from typing import Callable, Awaitable, Optional + + +class LogWatcher: + POLL_INTERVAL = 0.1 + FILE_RETRY_INTERVAL = 1.0 + + def __init__( + self, + path: str, + on_line: Callable[[str], Awaitable[None]], + on_offline: Optional[Callable[[], Awaitable[None]]] = None, + offline_timeout: float = 60.0, + ): + self._path = Path(path) + self._on_line = on_line + self._on_offline = on_offline + self._offline_timeout = offline_timeout + self._task: Optional[asyncio.Task] = None + + def start(self) -> asyncio.Task: + self._task = asyncio.create_task(self._run()) + return self._task + + def stop(self): + if self._task: + self._task.cancel() + + async def _run(self): + offline_since: Optional[float] = None + + while True: + try: + with open(self._path, 'r', errors='replace') as f: + f.seek(0, 2) # start at end of file (tail -f behaviour) + offline_since = None + while True: + line = f.readline() + if line: + await self._on_line(line) + else: + await asyncio.sleep(self.POLL_INTERVAL) + except OSError: + now = asyncio.get_event_loop().time() + if offline_since is None: + offline_since = now + elif now - offline_since >= self._offline_timeout and self._on_offline: + await self._on_offline() + offline_since = None # reset so we don't spam + await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout)) + except asyncio.CancelledError: + return diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/tests/test_log_watcher.py b/tests/test_log_watcher.py new file mode 100644 index 0000000..929fd97 --- /dev/null +++ b/tests/test_log_watcher.py @@ -0,0 +1,72 @@ +import asyncio +import os +import tempfile +import pytest +from log_watcher import LogWatcher + +pytestmark = pytest.mark.asyncio + + +async def test_watcher_receives_new_lines(): + with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f: + path = f.name + try: + received = [] + + async def on_line(line): + received.append(line.rstrip('\n')) + + watcher = LogWatcher(path, on_line) + watcher.start() + await asyncio.sleep(0.05) # let watcher open and seek to end + + with open(path, 'a') as f: + f.write("line one\n") + f.write("line two\n") + + await asyncio.sleep(0.3) # wait for poll cycles + watcher.stop() + + assert received == ["line one", "line two"] + finally: + os.unlink(path) + + +async def test_watcher_handles_missing_file_then_appears(): + path = tempfile.mktemp(suffix='.log') + received = [] + + async def on_line(line): + received.append(line.rstrip('\n')) + + watcher = LogWatcher(path, on_line) + watcher.start() + await asyncio.sleep(0.15) # file doesn't exist yet + + with open(path, 'w') as f: + f.write("hello\n") + + await asyncio.sleep(0.3) + watcher.stop() + + # File appeared after watcher started — lines written before watcher opens + # will be consumed. This test just checks it doesn't crash. + assert isinstance(received, list) + + if os.path.exists(path): + os.unlink(path) + + +async def test_watcher_calls_on_offline_after_timeout(): + path = tempfile.mktemp(suffix='.log') # never created + offline_called = [] + + async def on_offline(): + offline_called.append(True) + + watcher = LogWatcher(path, lambda _: None, on_offline=on_offline, offline_timeout=0.2) + watcher.start() + await asyncio.sleep(0.5) + watcher.stop() + + assert offline_called, "on_offline should have been called"