logloglog 0.0.1__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.
- logloglog/__init__.py +40 -0
- logloglog/__main__.py +8 -0
- logloglog/cache.py +85 -0
- logloglog/line_index.py +274 -0
- logloglog/log_file.py +187 -0
- logloglog/logloglog.py +647 -0
- logloglog/tools/__init__.py +1 -0
- logloglog/tools/stream_logs.py +317 -0
- logloglog/ui/__init__.py +0 -0
- logloglog/ui/textual/__init__.py +3 -0
- logloglog/ui/textual/log_widget.py +298 -0
- logloglog/widthview.py +98 -0
- logloglog-0.0.1.dist-info/METADATA +73 -0
- logloglog-0.0.1.dist-info/RECORD +17 -0
- logloglog-0.0.1.dist-info/WHEEL +4 -0
- logloglog-0.0.1.dist-info/entry_points.txt +3 -0
- logloglog-0.0.1.dist-info/licenses/LICENSE.md +7 -0
logloglog/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""LogLogLog - Efficient scrollback indexing for large log files."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from .logloglog import LogLogLog
|
|
9
|
+
from .widthview import WidthView
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("logloglog")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
# Package is not installed
|
|
15
|
+
__version__ = "0.0.0+dev"
|
|
16
|
+
|
|
17
|
+
__all__ = ["LogLogLog", "WidthView", "configure_logging"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Configure logging for LogLogLog
|
|
21
|
+
def configure_logging(level=logging.INFO):
|
|
22
|
+
"""Configure logging for LogLogLog."""
|
|
23
|
+
# Create formatter
|
|
24
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
25
|
+
|
|
26
|
+
# Create console handler
|
|
27
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
28
|
+
handler.setFormatter(formatter)
|
|
29
|
+
|
|
30
|
+
# Configure logloglog loggers
|
|
31
|
+
for logger_name in ["logloglog.logloglog", "logloglog.wraptree", "logloglog.index"]:
|
|
32
|
+
logger = logging.getLogger(logger_name)
|
|
33
|
+
logger.setLevel(level)
|
|
34
|
+
# Remove existing handlers to avoid duplicates
|
|
35
|
+
logger.handlers.clear()
|
|
36
|
+
logger.addHandler(handler)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Auto-configure with DEBUG level for performance monitoring
|
|
40
|
+
configure_logging(logging.DEBUG)
|
logloglog/__main__.py
ADDED
logloglog/cache.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Cache management for LogLogLog."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import platformdirs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Cache constants
|
|
12
|
+
CACHE_DIR = Path(platformdirs.user_cache_dir("logloglog"))
|
|
13
|
+
TMP_DIR = Path(tempfile.gettempdir()) / "logloglog"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Cache:
|
|
17
|
+
"""Manages cache directories for log files."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cache_dir: Path = None):
|
|
20
|
+
"""Initialize cache manager.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
cache_dir: Cache directory (defaults to CACHE_DIR)
|
|
24
|
+
"""
|
|
25
|
+
self.cache_dir = cache_dir or CACHE_DIR
|
|
26
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def get_dir(self, path: Path) -> Path:
|
|
29
|
+
"""Get cache directory for a log file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Path to the log file
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Cache directory path
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
OSError: If file cannot be accessed or cache directory cannot be created
|
|
39
|
+
"""
|
|
40
|
+
# Get file stats for unique identification
|
|
41
|
+
stat = os.stat(path)
|
|
42
|
+
# Create hash from device and inode only (removed ctime for stability)
|
|
43
|
+
hash_input = f"{stat.st_dev}_{stat.st_ino}"
|
|
44
|
+
hash_digest = hashlib.md5(hash_input.encode()).hexdigest()[:8]
|
|
45
|
+
|
|
46
|
+
# Create cache directory name
|
|
47
|
+
name = path.name
|
|
48
|
+
cache_name = f"{name}[{hash_digest}]"
|
|
49
|
+
cache_path = self.cache_dir / cache_name
|
|
50
|
+
|
|
51
|
+
# Create directory if it doesn't exist
|
|
52
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
# Create symlink to original file for reference
|
|
55
|
+
symlink_path = cache_path / "file"
|
|
56
|
+
if not symlink_path.exists():
|
|
57
|
+
symlink_path.symlink_to(path.resolve())
|
|
58
|
+
|
|
59
|
+
return cache_path
|
|
60
|
+
|
|
61
|
+
def cleanup(self):
|
|
62
|
+
"""Clean up cache directories for files that no longer exist."""
|
|
63
|
+
if not self.cache_dir.exists():
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
for cache_subdir in self.cache_dir.iterdir():
|
|
67
|
+
if not cache_subdir.is_dir():
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# Check if the symlink exists and points to a valid file
|
|
71
|
+
symlink_path = cache_subdir / "file"
|
|
72
|
+
if symlink_path.exists():
|
|
73
|
+
try:
|
|
74
|
+
# Try to resolve the symlink
|
|
75
|
+
target = symlink_path.resolve()
|
|
76
|
+
if not target.exists():
|
|
77
|
+
# Original file is gone, remove cache directory
|
|
78
|
+
shutil.rmtree(cache_subdir)
|
|
79
|
+
except (OSError, FileNotFoundError):
|
|
80
|
+
# Symlink is broken, remove cache directory
|
|
81
|
+
shutil.rmtree(cache_subdir)
|
|
82
|
+
else:
|
|
83
|
+
# No symlink found, directory is orphaned
|
|
84
|
+
shutil.rmtree(cache_subdir)
|
|
85
|
+
# TODO: Add inode-based cleanup for more robust file tracking
|
logloglog/line_index.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Simple line indexing with periodic summaries for efficient wrapping calculations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
from arrayfile import Array
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Configuration
|
|
11
|
+
MAX_WIDTH = 512 # Maximum terminal width we support
|
|
12
|
+
SUMMARY_INTERVAL = 1000 # Store summary every N lines
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LineIndex:
|
|
16
|
+
"""
|
|
17
|
+
Indexes log lines with byte positions, widths, and periodic summaries.
|
|
18
|
+
|
|
19
|
+
Stores:
|
|
20
|
+
- line_positions: byte offset of each line in the log file
|
|
21
|
+
- line_widths: display width of each line
|
|
22
|
+
- summaries: every 1000 lines, cumulative display rows for each width 1-512
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, index_path: Path):
|
|
26
|
+
"""Initialize line index with given path."""
|
|
27
|
+
self.index_path = index_path
|
|
28
|
+
self._line_positions = None
|
|
29
|
+
self._line_widths = None
|
|
30
|
+
self._summaries = None
|
|
31
|
+
self._line_count = 0
|
|
32
|
+
self._current_block_width_counts = {} # Track widths in current 1000-line block
|
|
33
|
+
self._pending_positions = [] # Batch positions for extend()
|
|
34
|
+
self._pending_widths = [] # Batch widths for extend()
|
|
35
|
+
|
|
36
|
+
def open(self, create: bool = False):
|
|
37
|
+
"""Open index files."""
|
|
38
|
+
self.index_path.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
mode = "w+b" if create else "r+b"
|
|
41
|
+
|
|
42
|
+
# Line positions (uint64 for file offsets)
|
|
43
|
+
self._line_positions = Array("Q", str(self.index_path / "positions.dat"), mode)
|
|
44
|
+
|
|
45
|
+
# Line widths (uint16, capped at 65535)
|
|
46
|
+
self._line_widths = Array("H", str(self.index_path / "widths.dat"), mode)
|
|
47
|
+
|
|
48
|
+
# Summaries (uint32 array, MAX_WIDTH entries per summary)
|
|
49
|
+
self._summaries = Array("I", str(self.index_path / "summaries.dat"), mode)
|
|
50
|
+
|
|
51
|
+
# Count existing lines
|
|
52
|
+
self._line_count = len(self._line_positions)
|
|
53
|
+
|
|
54
|
+
def close(self):
|
|
55
|
+
"""Close all index files."""
|
|
56
|
+
# Flush any pending data before closing
|
|
57
|
+
self._flush_pending()
|
|
58
|
+
|
|
59
|
+
if self._line_positions:
|
|
60
|
+
self._line_positions.close()
|
|
61
|
+
self._line_positions = None
|
|
62
|
+
if self._line_widths:
|
|
63
|
+
self._line_widths.close()
|
|
64
|
+
self._line_widths = None
|
|
65
|
+
if self._summaries:
|
|
66
|
+
self._summaries.close()
|
|
67
|
+
self._summaries = None
|
|
68
|
+
|
|
69
|
+
def append_line(self, position: int, width: int):
|
|
70
|
+
"""
|
|
71
|
+
Append a new line to the index.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
position: Byte offset of line start in log file
|
|
75
|
+
width: Display width of the line
|
|
76
|
+
"""
|
|
77
|
+
# Cap width at uint16 max
|
|
78
|
+
width = min(width, 65535)
|
|
79
|
+
|
|
80
|
+
# Batch in memory
|
|
81
|
+
self._pending_positions.append(position)
|
|
82
|
+
self._pending_widths.append(width)
|
|
83
|
+
self._line_count += 1
|
|
84
|
+
|
|
85
|
+
# Track width for current block
|
|
86
|
+
self._current_block_width_counts[width] = self._current_block_width_counts.get(width, 0) + 1
|
|
87
|
+
|
|
88
|
+
# Check if we need to flush and store a summary
|
|
89
|
+
if self._line_count % SUMMARY_INTERVAL == 0:
|
|
90
|
+
self._flush_pending()
|
|
91
|
+
self._store_summary()
|
|
92
|
+
self._current_block_width_counts.clear()
|
|
93
|
+
|
|
94
|
+
def _flush_pending(self):
|
|
95
|
+
"""Flush pending positions and widths to disk."""
|
|
96
|
+
if self._pending_positions:
|
|
97
|
+
self._line_positions.extend(self._pending_positions)
|
|
98
|
+
self._pending_positions.clear()
|
|
99
|
+
if self._pending_widths:
|
|
100
|
+
self._line_widths.extend(self._pending_widths)
|
|
101
|
+
self._pending_widths.clear()
|
|
102
|
+
|
|
103
|
+
def _store_summary(self):
|
|
104
|
+
"""Store summary using already-tracked width counts."""
|
|
105
|
+
# Calculate totals for each terminal width
|
|
106
|
+
width_totals = [0] * MAX_WIDTH
|
|
107
|
+
for line_width, count in self._current_block_width_counts.items():
|
|
108
|
+
if line_width == 0:
|
|
109
|
+
# Empty lines always take 1 row regardless of terminal width
|
|
110
|
+
for i in range(MAX_WIDTH):
|
|
111
|
+
width_totals[i] += count
|
|
112
|
+
else:
|
|
113
|
+
# Calculate rows for each terminal width
|
|
114
|
+
# Ceiling division: (line_width + term_width - 1) // term_width
|
|
115
|
+
# This is always >= 1 when both operands are positive
|
|
116
|
+
for term_width in range(1, MAX_WIDTH + 1):
|
|
117
|
+
rows = (line_width + term_width - 1) // term_width
|
|
118
|
+
width_totals[term_width - 1] += rows * count
|
|
119
|
+
|
|
120
|
+
# Store all width totals in summary array (batch append for performance)
|
|
121
|
+
self._summaries.extend(width_totals)
|
|
122
|
+
|
|
123
|
+
def get_line_position(self, line_no: int) -> int:
|
|
124
|
+
"""Get byte position of a line."""
|
|
125
|
+
if line_no < 0 or line_no >= self._line_count:
|
|
126
|
+
raise IndexError(f"Line {line_no} out of range")
|
|
127
|
+
|
|
128
|
+
# Check if it's in the flushed data or pending batch
|
|
129
|
+
flushed_count = len(self._line_positions)
|
|
130
|
+
if line_no < flushed_count:
|
|
131
|
+
return self._line_positions[line_no]
|
|
132
|
+
else:
|
|
133
|
+
# It's in the pending batch
|
|
134
|
+
pending_idx = line_no - flushed_count
|
|
135
|
+
return self._pending_positions[pending_idx]
|
|
136
|
+
|
|
137
|
+
def get_line_width(self, line_no: int) -> int:
|
|
138
|
+
"""Get display width of a line."""
|
|
139
|
+
if line_no < 0 or line_no >= self._line_count:
|
|
140
|
+
raise IndexError(f"Line {line_no} out of range")
|
|
141
|
+
|
|
142
|
+
# Check if it's in the flushed data or pending batch
|
|
143
|
+
flushed_count = len(self._line_widths)
|
|
144
|
+
if line_no < flushed_count:
|
|
145
|
+
return self._line_widths[line_no]
|
|
146
|
+
else:
|
|
147
|
+
# It's in the pending batch
|
|
148
|
+
pending_idx = line_no - flushed_count
|
|
149
|
+
return self._pending_widths[pending_idx]
|
|
150
|
+
|
|
151
|
+
def get_total_display_rows(self, width: int) -> int:
|
|
152
|
+
"""
|
|
153
|
+
Get total display rows for all lines at given terminal width.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
width: Terminal width
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Total number of display rows
|
|
160
|
+
"""
|
|
161
|
+
if width <= 0:
|
|
162
|
+
return 0 # No display possible with zero or negative width
|
|
163
|
+
if width > MAX_WIDTH:
|
|
164
|
+
width = MAX_WIDTH
|
|
165
|
+
|
|
166
|
+
total_rows = 0
|
|
167
|
+
|
|
168
|
+
# Add up complete summaries
|
|
169
|
+
complete_summaries = self._line_count // SUMMARY_INTERVAL
|
|
170
|
+
for i in range(complete_summaries):
|
|
171
|
+
summary_offset = i * MAX_WIDTH + (width - 1)
|
|
172
|
+
total_rows += self._summaries[summary_offset]
|
|
173
|
+
|
|
174
|
+
# Add remaining lines not in a summary
|
|
175
|
+
start_line = complete_summaries * SUMMARY_INTERVAL
|
|
176
|
+
for line_idx in range(start_line, self._line_count):
|
|
177
|
+
line_width = self.get_line_width(line_idx)
|
|
178
|
+
# Ceiling division is always >= 1 for positive operands
|
|
179
|
+
rows = (line_width + width - 1) // width if width > 0 and line_width > 0 else 1
|
|
180
|
+
total_rows += rows
|
|
181
|
+
|
|
182
|
+
return total_rows
|
|
183
|
+
|
|
184
|
+
def get_display_row_for_line(self, line_no: int, width: int) -> int:
|
|
185
|
+
"""
|
|
186
|
+
Get the display row number where a logical line starts.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
line_no: Logical line number
|
|
190
|
+
width: Terminal width
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Display row number where this line starts
|
|
194
|
+
"""
|
|
195
|
+
if line_no < 0 or line_no >= self._line_count:
|
|
196
|
+
raise IndexError(f"Line {line_no} out of range")
|
|
197
|
+
|
|
198
|
+
if width <= 0:
|
|
199
|
+
return 0 # No display possible
|
|
200
|
+
if width > MAX_WIDTH:
|
|
201
|
+
width = MAX_WIDTH
|
|
202
|
+
|
|
203
|
+
display_row = 0
|
|
204
|
+
|
|
205
|
+
# Add complete summaries before this line
|
|
206
|
+
summary_idx = line_no // SUMMARY_INTERVAL
|
|
207
|
+
for i in range(summary_idx):
|
|
208
|
+
summary_offset = i * MAX_WIDTH + (width - 1)
|
|
209
|
+
display_row += self._summaries[summary_offset]
|
|
210
|
+
|
|
211
|
+
# Add individual lines from last summary to target line
|
|
212
|
+
start_line = summary_idx * SUMMARY_INTERVAL
|
|
213
|
+
for line_idx in range(start_line, line_no):
|
|
214
|
+
line_width = self.get_line_width(line_idx)
|
|
215
|
+
rows = (line_width + width - 1) // width if width > 0 and line_width > 0 else 1
|
|
216
|
+
display_row += rows
|
|
217
|
+
|
|
218
|
+
return display_row
|
|
219
|
+
|
|
220
|
+
def get_line_for_display_row(self, display_row: int, width: int) -> Tuple[int, int]:
|
|
221
|
+
"""
|
|
222
|
+
Find the logical line containing the given display row.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
display_row: Display row to find
|
|
226
|
+
width: Terminal width
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (line_number, row_offset_within_line)
|
|
230
|
+
"""
|
|
231
|
+
if width <= 0:
|
|
232
|
+
raise IndexError(f"Display row {display_row} out of range") # No display possible
|
|
233
|
+
if width > MAX_WIDTH:
|
|
234
|
+
width = MAX_WIDTH
|
|
235
|
+
|
|
236
|
+
current_row = 0
|
|
237
|
+
|
|
238
|
+
# Binary search through summaries to find the right range
|
|
239
|
+
complete_summaries = self._line_count // SUMMARY_INTERVAL
|
|
240
|
+
summary_idx = 0
|
|
241
|
+
|
|
242
|
+
# Find which summary block contains our display row
|
|
243
|
+
for i in range(complete_summaries):
|
|
244
|
+
summary_offset = i * MAX_WIDTH + (width - 1)
|
|
245
|
+
summary_rows = self._summaries[summary_offset]
|
|
246
|
+
if current_row + summary_rows > display_row:
|
|
247
|
+
summary_idx = i
|
|
248
|
+
break
|
|
249
|
+
current_row += summary_rows
|
|
250
|
+
else:
|
|
251
|
+
# It's in the incomplete last block
|
|
252
|
+
summary_idx = complete_summaries
|
|
253
|
+
|
|
254
|
+
# Linear search within the summary block
|
|
255
|
+
start_line = summary_idx * SUMMARY_INTERVAL
|
|
256
|
+
end_line = min(start_line + SUMMARY_INTERVAL, self._line_count)
|
|
257
|
+
|
|
258
|
+
for line_idx in range(start_line, end_line):
|
|
259
|
+
line_width = self.get_line_width(line_idx)
|
|
260
|
+
rows = (line_width + width - 1) // width if width > 0 and line_width > 0 else 1
|
|
261
|
+
|
|
262
|
+
if current_row + rows > display_row:
|
|
263
|
+
# Found the line
|
|
264
|
+
offset_within_line = display_row - current_row
|
|
265
|
+
return (line_idx, offset_within_line)
|
|
266
|
+
|
|
267
|
+
current_row += rows
|
|
268
|
+
|
|
269
|
+
# Display row is beyond the end
|
|
270
|
+
raise IndexError(f"Display row {display_row} out of range")
|
|
271
|
+
|
|
272
|
+
def __len__(self) -> int:
|
|
273
|
+
"""Get total number of indexed lines."""
|
|
274
|
+
return self._line_count
|
logloglog/log_file.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Simple file abstraction for log file operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LogFile:
|
|
9
|
+
"""
|
|
10
|
+
Simple file abstraction for reading and writing log files.
|
|
11
|
+
|
|
12
|
+
Keeps file handle open during batch operations for performance.
|
|
13
|
+
Call open() to start a batch read session, close() when done.
|
|
14
|
+
Individual operations (append, get_size) open/close as needed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: Union[Path, str], mode: str = "r"):
|
|
18
|
+
"""
|
|
19
|
+
Initialize LogFile.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
path: Path to the log file
|
|
23
|
+
mode: File mode - "r" for read-only, "a" for append, "w" for write
|
|
24
|
+
"""
|
|
25
|
+
self.path = Path(path)
|
|
26
|
+
self.mode = mode
|
|
27
|
+
self._read_position = 0
|
|
28
|
+
self._file_handle = None
|
|
29
|
+
|
|
30
|
+
# Validate mode
|
|
31
|
+
if mode not in ("r", "a", "w"):
|
|
32
|
+
raise ValueError(f"Invalid mode: {mode}. Must be 'r', 'a', or 'w'")
|
|
33
|
+
|
|
34
|
+
# Create file if it doesn't exist and we're in write/append mode
|
|
35
|
+
if mode in ("a", "w") and not self.path.exists():
|
|
36
|
+
self.path.touch()
|
|
37
|
+
|
|
38
|
+
def open(self):
|
|
39
|
+
"""Open the file for reading. Call this before batch read operations."""
|
|
40
|
+
if self._file_handle is None:
|
|
41
|
+
self._file_handle = open(self.path, "rb")
|
|
42
|
+
self._file_handle.seek(self._read_position)
|
|
43
|
+
|
|
44
|
+
def close(self):
|
|
45
|
+
"""Close the file handle. Call this after batch operations complete."""
|
|
46
|
+
if self._file_handle is not None:
|
|
47
|
+
self._file_handle.close()
|
|
48
|
+
self._file_handle = None
|
|
49
|
+
|
|
50
|
+
def read_line(self) -> Optional[str]:
|
|
51
|
+
"""
|
|
52
|
+
Read the next line from the current position.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The next line without trailing newline, or None if no more data.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
line_bytes = self._file_handle.readline()
|
|
59
|
+
if line_bytes:
|
|
60
|
+
# Track position without syscall - we know it's current + bytes read
|
|
61
|
+
self._read_position += len(line_bytes)
|
|
62
|
+
return line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
63
|
+
except (IOError, OSError):
|
|
64
|
+
pass
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def read_all_lines(self) -> list[str]:
|
|
68
|
+
"""
|
|
69
|
+
Read all remaining lines from current position.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of lines without trailing newlines.
|
|
73
|
+
"""
|
|
74
|
+
lines = []
|
|
75
|
+
while line := self.read_line():
|
|
76
|
+
lines.append(line)
|
|
77
|
+
return lines
|
|
78
|
+
|
|
79
|
+
def append_line(self, line: str) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Append a line to the file.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
line: Line to append (newline will be added automatically)
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
IOError: If file is opened in read-only mode
|
|
88
|
+
"""
|
|
89
|
+
if self.mode == "r":
|
|
90
|
+
raise IOError("Cannot write to file opened in read-only mode")
|
|
91
|
+
|
|
92
|
+
with open(self.path, "ab") as f:
|
|
93
|
+
# Ensure line ends with newline
|
|
94
|
+
if not line.endswith("\n"):
|
|
95
|
+
line += "\n"
|
|
96
|
+
f.write(line.encode("utf-8"))
|
|
97
|
+
|
|
98
|
+
def append_lines(self, lines: list[str]) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Append multiple lines to the file.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
lines: Lines to append (newlines will be added as needed)
|
|
104
|
+
"""
|
|
105
|
+
if self.mode == "r":
|
|
106
|
+
raise IOError("Cannot write to file opened in read-only mode")
|
|
107
|
+
|
|
108
|
+
with open(self.path, "ab") as f:
|
|
109
|
+
for line in lines:
|
|
110
|
+
if not line.endswith("\n"):
|
|
111
|
+
line += "\n"
|
|
112
|
+
f.write(line.encode("utf-8"))
|
|
113
|
+
|
|
114
|
+
def has_more_data(self) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Check if there's more data available to read.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if file has grown beyond current read position.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
return self.path.stat().st_size > self._read_position
|
|
123
|
+
except (IOError, OSError):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def get_size(self) -> int:
|
|
127
|
+
"""
|
|
128
|
+
Get current file size in bytes.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
File size in bytes, or 0 if file doesn't exist.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
return self.path.stat().st_size
|
|
135
|
+
except (IOError, OSError):
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
def seek_to(self, position: int) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Set the read position.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
position: Byte position to seek to
|
|
144
|
+
"""
|
|
145
|
+
self._read_position = max(0, position)
|
|
146
|
+
# If file handle is open, seek it too
|
|
147
|
+
if self._file_handle is not None:
|
|
148
|
+
self._file_handle.seek(self._read_position)
|
|
149
|
+
|
|
150
|
+
def get_position(self) -> int:
|
|
151
|
+
"""
|
|
152
|
+
Get current read position.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Current byte position for reads.
|
|
156
|
+
"""
|
|
157
|
+
return self._read_position
|
|
158
|
+
|
|
159
|
+
def reset(self) -> None:
|
|
160
|
+
"""Reset read position to beginning of file."""
|
|
161
|
+
self._read_position = 0
|
|
162
|
+
|
|
163
|
+
# Async versions for Textual/asyncio compatibility
|
|
164
|
+
|
|
165
|
+
async def aread_line(self) -> Optional[str]:
|
|
166
|
+
"""Async version of read_line()."""
|
|
167
|
+
return await asyncio.to_thread(self.read_line)
|
|
168
|
+
|
|
169
|
+
async def aread_all_lines(self) -> list[str]:
|
|
170
|
+
"""Async version of read_all_lines()."""
|
|
171
|
+
return await asyncio.to_thread(self.read_all_lines)
|
|
172
|
+
|
|
173
|
+
async def aappend_line(self, line: str) -> None:
|
|
174
|
+
"""Async version of append_line()."""
|
|
175
|
+
await asyncio.to_thread(self.append_line, line)
|
|
176
|
+
|
|
177
|
+
async def aappend_lines(self, lines: list[str]) -> None:
|
|
178
|
+
"""Async version of append_lines()."""
|
|
179
|
+
await asyncio.to_thread(self.append_lines, lines)
|
|
180
|
+
|
|
181
|
+
async def ahas_more_data(self) -> bool:
|
|
182
|
+
"""Async version of has_more_data()."""
|
|
183
|
+
return await asyncio.to_thread(self.has_more_data)
|
|
184
|
+
|
|
185
|
+
async def aget_size(self) -> int:
|
|
186
|
+
"""Async version of get_size()."""
|
|
187
|
+
return await asyncio.to_thread(self.get_size)
|