feat: asyncio log file watcher with offline detection
This commit is contained in:
55
log_watcher.py
Normal file
55
log_watcher.py
Normal file
@@ -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
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
72
tests/test_log_watcher.py
Normal file
72
tests/test_log_watcher.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user