Skip to content

Commit 0437c35

Browse files
Merge pull request #4 from vinnytherobot/feat/search-context-lines
feat(cli): add search context lines
2 parents b82ea64 + 0cdbf31 commit 0437c35

9 files changed

Lines changed: 162 additions & 19 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,4 @@ AGENTS.md
8383
CLAUDE.md
8484
RELEASE_NOTES_v*.md
8585
TASKS.md
86-
TODO.md
86+
TODO.md

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.0] - 2026-06-07
9+
10+
### Added
11+
12+
- Add grep-style search context in stream mode with `--context`, `--before-context`, and `--after-context`.
13+
- Add CLI coverage for context output and validation when context is used without `--search`.
14+
815
## [0.4.7](https://github.com/vinnytherobot/logscope-cli/compare/v0.4.6...v0.4.7) (2026-05-02)
916

1017

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* **Custom Keyword Highlighting**: Highlight specific keywords in log messages with `--highlight` and customize colors with `--highlight-color`.
2727
* **Live Dashboard**: Watch logs stream in real-time alongside a live statistics panel keeping track of Error vs Info counts (`--dashboard`).
2828
* **HTML Export**: Loved your console output so much you want to share it? Export the beautiful log structure directly to an HTML file to share with your team! (`--export-html results.html`)
29-
* **Filtering**: Filter by one or more levels (`--level ERROR` or `--level ERROR,WARN,INFO`). Search by substring (`--search`) or regular expression (`--regex` / `-e`), with optional **case-sensitive** matching and **invert match** (`--invert-match` / `-v`, grep-style) to hide matching lines.
29+
* **Filtering**: Filter by one or more levels (`--level ERROR` or `--level ERROR,WARN,INFO`). Search by substring (`--search`) or regular expression (`--regex` / `-e`), with optional **case-sensitive** matching, **invert match** (`--invert-match` / `-v`), and grep-style context lines (`--context`, `--before-context`, `--after-context`).
3030
* **Themes**: Choose from 6 beautiful themes (`default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`) or create custom themes via config file.
3131
* **Plain output**: Use `--no-color` when you need unstyled text (e.g. piping to other tools or logs without ANSI codes).
3232
* **Gzip logs**: Read `.gz` files directly—LogScope opens them as text without a manual `zcat` pipe.
@@ -71,6 +71,14 @@ logscope production.log --level ERROR,WARN,INFO
7171
# Search text dynamically
7272
logscope server.log --search "Connection Timeout"
7373

74+
# Show surrounding lines for each search hit
75+
logscope server.log --search "payment failed" --context 2
76+
77+
# Show asymmetric context around each search hit
78+
logscope server.log --search "timeout" --before-context 3 --after-context 1
79+
80+
# Context options require --search and apply to standard stream output
81+
7482
# Regex search (requires --search)
7583
logscope server.log --search "timeout|refused|ECONNRESET" --regex
7684

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This page documents the public Python-facing surfaces currently used by LogScope
66

77
- `logscope.cli:app`
88
- Typer app exported as the `logscope` command by Poetry scripts.
9-
- Command options include filtering (`--level`), search (`--search`, `--regex`), display (`--dashboard`), and export (`--export-html`).
9+
- Command options include filtering (`--level`), search (`--search`, `--regex`), stream-mode search context (`--context`, `--before-context`, `--after-context`), display (`--dashboard`), and export (`--export-html`).
1010

1111
## Parser API
1212

