Skip to content

Commit e1f5569

Browse files
vinnytherobotclaude
andcommitted
feat(cli): add --min-level threshold filter
Add --min-level/-m option to filter logs by minimum severity threshold. This allows users to show logs at or above a given level (e.g., --min-level WARN shows WARN, ERROR, CRITICAL, ALERT, FATAL). - Add LEVEL_ORDER mapping for severity comparison - Add line_passes_min_level() function - Update line_passes_filters() to include min_level parameter - Add CLI option with short flag -m - Add comprehensive tests for min_level filtering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ab4e63d commit e1f5569

4 files changed

Lines changed: 86 additions & 3 deletions

File tree

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"Bash(python:*)",
66
"Bash(git add:*)",
77
"Bash(git commit:*)",
8-
"Bash(git reset:*)"
8+
"Bash(git reset:*)",
9+
"Bash(pip list:*)",
10+
"Bash(pytest -q)",
11+
"Bash(ruff check:*)"
912
]
1013
}
1114
}

logscope/cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def main(
8888
log_file: Annotated[Optional[Path], typer.Argument(help="Path to the log file (leave empty to read from STDIN via pipe)")] = None,
8989
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output in real-time (like tail -f)")] = False,
9090
level: Annotated[Optional[str], typer.Option("--level", "-l", help="Filter by level; comma-separated for multiple (e.g. ERROR,WARN,INFO)")] = None,
91+
min_level: Annotated[Optional[str], typer.Option("--min-level", "-m", help="Show logs at or above this level threshold (e.g. WARN shows WARN, ERROR, CRITICAL, ALERT, FATAL)")] = None,
9192
search: Annotated[Optional[str], typer.Option("--search", "-s", help="Search string to filter logs (substring unless --regex)")] = None,
9293
dashboard: Annotated[bool, typer.Option("--dashboard", "-d", help="Open visual dashboard showing log statistics")] = False,
9394
export_html: Annotated[Optional[Path], typer.Option("--export-html", help="Export the beautiful log output to an HTML file")] = None,
@@ -158,6 +159,7 @@ def main(
158159
invert_match=invert_match,
159160
highlight=highlight,
160161
highlight_color=highlight_color,
162+
min_level=min_level,
161163
)
162164
else:
163165
stream_logs(
@@ -175,6 +177,7 @@ def main(
175177
invert_match=invert_match,
176178
highlight=highlight,
177179
highlight_color=highlight_color,
180+
min_level=min_level,
178181
)
179182
finally:
180183
if log_file is not None:

logscope/viewer.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616
from .parser import parse_line, LogEntry, _normalize_level
1717
from .themes import DEFAULT_THEMES
1818

19+
# Level severity order (lowest to highest)
20+
# Used for --min-level threshold filtering
21+
LEVEL_ORDER = {
22+
"TRACE": 0,
23+
"DEBUG": 1,
24+
"INFO": 2,
25+
"NOTICE": 3,
26+
"WARN": 4,
27+
"ERROR": 5,
28+
"CRITICAL": 6,
29+
"ALERT": 7,
30+
"FATAL": 8,
31+
"UNKNOWN": 0, # Treat as lowest
32+
}
33+
1934
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
2035
sys.stdout.reconfigure(encoding="utf-8")
2136

@@ -116,6 +131,15 @@ def line_passes_level(entry_level: str, allowed: Optional[Set[str]]) -> bool:
116131
return entry_level in allowed
117132

118133

134+
def line_passes_min_level(entry_level: str, min_level: Optional[str]) -> bool:
135+
"""Check if entry level meets minimum severity threshold."""
136+
if not min_level:
137+
return True
138+
entry_severity = LEVEL_ORDER.get(entry_level, 0)
139+
min_severity = LEVEL_ORDER.get(_normalize_level(min_level), 0)
140+
return entry_severity >= min_severity
141+
142+
119143
def line_passes_search(
120144
line: str,
121145
search: Optional[str],
@@ -149,10 +173,13 @@ def line_passes_filters(
149173
use_regex: bool,
150174
case_sensitive: bool,
151175
invert_match: bool,
176+
min_level: Optional[str] = None,
152177
) -> bool:
153-
"""Check if an entry passes all filters (level, search, timestamp)."""
178+
"""Check if an entry passes all filters (level, min_level, search, timestamp)."""
154179
if not line_passes_level(entry.level, level_set):
155180
return False
181+
if not line_passes_min_level(entry.level, min_level):
182+
return False
156183
if not line_passes_search(
157184
entry.raw,
158185
search,
@@ -213,6 +240,7 @@ def stream_logs(
213240
invert_match: bool = False,
214241
highlight: Optional[str] = None,
215242
highlight_color: str = "bold magenta",
243+
min_level: Optional[str] = None,
216244
):
217245
"""Basic console mode: prints directly to stdout, supporting tails."""
218246
if export_html:
@@ -236,6 +264,7 @@ def stream_logs(
236264
use_regex=use_regex,
237265
case_sensitive=case_sensitive,
238266
invert_match=invert_match,
267+
min_level=min_level,
239268
):
240269
continue
241270

@@ -268,6 +297,7 @@ def run_dashboard(
268297
invert_match: bool = False,
269298
highlight: Optional[str] = None,
270299
highlight_color: str = "bold magenta",
300+
min_level: Optional[str] = None,
271301
):
272302
"""Dashboard mode: Shows a summary stats panel and recent logs layout."""
273303

@@ -347,6 +377,7 @@ def generate_layout() -> Layout:
347377
use_regex=use_regex,
348378
case_sensitive=case_sensitive,
349379
invert_match=invert_match,
380+
min_level=min_level,
350381
):
351382
continue
352383

tests/test_filters.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import re
22
from datetime import datetime, timedelta
33

4-
from logscope.viewer import line_passes_level, line_passes_search, parse_level_filter, line_passes_filters
4+
from logscope.viewer import line_passes_level, line_passes_search, parse_level_filter, line_passes_filters, line_passes_min_level
55
from logscope.parser import LogEntry
66

77

@@ -121,3 +121,49 @@ def test_line_passes_filters_exact_boundary():
121121

122122
# Exact until should pass (inclusive)
123123
assert line_passes_filters(entry, None, None, None, entry_time, pattern=None, use_regex=False, case_sensitive=False, invert_match=False) is True
124+
125+
126+
def test_line_passes_min_level_threshold():
127+
"""Test --min-level threshold filtering."""
128+
# TRACE is lowest, FATAL is highest
129+
assert line_passes_min_level("TRACE", None) is True
130+
assert line_passes_min_level("TRACE", "TRACE") is True
131+
assert line_passes_min_level("TRACE", "DEBUG") is False
132+
assert line_passes_min_level("ERROR", "WARN") is True
133+
assert line_passes_min_level("ERROR", "ERROR") is True
134+
assert line_passes_min_level("ERROR", "CRITICAL") is False
135+
assert line_passes_min_level("FATAL", "ERROR") is True
136+
assert line_passes_min_level("FATAL", "FATAL") is True
137+
assert line_passes_min_level("INFO", "DEBUG") is True
138+
139+
140+
def test_line_passes_min_level_unknown_level():
141+
"""Test that UNKNOWN level is treated as lowest severity (same as TRACE)."""
142+
assert line_passes_min_level("UNKNOWN", None) is True
143+
# UNKNOWN has same severity as TRACE (0), so it passes TRACE threshold
144+
assert line_passes_min_level("UNKNOWN", "TRACE") is True
145+
# But fails higher thresholds
146+
assert line_passes_min_level("UNKNOWN", "DEBUG") is False
147+
assert line_passes_min_level("UNKNOWN", "INFO") is False
148+
149+
150+
def test_line_passes_min_level_case_insensitive():
151+
"""Test that min_level comparison is case-insensitive."""
152+
assert line_passes_min_level("ERROR", "error") is True
153+
assert line_passes_min_level("ERROR", "WaRn") is True
154+
assert line_passes_min_level("INFO", "DEBUG") is True
155+
156+
157+
def test_line_passes_filters_with_min_level():
158+
"""Test --min-level combined with other filters."""
159+
entry = make_entry(datetime(2026, 4, 5, 10, 0, 0), level="ERROR", message="error message")
160+
161+
# Combined with level filter - ERROR passes both ERROR level set and min_level WARN
162+
assert line_passes_filters(entry, {"ERROR"}, None, None, None, pattern=None, use_regex=False, case_sensitive=False, invert_match=False, min_level="WARN") is True
163+
164+
# ERROR doesn't pass level filter for INFO only
165+
assert line_passes_filters(entry, {"INFO"}, None, None, None, pattern=None, use_regex=False, case_sensitive=False, invert_match=False, min_level=None) is False
166+
167+
# Combined with time filter
168+
since = datetime(2026, 4, 5, 9, 0, 0)
169+
assert line_passes_filters(entry, None, None, since, None, pattern=None, use_regex=False, case_sensitive=False, invert_match=False, min_level="WARN") is True

0 commit comments

Comments
 (0)