Refactor: move log related inside the logs module

This commit is contained in:
arabcoders 2026-01-28 16:22:44 +03:00
parent b1a05b1d79
commit f88422e92b
4 changed files with 142 additions and 122 deletions

View file

@ -79,5 +79,20 @@ def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
def gen_random(length: int = 16) -> str: def gen_random(length: int = 16) -> str:
import secrets import secrets
import string
return "".join(secrets.token_urlsafe(length)[:length]) if length < 1:
msg = "length must be >= 1"
raise ValueError(msg)
middle_alphabet = string.ascii_letters + string.digits + "-_"
edge_alphabet = string.ascii_letters + string.digits
if 1 == length:
return secrets.choice(edge_alphabet)
return (
secrets.choice(edge_alphabet)
+ "".join(secrets.choice(middle_alphabet) for _ in range(length - 2))
+ secrets.choice(edge_alphabet)
)

View file

@ -33,8 +33,6 @@ FILES_TYPE: list = [
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c") TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
"Regex to find tags in templates." "Regex to find tags in templates."
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Regex to match ISO 8601 datetime strings."
class FileLogFormatter(logging.Formatter): class FileLogFormatter(logging.Formatter):
@ -969,116 +967,6 @@ def strip_newline(string: str) -> str:
return res.strip() if res else "" return res.strip() if res else ""
async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
"""
Read a log file and return a set of log lines along with pagination metadata.
Args:
file (Path): The log file path.
offset (int): Number of lines to skip from the end (newer entries).
limit (int): Number of lines to return.
Returns:
dict: A dictionary containing:
- logs: List of log entries.
- next_offset: Offset for the next page or None.
- end_is_reached: True if there are no older logs.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return {"logs": [], "next_offset": None, "end_is_reached": True}
result = []
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
file_size: int = await f.tell()
block_size = 1024
block_end: int = file_size
buffer: bytes = b""
lines: list = []
required_count: int = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start: int = max(0, block_end - block_size)
await f.seek(block_start)
chunk: bytes = await f.read(block_end - block_start)
buffer: bytes = chunk + buffer # prepend the chunk
lines = buffer.splitlines()
block_end = block_start
if len(lines) > offset + limit:
next_offset: int = offset + limit
end_is_reached = False
else:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception:
return {"logs": [], "next_offset": None, "end_is_reached": True}
async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
"""
Continuously read a log file and emit new lines.
Args:
file (str): The log file path.
emitter (callable): A callable to emit new lines.
sleep_time (float): The time to sleep between reads.
"""
from asyncio import sleep as asyncio_sleep
from hashlib import sha256
from anyio import open_file
if not file.exists():
return
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line: bytes = await f.readline()
if not line:
await asyncio_sleep(sleep_time)
continue
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]: def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
""" """
Validate and load a cookie file. Validate and load a cookie file.

View file

@ -1,5 +1,7 @@
import asyncio import asyncio
import logging import logging
import os
import re
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
@ -8,10 +10,121 @@ from aiohttp.web import Request, Response
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.router import route from app.library.router import route
from app.library.Utils import read_logfile, tail_log
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Match ISO8601."
async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
"""
Read a log file and return a set of log lines along with pagination metadata.
Args:
file (Path): The log file path.
offset (int): Number of lines to skip from the end (newer entries).
limit (int): Number of lines to return.
Returns:
dict: A dictionary containing:
- logs: List of log entries.
- next_offset: Offset for the next page or None.
- end_is_reached: True if there are no older logs.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return {"logs": [], "next_offset": None, "end_is_reached": True}
result = []
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
file_size: int = await f.tell()
block_size = 1024
block_end: int = file_size
buffer: bytes = b""
lines: list = []
required_count: int = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start: int = max(0, block_end - block_size)
await f.seek(block_start)
chunk: bytes = await f.read(block_end - block_start)
buffer: bytes = chunk + buffer # prepend the chunk
lines = buffer.splitlines()
block_end = block_start
if len(lines) > offset + limit:
next_offset: int = offset + limit
end_is_reached = False
else:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception:
return {"logs": [], "next_offset": None, "end_is_reached": True}
async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
"""
Live tail.
Args:
file (str): The log file path.
emitter (callable): A callable to emit new lines.
sleep_time (float): The time to sleep between reads.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line: bytes = await f.readline()
if not line:
await asyncio.sleep(sleep_time)
continue
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
@route("GET", "api/logs/", "logs") @route("GET", "api/logs/", "logs")
async def logs(request: Request, config: Config, encoder: Encoder) -> Response: async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
@ -35,7 +148,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
if limit < 1 or limit > 150: if limit < 1 or limit > 150:
limit = 50 limit = 50
logs_data = await read_logfile( logs_data = await _read_logfile(
file=Path(config.config_path) / "logs" / "app.log", file=Path(config.config_path) / "logs" / "app.log",
offset=offset, offset=offset,
limit=limit, limit=limit,
@ -85,12 +198,17 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n" payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
await response.write(payload.encode("utf-8")) await response.write(payload.encode("utf-8"))
log_task: asyncio.Task[None] = asyncio.create_task(tail_log(file=log_file, emitter=emit_log), name="log_stream") from app.features.core.utils import gen_random
log_task: asyncio.Task[None] = asyncio.create_task(
_tail_log(file=log_file, emitter=emit_log),
name=f"log_stream_{gen_random(8)}",
)
try: try:
LOG.debug("Log streaming connected.") LOG.debug("Log streaming connected.")
while not log_task.done(): while not log_task.done():
await asyncio.sleep(0.5) await asyncio.sleep(1.0)
if request.transport is None or request.transport.is_closing(): if request.transport is None or request.transport.is_closing():
log_task.cancel() log_task.cancel()
break break

View file

@ -34,15 +34,14 @@ from app.library.Utils import (
merge_dict, merge_dict,
move_file, move_file,
parse_tags, parse_tags,
read_logfile,
rename_file, rename_file,
str_to_dt, str_to_dt,
strip_newline, strip_newline,
tail_log,
timed_lru_cache, timed_lru_cache,
validate_url, validate_url,
validate_uuid, validate_uuid,
) )
from app.routes.api.logs import _read_logfile, _tail_log
class TestTimedLruCache: class TestTimedLruCache:
@ -1517,7 +1516,7 @@ class TestReadLogfile:
"""Test reading non-existent log file.""" """Test reading non-existent log file."""
async def test(): async def test():
result = await read_logfile(self.log_file) result = await _read_logfile(self.log_file)
assert isinstance(result, dict) assert isinstance(result, dict)
assert "logs" in result assert "logs" in result
@ -1528,7 +1527,7 @@ class TestReadLogfile:
self.log_file.write_text("line 1\nline 2\nline 3\n") self.log_file.write_text("line 1\nline 2\nline 3\n")
async def test(): async def test():
result = await read_logfile(self.log_file, limit=2) result = await _read_logfile(self.log_file, limit=2)
assert isinstance(result, dict) assert isinstance(result, dict)
assert "logs" in result assert "logs" in result
@ -1559,7 +1558,7 @@ class TestTailLog:
async def test(): async def test():
try: try:
await tail_log(self.log_file, emitter, sleep_time=0.1) await _tail_log(self.log_file, emitter, sleep_time=0.1)
except Exception: except Exception:
pass # Expected for non-existent file pass # Expected for non-existent file