diff --git a/.gitignore b/.gitignore index 1368eac..0b8af11 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,4 @@ AGENTS.md CLAUDE.md RELEASE_NOTES_v*.md TASKS.md -TODO.md \ No newline at end of file +TODO.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e81f27f..29334fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 696eeee..1946db8 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/docs/api.md b/docs/api.md index 12c9086..485de4c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/logscope/__init__.py b/logscope/__init__.py index b5617a1..196751f 100644 --- a/logscope/__init__.py +++ b/logscope/__init__.py @@ -1,4 +1,4 @@ """ LogScope — Beautiful log viewer for the terminal """ -__version__ = "0.4.6" +__version__ = "0.5.0" diff --git a/logscope/cli.py b/logscope/cli.py index 5de1cc0..9fe26ed 100644 --- a/logscope/cli.py +++ b/logscope/cli.py @@ -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", @@ -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: @@ -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: diff --git a/logscope/viewer.py b/logscope/viewer.py index 0fd2305..629350d 100644 --- a/logscope/viewer.py +++ b/logscope/viewer.py @@ -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 @@ -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 @@ -241,6 +301,8 @@ 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: @@ -248,29 +310,37 @@ def stream_logs( 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, diff --git a/pyproject.toml b/pyproject.toml index 7dee62b..d8f1b5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4e1f58a --- /dev/null +++ b/tests/test_cli.py @@ -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