logscope-cli 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logscope/__init__.py +4 -0
- logscope/cli.py +187 -0
- logscope/parser.py +188 -0
- logscope/themes.py +160 -0
- logscope/viewer.py +401 -0
- logscope_cli-0.4.1.dist-info/METADATA +177 -0
- logscope_cli-0.4.1.dist-info/RECORD +10 -0
- logscope_cli-0.4.1.dist-info/WHEEL +4 -0
- logscope_cli-0.4.1.dist-info/entry_points.txt +3 -0
- logscope_cli-0.4.1.dist-info/licenses/LICENSE +21 -0
logscope/__init__.py
ADDED
logscope/cli.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import gzip
|
|
3
|
+
import typer
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from typing_extensions import Annotated
|
|
10
|
+
from .viewer import stream_logs, run_dashboard, manager
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
help="LogScope — Beautiful log viewer for the terminal",
|
|
14
|
+
add_completion=False,
|
|
15
|
+
rich_markup_mode="rich"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def parse_relative_time(time_str: str) -> Optional[datetime]:
|
|
19
|
+
"""Parse relative time like '10m', '1h', '2d' or ISO strings."""
|
|
20
|
+
if not time_str:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
# Check if absolute ISO format first
|
|
24
|
+
try:
|
|
25
|
+
return datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
|
26
|
+
except ValueError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Regex for relative formats: <digit><unit>
|
|
30
|
+
match = re.match(r'^(\d+)([smhd])$', time_str.lower())
|
|
31
|
+
if not match:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
value, unit = int(match.group(1)), match.group(2)
|
|
35
|
+
now = datetime.now()
|
|
36
|
+
|
|
37
|
+
if unit == 's':
|
|
38
|
+
return now - timedelta(seconds=value)
|
|
39
|
+
if unit == 'm':
|
|
40
|
+
return now - timedelta(minutes=value)
|
|
41
|
+
if unit == 'h':
|
|
42
|
+
return now - timedelta(hours=value)
|
|
43
|
+
if unit == 'd':
|
|
44
|
+
return now - timedelta(days=value)
|
|
45
|
+
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def load_theme(requested_theme: Optional[str], no_color: bool = False):
|
|
49
|
+
"""Load theme from config file or use requested/default. Persist if requested."""
|
|
50
|
+
config_file = Path(".logscoperc")
|
|
51
|
+
if not config_file.exists():
|
|
52
|
+
config_file = Path.home() / ".logscoperc"
|
|
53
|
+
|
|
54
|
+
config = {}
|
|
55
|
+
if config_file.exists():
|
|
56
|
+
try:
|
|
57
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
58
|
+
config = json.load(f)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
# If requested via CLI, save it to the config for future use
|
|
63
|
+
if requested_theme:
|
|
64
|
+
config["theme"] = requested_theme
|
|
65
|
+
try:
|
|
66
|
+
# We prefer saving to local .logscoperc if it exists, otherwise home.
|
|
67
|
+
# If neither exists, we'll create one in the home directory for persistence.
|
|
68
|
+
save_path = Path(".logscoperc") if Path(".logscoperc").exists() else Path.home() / ".logscoperc"
|
|
69
|
+
with open(save_path, "w", encoding="utf-8") as f:
|
|
70
|
+
json.dump(config, f, indent=4)
|
|
71
|
+
manager.console.print(f"[bold green]✅ Theme '{requested_theme}' saved as your default preference![/bold green]")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
manager.console.print(f"[dim red]⚠️ Failed to save theme preference: {e}[/dim red]")
|
|
74
|
+
else:
|
|
75
|
+
requested_theme = config.get("theme", "default")
|
|
76
|
+
|
|
77
|
+
if "custom_themes" in config:
|
|
78
|
+
# Merge custom themes into a copy of DEFAULT_THEMES to avoid mutating the module global
|
|
79
|
+
from .themes import DEFAULT_THEMES
|
|
80
|
+
themes_copy = dict(DEFAULT_THEMES)
|
|
81
|
+
themes_copy.update(config["custom_themes"])
|
|
82
|
+
manager.apply_theme(requested_theme, custom_themes=themes_copy, no_color=no_color)
|
|
83
|
+
else:
|
|
84
|
+
manager.apply_theme(requested_theme, no_color=no_color)
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def main(
|
|
88
|
+
log_file: Annotated[Optional[Path], typer.Argument(help="Path to the log file (leave empty to read from STDIN via pipe)")] = None,
|
|
89
|
+
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output in real-time (like tail -f)")] = False,
|
|
90
|
+
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,
|
|
92
|
+
search: Annotated[Optional[str], typer.Option("--search", "-s", help="Search string to filter logs (substring unless --regex)")] = None,
|
|
93
|
+
dashboard: Annotated[bool, typer.Option("--dashboard", "-d", help="Open visual dashboard showing log statistics")] = False,
|
|
94
|
+
export_html: Annotated[Optional[Path], typer.Option("--export-html", help="Export the beautiful log output to an HTML file")] = None,
|
|
95
|
+
line_numbers: Annotated[bool, typer.Option("--line-numbers", "-n", help="Show line numbers for each log message")] = False,
|
|
96
|
+
since: Annotated[Optional[str], typer.Option("--since", help="Show logs since a point in time (e.g. '1h', '30m', '2026-01-01T00:00:00')")] = None,
|
|
97
|
+
until: Annotated[Optional[str], typer.Option("--until", help="Show logs until a point in time")] = None,
|
|
98
|
+
theme: Annotated[Optional[str], typer.Option("--theme", "-t", help="Choose a theme for colors and emojis (default, neon, ocean, forest, minimal)")] = None,
|
|
99
|
+
use_regex: Annotated[bool, typer.Option("--regex", "-e", help="Treat --search as a regular expression")] = False,
|
|
100
|
+
case_sensitive: Annotated[bool, typer.Option("--case-sensitive", help="Case-sensitive substring or regex search")] = False,
|
|
101
|
+
invert_match: Annotated[bool, typer.Option("--invert-match", "-v", help="Hide lines that match --search (grep -v)")] = False,
|
|
102
|
+
no_color: Annotated[bool, typer.Option("--no-color", help="Disable colors and terminal highlighting")] = False,
|
|
103
|
+
highlight: Annotated[Optional[str], typer.Option("--highlight", "-H", help="Highlight specific keyword in log messages (can be used multiple times)")] = None,
|
|
104
|
+
highlight_color: Annotated[str, typer.Option("--highlight-color", help="Rich style for highlighted keywords (default: bold magenta)")] = "bold magenta",
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
[blue]LogScope[/blue] parses standard logs and makes them [bold]beautiful[/bold] and [bold]readable[/bold].
|
|
108
|
+
"""
|
|
109
|
+
if use_regex and not search:
|
|
110
|
+
typer.echo("❌ Error: --regex requires --search.", err=True)
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
if invert_match and not search:
|
|
113
|
+
typer.echo("❌ Error: --invert-match requires --search.", err=True)
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
|
|
116
|
+
search_pattern = None
|
|
117
|
+
if search and use_regex:
|
|
118
|
+
try:
|
|
119
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
120
|
+
search_pattern = re.compile(search, flags)
|
|
121
|
+
except re.error as exc:
|
|
122
|
+
typer.echo(f"❌ Error: invalid regular expression: {exc}", err=True)
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
|
|
125
|
+
if log_file is None:
|
|
126
|
+
if sys.stdin.isatty():
|
|
127
|
+
typer.echo("❌ Error: Please provide a log file path or pipe data to STDIN (cat file | logscope).", err=True)
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
file_obj = sys.stdin
|
|
130
|
+
else:
|
|
131
|
+
if log_file.suffix.lower() == ".gz":
|
|
132
|
+
file_obj = gzip.open(log_file, "rt", encoding="utf-8", errors="replace")
|
|
133
|
+
else:
|
|
134
|
+
file_obj = open(log_file, "r", encoding="utf-8", errors="replace")
|
|
135
|
+
|
|
136
|
+
since_dt = parse_relative_time(since) if since else None
|
|
137
|
+
until_dt = parse_relative_time(until) if until else None
|
|
138
|
+
|
|
139
|
+
load_theme(theme, no_color=no_color)
|
|
140
|
+
|
|
141
|
+
# Inform user about themes only if they are using default and haven't hidden the tip by having a config
|
|
142
|
+
has_config = Path(".logscoperc").exists() or (Path.home() / ".logscoperc").exists()
|
|
143
|
+
if not theme and not has_config:
|
|
144
|
+
manager.console.print("[dim]💡 Tip: Use '--theme' or create a '.logscoperc' file to change colors. Themes: neon, ocean, forest, minimal[/dim]\n")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
if dashboard:
|
|
148
|
+
run_dashboard(
|
|
149
|
+
file_obj,
|
|
150
|
+
follow=follow,
|
|
151
|
+
level_filter=level,
|
|
152
|
+
search_filter=search,
|
|
153
|
+
show_line_numbers=line_numbers,
|
|
154
|
+
since=since_dt,
|
|
155
|
+
until=until_dt,
|
|
156
|
+
use_regex=use_regex,
|
|
157
|
+
search_pattern=search_pattern,
|
|
158
|
+
case_sensitive=case_sensitive,
|
|
159
|
+
invert_match=invert_match,
|
|
160
|
+
highlight=highlight,
|
|
161
|
+
highlight_color=highlight_color,
|
|
162
|
+
min_level=min_level,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
stream_logs(
|
|
166
|
+
file_obj,
|
|
167
|
+
follow=follow,
|
|
168
|
+
level=level,
|
|
169
|
+
search=search,
|
|
170
|
+
export_html=export_html,
|
|
171
|
+
show_line_numbers=line_numbers,
|
|
172
|
+
since=since_dt,
|
|
173
|
+
until=until_dt,
|
|
174
|
+
use_regex=use_regex,
|
|
175
|
+
search_pattern=search_pattern,
|
|
176
|
+
case_sensitive=case_sensitive,
|
|
177
|
+
invert_match=invert_match,
|
|
178
|
+
highlight=highlight,
|
|
179
|
+
highlight_color=highlight_color,
|
|
180
|
+
min_level=min_level,
|
|
181
|
+
)
|
|
182
|
+
finally:
|
|
183
|
+
if log_file is not None:
|
|
184
|
+
file_obj.close()
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
app()
|
logscope/parser.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
# Compiled regex patterns for performance
|
|
8
|
+
_BRACKET_LEVEL_PATTERN = re.compile(
|
|
9
|
+
r'\[(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|ERR|CRITICAL|ALERT|FATAL|EMERGENCY)\]',
|
|
10
|
+
re.IGNORECASE
|
|
11
|
+
)
|
|
12
|
+
_BRACKETLESS_LEVEL_PATTERN = re.compile(
|
|
13
|
+
r'\b(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|ERR|CRITICAL|ALERT|FATAL|EMERGENCY)\b',
|
|
14
|
+
re.IGNORECASE
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Multiple timestamp patterns for different log formats
|
|
18
|
+
_TIMESTAMP_PATTERNS = [
|
|
19
|
+
# ISO 8601: 2026-03-21T10:00:00Z or 2026-03-21T10:00:00.123Z or 2026-03-21T10:00:00+00:00
|
|
20
|
+
re.compile(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)'),
|
|
21
|
+
# ISO-like with space: 2026-03-21 10:00:00 or 2026-03-21 10:00:00.123
|
|
22
|
+
re.compile(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)'),
|
|
23
|
+
# Common Log Format / Apache: 21/Mar/2026:10:00:00 +0000 or [21/Mar/2026:10:00:00 +0000]
|
|
24
|
+
re.compile(r'(\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}(?:\s+[+-]\d{4})?)'),
|
|
25
|
+
# Syslog-style: Mar 21 10:00:00 (year is assumed current year)
|
|
26
|
+
re.compile(r'([A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})'),
|
|
27
|
+
# Unix timestamp: 1711054800 (10 digits for seconds)
|
|
28
|
+
re.compile(r'\b(\d{10})\b'),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Month name mapping for parsing
|
|
32
|
+
_MONTH_MAP = {
|
|
33
|
+
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
|
34
|
+
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LogEntry:
|
|
39
|
+
level: str
|
|
40
|
+
message: str
|
|
41
|
+
raw: str
|
|
42
|
+
timestamp: Optional[datetime] = None
|
|
43
|
+
service: Optional[str] = None
|
|
44
|
+
trace_id: Optional[str] = None
|
|
45
|
+
span_id: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Level normalization constants
|
|
49
|
+
_NORMALIZE_LEVEL_MAP = {
|
|
50
|
+
"WARNING": "WARN",
|
|
51
|
+
"EMERGENCY": "FATAL",
|
|
52
|
+
"ERR": "ERROR",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _normalize_level(level: str) -> str:
|
|
57
|
+
"""Normalize log level aliases to canonical forms."""
|
|
58
|
+
return _NORMALIZE_LEVEL_MAP.get(level.upper(), level.upper())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_json_observability(data: dict) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
62
|
+
"""Pull service / trace / span from common JSON log shapes (K8s, OTel, Docker)."""
|
|
63
|
+
k8s = data.get("kubernetes")
|
|
64
|
+
k8s_d: dict = k8s if isinstance(k8s, dict) else {}
|
|
65
|
+
pod_name = k8s_d.get("pod_name")
|
|
66
|
+
if not pod_name and isinstance(k8s_d.get("pod"), dict):
|
|
67
|
+
pod_name = k8s_d["pod"].get("name")
|
|
68
|
+
|
|
69
|
+
service = (
|
|
70
|
+
data.get("service")
|
|
71
|
+
or data.get("service.name")
|
|
72
|
+
or data.get("service_name")
|
|
73
|
+
or pod_name
|
|
74
|
+
or k8s_d.get("container_name")
|
|
75
|
+
or data.get("container")
|
|
76
|
+
or data.get("container.name")
|
|
77
|
+
or data.get("logger")
|
|
78
|
+
or data.get("logger.name")
|
|
79
|
+
)
|
|
80
|
+
if service is not None:
|
|
81
|
+
service = str(service)
|
|
82
|
+
|
|
83
|
+
trace_id = data.get("trace_id") or data.get("traceId") or data.get("trace.id")
|
|
84
|
+
if not trace_id and isinstance(data.get("trace"), dict):
|
|
85
|
+
trace_id = data["trace"].get("id")
|
|
86
|
+
if not trace_id and isinstance(data.get("otelTraceID"), str):
|
|
87
|
+
trace_id = data["otelTraceID"]
|
|
88
|
+
if trace_id is not None:
|
|
89
|
+
trace_id = str(trace_id)
|
|
90
|
+
|
|
91
|
+
span_id = data.get("span_id") or data.get("spanId") or data.get("span.id")
|
|
92
|
+
if span_id is not None:
|
|
93
|
+
span_id = str(span_id)
|
|
94
|
+
|
|
95
|
+
return service, trace_id, span_id
|
|
96
|
+
|
|
97
|
+
def parse_line(line: str) -> LogEntry:
|
|
98
|
+
"""Parse a single line of log and extract severity level."""
|
|
99
|
+
line = line.strip()
|
|
100
|
+
|
|
101
|
+
# 1. Check if JSON log object (common in docker/kubernetes/modern APIs)
|
|
102
|
+
if line.startswith('{') and line.endswith('}'):
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(line)
|
|
105
|
+
# Find level key
|
|
106
|
+
level = _normalize_level(data.get('level', data.get('severity', data.get('log.level', 'UNKNOWN'))))
|
|
107
|
+
# Find message key
|
|
108
|
+
message = str(data.get('message', data.get('msg', data.get('text', line))))
|
|
109
|
+
|
|
110
|
+
# Find timestamp
|
|
111
|
+
timestamp_str = data.get('timestamp', data.get('time', data.get('@timestamp')))
|
|
112
|
+
timestamp = None
|
|
113
|
+
if timestamp_str:
|
|
114
|
+
try:
|
|
115
|
+
# Basic ISO parsing
|
|
116
|
+
timestamp = datetime.fromisoformat(str(timestamp_str).replace('Z', '+00:00'))
|
|
117
|
+
except ValueError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
svc, tid, sid = _extract_json_observability(data)
|
|
121
|
+
return LogEntry(
|
|
122
|
+
level=level,
|
|
123
|
+
message=message,
|
|
124
|
+
raw=line,
|
|
125
|
+
timestamp=timestamp,
|
|
126
|
+
service=svc,
|
|
127
|
+
trace_id=tid,
|
|
128
|
+
span_id=sid,
|
|
129
|
+
)
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
# 2. Try typical log formats like [INFO], (WARN), ERROR:
|
|
134
|
+
match = _BRACKET_LEVEL_PATTERN.search(line)
|
|
135
|
+
if not match:
|
|
136
|
+
# Try finding without brackets as a fallback, e.g. "INFO:" or "INFO - "
|
|
137
|
+
match = _BRACKETLESS_LEVEL_PATTERN.search(line)
|
|
138
|
+
|
|
139
|
+
if match:
|
|
140
|
+
level = _normalize_level(match.group(1))
|
|
141
|
+
|
|
142
|
+
# Remove the [LEVEL] part from the message for cleaner display
|
|
143
|
+
message = line.replace(match.group(0), '', 1).strip()
|
|
144
|
+
|
|
145
|
+
# Clean up common separators left behind like ": " or "- "
|
|
146
|
+
if message.startswith(':') or message.startswith('-'):
|
|
147
|
+
message = message[1:].strip()
|
|
148
|
+
|
|
149
|
+
return LogEntry(level=level, message=message, raw=line, timestamp=extract_timestamp(line))
|
|
150
|
+
|
|
151
|
+
# fallback
|
|
152
|
+
return LogEntry(level="UNKNOWN", message=line, raw=line, timestamp=extract_timestamp(line))
|
|
153
|
+
|
|
154
|
+
def extract_timestamp(text: str) -> Optional[datetime]:
|
|
155
|
+
"""Extract a timestamp from a raw string using multiple format patterns."""
|
|
156
|
+
for pattern in _TIMESTAMP_PATTERNS:
|
|
157
|
+
match = pattern.search(text)
|
|
158
|
+
if match:
|
|
159
|
+
ts_str = match.group(1)
|
|
160
|
+
try:
|
|
161
|
+
# Try ISO format first (handles most cases)
|
|
162
|
+
if '-' in ts_str and ('T' in ts_str or ts_str[10:11] == ' '):
|
|
163
|
+
# Handle ISO-like with space instead of T
|
|
164
|
+
return datetime.fromisoformat(ts_str.replace('Z', '+00:00').replace(' ', 'T'))
|
|
165
|
+
# Handle Common Log Format: 21/Mar/2026:10:00:00 +0000
|
|
166
|
+
elif '/' in ts_str:
|
|
167
|
+
parts = ts_str.split()
|
|
168
|
+
main_part = parts[0]
|
|
169
|
+
# Parse: DD/Mon/YYYY:HH:MM:SS
|
|
170
|
+
match_parts = re.match(r'(\d{2})/([A-Za-z]{3})/(\d{4}):(\d{2}):(\d{2}):(\d{2})', main_part)
|
|
171
|
+
if match_parts:
|
|
172
|
+
day, month_str, year, hour, minute, second = match_parts.groups()
|
|
173
|
+
month = _MONTH_MAP.get(month_str, 1)
|
|
174
|
+
return datetime(int(year), month, int(day), int(hour), int(minute), int(second))
|
|
175
|
+
# Handle Syslog-style: Mar 21 10:00:00
|
|
176
|
+
elif ts_str[0].isalpha():
|
|
177
|
+
match_parts = re.match(r'([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})', ts_str)
|
|
178
|
+
if match_parts:
|
|
179
|
+
month_str, day, hour, minute, second = match_parts.groups()
|
|
180
|
+
month = _MONTH_MAP.get(month_str, 1)
|
|
181
|
+
year = datetime.now().year # Assume current year
|
|
182
|
+
return datetime(year, month, int(day), int(hour), int(minute), int(second))
|
|
183
|
+
# Handle Unix timestamp
|
|
184
|
+
elif ts_str.isdigit():
|
|
185
|
+
return datetime.fromtimestamp(int(ts_str))
|
|
186
|
+
except (ValueError, OSError):
|
|
187
|
+
continue
|
|
188
|
+
return None
|
logscope/themes.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Default themes and mappings for LogScope
|
|
2
|
+
|
|
3
|
+
DEFAULT_THEMES = {
|
|
4
|
+
"default": {
|
|
5
|
+
"levels": {
|
|
6
|
+
"TRACE": ("🔍", "dim white"),
|
|
7
|
+
"DEBUG": ("🐛", "bold blue"),
|
|
8
|
+
"INFO": ("🔵", "bold green"),
|
|
9
|
+
"NOTICE": ("🔔", "bold cyan"),
|
|
10
|
+
"WARN": ("🟡", "bold yellow"),
|
|
11
|
+
"ERROR": ("🔴", "bold red"),
|
|
12
|
+
"CRITICAL": ("💥", "bold magenta"),
|
|
13
|
+
"ALERT": ("🚨", "bold color(208)"),
|
|
14
|
+
"FATAL": ("💀", "bold dark_red"),
|
|
15
|
+
"UNKNOWN": ("⚪", "dim white")
|
|
16
|
+
},
|
|
17
|
+
"highlights": {
|
|
18
|
+
"logscope.ip": "bold green",
|
|
19
|
+
"logscope.url": "underline blue",
|
|
20
|
+
"logscope.timestamp": "cyan",
|
|
21
|
+
"logscope.uuid": "bold magenta",
|
|
22
|
+
"logscope.email": "underline yellow",
|
|
23
|
+
"logscope.path": "dim blue",
|
|
24
|
+
"logscope.status_ok": "bold green",
|
|
25
|
+
"logscope.status_warn": "bold yellow",
|
|
26
|
+
"logscope.status_err": "bold red",
|
|
27
|
+
"logscope.method": "bold cyan"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"neon": {
|
|
31
|
+
"levels": {
|
|
32
|
+
"TRACE": ("·", "magenta"),
|
|
33
|
+
"DEBUG": ("⚡", "bold magenta"),
|
|
34
|
+
"INFO": ("✨", "bold pink1"),
|
|
35
|
+
"NOTICE": ("🌌", "bold purple"),
|
|
36
|
+
"WARN": ("🔥", "bold yellow1"),
|
|
37
|
+
"ERROR": ("🧨", "bold red1"),
|
|
38
|
+
"CRITICAL": ("⬢", "bold magenta"),
|
|
39
|
+
"ALERT": ("📣", "bold orange1"),
|
|
40
|
+
"FATAL": ("☣️", "bold dark_red"),
|
|
41
|
+
"UNKNOWN": ("?", "dim white")
|
|
42
|
+
},
|
|
43
|
+
"highlights": {
|
|
44
|
+
"logscope.ip": "bold pink1",
|
|
45
|
+
"logscope.url": "italic underline magenta",
|
|
46
|
+
"logscope.timestamp": "bold purple",
|
|
47
|
+
"logscope.uuid": "bold yellow",
|
|
48
|
+
"logscope.email": "bold pink1",
|
|
49
|
+
"logscope.path": "italic magenta",
|
|
50
|
+
"logscope.status_ok": "bold pink1",
|
|
51
|
+
"logscope.status_warn": "bold yellow",
|
|
52
|
+
"logscope.status_err": "bold red",
|
|
53
|
+
"logscope.method": "bold cyan"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"ocean": {
|
|
57
|
+
"levels": {
|
|
58
|
+
"TRACE": ("⚓", "dim cyan"),
|
|
59
|
+
"DEBUG": ("🌊", "blue"),
|
|
60
|
+
"INFO": ("💧", "bold blue"),
|
|
61
|
+
"NOTICE": ("🐳", "cyan"),
|
|
62
|
+
"WARN": ("🐚", "yellow"),
|
|
63
|
+
"ERROR": ("🌋", "red"),
|
|
64
|
+
"CRITICAL": ("⛈️ ", "bold blue"),
|
|
65
|
+
"ALERT": ("🚩", "bold orange1"),
|
|
66
|
+
"FATAL": ("🌪️ ", "bold red"),
|
|
67
|
+
"UNKNOWN": ("?", "dim cyan")
|
|
68
|
+
},
|
|
69
|
+
"highlights": {
|
|
70
|
+
"logscope.ip": "bold cyan",
|
|
71
|
+
"logscope.url": "underline deep_sky_blue1",
|
|
72
|
+
"logscope.timestamp": "blue",
|
|
73
|
+
"logscope.uuid": "bold deep_sky_blue3",
|
|
74
|
+
"logscope.email": "underline cyan",
|
|
75
|
+
"logscope.path": "dim cyan",
|
|
76
|
+
"logscope.status_ok": "bold green",
|
|
77
|
+
"logscope.status_warn": "bold yellow",
|
|
78
|
+
"logscope.status_err": "bold red",
|
|
79
|
+
"logscope.method": "bold cyan"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"forest": {
|
|
83
|
+
"levels": {
|
|
84
|
+
"TRACE": ("🍃", "dim green"),
|
|
85
|
+
"DEBUG": ("🌿", "green"),
|
|
86
|
+
"INFO": ("🌳", "bold green"),
|
|
87
|
+
"NOTICE": ("🍄", "magenta"),
|
|
88
|
+
"WARN": ("🍂", "yellow"),
|
|
89
|
+
"ERROR": ("🪵", "red"),
|
|
90
|
+
"CRITICAL": ("🌲", "bold white"),
|
|
91
|
+
"ALERT": ("🐺", "bold orange1"),
|
|
92
|
+
"FATAL": ("💀", "bold dark_red"),
|
|
93
|
+
"UNKNOWN": ("?", "dim green")
|
|
94
|
+
},
|
|
95
|
+
"highlights": {
|
|
96
|
+
"logscope.ip": "bold green3",
|
|
97
|
+
"logscope.url": "underline dark_green",
|
|
98
|
+
"logscope.timestamp": "yellow4",
|
|
99
|
+
"logscope.uuid": "bold orange4",
|
|
100
|
+
"logscope.email": "underline green",
|
|
101
|
+
"logscope.path": "dim green3",
|
|
102
|
+
"logscope.status_ok": "bold green",
|
|
103
|
+
"logscope.status_warn": "bold yellow",
|
|
104
|
+
"logscope.status_err": "bold red",
|
|
105
|
+
"logscope.method": "bold green1"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"minimal": {
|
|
109
|
+
"levels": {
|
|
110
|
+
"TRACE": (" ", "dim"),
|
|
111
|
+
"DEBUG": (" ", "dim"),
|
|
112
|
+
"INFO": (" ", "white"),
|
|
113
|
+
"NOTICE": ("!", "cyan"),
|
|
114
|
+
"WARN": ("?", "yellow"),
|
|
115
|
+
"ERROR": ("#", "red"),
|
|
116
|
+
"CRITICAL": ("*", "bold red"),
|
|
117
|
+
"ALERT": ("!", "bold yellow"),
|
|
118
|
+
"FATAL": ("X", "bold red"),
|
|
119
|
+
"UNKNOWN": ("?", "dim")
|
|
120
|
+
},
|
|
121
|
+
"highlights": {
|
|
122
|
+
"logscope.ip": "bold",
|
|
123
|
+
"logscope.url": "underline",
|
|
124
|
+
"logscope.timestamp": "dim",
|
|
125
|
+
"logscope.uuid": "dim",
|
|
126
|
+
"logscope.email": "underline",
|
|
127
|
+
"logscope.path": "italic",
|
|
128
|
+
"logscope.status_ok": "green",
|
|
129
|
+
"logscope.status_warn": "yellow",
|
|
130
|
+
"logscope.status_err": "red",
|
|
131
|
+
"logscope.method": "bold"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"spectra": {
|
|
135
|
+
"levels": {
|
|
136
|
+
"TRACE": ("◦", "dim cyan"),
|
|
137
|
+
"DEBUG": ("◇", "cyan"),
|
|
138
|
+
"INFO": ("◆", "bold bright_cyan"),
|
|
139
|
+
"NOTICE": ("▹", "bold turquoise2"),
|
|
140
|
+
"WARN": ("⚠", "bold bright_yellow"),
|
|
141
|
+
"ERROR": ("✖", "bold bright_red"),
|
|
142
|
+
"CRITICAL": ("⬢", "bold magenta"),
|
|
143
|
+
"ALERT": ("⚡", "bold color(214)"),
|
|
144
|
+
"FATAL": ("☠", "bold red3"),
|
|
145
|
+
"UNKNOWN": ("·", "dim white")
|
|
146
|
+
},
|
|
147
|
+
"highlights": {
|
|
148
|
+
"logscope.ip": "bold bright_cyan",
|
|
149
|
+
"logscope.url": "underline bright_magenta",
|
|
150
|
+
"logscope.timestamp": "bold cyan",
|
|
151
|
+
"logscope.uuid": "bold bright_white",
|
|
152
|
+
"logscope.email": "underline turquoise2",
|
|
153
|
+
"logscope.path": "italic cyan",
|
|
154
|
+
"logscope.status_ok": "bold spring_green1",
|
|
155
|
+
"logscope.status_warn": "bold gold1",
|
|
156
|
+
"logscope.status_err": "bold red1",
|
|
157
|
+
"logscope.method": "bold deep_sky_blue1"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}
|
logscope/viewer.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional, List, TextIO, Set, Pattern
|
|
6
|
+
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.layout import Layout
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from rich.highlighter import RegexHighlighter
|
|
14
|
+
from rich.theme import Theme
|
|
15
|
+
|
|
16
|
+
from .parser import parse_line, LogEntry, _normalize_level
|
|
17
|
+
from .themes import DEFAULT_THEMES
|
|
18
|
+
|
|
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
|
+
|
|
34
|
+
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
|
35
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
class LogScopeHighlighter(RegexHighlighter):
|
|
38
|
+
"""Apply style to anything that looks like an IP address, URL, or timestamp."""
|
|
39
|
+
base_style = "logscope."
|
|
40
|
+
highlights = [
|
|
41
|
+
r"(?P<ip>\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b)",
|
|
42
|
+
r"(?P<url>https?://[a-zA-Z0-9./?=#_%:-]+)",
|
|
43
|
+
r"(?P<timestamp>\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)",
|
|
44
|
+
r"(?P<uuid>\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b)",
|
|
45
|
+
r"(?P<email>\b[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+\b)",
|
|
46
|
+
r"(?P<path>(?:[a-zA-Z]:|\/)[a-zA-Z0-9._\-\/\\ ]+)",
|
|
47
|
+
r"(?P<status_ok>\b(200|201|204)\b)",
|
|
48
|
+
r"(?P<status_warn>\b(301|302|400|401|403|404)\b)",
|
|
49
|
+
r"(?P<status_err>\b(500|502|503|504)\b)",
|
|
50
|
+
r"(?P<method>\b(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\b)",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LogScopeManager:
|
|
55
|
+
"""Manages the console state and current theme."""
|
|
56
|
+
def __init__(self, theme_name: str = "default"):
|
|
57
|
+
self._no_color = False
|
|
58
|
+
self.apply_theme(theme_name)
|
|
59
|
+
|
|
60
|
+
def apply_theme(self, theme_name_or_dict, no_color: bool = False, custom_themes: Optional[dict] = None):
|
|
61
|
+
self._no_color = no_color
|
|
62
|
+
themes = custom_themes if custom_themes is not None else DEFAULT_THEMES
|
|
63
|
+
if isinstance(theme_name_or_dict, str):
|
|
64
|
+
theme_config = themes.get(theme_name_or_dict, themes["default"])
|
|
65
|
+
else:
|
|
66
|
+
theme_config = theme_name_or_dict
|
|
67
|
+
|
|
68
|
+
self.level_mapping = theme_config["levels"]
|
|
69
|
+
self.rich_theme = Theme(theme_config["highlights"])
|
|
70
|
+
self.console = Console(
|
|
71
|
+
theme=self.rich_theme,
|
|
72
|
+
highlighter=LogScopeHighlighter(),
|
|
73
|
+
no_color=no_color,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def format_log(self, entry: LogEntry, line_number: Optional[int] = None, highlight: Optional[str] = None, highlight_color: str = "bold magenta", case_sensitive: bool = False) -> Text:
|
|
77
|
+
"""Format a log entry with current theme's colors and emojis."""
|
|
78
|
+
icon, style = self.level_mapping.get(entry.level, self.level_mapping.get("UNKNOWN", ("⚪", "dim white")))
|
|
79
|
+
|
|
80
|
+
text = Text()
|
|
81
|
+
if line_number is not None:
|
|
82
|
+
text.append(f"{line_number:>4} │ ", style="dim")
|
|
83
|
+
|
|
84
|
+
text.append(f"{icon} {entry.level:<7} ", style=style)
|
|
85
|
+
|
|
86
|
+
# Apply custom highlight to message if keyword is specified
|
|
87
|
+
if highlight and highlight.strip():
|
|
88
|
+
message = entry.message
|
|
89
|
+
keyword = highlight.strip()
|
|
90
|
+
if self._no_color:
|
|
91
|
+
text.append(message)
|
|
92
|
+
elif case_sensitive:
|
|
93
|
+
# Case-sensitive: simple split
|
|
94
|
+
parts = message.split(keyword)
|
|
95
|
+
if len(parts) > 1:
|
|
96
|
+
for i, part in enumerate(parts):
|
|
97
|
+
text.append(part)
|
|
98
|
+
if i < len(parts) - 1:
|
|
99
|
+
text.append(keyword, style=highlight_color)
|
|
100
|
+
else:
|
|
101
|
+
text.append(message)
|
|
102
|
+
else:
|
|
103
|
+
# Case-insensitive: use regex to find matches and preserve original case
|
|
104
|
+
import re
|
|
105
|
+
pattern = re.compile(re.escape(keyword), re.IGNORECASE)
|
|
106
|
+
last_end = 0
|
|
107
|
+
for match in pattern.finditer(message):
|
|
108
|
+
text.append(message[last_end:match.start()])
|
|
109
|
+
text.append(match.group(), style=highlight_color)
|
|
110
|
+
last_end = match.end()
|
|
111
|
+
text.append(message[last_end:])
|
|
112
|
+
else:
|
|
113
|
+
text.append(entry.message)
|
|
114
|
+
|
|
115
|
+
return text
|
|
116
|
+
|
|
117
|
+
# Global manager instance
|
|
118
|
+
manager = LogScopeManager()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_level_filter(level: Optional[str]) -> Optional[Set[str]]:
|
|
122
|
+
if not level or not level.strip():
|
|
123
|
+
return None
|
|
124
|
+
parts = {_normalize_level(p.strip()) for p in level.split(",") if p.strip()}
|
|
125
|
+
return parts or None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def line_passes_level(entry_level: str, allowed: Optional[Set[str]]) -> bool:
|
|
129
|
+
if not allowed:
|
|
130
|
+
return True
|
|
131
|
+
return entry_level in allowed
|
|
132
|
+
|
|
133
|
+
|
|
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
|
+
|
|
143
|
+
def line_passes_search(
|
|
144
|
+
line: str,
|
|
145
|
+
search: Optional[str],
|
|
146
|
+
*,
|
|
147
|
+
pattern: Optional[Pattern[str]],
|
|
148
|
+
use_regex: bool,
|
|
149
|
+
case_sensitive: bool,
|
|
150
|
+
invert_match: bool,
|
|
151
|
+
) -> bool:
|
|
152
|
+
if not search:
|
|
153
|
+
return True
|
|
154
|
+
if use_regex and pattern is not None:
|
|
155
|
+
matched = pattern.search(line) is not None
|
|
156
|
+
elif case_sensitive:
|
|
157
|
+
matched = search in line
|
|
158
|
+
else:
|
|
159
|
+
matched = search.lower() in line.lower()
|
|
160
|
+
if invert_match:
|
|
161
|
+
return not matched
|
|
162
|
+
return matched
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def line_passes_filters(
|
|
166
|
+
entry: LogEntry,
|
|
167
|
+
level_set: Optional[Set[str]],
|
|
168
|
+
search: Optional[str],
|
|
169
|
+
since: Optional[datetime],
|
|
170
|
+
until: Optional[datetime],
|
|
171
|
+
*,
|
|
172
|
+
pattern: Optional[Pattern[str]],
|
|
173
|
+
use_regex: bool,
|
|
174
|
+
case_sensitive: bool,
|
|
175
|
+
invert_match: bool,
|
|
176
|
+
min_level: Optional[str] = None,
|
|
177
|
+
) -> bool:
|
|
178
|
+
"""Check if an entry passes all filters (level, min_level, search, timestamp)."""
|
|
179
|
+
if not line_passes_level(entry.level, level_set):
|
|
180
|
+
return False
|
|
181
|
+
if not line_passes_min_level(entry.level, min_level):
|
|
182
|
+
return False
|
|
183
|
+
if not line_passes_search(
|
|
184
|
+
entry.raw,
|
|
185
|
+
search,
|
|
186
|
+
pattern=pattern,
|
|
187
|
+
use_regex=use_regex,
|
|
188
|
+
case_sensitive=case_sensitive,
|
|
189
|
+
invert_match=invert_match,
|
|
190
|
+
):
|
|
191
|
+
return False
|
|
192
|
+
if entry.timestamp:
|
|
193
|
+
if since and entry.timestamp.replace(tzinfo=None) < since.replace(tzinfo=None):
|
|
194
|
+
return False
|
|
195
|
+
if until and entry.timestamp.replace(tzinfo=None) > until.replace(tzinfo=None):
|
|
196
|
+
return False
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_lines(file: TextIO, follow: bool):
|
|
201
|
+
"""Generator that yields (line_number, line) tuples from a file, optionally tailing it."""
|
|
202
|
+
line_number = 0
|
|
203
|
+
# yield existing lines
|
|
204
|
+
for line in file:
|
|
205
|
+
line_number += 1
|
|
206
|
+
if line.strip():
|
|
207
|
+
yield line_number, line
|
|
208
|
+
|
|
209
|
+
if not follow:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# tailing
|
|
213
|
+
manager.console.print("[dim]-- 🔭 Tailing new logs... (Press Ctrl+C to exit) --[/dim]")
|
|
214
|
+
try:
|
|
215
|
+
while True:
|
|
216
|
+
line = file.readline()
|
|
217
|
+
if not line:
|
|
218
|
+
time.sleep(0.1)
|
|
219
|
+
continue
|
|
220
|
+
line_number += 1
|
|
221
|
+
if line.strip():
|
|
222
|
+
yield line_number, line
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def stream_logs(
|
|
228
|
+
file: TextIO,
|
|
229
|
+
follow: bool,
|
|
230
|
+
level: Optional[str] = None,
|
|
231
|
+
search: Optional[str] = None,
|
|
232
|
+
export_html: Optional[Path] = None,
|
|
233
|
+
show_line_numbers: bool = False,
|
|
234
|
+
since: Optional[datetime] = None,
|
|
235
|
+
until: Optional[datetime] = None,
|
|
236
|
+
*,
|
|
237
|
+
use_regex: bool = False,
|
|
238
|
+
search_pattern: Optional[Pattern[str]] = None,
|
|
239
|
+
case_sensitive: bool = False,
|
|
240
|
+
invert_match: bool = False,
|
|
241
|
+
highlight: Optional[str] = None,
|
|
242
|
+
highlight_color: str = "bold magenta",
|
|
243
|
+
min_level: Optional[str] = None,
|
|
244
|
+
):
|
|
245
|
+
"""Basic console mode: prints directly to stdout, supporting tails."""
|
|
246
|
+
if export_html:
|
|
247
|
+
manager.console.record = True
|
|
248
|
+
|
|
249
|
+
level_set = parse_level_filter(level)
|
|
250
|
+
|
|
251
|
+
line_count = 0
|
|
252
|
+
try:
|
|
253
|
+
for line_number, line in get_lines(file, follow):
|
|
254
|
+
line_count = line_number
|
|
255
|
+
entry = parse_line(line)
|
|
256
|
+
|
|
257
|
+
if not line_passes_filters(
|
|
258
|
+
entry,
|
|
259
|
+
level_set,
|
|
260
|
+
search,
|
|
261
|
+
since,
|
|
262
|
+
until,
|
|
263
|
+
pattern=search_pattern,
|
|
264
|
+
use_regex=use_regex,
|
|
265
|
+
case_sensitive=case_sensitive,
|
|
266
|
+
invert_match=invert_match,
|
|
267
|
+
min_level=min_level,
|
|
268
|
+
):
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
formatted = manager.format_log(
|
|
272
|
+
entry,
|
|
273
|
+
line_number=line_count if show_line_numbers else None,
|
|
274
|
+
highlight=highlight,
|
|
275
|
+
highlight_color=highlight_color,
|
|
276
|
+
case_sensitive=case_sensitive,
|
|
277
|
+
)
|
|
278
|
+
manager.console.print(formatted)
|
|
279
|
+
finally:
|
|
280
|
+
if export_html:
|
|
281
|
+
manager.console.save_html(str(export_html), clear=False)
|
|
282
|
+
manager.console.print(f"\n[bold green]✅ Logs exported successfully to {export_html}[/bold green]")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_dashboard(
|
|
286
|
+
file: TextIO,
|
|
287
|
+
follow: bool,
|
|
288
|
+
level_filter: Optional[str] = None,
|
|
289
|
+
search_filter: Optional[str] = None,
|
|
290
|
+
show_line_numbers: bool = False,
|
|
291
|
+
since: Optional[datetime] = None,
|
|
292
|
+
until: Optional[datetime] = None,
|
|
293
|
+
*,
|
|
294
|
+
use_regex: bool = False,
|
|
295
|
+
search_pattern: Optional[Pattern[str]] = None,
|
|
296
|
+
case_sensitive: bool = False,
|
|
297
|
+
invert_match: bool = False,
|
|
298
|
+
highlight: Optional[str] = None,
|
|
299
|
+
highlight_color: str = "bold magenta",
|
|
300
|
+
min_level: Optional[str] = None,
|
|
301
|
+
):
|
|
302
|
+
"""Dashboard mode: Shows a summary stats panel and recent logs layout."""
|
|
303
|
+
|
|
304
|
+
level_set = parse_level_filter(level_filter)
|
|
305
|
+
|
|
306
|
+
stats = {
|
|
307
|
+
"FATAL": 0,
|
|
308
|
+
"ALERT": 0,
|
|
309
|
+
"CRITICAL": 0,
|
|
310
|
+
"ERROR": 0,
|
|
311
|
+
"WARN": 0,
|
|
312
|
+
"NOTICE": 0,
|
|
313
|
+
"INFO": 0,
|
|
314
|
+
"DEBUG": 0,
|
|
315
|
+
"TRACE": 0,
|
|
316
|
+
"UNKNOWN": 0
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
total_processed = 0
|
|
320
|
+
recent_logs: List[Text] = []
|
|
321
|
+
MAX_LOGS = 25 # Number of lines to keep in the scrolling window
|
|
322
|
+
|
|
323
|
+
def generate_layout() -> Layout:
|
|
324
|
+
layout = Layout()
|
|
325
|
+
layout.split_column(
|
|
326
|
+
Layout(name="header", size=5),
|
|
327
|
+
Layout(name="body")
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Stats table
|
|
331
|
+
table = Table(show_header=False, expand=True, border_style="dim", box=None)
|
|
332
|
+
table.add_column("C1", justify="center")
|
|
333
|
+
table.add_column("C2", justify="center")
|
|
334
|
+
table.add_column("C3", justify="center")
|
|
335
|
+
table.add_column("C4", justify="center")
|
|
336
|
+
|
|
337
|
+
table.add_row(
|
|
338
|
+
f"[bold dark_red]💀 Fatal: {stats.get('FATAL', 0)}[/bold dark_red]",
|
|
339
|
+
f"[bold magenta]💥 Critical: {stats.get('CRITICAL', 0)}[/bold magenta]",
|
|
340
|
+
f"[bold red]🔴 Errors: {stats.get('ERROR', 0)}[/bold red]",
|
|
341
|
+
f"[bold yellow]🟡 Warns: {stats.get('WARN', 0)}[/bold yellow]"
|
|
342
|
+
)
|
|
343
|
+
table.add_row(
|
|
344
|
+
f"[bold green]🔵 Info: {stats.get('INFO', 0)}[/bold green]",
|
|
345
|
+
f"[bold blue]🐛 Debug: {stats.get('DEBUG', 0)}[/bold blue]",
|
|
346
|
+
f"[dim white]🔍 Trace: {stats.get('TRACE', 0)}[/dim white]",
|
|
347
|
+
f"[dim white]⚪ Unknown: {stats.get('UNKNOWN', 0)}[/dim white]"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
layout["header"].update(Panel(table, title=f"[bold]✨ LogScope Live Dashboard — Total: {total_processed}[/bold]", border_style="cyan"))
|
|
351
|
+
|
|
352
|
+
# Logs
|
|
353
|
+
log_group = Group(*recent_logs)
|
|
354
|
+
title = "Recent Logs (Auto-highlight enabled)"
|
|
355
|
+
if follow:
|
|
356
|
+
title += " - [blink green]● LIVE[/blink green]"
|
|
357
|
+
|
|
358
|
+
layout["body"].update(Panel(log_group, title=title))
|
|
359
|
+
|
|
360
|
+
return layout
|
|
361
|
+
|
|
362
|
+
manager.console.clear()
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
with Live(generate_layout(), console=manager.console, refresh_per_second=10) as live:
|
|
366
|
+
for line_number, line in get_lines(file, follow):
|
|
367
|
+
total_processed = line_number
|
|
368
|
+
entry = parse_line(line)
|
|
369
|
+
|
|
370
|
+
if not line_passes_filters(
|
|
371
|
+
entry,
|
|
372
|
+
level_set,
|
|
373
|
+
search_filter,
|
|
374
|
+
since,
|
|
375
|
+
until,
|
|
376
|
+
pattern=search_pattern,
|
|
377
|
+
use_regex=use_regex,
|
|
378
|
+
case_sensitive=case_sensitive,
|
|
379
|
+
invert_match=invert_match,
|
|
380
|
+
min_level=min_level,
|
|
381
|
+
):
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# Update stats tally
|
|
385
|
+
entry_level = entry.level if entry.level in stats else "UNKNOWN"
|
|
386
|
+
stats[entry_level] += 1
|
|
387
|
+
|
|
388
|
+
formatted = manager.format_log(
|
|
389
|
+
entry,
|
|
390
|
+
line_number=total_processed if show_line_numbers else None,
|
|
391
|
+
highlight=highlight,
|
|
392
|
+
highlight_color=highlight_color,
|
|
393
|
+
case_sensitive=case_sensitive,
|
|
394
|
+
)
|
|
395
|
+
recent_logs.append(formatted)
|
|
396
|
+
if len(recent_logs) > MAX_LOGS:
|
|
397
|
+
recent_logs.pop(0)
|
|
398
|
+
|
|
399
|
+
live.update(generate_layout())
|
|
400
|
+
except KeyboardInterrupt:
|
|
401
|
+
pass
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logscope-cli
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: LogScope — Beautiful log viewer for the terminal
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: vinnytherobot
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: rich (>=13.0.0,<14.0.0)
|
|
16
|
+
Requires-Dist: typer (>=0.15.1,<0.16.0)
|
|
17
|
+
Requires-Dist: typing-extensions (>=4.0.0,<5.0.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
<div align="center">
|
|
21
|
+
<img src="./assets/logscope-logo.png" alt="LogScope Logo" width="500" height="500">
|
|
22
|
+
|
|
23
|
+
# LogScope
|
|
24
|
+
|
|
25
|
+
**Beautiful, simple, and powerful log viewer for the terminal.**
|
|
26
|
+
|
|
27
|
+
[](https://python.org)
|
|
28
|
+
[](https://typer.tiangolo.com/)
|
|
29
|
+
[](https://rich.readthedocs.io/)
|
|
30
|
+
[](#)
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
A modern CLI tool that turns boring text logs or messy JSON lines into stunning, structured, and colorful terminal outputs—complete with a live dashboard, smart highlighting, and HTML exporting.
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Features that shine
|
|
40
|
+
|
|
41
|
+
* **Fast & Lightweight**: Tail files natively or stream huge data directly via pipes (`cat server.log | logscope`).
|
|
42
|
+
* **Colored & Structured Logs**: Automatically identifies `INFO`, `WARNING`, `ERROR`, `CRITICAL`, and `DEBUG`, applying beautiful typography.
|
|
43
|
+
* **Universal Parser**: Reads typical bracket logs (`[INFO]`) **and** parses modern NDJSON / JSON logs out of the box (e.g., Kubernetes, Docker).
|
|
44
|
+
* **Auto-Highlighting**: Magically highlights `IPs`, `URLs`, `Dates/Timestamps`, `UUIDs`, and `E-Mails` with dynamic colors.
|
|
45
|
+
* **Custom Keyword Highlighting**: Highlight specific keywords in log messages with `--highlight` and customize colors with `--highlight-color`.
|
|
46
|
+
* **Live Dashboard**: Watch logs stream in real-time alongside a live statistics panel keeping track of Error vs Info counts (`--dashboard`).
|
|
47
|
+
* **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`)
|
|
48
|
+
* **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.
|
|
49
|
+
* **Themes**: Choose from 6 beautiful themes (`default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`) or create custom themes via config file.
|
|
50
|
+
* **Plain output**: Use `--no-color` when you need unstyled text (e.g. piping to other tools or logs without ANSI codes).
|
|
51
|
+
* **Gzip logs**: Read `.gz` files directly—LogScope opens them as text without a manual `zcat` pipe.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
Ensure you have Python 3.9+ and pip installed.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Clone the repository
|
|
61
|
+
git clone https://github.com/vinnytherobot/logscope.git
|
|
62
|
+
cd logscope
|
|
63
|
+
|
|
64
|
+
# Install via Poetry
|
|
65
|
+
poetry install
|
|
66
|
+
poetry run logscope --help
|
|
67
|
+
|
|
68
|
+
# Or install globally via pip
|
|
69
|
+
pip install -e .
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage & Examples
|
|
75
|
+
|
|
76
|
+
### Using a File
|
|
77
|
+
```bash
|
|
78
|
+
# Basic colorized look
|
|
79
|
+
logscope /var/log/syslog
|
|
80
|
+
|
|
81
|
+
# Tailing a log in real-time (like tail -f)
|
|
82
|
+
logscope backend.log --follow
|
|
83
|
+
|
|
84
|
+
# Filter only errors
|
|
85
|
+
logscope production.log --level ERROR
|
|
86
|
+
|
|
87
|
+
# Multiple levels (comma-separated)
|
|
88
|
+
logscope production.log --level ERROR,WARN,INFO
|
|
89
|
+
|
|
90
|
+
# Search text dynamically
|
|
91
|
+
logscope server.log --search "Connection Timeout"
|
|
92
|
+
|
|
93
|
+
# Regex search (requires --search)
|
|
94
|
+
logscope server.log --search "timeout|refused|ECONNRESET" --regex
|
|
95
|
+
|
|
96
|
+
# Hide lines that match a pattern
|
|
97
|
+
logscope noisy.log --search "healthcheck" --invert-match
|
|
98
|
+
|
|
99
|
+
# Case-sensitive search
|
|
100
|
+
logscope app.log --search "UserID" --case-sensitive
|
|
101
|
+
|
|
102
|
+
# Highlight specific keywords
|
|
103
|
+
logscope server.log --highlight "timeout" --highlight-color "bold red"
|
|
104
|
+
|
|
105
|
+
# No colors (plain terminal output)
|
|
106
|
+
logscope app.log --no-color
|
|
107
|
+
|
|
108
|
+
# Compressed log file
|
|
109
|
+
logscope archive/app.log.gz
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Piping from other commands (Stdin support)
|
|
113
|
+
LogScope acts as a brilliant text reformatter for other tools!
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
kubectl logs my-pod -f | logscope
|
|
117
|
+
docker logs api-gateway | logscope --level CRITICAL
|
|
118
|
+
cat nginx.log | grep -v GET | logscope --dashboard
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### The Live Dashboard Mode
|
|
122
|
+
Monitor your logs like a pro with a live dashboard tracking error occurrences.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
logscope app.log --dashboard --follow
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Exporting to HTML
|
|
129
|
+
Need to attach the logs to a Jira ticket or Slack message but want to keep the formatting?
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
logscope failed_job.log --export-html bug_report.html
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Themes
|
|
136
|
+
Choose from 6 beautiful themes: `default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
logscope app.log --theme neon
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Create a `.logscoperc` file to set your preferred theme:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"theme": "neon",
|
|
147
|
+
"custom_themes": {
|
|
148
|
+
"my-theme": {
|
|
149
|
+
"levels": {
|
|
150
|
+
"ERROR": ("✖", "bold red")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Stack
|
|
160
|
+
|
|
161
|
+
* [**Rich**](https://github.com/Textualize/rich) -> UI Layouts, Colors, Highlighters, HTML Export.
|
|
162
|
+
* [**Typer**](https://github.com/tiangolo/typer) -> Modern, fast, and robust CLI creation.
|
|
163
|
+
* [**typing-extensions**](https://github.com/python/typing_extensions) -> Typed CLI annotations on Python 3.9.
|
|
164
|
+
* **Pathlib / Sys / gzip** -> File and standard input streaming; gzip text logs.
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
Open an issue or submit a pull request! Tests are written using `pytest`.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Running tests
|
|
171
|
+
pytest tests/
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
MIT License.
|
|
176
|
+
|
|
177
|
+
Made by [vinnytherobot](https://github.com/vinnytherobot)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
logscope/__init__.py,sha256=UAliSoi17ev1nmQ7_30bjTgOlJ-VfaD3gmbwdnh4rno,85
|
|
2
|
+
logscope/cli.py,sha256=05Jfz1SG3vBEmQUwApkMTQ7O_iUXoVT9T6yDcApkDlQ,8498
|
|
3
|
+
logscope/parser.py,sha256=qW0keilWPZ6q423PL4PFSiZz988tRxS1Yqf0QQatqzo,7723
|
|
4
|
+
logscope/themes.py,sha256=zmdC9eDgIBubP7AZ-biWiwvQgqQN91QwClYvOoZ2Oss,6143
|
|
5
|
+
logscope/viewer.py,sha256=VEv-rZdXAIVERIFEPyRDjSIT2cbAz28ghWrAG4RGFsA,13505
|
|
6
|
+
logscope_cli-0.4.1.dist-info/entry_points.txt,sha256=TQg5CmFjOVS8K7py8doZkzqQ98z0oWewlXx3OTFef8Q,45
|
|
7
|
+
logscope_cli-0.4.1.dist-info/licenses/LICENSE,sha256=E9YjzvjsSNiaRgFq6KALrt5XfA9z0qzO2ZO8o1dl3-4,1089
|
|
8
|
+
logscope_cli-0.4.1.dist-info/METADATA,sha256=3lqXu7SyGn_U53nH7k0uMBbrptf_OiNGIkck-RptLSU,5945
|
|
9
|
+
logscope_cli-0.4.1.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
|
|
10
|
+
logscope_cli-0.4.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 João Vinny
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|