|
1 | 1 | import sys |
2 | 2 | import time |
| 3 | +from collections import deque |
3 | 4 | from pathlib import Path |
4 | 5 | from datetime import datetime |
5 | | -from typing import Optional, List, TextIO, Set, Pattern |
| 6 | +from typing import Deque, Iterable, Iterator, List, Optional, Pattern, Set, TextIO, Tuple |
6 | 7 |
|
7 | 8 | from rich.console import Console, Group |
8 | 9 | from rich.live import Live |
@@ -197,6 +198,65 @@ def line_passes_filters( |
197 | 198 | return True |
198 | 199 |
|
199 | 200 |
|
| 201 | +def iter_search_context( |
| 202 | + entries: Iterable[Tuple[int, LogEntry]], |
| 203 | + search: Optional[str], |
| 204 | + *, |
| 205 | + pattern: Optional[Pattern[str]], |
| 206 | + use_regex: bool, |
| 207 | + case_sensitive: bool, |
| 208 | + invert_match: bool, |
| 209 | + before_context: int = 0, |
| 210 | + after_context: int = 0, |
| 211 | +) -> Iterator[Tuple[int, LogEntry]]: |
| 212 | + """Yield matching entries plus grep-style before/after context lines.""" |
| 213 | + if not search or (before_context <= 0 and after_context <= 0): |
| 214 | + for line_number, entry in entries: |
| 215 | + if line_passes_search( |
| 216 | + entry.raw, |
| 217 | + search, |
| 218 | + pattern=pattern, |
| 219 | + use_regex=use_regex, |
| 220 | + case_sensitive=case_sensitive, |
| 221 | + invert_match=invert_match, |
| 222 | + ): |
| 223 | + yield line_number, entry |
| 224 | + return |
| 225 | + |
| 226 | + before: Deque[Tuple[int, LogEntry]] = deque(maxlen=before_context) |
| 227 | + after_remaining = 0 |
| 228 | + emitted: Set[int] = set() |
| 229 | + |
| 230 | + for line_number, entry in entries: |
| 231 | + matched = line_passes_search( |
| 232 | + entry.raw, |
| 233 | + search, |
| 234 | + pattern=pattern, |
| 235 | + use_regex=use_regex, |
| 236 | + case_sensitive=case_sensitive, |
| 237 | + invert_match=invert_match, |
| 238 | + ) |
| 239 | + |
| 240 | + if matched: |
| 241 | + for buffered_line_number, buffered_entry in before: |
| 242 | + if buffered_line_number not in emitted: |
| 243 | + emitted.add(buffered_line_number) |
| 244 | + yield buffered_line_number, buffered_entry |
| 245 | + |
| 246 | + if line_number not in emitted: |
| 247 | + emitted.add(line_number) |
| 248 | + yield line_number, entry |
| 249 | + |
| 250 | + after_remaining = max(after_remaining, after_context) |
| 251 | + elif after_remaining > 0: |
| 252 | + if line_number not in emitted: |
| 253 | + emitted.add(line_number) |
| 254 | + yield line_number, entry |
| 255 | + after_remaining -= 1 |
| 256 | + |
| 257 | + before.append((line_number, entry)) |
| 258 | + |
| 259 | + |
200 | 260 | def get_lines(file: TextIO, follow: bool): |
201 | 261 | """Generator that yields (line_number, line) tuples from a file, optionally tailing it.""" |
202 | 262 | line_number = 0 |
@@ -241,36 +301,46 @@ def stream_logs( |
241 | 301 | highlight: Optional[str] = None, |
242 | 302 | highlight_color: str = "bold magenta", |
243 | 303 | min_level: Optional[str] = None, |
| 304 | + before_context: int = 0, |
| 305 | + after_context: int = 0, |
244 | 306 | ): |
245 | 307 | """Basic console mode: prints directly to stdout, supporting tails.""" |
246 | 308 | if export_html: |
247 | 309 | manager.console.record = True |
248 | 310 |
|
249 | 311 | level_set = parse_level_filter(level) |
250 | 312 |
|
251 | | - line_count = 0 |
252 | | - try: |
| 313 | + def filtered_entries() -> Iterator[Tuple[int, LogEntry]]: |
253 | 314 | for line_number, line in get_lines(file, follow): |
254 | | - line_count = line_number |
255 | 315 | entry = parse_line(line) |
256 | | - |
257 | | - if not line_passes_filters( |
| 316 | + if line_passes_filters( |
258 | 317 | entry, |
259 | 318 | level_set, |
260 | | - search, |
| 319 | + None, |
261 | 320 | since, |
262 | 321 | until, |
263 | | - pattern=search_pattern, |
264 | | - use_regex=use_regex, |
265 | | - case_sensitive=case_sensitive, |
266 | | - invert_match=invert_match, |
| 322 | + pattern=None, |
| 323 | + use_regex=False, |
| 324 | + case_sensitive=False, |
| 325 | + invert_match=False, |
267 | 326 | min_level=min_level, |
268 | 327 | ): |
269 | | - continue |
270 | | - |
| 328 | + yield line_number, entry |
| 329 | + |
| 330 | + try: |
| 331 | + for line_number, entry in iter_search_context( |
| 332 | + filtered_entries(), |
| 333 | + search, |
| 334 | + pattern=search_pattern, |
| 335 | + use_regex=use_regex, |
| 336 | + case_sensitive=case_sensitive, |
| 337 | + invert_match=invert_match, |
| 338 | + before_context=before_context, |
| 339 | + after_context=after_context, |
| 340 | + ): |
271 | 341 | formatted = manager.format_log( |
272 | 342 | entry, |
273 | | - line_number=line_count if show_line_numbers else None, |
| 343 | + line_number=line_number if show_line_numbers else None, |
274 | 344 | highlight=highlight, |
275 | 345 | highlight_color=highlight_color, |
276 | 346 | case_sensitive=case_sensitive, |
|
0 commit comments