logloglog 0.0.1__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ # WTFPL + Warranty
2
+
3
+ Licensed under the WTFPL with one additional clause:
4
+
5
+ 1. Don't blame me.
6
+
7
+ Do whatever the fuck you want, just don't blame me.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: logloglog
3
+ Version: 0.0.1
4
+ Summary: Efficient scrollback indexing for large log files with terminal word wrapping
5
+ Keywords: logging,terminal,scrollback,indexing
6
+ Author-email: Gareth Davidson <gaz@bitplane.net>
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: Public Domain
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: System :: Logging
18
+ Classifier: Topic :: Terminals
19
+ License-File: LICENSE.md
20
+ Requires-Dist: wcwidth~=0.2.5
21
+ Requires-Dist: platformdirs~=3.0
22
+ Requires-Dist: arrayfile~=0.0.1
23
+ Requires-Dist: logloglog[textual] ; extra == "dev"
24
+ Requires-Dist: pre-commit ; extra == "dev"
25
+ Requires-Dist: pytest ; extra == "dev"
26
+ Requires-Dist: pytest-asyncio ; extra == "dev"
27
+ Requires-Dist: coverage ; extra == "dev"
28
+ Requires-Dist: pytest-cov ; extra == "dev"
29
+ Requires-Dist: build ; extra == "dev"
30
+ Requires-Dist: twine ; extra == "dev"
31
+ Requires-Dist: ruff ; extra == "dev"
32
+ Requires-Dist: textual ; extra == "textual"
33
+ Requires-Dist: textual-window ; extra == "textual"
34
+ Provides-Extra: dev
35
+ Provides-Extra: textual
36
+
37
+ # 🪵🪵🪵
38
+
39
+ ![log](log.webp)
40
+
41
+ > What rolls down stairs, alone or in pairs, and over your neighbor's dog?
42
+ > What's great for a snack, And fits on your back?
43
+ > It's Log Log Log!
44
+
45
+ I needed line wrapping in my tty with fast access, so why not make a log that
46
+ supports egregious sizes and low seek times?
47
+
48
+ ## ⏩
49
+
50
+ ```bash
51
+ # makes log.log.log from /var/log
52
+ make log
53
+ ```
54
+
55
+ ## TODO
56
+
57
+ - [ ] Textual demo
58
+ - [x] stats in window
59
+ - [ ] slim demo down
60
+ - [x] follow last line when at end
61
+ - [x] Async/non-blocking design
62
+ - [x] Make log processing async to avoid blocking
63
+ - [x] Support streaming updates
64
+ - [ ] Multiple display backends
65
+ - [ ] Python logging integration
66
+ - [ ] Direct logger handlers
67
+ - [ ] Log level filtering and formatting
68
+ - [ ] Cache management
69
+ - [ ] Periodic cleanup of cache directories
70
+ - [ ] Check file existence by inode lookup
71
+ - [ ] Handle file rotation and moves
72
+
73
+
@@ -0,0 +1,36 @@
1
+ # 🪵🪵🪵
2
+
3
+ ![log](log.webp)
4
+
5
+ > What rolls down stairs, alone or in pairs, and over your neighbor's dog?
6
+ > What's great for a snack, And fits on your back?
7
+ > It's Log Log Log!
8
+
9
+ I needed line wrapping in my tty with fast access, so why not make a log that
10
+ supports egregious sizes and low seek times?
11
+
12
+ ## ⏩
13
+
14
+ ```bash
15
+ # makes log.log.log from /var/log
16
+ make log
17
+ ```
18
+
19
+ ## TODO
20
+
21
+ - [ ] Textual demo
22
+ - [x] stats in window
23
+ - [ ] slim demo down
24
+ - [x] follow last line when at end
25
+ - [x] Async/non-blocking design
26
+ - [x] Make log processing async to avoid blocking
27
+ - [x] Support streaming updates
28
+ - [ ] Multiple display backends
29
+ - [ ] Python logging integration
30
+ - [ ] Direct logger handlers
31
+ - [ ] Log level filtering and formatting
32
+ - [ ] Cache management
33
+ - [ ] Periodic cleanup of cache directories
34
+ - [ ] Check file existence by inode lookup
35
+ - [ ] Handle file rotation and moves
36
+
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "logloglog"
3
+ description = "Efficient scrollback indexing for large log files with terminal word wrapping"
4
+ version = "0.0.1"
5
+ authors = [
6
+ { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = {text = "WTFPL"}
11
+ keywords = ["logging", "terminal", "scrollback", "indexing"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: Public Domain",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Operating System :: OS Independent",
21
+ "Topic :: System :: Logging",
22
+ "Topic :: Terminals",
23
+ ]
24
+
25
+ dependencies = [
26
+ "wcwidth~=0.2.5",
27
+ "platformdirs~=3.0",
28
+ "arrayfile~=0.0.1"
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "logloglog[textual]",
34
+ "pre-commit",
35
+ "pytest",
36
+ "pytest-asyncio",
37
+ "coverage",
38
+ "pytest-cov",
39
+ "build",
40
+ "twine",
41
+ "ruff"
42
+ ]
43
+ textual = [
44
+ "textual",
45
+ "textual-window"
46
+ ]
47
+
48
+ [project.scripts]
49
+ stream-logs = "logloglog.tools.stream_logs:main"
50
+
51
+ [build-system]
52
+ build-backend = "flit_core.buildapi"
53
+ requires = ["flit_core >=3.2,<4"]
54
+
55
+ [tool.ruff]
56
+ line-length = 120
57
+ target-version = "py310"
58
+
59
+ [tool.ruff.format]
60
+ docstring-code-format = true
@@ -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)
@@ -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()
@@ -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