171 lines
4.8 KiB
Python
171 lines
4.8 KiB
Python
|
# -*- coding: utf-8 eval: (blacken-mode 1) -*-
|
||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||
|
#
|
||
|
# August 21 2023, Christian Hopps <chopps@labn.net>
|
||
|
#
|
||
|
# Copyright (c) 2023, LabN Consulting, L.L.C.
|
||
|
#
|
||
|
"""A module supporting an object for watching a logfile."""
|
||
|
import asyncio
|
||
|
import logging
|
||
|
import re
|
||
|
|
||
|
from pathlib import Path
|
||
|
|
||
|
|
||
|
class MatchFoundError(Exception):
|
||
|
"""An error raised when a match is not found."""
|
||
|
def __init__(self, watchlog, match):
|
||
|
self.watchlog = watchlog
|
||
|
self.match = match
|
||
|
super().__init__(watchlog, match)
|
||
|
|
||
|
|
||
|
class WatchLog:
|
||
|
"""An object for watching a logfile."""
|
||
|
|
||
|
def __init__(self, path, encoding="utf-8"):
|
||
|
"""Watch a logfile.
|
||
|
|
||
|
Args:
|
||
|
path: that path of the logfile to watch
|
||
|
encoding: the encoding of the logfile
|
||
|
"""
|
||
|
# Immutable
|
||
|
self.path = Path(path)
|
||
|
self.encoding = encoding
|
||
|
|
||
|
# Mutable
|
||
|
self.content = ""
|
||
|
self.last_snap_mark = 0
|
||
|
self.last_user_mark = 0
|
||
|
self.stat = None
|
||
|
|
||
|
if self.path.exists():
|
||
|
self.snapshot()
|
||
|
|
||
|
def _stat_snapshot(self):
|
||
|
ostat = self.stat
|
||
|
|
||
|
if not self.path.exists():
|
||
|
self.stat = None
|
||
|
return ostat is not None
|
||
|
|
||
|
stat = self.path.stat()
|
||
|
self.stat = stat
|
||
|
|
||
|
if ostat is None:
|
||
|
return True
|
||
|
|
||
|
return (
|
||
|
stat.st_mtime_ns != ostat.st_mtime_ns
|
||
|
or stat.st_ctime_ns != ostat.st_ctime_ns
|
||
|
or stat.st_ino != ostat.st_ino
|
||
|
or stat.st_size != ostat.st_size
|
||
|
)
|
||
|
|
||
|
def reset(self):
|
||
|
self.content = ""
|
||
|
self.last_user_mark = 0
|
||
|
self.last_snap_mark = 0
|
||
|
|
||
|
def update_content(self):
|
||
|
ostat = self.stat
|
||
|
osize = ostat.st_size if ostat else 0
|
||
|
oino = ostat.st_ino if ostat else -1
|
||
|
if not self._stat_snapshot():
|
||
|
logging.debug("XXX logfile %s no stat change", self.path)
|
||
|
return ""
|
||
|
|
||
|
nino = self.stat.st_ino
|
||
|
# If the inode changed and we had content previously warn
|
||
|
if oino != -1 and oino != nino and self.content:
|
||
|
logging.warning(
|
||
|
"logfile %s replaced (new inode) resetting content", self.path
|
||
|
)
|
||
|
self.reset()
|
||
|
osize = 0
|
||
|
|
||
|
nsize = self.stat.st_size
|
||
|
if osize > nsize:
|
||
|
logging.warning("logfile %s shrunk resetting content", self.path)
|
||
|
self.reset()
|
||
|
osize = 0
|
||
|
|
||
|
if osize == nsize:
|
||
|
logging.debug(
|
||
|
"XXX watchlog: %s no update, osize == nsize == %s", self.path, osize
|
||
|
)
|
||
|
return ""
|
||
|
|
||
|
# Read non-blocking
|
||
|
with open(self.path, "r", encoding=self.encoding) as f:
|
||
|
if osize:
|
||
|
f.seek(osize)
|
||
|
logging.debug(
|
||
|
"XXX watchlog: %s reading new content from %s to %s",
|
||
|
self.path,
|
||
|
osize,
|
||
|
nsize,
|
||
|
)
|
||
|
newcontent = f.read(nsize - osize)
|
||
|
|
||
|
self.content += newcontent
|
||
|
return newcontent
|
||
|
|
||
|
def raise_if_match_task(self, match):
|
||
|
"""Start an async task that searches for a match.
|
||
|
|
||
|
This doesn't work well with pytest as the task must be awaited for the exception
|
||
|
to propagate.
|
||
|
"""
|
||
|
|
||
|
async def scan_for_match(wl, regex):
|
||
|
while True:
|
||
|
logging.debug("watchlog: %s scan for updating content", wl.path)
|
||
|
wl.update_content()
|
||
|
if m := regex.search(wl.content):
|
||
|
logging.error(
|
||
|
"XXX watchlog: %s regexp FOUND raising exception!", wl.path
|
||
|
)
|
||
|
raise MatchFoundError(wl, m)
|
||
|
await asyncio.sleep(2)
|
||
|
|
||
|
aw = scan_for_match(self, re.compile(match))
|
||
|
return asyncio.create_task(aw)
|
||
|
|
||
|
def from_mark(self, mark=None):
|
||
|
"""Return the file content starting from ``mark``.
|
||
|
|
||
|
If ``mark`` is None then return content since last ``set_mark`` was called.
|
||
|
|
||
|
Args:
|
||
|
mark: the mark in the content to return file content from.
|
||
|
|
||
|
Return:
|
||
|
returns the content between ``mark`` and the end of content.
|
||
|
"""
|
||
|
return self.content[mark:]
|
||
|
|
||
|
def set_mark(self):
|
||
|
"""Set a mark for later use."""
|
||
|
last_mark = self.last_user_mark
|
||
|
self.last_user_mark = len(self.content)
|
||
|
return last_mark
|
||
|
|
||
|
def snapshot(self):
|
||
|
"""Update the file content and return new text.
|
||
|
|
||
|
Returns any new text added since the last snapshot,
|
||
|
also updates the snapshot mark.
|
||
|
|
||
|
Return:
|
||
|
Newly added text.
|
||
|
"""
|
||
|
# Update the content which may reset marks
|
||
|
self.update_content()
|
||
|
|
||
|
last_mark = self.last_snap_mark
|
||
|
self.last_snap_mark = len(self.content)
|
||
|
return self.content[last_mark:]
|