logscope/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""
22
LogScope — Beautiful log viewer for the terminal
33
"""
4-
__version__ = "0.4.6"
4+
__version__ = "0.5.0"

logscope/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ def main(
9999
use_regex: Annotated[bool, typer.Option("--regex", "-e", help="Treat --search as a regular expression")] = False,
100100
case_sensitive: Annotated[bool, typer.Option("--case-sensitive", help="Case-sensitive substring or regex search")] = False,
101101
invert_match: Annotated[bool, typer.Option("--invert-match", "-v", help="Hide lines that match --search (grep -v)")] = False,
102+
before_context: Annotated[int, typer.Option("--before-context", "-B", min=0, help="Show N lines before each --search match")] = 0,
103+
after_context: Annotated[int, typer.Option("--after-context", "-A", min=0, help="Show N lines after each --search match")] = 0,
104+
context: Annotated[int, typer.Option("--context", "-C", min=0, help="Show N lines before and after each --search match")] = 0,
102105
no_color: Annotated[bool, typer.Option("--no-color", help="Disable colors and terminal highlighting")] = False,
103106
highlight: Annotated[Optional[str], typer.Option("--highlight", "-H", help="Highlight specific keyword in log messages (can be used multiple times)")] = None,
104107
highlight_color: Annotated[str, typer.Option("--highlight-color", help="Rich style for highlighted keywords (default: bold magenta)")] = "bold magenta",
@@ -113,6 +116,15 @@ def main(
113116
typer.echo("❌ Error: --invert-match requires --search.", err=True)
114117
raise typer.Exit(1)
115118

119+
effective_before_context = before_context if before_context else context
120+
effective_after_context = after_context if after_context else context
121+
if (effective_before_context or effective_after_context) and not search:
122+
typer.echo("❌ Error: --context requires --search.", err=True)
123+
raise typer.Exit(1)
124+
if dashboard and (effective_before_context or effective_after_context):
125+
typer.echo("❌ Error: --context is only supported in stream mode.", err=True)
126+
raise typer.Exit(1)
127+
116128
search_pattern = None
117129
if search and use_regex:
118130
try:
@@ -178,6 +190,8 @@ def main(
178190
highlight=highlight,
179191
highlight_color=highlight_color,
180192
min_level=min_level,
193+
before_context=effective_before_context,
194+
after_context=effective_after_context,
181195
)
182196
finally:
183197
if log_file is not None:

logscope/viewer.py

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import sys
22
import time
3+
from collections import deque
34
from pathlib import Path
45
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
67

78
from rich.console import Console, Group
89
from rich.live import Live
@@ -197,6 +198,65 @@ def line_passes_filters(
197198
return True
198199

199200

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+
200260
def get_lines(file: TextIO, follow: bool):
201261
"""Generator that yields (line_number, line) tuples from a file, optionally tailing it."""
202262
line_number = 0
@@ -241,36 +301,46 @@ def stream_logs(
241301
highlight: Optional[str] = None,
242302
highlight_color: str = "bold magenta",
243303
min_level: Optional[str] = None,
304+
before_context: int = 0,
305+
after_context: int = 0,
244306
):
245307
"""Basic console mode: prints directly to stdout, supporting tails."""
246308
if export_html:
247309
manager.console.record = True
248310

249311
level_set = parse_level_filter(level)
250312

251-
line_count = 0
252-
try:
313+
def filtered_entries() -> Iterator[Tuple[int, LogEntry]]:
253314
for line_number, line in get_lines(file, follow):
254-
line_count = line_number
255315
entry = parse_line(line)
256-
257-
if not line_passes_filters(
316+
if line_passes_filters(
258317
entry,
259318
level_set,
260-
search,
319+
None,
261320
since,
262321
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,
267326
min_level=min_level,
268327
):
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+
):
271341
formatted = manager.format_log(
272342
entry,
273-
line_number=line_count if show_line_numbers else None,
343+
line_number=line_number if show_line_numbers else None,
274344
highlight=highlight,
275345
highlight_color=highlight_color,
276346
case_sensitive=case_sensitive,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "logscope-cli"
3-
version = "0.4.7"
3+
version = "0.5.0"
44
description = "LogScope — Beautiful log viewer for the terminal"
55
authors = ["vinnytherobot"]
66
readme = "README.md"

tests/test_cli.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typer.testing import CliRunner
2+
3+
from logscope.cli import app
4+
5+
6+
runner = CliRunner()
7+
8+
9+
def test_cli_context_outputs_neighboring_search_lines(tmp_path):
10+
log_file = tmp_path / "app.log"
11+
log_file.write_text(
12+
"\n".join(
13+
[
14+
"[INFO] service started",
15+
"[DEBUG] opening database connection",
16+
"[ERROR] payment failed for order 42",
17+
"[INFO] retry scheduled",
18+
"[INFO] healthcheck ok",
19+
]
20+
),
21+
encoding="utf-8",
22+
)
23+
24+
result = runner.invoke(
25+
app,
26+
[str(log_file), "--search", "payment failed", "--context", "1", "--no-color"],
27+
)
28+
29+
assert result.exit_code == 0
30+
assert "opening database connection" in result.output
31+
assert "payment failed for order 42" in result.output
32+
assert "retry scheduled" in result.output
33+
assert "service started" not in result.output
34+
assert "healthcheck ok" not in result.output
35+
36+
37+
def test_cli_rejects_context_without_search(tmp_path):
38+
log_file = tmp_path / "app.log"
39+
log_file.write_text("[INFO] service started", encoding="utf-8")
40+
41+
result = runner.invoke(app, [str(log_file), "--context", "1"])
42+
43+
assert result.exit_code == 1
44+
assert "--context requires --search" in result.output

0 commit comments

Comments
 (0)