mlx-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mlx_stack/__init__.py +5 -0
- mlx_stack/_version.py +24 -0
- mlx_stack/cli/__init__.py +5 -0
- mlx_stack/cli/bench.py +221 -0
- mlx_stack/cli/config.py +166 -0
- mlx_stack/cli/down.py +109 -0
- mlx_stack/cli/init.py +180 -0
- mlx_stack/cli/install.py +165 -0
- mlx_stack/cli/logs.py +234 -0
- mlx_stack/cli/main.py +187 -0
- mlx_stack/cli/models.py +304 -0
- mlx_stack/cli/profile.py +65 -0
- mlx_stack/cli/pull.py +134 -0
- mlx_stack/cli/recommend.py +397 -0
- mlx_stack/cli/status.py +111 -0
- mlx_stack/cli/up.py +163 -0
- mlx_stack/cli/watch.py +252 -0
- mlx_stack/core/__init__.py +1 -0
- mlx_stack/core/benchmark.py +1182 -0
- mlx_stack/core/catalog.py +560 -0
- mlx_stack/core/config.py +471 -0
- mlx_stack/core/deps.py +323 -0
- mlx_stack/core/hardware.py +304 -0
- mlx_stack/core/launchd.py +531 -0
- mlx_stack/core/litellm_gen.py +188 -0
- mlx_stack/core/log_rotation.py +231 -0
- mlx_stack/core/log_viewer.py +386 -0
- mlx_stack/core/models.py +639 -0
- mlx_stack/core/paths.py +79 -0
- mlx_stack/core/process.py +887 -0
- mlx_stack/core/pull.py +815 -0
- mlx_stack/core/scoring.py +611 -0
- mlx_stack/core/stack_down.py +317 -0
- mlx_stack/core/stack_init.py +524 -0
- mlx_stack/core/stack_status.py +229 -0
- mlx_stack/core/stack_up.py +856 -0
- mlx_stack/core/watchdog.py +744 -0
- mlx_stack/data/__init__.py +1 -0
- mlx_stack/data/catalog/__init__.py +1 -0
- mlx_stack/data/catalog/deepseek-r1-32b.yaml +46 -0
- mlx_stack/data/catalog/deepseek-r1-8b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-12b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-27b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-4b.yaml +45 -0
- mlx_stack/data/catalog/llama3.3-8b.yaml +44 -0
- mlx_stack/data/catalog/nemotron-49b.yaml +41 -0
- mlx_stack/data/catalog/nemotron-8b.yaml +44 -0
- mlx_stack/data/catalog/qwen3-8b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-0.8b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-14b.yaml +46 -0
- mlx_stack/data/catalog/qwen3.5-32b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-3b.yaml +44 -0
- mlx_stack/data/catalog/qwen3.5-72b.yaml +42 -0
- mlx_stack/data/catalog/qwen3.5-8b.yaml +45 -0
- mlx_stack/py.typed +1 -0
- mlx_stack/utils/__init__.py +1 -0
- mlx_stack-0.1.0.dist-info/METADATA +397 -0
- mlx_stack-0.1.0.dist-info/RECORD +61 -0
- mlx_stack-0.1.0.dist-info/WHEEL +4 -0
- mlx_stack-0.1.0.dist-info/entry_points.txt +2 -0
- mlx_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Log rotation infrastructure for mlx-stack.
|
|
2
|
+
|
|
3
|
+
Implements copytruncate-based rotation for service log files in
|
|
4
|
+
~/.mlx-stack/logs/. The rotation strategy copies the log file to an
|
|
5
|
+
archive, compresses it with gzip, then truncates the original in-place
|
|
6
|
+
so that the writing process's file descriptor remains valid.
|
|
7
|
+
|
|
8
|
+
Archive naming uses sequential numbering:
|
|
9
|
+
<name>.log.1.gz = most recent rotation
|
|
10
|
+
<name>.log.2.gz = second most recent
|
|
11
|
+
...
|
|
12
|
+
<name>.log.N.gz = oldest retained rotation
|
|
13
|
+
|
|
14
|
+
Archives beyond max_files are deleted.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import gzip
|
|
20
|
+
import logging
|
|
21
|
+
import shutil
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LogRotationError(Exception):
|
|
28
|
+
"""Raised when a log rotation operation fails."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rotate_log(
|
|
32
|
+
log_path: Path,
|
|
33
|
+
max_size_mb: int = 50,
|
|
34
|
+
max_files: int = 5,
|
|
35
|
+
) -> bool:
|
|
36
|
+
"""Rotate a log file using the copytruncate strategy.
|
|
37
|
+
|
|
38
|
+
If the log file exceeds ``max_size_mb``, this function:
|
|
39
|
+
1. Shifts existing archives up by one number (.1.gz → .2.gz, etc.)
|
|
40
|
+
2. Copies the current log to ``<name>.log.1.gz`` (gzip compressed)
|
|
41
|
+
3. Truncates the original log file in-place (preserving the FD)
|
|
42
|
+
4. Deletes archives numbered higher than ``max_files``
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
log_path: Path to the log file to rotate.
|
|
46
|
+
max_size_mb: Maximum log file size in megabytes before rotation.
|
|
47
|
+
max_files: Maximum number of rotated archive files to retain.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if rotation was performed, False if skipped (file missing,
|
|
51
|
+
empty, or below threshold).
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
LogRotationError: If a permission error or I/O error prevents
|
|
55
|
+
rotation.
|
|
56
|
+
"""
|
|
57
|
+
# Edge case: missing file — silently skip
|
|
58
|
+
if not log_path.exists():
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
# Edge case: empty file — skip
|
|
62
|
+
try:
|
|
63
|
+
file_size = log_path.stat().st_size
|
|
64
|
+
except OSError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
if file_size == 0:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Edge case: below threshold — skip
|
|
71
|
+
threshold_bytes = max_size_mb * 1024 * 1024
|
|
72
|
+
if file_size < threshold_bytes:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
_perform_rotation(log_path, max_files)
|
|
77
|
+
except PermissionError as exc:
|
|
78
|
+
msg = f"Permission denied during log rotation of {log_path}: {exc}"
|
|
79
|
+
raise LogRotationError(msg) from None
|
|
80
|
+
except OSError as exc:
|
|
81
|
+
msg = f"I/O error during log rotation of {log_path}: {exc}"
|
|
82
|
+
raise LogRotationError(msg) from None
|
|
83
|
+
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _perform_rotation(log_path: Path, max_files: int) -> None:
|
|
88
|
+
"""Execute the actual rotation steps.
|
|
89
|
+
|
|
90
|
+
This is separated from ``rotate_log`` to keep the edge-case
|
|
91
|
+
handling clean.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
log_path: Path to the log file.
|
|
95
|
+
max_files: Maximum number of archive files to keep.
|
|
96
|
+
"""
|
|
97
|
+
base = log_path.parent
|
|
98
|
+
stem = log_path.name # e.g. "fast.log"
|
|
99
|
+
|
|
100
|
+
# Step 1: Delete archives beyond max_files (they'll shift up by 1)
|
|
101
|
+
# After shifting, the old .max_files.gz becomes .(max_files+1).gz
|
|
102
|
+
# so we need to delete .max_files.gz before shifting
|
|
103
|
+
_delete_excess_archives(base, stem, max_files)
|
|
104
|
+
|
|
105
|
+
# Step 2: Shift existing archives up by one number
|
|
106
|
+
_shift_archives(base, stem, max_files)
|
|
107
|
+
|
|
108
|
+
# Step 3: Copy current log to temporary file, compress to .1.gz
|
|
109
|
+
archive_path = base / f"{stem}.1.gz"
|
|
110
|
+
_copy_and_compress(log_path, archive_path)
|
|
111
|
+
|
|
112
|
+
# Step 4: Truncate the original log file in-place
|
|
113
|
+
_truncate_file(log_path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _archive_path_for(base: Path, stem: str, number: int) -> Path:
|
|
117
|
+
"""Return the path for archive number N.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
base: Parent directory.
|
|
121
|
+
stem: Log file name (e.g. "fast.log").
|
|
122
|
+
number: Archive number (1 = most recent).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Path like ``base/fast.log.1.gz``.
|
|
126
|
+
"""
|
|
127
|
+
return base / f"{stem}.{number}.gz"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _delete_excess_archives(base: Path, stem: str, max_files: int) -> None:
|
|
131
|
+
"""Delete archives numbered higher than max_files.
|
|
132
|
+
|
|
133
|
+
After the upcoming shift, what is currently archive N will become
|
|
134
|
+
archive N+1. So we need to remove the current max_files archive
|
|
135
|
+
(which would become max_files+1 after shifting) and any existing
|
|
136
|
+
archives beyond max_files.
|
|
137
|
+
|
|
138
|
+
Scans the logs directory for ALL matching archive files using a glob
|
|
139
|
+
pattern (``<stem>.*.gz``) instead of stopping at the first missing
|
|
140
|
+
sequential index. Archives are sorted by modification time; the
|
|
141
|
+
oldest are deleted until the count is at or below ``max_files - 1``
|
|
142
|
+
(leaving room for the new rotation).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
base: Parent directory.
|
|
146
|
+
stem: Log file name.
|
|
147
|
+
max_files: Maximum archives to keep.
|
|
148
|
+
"""
|
|
149
|
+
import re
|
|
150
|
+
|
|
151
|
+
pattern = re.compile(rf"^{re.escape(stem)}\.(\d+)\.gz$")
|
|
152
|
+
archives: list[Path] = []
|
|
153
|
+
|
|
154
|
+
for path in base.iterdir():
|
|
155
|
+
if pattern.match(path.name) and path.is_file():
|
|
156
|
+
archives.append(path)
|
|
157
|
+
|
|
158
|
+
# We need room for the new archive that will become .1.gz after
|
|
159
|
+
# shifting, so keep at most max_files - 1 existing archives.
|
|
160
|
+
if len(archives) < max_files:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Sort by modification time, oldest first
|
|
164
|
+
archives.sort(key=lambda p: p.stat().st_mtime)
|
|
165
|
+
|
|
166
|
+
# Delete oldest until we have at most max_files - 1
|
|
167
|
+
excess = len(archives) - (max_files - 1)
|
|
168
|
+
for path in archives[:excess]:
|
|
169
|
+
path.unlink()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _shift_archives(base: Path, stem: str, max_files: int) -> None:
|
|
173
|
+
"""Shift existing archives up by one number.
|
|
174
|
+
|
|
175
|
+
Moves .N.gz → .(N+1).gz, starting from the highest existing
|
|
176
|
+
number down to 1, to avoid overwriting.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
base: Parent directory.
|
|
180
|
+
stem: Log file name.
|
|
181
|
+
max_files: Maximum archives (used as upper bound for search).
|
|
182
|
+
"""
|
|
183
|
+
# Find the highest existing archive number (up to max_files - 1,
|
|
184
|
+
# since max_files was already deleted)
|
|
185
|
+
highest = 0
|
|
186
|
+
for i in range(1, max_files):
|
|
187
|
+
if _archive_path_for(base, stem, i).exists():
|
|
188
|
+
highest = i
|
|
189
|
+
|
|
190
|
+
# Shift from highest down to 1
|
|
191
|
+
for i in range(highest, 0, -1):
|
|
192
|
+
src = _archive_path_for(base, stem, i)
|
|
193
|
+
dst = _archive_path_for(base, stem, i + 1)
|
|
194
|
+
if src.exists():
|
|
195
|
+
src.rename(dst)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _copy_and_compress(src: Path, dst_gz: Path) -> None:
|
|
199
|
+
"""Copy a file and compress it to a gzip archive.
|
|
200
|
+
|
|
201
|
+
Uses shutil.copy2 to copy to a temporary file, then compresses
|
|
202
|
+
the temporary file to the target gzip path.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
src: Source log file path.
|
|
206
|
+
dst_gz: Destination gzip archive path.
|
|
207
|
+
"""
|
|
208
|
+
# Create a temporary uncompressed copy
|
|
209
|
+
tmp_path = dst_gz.parent / f"{dst_gz.name}.tmp"
|
|
210
|
+
try:
|
|
211
|
+
shutil.copy2(str(src), str(tmp_path))
|
|
212
|
+
|
|
213
|
+
# Compress the copy
|
|
214
|
+
with open(tmp_path, "rb") as f_in:
|
|
215
|
+
with gzip.open(str(dst_gz), "wb") as f_out:
|
|
216
|
+
shutil.copyfileobj(f_in, f_out)
|
|
217
|
+
finally:
|
|
218
|
+
# Clean up temporary file
|
|
219
|
+
if tmp_path.exists():
|
|
220
|
+
tmp_path.unlink()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _truncate_file(path: Path) -> None:
|
|
224
|
+
"""Truncate a file in-place, preserving the path.
|
|
225
|
+
|
|
226
|
+
Uses ``open(path, "w").close()`` to truncate to zero bytes.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
path: Path to the file to truncate.
|
|
230
|
+
"""
|
|
231
|
+
open(path, "w").close() # noqa: SIM115
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Log viewing and management module for mlx-stack.
|
|
2
|
+
|
|
3
|
+
Provides core functionality for viewing, following, listing, and rotating
|
|
4
|
+
service log files in ~/.mlx-stack/logs/. Supports tail-style viewing,
|
|
5
|
+
real-time following with truncation detection, log listing with metadata,
|
|
6
|
+
on-demand rotation, and archived log viewing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mlx_stack.core.config import get_value
|
|
20
|
+
from mlx_stack.core.log_rotation import LogRotationError, rotate_log
|
|
21
|
+
from mlx_stack.core.paths import get_logs_dir
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
# Constants
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
|
|
27
|
+
# Polling interval for --follow mode (seconds)
|
|
28
|
+
FOLLOW_POLL_INTERVAL = 0.5
|
|
29
|
+
|
|
30
|
+
# Default number of lines for --tail
|
|
31
|
+
DEFAULT_TAIL_LINES = 50
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
# Data classes
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LogFileInfo:
|
|
40
|
+
"""Metadata about a log file."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
service: str
|
|
44
|
+
size_bytes: int
|
|
45
|
+
modified: datetime
|
|
46
|
+
is_archive: bool = False
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def size_display(self) -> str:
|
|
50
|
+
"""Return human-readable file size."""
|
|
51
|
+
if self.size_bytes < 1024:
|
|
52
|
+
return f"{self.size_bytes} B"
|
|
53
|
+
elif self.size_bytes < 1024 * 1024:
|
|
54
|
+
return f"{self.size_bytes / 1024:.1f} KB"
|
|
55
|
+
elif self.size_bytes < 1024 * 1024 * 1024:
|
|
56
|
+
return f"{self.size_bytes / (1024 * 1024):.1f} MB"
|
|
57
|
+
else:
|
|
58
|
+
return f"{self.size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def modified_display(self) -> str:
|
|
62
|
+
"""Return human-readable modification time."""
|
|
63
|
+
return self.modified.strftime("%Y-%m-%d %H:%M:%S")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RotationResult:
|
|
68
|
+
"""Result of a rotation operation."""
|
|
69
|
+
|
|
70
|
+
service: str
|
|
71
|
+
rotated: bool
|
|
72
|
+
error: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --------------------------------------------------------------------------- #
|
|
76
|
+
# Log listing
|
|
77
|
+
# --------------------------------------------------------------------------- #
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_log_files() -> list[LogFileInfo]:
|
|
81
|
+
"""List all log files in the logs directory.
|
|
82
|
+
|
|
83
|
+
Returns current log files (*.log) sorted alphabetically. Does not
|
|
84
|
+
include archived .gz files.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of LogFileInfo objects for each log file found.
|
|
88
|
+
"""
|
|
89
|
+
logs_dir = get_logs_dir()
|
|
90
|
+
if not logs_dir.exists():
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
results: list[LogFileInfo] = []
|
|
94
|
+
for path in sorted(logs_dir.iterdir()):
|
|
95
|
+
if path.suffix == ".log" and path.is_file():
|
|
96
|
+
# Extract service name: "fast.log" -> "fast"
|
|
97
|
+
service = path.stem
|
|
98
|
+
try:
|
|
99
|
+
stat = path.stat()
|
|
100
|
+
info = LogFileInfo(
|
|
101
|
+
name=path.name,
|
|
102
|
+
service=service,
|
|
103
|
+
size_bytes=stat.st_size,
|
|
104
|
+
modified=datetime.fromtimestamp(
|
|
105
|
+
stat.st_mtime, tz=timezone.utc
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
results.append(info)
|
|
109
|
+
except OSError:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
return results
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_log_path(service: str) -> Path | None:
|
|
116
|
+
"""Get the path to a service's log file.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
service: The service name (e.g. "fast", "litellm").
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Path to the log file, or None if it doesn't exist.
|
|
123
|
+
"""
|
|
124
|
+
logs_dir = get_logs_dir()
|
|
125
|
+
log_path = logs_dir / f"{service}.log"
|
|
126
|
+
if log_path.exists() and log_path.is_file():
|
|
127
|
+
return log_path
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_available_services() -> list[str]:
|
|
132
|
+
"""Get list of services that have log files.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Sorted list of service names with existing log files.
|
|
136
|
+
"""
|
|
137
|
+
return [info.service for info in list_log_files()]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
# Log viewing
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_log_tail(log_path: Path, num_lines: int = DEFAULT_TAIL_LINES) -> str:
|
|
146
|
+
"""Read the last N lines from a log file.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
log_path: Path to the log file.
|
|
150
|
+
num_lines: Number of lines to return from the end.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
String containing the last N lines of the file.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
content = log_path.read_text(encoding="utf-8", errors="replace")
|
|
157
|
+
except OSError:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
if not content:
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
lines = content.splitlines()
|
|
164
|
+
tail_lines = lines[-num_lines:] if len(lines) > num_lines else lines
|
|
165
|
+
return "\n".join(tail_lines)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_log_full(log_path: Path) -> str:
|
|
169
|
+
"""Read the full content of a log file.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
log_path: Path to the log file.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Full file content as a string.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
return log_path.read_text(encoding="utf-8", errors="replace")
|
|
179
|
+
except OSError:
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------- #
|
|
184
|
+
# Log following
|
|
185
|
+
# --------------------------------------------------------------------------- #
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def follow_log(
|
|
189
|
+
log_path: Path,
|
|
190
|
+
num_lines: int = DEFAULT_TAIL_LINES,
|
|
191
|
+
output_callback: Callable[[str], None] | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Follow a log file, printing new content as it appears.
|
|
194
|
+
|
|
195
|
+
Implements tail -f behavior using polling. Checks file size every
|
|
196
|
+
FOLLOW_POLL_INTERVAL seconds, reads new content when size increases.
|
|
197
|
+
Detects file truncation (size decrease) and resets read position.
|
|
198
|
+
|
|
199
|
+
This function blocks until interrupted. Ctrl-C is handled cleanly
|
|
200
|
+
(no traceback, exit 0).
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
log_path: Path to the log file to follow.
|
|
204
|
+
num_lines: Number of initial lines to show.
|
|
205
|
+
output_callback: Optional callable for writing output. If None,
|
|
206
|
+
uses print(). Signature: callback(text: str) -> None.
|
|
207
|
+
"""
|
|
208
|
+
write = output_callback or print
|
|
209
|
+
|
|
210
|
+
# Show initial tail
|
|
211
|
+
initial = read_log_tail(log_path, num_lines)
|
|
212
|
+
if initial:
|
|
213
|
+
write(initial)
|
|
214
|
+
|
|
215
|
+
# Start following from current end of file
|
|
216
|
+
try:
|
|
217
|
+
position = log_path.stat().st_size
|
|
218
|
+
except OSError:
|
|
219
|
+
position = 0
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
while True:
|
|
223
|
+
time.sleep(FOLLOW_POLL_INTERVAL)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
current_size = log_path.stat().st_size
|
|
227
|
+
except OSError:
|
|
228
|
+
# File may have been removed temporarily
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# Detect truncation (copytruncate rotation)
|
|
232
|
+
if current_size < position:
|
|
233
|
+
position = 0
|
|
234
|
+
|
|
235
|
+
if current_size > position:
|
|
236
|
+
try:
|
|
237
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
238
|
+
f.seek(position)
|
|
239
|
+
new_content = f.read()
|
|
240
|
+
if new_content:
|
|
241
|
+
# Strip trailing newline to avoid double-spacing
|
|
242
|
+
text = new_content.rstrip("\n")
|
|
243
|
+
if text:
|
|
244
|
+
write(text)
|
|
245
|
+
position = f.tell()
|
|
246
|
+
except OSError:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
except KeyboardInterrupt:
|
|
250
|
+
# Clean exit on Ctrl-C — no traceback
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --------------------------------------------------------------------------- #
|
|
255
|
+
# Archived log viewing
|
|
256
|
+
# --------------------------------------------------------------------------- #
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _get_archives_for_service(service: str) -> list[Path]:
|
|
260
|
+
"""Get archived log files for a service, sorted oldest first.
|
|
261
|
+
|
|
262
|
+
Archives are named like ``<service>.log.N.gz`` where N=1 is the most
|
|
263
|
+
recent. We return them sorted with the highest N first (oldest first)
|
|
264
|
+
for chronological display.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
service: The service name.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of archive paths, sorted oldest first.
|
|
271
|
+
"""
|
|
272
|
+
logs_dir = get_logs_dir()
|
|
273
|
+
if not logs_dir.exists():
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
pattern = re.compile(rf"^{re.escape(service)}\.log\.(\d+)\.gz$")
|
|
277
|
+
archives: list[tuple[int, Path]] = []
|
|
278
|
+
|
|
279
|
+
for path in logs_dir.iterdir():
|
|
280
|
+
match = pattern.match(path.name)
|
|
281
|
+
if match and path.is_file():
|
|
282
|
+
num = int(match.group(1))
|
|
283
|
+
archives.append((num, path))
|
|
284
|
+
|
|
285
|
+
# Sort by number descending (highest number = oldest)
|
|
286
|
+
archives.sort(key=lambda x: x[0], reverse=True)
|
|
287
|
+
return [path for _, path in archives]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def read_archive(archive_path: Path) -> str:
|
|
291
|
+
"""Read and decompress a gzip archive.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
archive_path: Path to the .gz archive file.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Decompressed content as a string.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
with gzip.open(str(archive_path), "rb") as f:
|
|
301
|
+
return f.read().decode("utf-8", errors="replace")
|
|
302
|
+
except (OSError, gzip.BadGzipFile):
|
|
303
|
+
return f"[Error reading archive: {archive_path.name}]\n"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def read_all_logs(service: str) -> str:
|
|
307
|
+
"""Read all logs for a service: archives + current, chronologically.
|
|
308
|
+
|
|
309
|
+
Archives are shown oldest first, then the current log file.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
service: The service name.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Combined content from all archives and the current log.
|
|
316
|
+
"""
|
|
317
|
+
parts: list[str] = []
|
|
318
|
+
|
|
319
|
+
# Read archives oldest first
|
|
320
|
+
archives = _get_archives_for_service(service)
|
|
321
|
+
for archive_path in archives:
|
|
322
|
+
content = read_archive(archive_path)
|
|
323
|
+
if content:
|
|
324
|
+
parts.append(f"--- Archive: {archive_path.name} ---")
|
|
325
|
+
parts.append(content.rstrip("\n"))
|
|
326
|
+
|
|
327
|
+
# Read current log
|
|
328
|
+
log_path = get_log_path(service)
|
|
329
|
+
if log_path is not None:
|
|
330
|
+
current = read_log_full(log_path)
|
|
331
|
+
if current:
|
|
332
|
+
parts.append(f"--- Current: {log_path.name} ---")
|
|
333
|
+
parts.append(current.rstrip("\n"))
|
|
334
|
+
|
|
335
|
+
return "\n".join(parts) if parts else ""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --------------------------------------------------------------------------- #
|
|
339
|
+
# On-demand rotation
|
|
340
|
+
# --------------------------------------------------------------------------- #
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def rotate_service_log(service: str) -> RotationResult:
|
|
344
|
+
"""Rotate a single service's log file.
|
|
345
|
+
|
|
346
|
+
Uses configured max_size_mb and max_files from the config module.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
service: The service name.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
RotationResult indicating whether rotation was performed.
|
|
353
|
+
"""
|
|
354
|
+
log_path = get_log_path(service)
|
|
355
|
+
if log_path is None:
|
|
356
|
+
return RotationResult(
|
|
357
|
+
service=service,
|
|
358
|
+
rotated=False,
|
|
359
|
+
error=f"No log file found for service '{service}'",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
max_size_mb = get_value("log-max-size-mb")
|
|
363
|
+
max_files = get_value("log-max-files")
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
rotated = rotate_log(log_path, max_size_mb=max_size_mb, max_files=max_files)
|
|
367
|
+
return RotationResult(service=service, rotated=rotated)
|
|
368
|
+
except LogRotationError as exc:
|
|
369
|
+
return RotationResult(service=service, rotated=False, error=str(exc))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def rotate_all_logs() -> list[RotationResult]:
|
|
373
|
+
"""Rotate all eligible service log files.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
List of RotationResult objects, one per service.
|
|
377
|
+
"""
|
|
378
|
+
services = get_available_services()
|
|
379
|
+
if not services:
|
|
380
|
+
return []
|
|
381
|
+
|
|
382
|
+
results: list[RotationResult] = []
|
|
383
|
+
for service in services:
|
|
384
|
+
result = rotate_service_log(service)
|
|
385
|
+
results.append(result)
|
|
386
|
+
return results
|