Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ AGENTS.md
CLAUDE.md
RELEASE_NOTES_v*.md
TASKS.md
TODO.md
TODO.md
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-06-07

### Added

- Add grep-style search context in stream mode with `--context`, `--before-context`, and `--after-context`.
- Add CLI coverage for context output and validation when context is used without `--search`.

## [0.4.7](https://github.com/vinnytherobot/logscope-cli/compare/v0.4.6...v0.4.7) (2026-05-02)


Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* **Custom Keyword Highlighting**: Highlight specific keywords in log messages with `--highlight` and customize colors with `--highlight-color`.
* **Live Dashboard**: Watch logs stream in real-time alongside a live statistics panel keeping track of Error vs Info counts (`--dashboard`).
* **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`)
* **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.
* **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`).
* **Themes**: Choose from 6 beautiful themes (`default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`) or create custom themes via config file.
* **Plain output**: Use `--no-color` when you need unstyled text (e.g. piping to other tools or logs without ANSI codes).
* **Gzip logs**: Read `.gz` files directly—LogScope opens them as text without a manual `zcat` pipe.
Expand Down Expand Up @@ -71,6 +71,14 @@ logscope production.log --level ERROR,WARN,INFO
# Search text dynamically
logscope server.log --search "Connection Timeout"

# Show surrounding lines for each search hit
logscope server.log --search "payment failed" --context 2

# Show asymmetric context around each search hit
logscope server.log --search "timeout" --before-context 3 --after-context 1

# Context options require --search and apply to standard stream output

# Regex search (requires --search)
logscope server.log --search "timeout|refused|ECONNRESET" --regex

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This page documents the public Python-facing surfaces currently used by LogScope

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

## Parser API

