From 4df5b22c127ea3f84dc7bb0ae57ed7b1db138fe7 Mon Sep 17 00:00:00 2001 From: grobomo <105956248+grobomo@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:46:17 -0500 Subject: [PATCH] T033: Add size-based log rotation (stdlib only) (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RotatingLog replaces raw file open — rotates coconut.log at configurable size threshold (default 5MB), keeps N backup files (.log.1, .log.2, ...). Config via COCONUT_LOG_MAX_BYTES and COCONUT_LOG_BACKUPS env vars. 5 tests passing. Co-authored-by: grobomo --- coconut.py | 6 +- config/coconut.env.example | 5 ++ core/logrotate.py | 80 +++++++++++++++++++++ scripts/test/test-logrotate.sh | 124 +++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 core/logrotate.py create mode 100644 scripts/test/test-logrotate.sh diff --git a/coconut.py b/coconut.py index 6ae2767..a7cfb81 100644 --- a/coconut.py +++ b/coconut.py @@ -15,6 +15,7 @@ from core import config, cache, classifier, llm from core.health import HealthWriter +from core.logrotate import RotatingLog from core.ratelimit import RateLimiter from adapters.base import Message @@ -51,8 +52,9 @@ def _init_log_file(data_dir): log_path = os.environ.get('COCONUT_LOG_FILE', '') if not log_path: log_path = os.path.join(data_dir, 'coconut.log') - os.makedirs(os.path.dirname(log_path) or '.', exist_ok=True) - _log_file = open(log_path, 'a') + max_bytes = int(os.environ.get('COCONUT_LOG_MAX_BYTES', 5 * 1024 * 1024)) + backups = int(os.environ.get('COCONUT_LOG_BACKUPS', 3)) + _log_file = RotatingLog(log_path, max_bytes=max_bytes, backups=backups) def _load_adapters(cfg): diff --git a/config/coconut.env.example b/config/coconut.env.example index e8003c8..c1e7939 100644 --- a/config/coconut.env.example +++ b/config/coconut.env.example @@ -44,6 +44,11 @@ COCONUT_RATE_LIMIT_ENABLED=true COCONUT_RATE_LIMIT_WINDOW=60 COCONUT_RATE_LIMIT_MAX=10 +# ── Logging ───────────────────────────────────────────────────── +# COCONUT_LOG_FILE="" # default: data/coconut.log +COCONUT_LOG_MAX_BYTES=5242880 # 5MB — rotate when exceeded +COCONUT_LOG_BACKUPS=3 # keep 3 rotated backups (.log.1, .log.2, .log.3) + # ── Persona ─────────────────────────────────────────────────────── COCONUT_SYSTEM_PROMPT_FILE="config/system-prompt.md" diff --git a/core/logrotate.py b/core/logrotate.py new file mode 100644 index 0000000..ed22b9c --- /dev/null +++ b/core/logrotate.py @@ -0,0 +1,80 @@ +"""Size-based log rotation — stdlib only. + +Rotates coconut.log when it exceeds a configurable max size. +Keeps N backup files (coconut.log.1, coconut.log.2, ...). + +Config (env vars): + COCONUT_LOG_MAX_BYTES — max log size before rotation (default 5MB) + COCONUT_LOG_BACKUPS — number of backup files to keep (default 3) +""" +import os + + +DEFAULT_MAX_BYTES = 5 * 1024 * 1024 # 5MB +DEFAULT_BACKUPS = 3 + + +class RotatingLog: + """File-like object that rotates on size threshold.""" + + def __init__(self, path, max_bytes=None, backups=None): + self.path = path + self.max_bytes = max_bytes or DEFAULT_MAX_BYTES + self.backups = backups if backups is not None else DEFAULT_BACKUPS + self._file = None + self._size = 0 + self._open() + + def _open(self): + """Open (or reopen) the log file for appending.""" + os.makedirs(os.path.dirname(self.path) or '.', exist_ok=True) + self._file = open(self.path, 'a') + try: + self._size = os.path.getsize(self.path) + except OSError: + self._size = 0 + + def write(self, data): + """Write data, rotating if size threshold exceeded.""" + if self._size + len(data) > self.max_bytes: + self._rotate() + self._file.write(data) + self._size += len(data) + + def flush(self): + if self._file: + self._file.flush() + + def close(self): + if self._file: + self._file.close() + self._file = None + + def _rotate(self): + """Rotate log files: .log -> .log.1 -> .log.2 -> ...""" + self.close() + + if self.backups <= 0: + # No backups — just truncate + if os.path.exists(self.path): + os.remove(self.path) + self._open() + return + + # Remove oldest backup if at limit + oldest = f'{self.path}.{self.backups}' + if os.path.exists(oldest): + os.remove(oldest) + + # Shift existing backups up by one + for i in range(self.backups - 1, 0, -1): + src = f'{self.path}.{i}' + dst = f'{self.path}.{i + 1}' + if os.path.exists(src): + os.replace(src, dst) + + # Current log becomes .1 + if os.path.exists(self.path): + os.replace(self.path, f'{self.path}.1') + + self._open() diff --git a/scripts/test/test-logrotate.sh b/scripts/test/test-logrotate.sh new file mode 100644 index 0000000..80b069c --- /dev/null +++ b/scripts/test/test-logrotate.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# T033: Log rotation tests +set -euo pipefail +cd "$(dirname "$0")/../.." + +PASS=0; FAIL=0 +pass() { PASS=$((PASS+1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +echo "=== Log Rotation Tests ===" + +# Test 1: RotatingLog creates file and writes +echo "Test 1: Basic write..." +python3 -c " +import os, tempfile, shutil +from core.logrotate import RotatingLog + +d = tempfile.mkdtemp() +try: + log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=1000, backups=2) + log.write('hello\n') + log.flush() + assert os.path.exists(os.path.join(d, 'test.log')) + with open(os.path.join(d, 'test.log')) as f: + assert f.read() == 'hello\n' + log.close() + print('OK') +finally: + shutil.rmtree(d) +" && pass "basic write" || fail "basic write" + +# Test 2: Rotation triggers at size threshold +echo "Test 2: Rotation triggers..." +python3 -c " +import os, tempfile, shutil +from core.logrotate import RotatingLog + +d = tempfile.mkdtemp() +try: + log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=100, backups=2) + # Write ~80 chars + log.write('A' * 80 + '\n') + log.flush() + + # Write another ~80 — should trigger rotation + log.write('B' * 80 + '\n') + log.flush() + assert os.path.exists(os.path.join(d, 'test.log.1')), 'test.log.1 not created' + # .1 should have the old content (A's) + with open(os.path.join(d, 'test.log.1')) as f: + assert 'A' * 80 in f.read() + # Current log should have new content (B's) + with open(os.path.join(d, 'test.log')) as f: + assert 'B' * 80 in f.read() + log.close() + print('OK') +finally: + shutil.rmtree(d) +" && pass "rotation triggers" || fail "rotation triggers" + +# Test 3: Backup limit respected +echo "Test 3: Backup limit..." +python3 -c " +import os, tempfile, shutil +from core.logrotate import RotatingLog + +d = tempfile.mkdtemp() +try: + log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=50, backups=2) + for i in range(5): + log.write(f'round-{i}-' + 'X' * 45 + '\n') + log.flush() + log.close() + + # Should have current + .1 + .2 only (backups=2) + assert os.path.exists(os.path.join(d, 'test.log')) + assert os.path.exists(os.path.join(d, 'test.log.1')) + assert os.path.exists(os.path.join(d, 'test.log.2')) + assert not os.path.exists(os.path.join(d, 'test.log.3')), '.log.3 should not exist' + print('OK') +finally: + shutil.rmtree(d) +" && pass "backup limit" || fail "backup limit" + +# Test 4: Zero backups = no rotation files kept +echo "Test 4: Zero backups..." +python3 -c " +import os, tempfile, shutil +from core.logrotate import RotatingLog + +d = tempfile.mkdtemp() +try: + log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=50, backups=0) + log.write('A' * 60 + '\n') + log.flush() + log.write('B' * 60 + '\n') + log.flush() + log.close() + + assert os.path.exists(os.path.join(d, 'test.log')) + assert not os.path.exists(os.path.join(d, 'test.log.1')), 'no backups should exist' + print('OK') +finally: + shutil.rmtree(d) +" && pass "zero backups" || fail "zero backups" + +# Test 5: Integration — coconut.py imports RotatingLog +echo "Test 5: Integration import..." +python3 -c " +import ast +with open('coconut.py') as f: + tree = ast.parse(f.read()) +imports = [n for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)] +found = any( + n.module == 'core.logrotate' and any(a.name == 'RotatingLog' for a in n.names) + for n in imports +) +assert found, 'coconut.py must import RotatingLog from core.logrotate' +print('OK') +" && pass "integration import" || fail "integration import" + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1