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 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
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for the package
3
+ """
4
+
5
+ from .program import run_program
6
+
7
+ if __name__ == "__main__":
8
+ run_program()
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
@@ -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)