Expand Down
2 changes: 1 addition & 1 deletion logscope/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
LogScope — Beautiful log viewer for the terminal
"""
__version__ = "0.4.6"
__version__ = "0.5.0"
14 changes: 14 additions & 0 deletions logscope/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ def main(
use_regex: Annotated[bool, typer.Option("--regex", "-e", help="Treat --search as a regular expression")] = False,
case_sensitive: Annotated[bool, typer.Option("--case-sensitive", help="Case-sensitive substring or regex search")] = False,
invert_match: Annotated[bool, typer.Option("--invert-match", "-v", help="Hide lines that match --search (grep -v)")] = False,
before_context: Annotated[int, typer.Option("--before-context", "-B", min=0, help="Show N lines before each --search match")] = 0,
after_context: Annotated[int, typer.Option("--after-context", "-A", min=0, help="Show N lines after each --search match")] = 0,
context: Annotated[int, typer.Option("--context", "-C", min=0, help="Show N lines before and after each --search match")] = 0,
no_color: Annotated[bool, typer.Option("--no-color", help="Disable colors and terminal highlighting")] = False,
highlight: Annotated[Optional[str], typer.Option("--highlight", "-H", help="Highlight specific keyword in log messages (can be used multiple times)")] = None,
highlight_color: Annotated[str, typer.Option("--highlight-color", help="Rich style for highlighted keywords (default: bold magenta)")] = "bold magenta",
Expand All @@ -113,6 +116,15 @@ def main(
typer.echo("❌ Error: --invert-match requires --search.", err=True)
raise typer.Exit(1)

effective_before_context = before_context if before_context else context
effective_after_context = after_context if after_context else context
if (effective_before_context or effective_after_context) and not search:
typer.echo("❌ Error: --context requires --search.", err=True)
raise typer.Exit(1)
if dashboard and (effective_before_context or effective_after_context):
typer.echo("❌ Error: --context is only supported in stream mode.", err=True)
raise typer.Exit(1)

search_pattern = None
if search and use_regex:
try:
Expand Down Expand Up @@ -178,6 +190,8 @@ def main(
highlight=highlight,
highlight_color=highlight_color,
min_level=min_level,
before_context=effective_before_context,
after_context=effective_after_context,
)
finally:
if log_file is not None:
Expand Down
98 changes: 84 additions & 14 deletions logscope/viewer.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import sys
import time
from collections import deque
from pathlib import Path
from datetime import datetime
from typing import Optional, List, TextIO, Set, Pattern
from typing import Deque, Iterable, Iterator, List, Optional, Pattern, Set, TextIO, Tuple

from rich.console import Console, Group
from rich.live import Live
Expand Down Expand Up @@ -197,6 +198,65 @@ def line_passes_filters(
return True


def iter_search_context(
entries: Iterable[Tuple[int, LogEntry]],
search: Optional[str],
*,
pattern: Optional[Pattern[str]],
use_regex: bool,
case_sensitive: bool,
invert_match: bool,
before_context: int = 0,
after_context: int = 0,
) -> Iterator[Tuple[int, LogEntry]]:
"""Yield matching entries plus grep-style before/after context lines."""
if not search or (before_context <= 0 and after_context <= 0):
for line_number, entry in entries:
if line_passes_search(
entry.raw,
search,
pattern=pattern,
use_regex=use_regex,
case_sensitive=case_sensitive,
invert_match=invert_match,
):
yield line_number, entry
return

before: Deque[Tuple[int, LogEntry]] = deque(maxlen=before_context)
after_remaining = 0
emitted: Set[int] = set()

for line_number, entry in entries:
matched = line_passes_search(
entry.raw,
search,
pattern=pattern,
use_regex=use_regex,
case_sensitive=case_sensitive,
invert_match=invert_match,
)

if matched:
for buffered_line_number, buffered_entry in before:
if buffered_line_number not in emitted:
emitted.add(buffered_line_number)
yield buffered_line_number, buffered_entry

if line_number not in emitted:
emitted.add(line_number)
yield line_number, entry

after_remaining = max(after_remaining, after_context)
elif after_remaining > 0:
if line_number not in emitted:
emitted.add(line_number)
yield line_number, entry
after_remaining -= 1

before.append((line_number, entry))


def get_lines(file: TextIO, follow: bool):
"""Generator that yields (line_number, line) tuples from a file, optionally tailing it."""
line_number = 0
Expand Down Expand Up @@ -241,36 +301,46 @@ def stream_logs(
highlight: Optional[str] = None,
highlight_color: str = "bold magenta",
min_level: Optional[str] = None,
before_context: int = 0,
after_context: int = 0,
):
"""Basic console mode: prints directly to stdout, supporting tails."""
if export_html:
manager.console.record = True

level_set = parse_level_filter(level)

line_count = 0
try:
def filtered_entries() -> Iterator[Tuple[int, LogEntry]]:
for line_number, line in get_lines(file, follow):
line_count = line_number
entry = parse_line(line)

if not line_passes_filters(
if line_passes_filters(
entry,
level_set,
search,
None,
since,
until,
pattern=search_pattern,
use_regex=use_regex,
case_sensitive=case_sensitive,
invert_match=invert_match,
pattern=None,
use_regex=False,
case_sensitive=False,
invert_match=False,
min_level=min_level,
):
continue

yield line_number, entry

try:
for line_number, entry in iter_search_context(
filtered_entries(),
search,
pattern=search_pattern,
use_regex=use_regex,
case_sensitive=case_sensitive,
invert_match=invert_match,
before_context=before_context,
after_context=after_context,
):
formatted = manager.format_log(
entry,
line_number=line_count if show_line_numbers else None,
line_number=line_number if show_line_numbers else None,
highlight=highlight,
highlight_color=highlight_color,
case_sensitive=case_sensitive,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "logscope-cli"
version = "0.4.7"
version = "0.5.0"
description = "LogScope — Beautiful log viewer for the terminal"
authors = ["vinnytherobot"]
readme = "README.md"
Expand Down
44 changes: 44 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typer.testing import CliRunner

from logscope.cli import app


runner = CliRunner()


def test_cli_context_outputs_neighboring_search_lines(tmp_path):
log_file = tmp_path / "app.log"
log_file.write_text(
"\n".join(
[
"[INFO] service started",
"[DEBUG] opening database connection",
"[ERROR] payment failed for order 42",
"[INFO] retry scheduled",
"[INFO] healthcheck ok",
]
),
encoding="utf-8",
)

result = runner.invoke(
app,
[str(log_file), "--search", "payment failed", "--context", "1", "--no-color"],
)

assert result.exit_code == 0
assert "opening database connection" in result.output
assert "payment failed for order 42" in result.output
assert "retry scheduled" in result.output
assert "service started" not in result.output
assert "healthcheck ok" not in result.output


def test_cli_rejects_context_without_search(tmp_path):
log_file = tmp_path / "app.log"
log_file.write_text("[INFO] service started", encoding="utf-8")

result = runner.invoke(app, [str(log_file), "--context", "1"])

assert result.exit_code == 1
assert "--context requires --search" in result.output
Loading