logpeek-cli 0.1.0__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.
- logpeek/__init__.py +2 -0
- logpeek/cli.py +408 -0
- logpeek/parser.py +171 -0
- logpeek_cli-0.1.0.dist-info/METADATA +97 -0
- logpeek_cli-0.1.0.dist-info/RECORD +9 -0
- logpeek_cli-0.1.0.dist-info/WHEEL +5 -0
- logpeek_cli-0.1.0.dist-info/entry_points.txt +2 -0
- logpeek_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- logpeek_cli-0.1.0.dist-info/top_level.txt +1 -0
logpeek/__init__.py
ADDED
logpeek/cli.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""logpeek CLI - Log file viewer and analyzer."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .parser import detect_level, parse_timestamp, extract_fields, detect_format, FIELD_PATTERNS
|
|
12
|
+
|
|
13
|
+
# Colors for log levels
|
|
14
|
+
LEVEL_COLORS = {
|
|
15
|
+
'ERROR': 'red',
|
|
16
|
+
'WARNING': 'yellow',
|
|
17
|
+
'INFO': 'green',
|
|
18
|
+
'DEBUG': 'cyan',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def colorize_line(line, level=None):
|
|
23
|
+
"""Colorize a log line based on its level."""
|
|
24
|
+
if level is None:
|
|
25
|
+
level = detect_level(line)
|
|
26
|
+
if level and level in LEVEL_COLORS:
|
|
27
|
+
return click.style(line, fg=LEVEL_COLORS[level])
|
|
28
|
+
return line
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_lines(file, follow=False):
|
|
32
|
+
"""Read lines from file or stdin. If follow, tail -f style."""
|
|
33
|
+
if file == '-' or file is None:
|
|
34
|
+
if follow:
|
|
35
|
+
raise click.ClickException("Cannot tail stdin. Provide a file path.")
|
|
36
|
+
for line in sys.stdin:
|
|
37
|
+
yield line.rstrip('\n')
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if follow:
|
|
41
|
+
with open(file, 'r') as f:
|
|
42
|
+
# Seek to end
|
|
43
|
+
f.seek(0, 2)
|
|
44
|
+
while True:
|
|
45
|
+
line = f.readline()
|
|
46
|
+
if line:
|
|
47
|
+
yield line.rstrip('\n')
|
|
48
|
+
else:
|
|
49
|
+
time.sleep(0.1)
|
|
50
|
+
else:
|
|
51
|
+
with open(file, 'r') as f:
|
|
52
|
+
for line in f:
|
|
53
|
+
yield line.rstrip('\n')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.group()
|
|
57
|
+
@click.version_option()
|
|
58
|
+
def main():
|
|
59
|
+
"""View, tail, filter, search, and analyze log files."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@main.command()
|
|
64
|
+
@click.argument('file', default='-')
|
|
65
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
66
|
+
@click.option('-n', '--lines', default=0, help='Last N lines (0=all)')
|
|
67
|
+
@click.option('--no-color', is_flag=True, help='Disable colorization')
|
|
68
|
+
def view(file, as_json, lines, no_color):
|
|
69
|
+
"""View log file with syntax highlighting."""
|
|
70
|
+
try:
|
|
71
|
+
all_lines = list(read_lines(file))
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
raise click.ClickException(f"File not found: {file}")
|
|
74
|
+
|
|
75
|
+
if lines > 0:
|
|
76
|
+
all_lines = all_lines[-lines:]
|
|
77
|
+
|
|
78
|
+
if as_json:
|
|
79
|
+
result = []
|
|
80
|
+
for line in all_lines:
|
|
81
|
+
level = detect_level(line)
|
|
82
|
+
ts = parse_timestamp(line)
|
|
83
|
+
result.append({
|
|
84
|
+
'line': line,
|
|
85
|
+
'level': level,
|
|
86
|
+
'timestamp': ts.isoformat() if ts else None,
|
|
87
|
+
})
|
|
88
|
+
click.echo(json.dumps(result, indent=2))
|
|
89
|
+
else:
|
|
90
|
+
for line in all_lines:
|
|
91
|
+
if no_color:
|
|
92
|
+
click.echo(line)
|
|
93
|
+
else:
|
|
94
|
+
click.echo(colorize_line(line))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.command()
|
|
98
|
+
@click.argument('file')
|
|
99
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON lines')
|
|
100
|
+
@click.option('--no-color', is_flag=True, help='Disable colorization')
|
|
101
|
+
def tail(file, as_json, no_color):
|
|
102
|
+
"""Live tail a log file (like tail -f)."""
|
|
103
|
+
try:
|
|
104
|
+
for line in read_lines(file, follow=True):
|
|
105
|
+
if as_json:
|
|
106
|
+
level = detect_level(line)
|
|
107
|
+
ts = parse_timestamp(line)
|
|
108
|
+
click.echo(json.dumps({
|
|
109
|
+
'line': line,
|
|
110
|
+
'level': level,
|
|
111
|
+
'timestamp': ts.isoformat() if ts else None,
|
|
112
|
+
}))
|
|
113
|
+
elif no_color:
|
|
114
|
+
click.echo(line)
|
|
115
|
+
else:
|
|
116
|
+
click.echo(colorize_line(line))
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@main.command()
|
|
122
|
+
@click.argument('file', default='-')
|
|
123
|
+
@click.option('--level', '-l', help='Filter by log level (error, warning, info, debug)')
|
|
124
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
125
|
+
@click.option('--no-color', is_flag=True, help='Disable colorization')
|
|
126
|
+
@click.option('--invert', '-v', is_flag=True, help='Invert match (exclude level)')
|
|
127
|
+
def filter(file, level, as_json, no_color, invert):
|
|
128
|
+
"""Filter log lines by level."""
|
|
129
|
+
if not level:
|
|
130
|
+
raise click.ClickException("--level is required")
|
|
131
|
+
|
|
132
|
+
target = level.upper()
|
|
133
|
+
# Normalize
|
|
134
|
+
if target in ('ERR',):
|
|
135
|
+
target = 'ERROR'
|
|
136
|
+
if target in ('WARN',):
|
|
137
|
+
target = 'WARNING'
|
|
138
|
+
|
|
139
|
+
results = []
|
|
140
|
+
try:
|
|
141
|
+
for line in read_lines(file):
|
|
142
|
+
detected = detect_level(line)
|
|
143
|
+
match = (detected == target)
|
|
144
|
+
if invert:
|
|
145
|
+
match = not match
|
|
146
|
+
if match:
|
|
147
|
+
if as_json:
|
|
148
|
+
ts = parse_timestamp(line)
|
|
149
|
+
results.append({
|
|
150
|
+
'line': line,
|
|
151
|
+
'level': detected,
|
|
152
|
+
'timestamp': ts.isoformat() if ts else None,
|
|
153
|
+
})
|
|
154
|
+
elif no_color:
|
|
155
|
+
click.echo(line)
|
|
156
|
+
else:
|
|
157
|
+
click.echo(colorize_line(line, detected))
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
raise click.ClickException(f"File not found: {file}")
|
|
160
|
+
|
|
161
|
+
if as_json:
|
|
162
|
+
click.echo(json.dumps(results, indent=2))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@main.command()
|
|
166
|
+
@click.argument('file', default='-')
|
|
167
|
+
@click.argument('pattern')
|
|
168
|
+
@click.option('-A', '--after', 'after_ctx', default=0, help='Lines after match')
|
|
169
|
+
@click.option('-B', '--before', 'before_ctx', default=0, help='Lines before match')
|
|
170
|
+
@click.option('-C', '--context', default=0, help='Lines before and after match')
|
|
171
|
+
@click.option('-i', '--ignore-case', is_flag=True, help='Case insensitive')
|
|
172
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
173
|
+
@click.option('--no-color', is_flag=True, help='Disable colorization')
|
|
174
|
+
@click.option('-c', '--count', is_flag=True, help='Only show match count')
|
|
175
|
+
def search(file, pattern, after_ctx, before_ctx, context, ignore_case, as_json, no_color, count):
|
|
176
|
+
"""Search log lines with regex pattern."""
|
|
177
|
+
if context > 0:
|
|
178
|
+
after_ctx = context
|
|
179
|
+
before_ctx = context
|
|
180
|
+
|
|
181
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
182
|
+
try:
|
|
183
|
+
regex = re.compile(pattern, flags)
|
|
184
|
+
except re.error as e:
|
|
185
|
+
raise click.ClickException(f"Invalid regex: {e}")
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
all_lines = list(read_lines(file))
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
raise click.ClickException(f"File not found: {file}")
|
|
191
|
+
|
|
192
|
+
matches = []
|
|
193
|
+
match_indices = set()
|
|
194
|
+
for i, line in enumerate(all_lines):
|
|
195
|
+
if regex.search(line):
|
|
196
|
+
match_indices.add(i)
|
|
197
|
+
for j in range(max(0, i - before_ctx), min(len(all_lines), i + after_ctx + 1)):
|
|
198
|
+
match_indices.add(j)
|
|
199
|
+
matches.append({'index': i, 'line': line, 'level': detect_level(line)})
|
|
200
|
+
|
|
201
|
+
if count:
|
|
202
|
+
if as_json:
|
|
203
|
+
click.echo(json.dumps({'count': len(matches)}))
|
|
204
|
+
else:
|
|
205
|
+
click.echo(len(matches))
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if as_json:
|
|
209
|
+
click.echo(json.dumps(matches, indent=2))
|
|
210
|
+
else:
|
|
211
|
+
sorted_indices = sorted(match_indices)
|
|
212
|
+
prev = -2
|
|
213
|
+
for i in sorted_indices:
|
|
214
|
+
if i > prev + 1 and prev >= 0:
|
|
215
|
+
click.echo('--')
|
|
216
|
+
line = all_lines[i]
|
|
217
|
+
if no_color:
|
|
218
|
+
click.echo(line)
|
|
219
|
+
elif i in {m['index'] for m in matches}:
|
|
220
|
+
# Highlight the match
|
|
221
|
+
highlighted = regex.sub(lambda m: click.style(m.group(), fg='bright_white', bold=True), line)
|
|
222
|
+
click.echo(colorize_line(highlighted, detect_level(line)) if detect_level(line) else highlighted)
|
|
223
|
+
else:
|
|
224
|
+
click.echo(click.style(line, dim=True))
|
|
225
|
+
prev = i
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@main.command()
|
|
229
|
+
@click.argument('file', default='-')
|
|
230
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
231
|
+
def stats(file, as_json):
|
|
232
|
+
"""Show log level distribution and statistics."""
|
|
233
|
+
level_counts = Counter()
|
|
234
|
+
total = 0
|
|
235
|
+
hourly = Counter()
|
|
236
|
+
first_ts = None
|
|
237
|
+
last_ts = None
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
for line in read_lines(file):
|
|
241
|
+
total += 1
|
|
242
|
+
level = detect_level(line)
|
|
243
|
+
if level:
|
|
244
|
+
level_counts[level] += 1
|
|
245
|
+
ts = parse_timestamp(line)
|
|
246
|
+
if ts:
|
|
247
|
+
if first_ts is None:
|
|
248
|
+
first_ts = ts
|
|
249
|
+
last_ts = ts
|
|
250
|
+
hourly[ts.strftime('%Y-%m-%d %H:00')] += 1
|
|
251
|
+
except FileNotFoundError:
|
|
252
|
+
raise click.ClickException(f"File not found: {file}")
|
|
253
|
+
|
|
254
|
+
error_count = level_counts.get('ERROR', 0)
|
|
255
|
+
error_rate = (error_count / total * 100) if total > 0 else 0
|
|
256
|
+
|
|
257
|
+
result = {
|
|
258
|
+
'total_lines': total,
|
|
259
|
+
'levels': dict(level_counts.most_common()),
|
|
260
|
+
'error_rate': round(error_rate, 2),
|
|
261
|
+
'first_timestamp': first_ts.isoformat() if first_ts else None,
|
|
262
|
+
'last_timestamp': last_ts.isoformat() if last_ts else None,
|
|
263
|
+
'lines_per_hour': dict(sorted(hourly.items())),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if as_json:
|
|
267
|
+
click.echo(json.dumps(result, indent=2))
|
|
268
|
+
else:
|
|
269
|
+
click.echo(f"Total lines: {total}")
|
|
270
|
+
click.echo(f"Error rate: {error_rate:.1f}%")
|
|
271
|
+
click.echo()
|
|
272
|
+
if first_ts:
|
|
273
|
+
click.echo(f"Time range: {first_ts} → {last_ts}")
|
|
274
|
+
click.echo()
|
|
275
|
+
click.echo("Level distribution:")
|
|
276
|
+
for level in ['ERROR', 'WARNING', 'INFO', 'DEBUG']:
|
|
277
|
+
count = level_counts.get(level, 0)
|
|
278
|
+
if count > 0:
|
|
279
|
+
pct = count / total * 100
|
|
280
|
+
bar = '█' * int(pct / 2)
|
|
281
|
+
color = LEVEL_COLORS.get(level, 'white')
|
|
282
|
+
click.echo(f" {click.style(level.ljust(8), fg=color)} {count:>6} ({pct:5.1f}%) {bar}")
|
|
283
|
+
unlabeled = total - sum(level_counts.values())
|
|
284
|
+
if unlabeled > 0:
|
|
285
|
+
pct = unlabeled / total * 100
|
|
286
|
+
click.echo(f" {'OTHER'.ljust(8)} {unlabeled:>6} ({pct:5.1f}%)")
|
|
287
|
+
|
|
288
|
+
if hourly:
|
|
289
|
+
click.echo()
|
|
290
|
+
click.echo("Lines per hour (top 10):")
|
|
291
|
+
for hour, cnt in Counter(hourly).most_common(10):
|
|
292
|
+
click.echo(f" {hour} {cnt}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@main.command()
|
|
296
|
+
@click.argument('file', default='-')
|
|
297
|
+
@click.option('--from', 'from_time', required=True, help='Start time (ISO or date)')
|
|
298
|
+
@click.option('--to', 'to_time', required=True, help='End time (ISO or date)')
|
|
299
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
300
|
+
@click.option('--no-color', is_flag=True, help='Disable colorization')
|
|
301
|
+
def between(file, from_time, to_time, as_json, no_color):
|
|
302
|
+
"""Filter log lines within a time range."""
|
|
303
|
+
try:
|
|
304
|
+
start = _parse_user_time(from_time)
|
|
305
|
+
end = _parse_user_time(to_time, end_of_day=True)
|
|
306
|
+
except ValueError as e:
|
|
307
|
+
raise click.ClickException(f"Cannot parse time: {e}")
|
|
308
|
+
|
|
309
|
+
results = []
|
|
310
|
+
try:
|
|
311
|
+
for line in read_lines(file):
|
|
312
|
+
ts = parse_timestamp(line)
|
|
313
|
+
if ts and start <= ts <= end:
|
|
314
|
+
if as_json:
|
|
315
|
+
results.append({
|
|
316
|
+
'line': line,
|
|
317
|
+
'level': detect_level(line),
|
|
318
|
+
'timestamp': ts.isoformat(),
|
|
319
|
+
})
|
|
320
|
+
elif no_color:
|
|
321
|
+
click.echo(line)
|
|
322
|
+
else:
|
|
323
|
+
click.echo(colorize_line(line))
|
|
324
|
+
except FileNotFoundError:
|
|
325
|
+
raise click.ClickException(f"File not found: {file}")
|
|
326
|
+
|
|
327
|
+
if as_json:
|
|
328
|
+
click.echo(json.dumps(results, indent=2))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _parse_user_time(s, end_of_day=False):
|
|
332
|
+
"""Parse user-provided time string."""
|
|
333
|
+
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
|
|
334
|
+
try:
|
|
335
|
+
dt = datetime.strptime(s, fmt)
|
|
336
|
+
if end_of_day and fmt == '%Y-%m-%d':
|
|
337
|
+
dt = dt.replace(hour=23, minute=59, second=59)
|
|
338
|
+
return dt
|
|
339
|
+
except ValueError:
|
|
340
|
+
continue
|
|
341
|
+
raise ValueError(f"Unrecognized time format: {s}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@main.command()
|
|
345
|
+
@click.argument('file', default='-')
|
|
346
|
+
@click.option('--field', '-f', required=True, type=click.Choice(['ip', 'url', 'email', 'status']),
|
|
347
|
+
help='Field type to extract')
|
|
348
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
349
|
+
@click.option('--unique', '-u', is_flag=True, help='Only unique values')
|
|
350
|
+
def extract(file, field, as_json, unique):
|
|
351
|
+
"""Extract fields (IPs, URLs, emails, HTTP status codes) from logs."""
|
|
352
|
+
values = []
|
|
353
|
+
seen = set()
|
|
354
|
+
try:
|
|
355
|
+
for line in read_lines(file):
|
|
356
|
+
for val in extract_fields(line, field):
|
|
357
|
+
if unique:
|
|
358
|
+
if val in seen:
|
|
359
|
+
continue
|
|
360
|
+
seen.add(val)
|
|
361
|
+
values.append(val)
|
|
362
|
+
except FileNotFoundError:
|
|
363
|
+
raise click.ClickException(f"File not found: {file}")
|
|
364
|
+
|
|
365
|
+
if as_json:
|
|
366
|
+
click.echo(json.dumps({'field': field, 'count': len(values), 'values': values}, indent=2))
|
|
367
|
+
else:
|
|
368
|
+
for v in values:
|
|
369
|
+
click.echo(v)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@main.command()
|
|
373
|
+
@click.argument('file', default='-')
|
|
374
|
+
@click.option('--field', '-f', required=True, type=click.Choice(['ip', 'url', 'email', 'status']),
|
|
375
|
+
help='Field type to count')
|
|
376
|
+
@click.option('-n', '--number', default=10, help='Top N results')
|
|
377
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
378
|
+
def top(file, field, number, as_json):
|
|
379
|
+
"""Show top N values for a field."""
|
|
380
|
+
counter = Counter()
|
|
381
|
+
try:
|
|
382
|
+
for line in read_lines(file):
|
|
383
|
+
for val in extract_fields(line, field):
|
|
384
|
+
counter[val] += 1
|
|
385
|
+
except FileNotFoundError:
|
|
386
|
+
raise click.ClickException(f"File not found: {file}")
|
|
387
|
+
|
|
388
|
+
top_items = counter.most_common(number)
|
|
389
|
+
|
|
390
|
+
if as_json:
|
|
391
|
+
click.echo(json.dumps({
|
|
392
|
+
'field': field,
|
|
393
|
+
'top': [{'value': v, 'count': c} for v, c in top_items],
|
|
394
|
+
'unique_count': len(counter),
|
|
395
|
+
}, indent=2))
|
|
396
|
+
else:
|
|
397
|
+
if not top_items:
|
|
398
|
+
click.echo(f"No {field} values found.")
|
|
399
|
+
return
|
|
400
|
+
max_count = top_items[0][1] if top_items else 1
|
|
401
|
+
for val, cnt in top_items:
|
|
402
|
+
bar = '█' * max(1, int(cnt / max_count * 20))
|
|
403
|
+
click.echo(f" {cnt:>6} {val} {bar}")
|
|
404
|
+
click.echo(f"\n {len(counter)} unique values")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
if __name__ == '__main__':
|
|
408
|
+
main()
|
logpeek/parser.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Log line parsing and format detection."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
# Log level patterns
|
|
7
|
+
LEVEL_PATTERNS = [
|
|
8
|
+
re.compile(r'\b(EMERGENCY|EMERG)\b', re.I),
|
|
9
|
+
re.compile(r'\b(ALERT)\b', re.I),
|
|
10
|
+
re.compile(r'\b(CRITICAL|CRIT|FATAL)\b', re.I),
|
|
11
|
+
re.compile(r'\b(ERROR|ERR)\b', re.I),
|
|
12
|
+
re.compile(r'\b(WARNING|WARN)\b', re.I),
|
|
13
|
+
re.compile(r'\b(NOTICE)\b', re.I),
|
|
14
|
+
re.compile(r'\b(INFO)\b', re.I),
|
|
15
|
+
re.compile(r'\b(DEBUG|TRACE)\b', re.I),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
LEVEL_NAMES = {
|
|
19
|
+
'EMERGENCY': 'error', 'EMERG': 'error',
|
|
20
|
+
'ALERT': 'error',
|
|
21
|
+
'CRITICAL': 'error', 'CRIT': 'error', 'FATAL': 'error',
|
|
22
|
+
'ERROR': 'error', 'ERR': 'error',
|
|
23
|
+
'WARNING': 'warning', 'WARN': 'warning',
|
|
24
|
+
'NOTICE': 'info',
|
|
25
|
+
'INFO': 'info',
|
|
26
|
+
'DEBUG': 'debug', 'TRACE': 'debug',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
LEVEL_CANONICAL = {
|
|
30
|
+
'EMERGENCY': 'ERROR', 'EMERG': 'ERROR',
|
|
31
|
+
'ALERT': 'ERROR',
|
|
32
|
+
'CRITICAL': 'ERROR', 'CRIT': 'ERROR', 'FATAL': 'ERROR',
|
|
33
|
+
'ERROR': 'ERROR', 'ERR': 'ERROR',
|
|
34
|
+
'WARNING': 'WARNING', 'WARN': 'WARNING',
|
|
35
|
+
'NOTICE': 'INFO',
|
|
36
|
+
'INFO': 'INFO',
|
|
37
|
+
'DEBUG': 'DEBUG', 'TRACE': 'DEBUG',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Timestamp patterns (order matters - try most specific first)
|
|
41
|
+
TIMESTAMP_PATTERNS = [
|
|
42
|
+
# ISO 8601: 2026-02-08T12:34:56.789Z or with offset
|
|
43
|
+
(re.compile(r'(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)'), '%Y-%m-%dT%H:%M:%S'),
|
|
44
|
+
# Common: 2026-02-08 12:34:56
|
|
45
|
+
(re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})'), '%Y-%m-%d %H:%M:%S'),
|
|
46
|
+
# Common: 2026/02/08 12:34:56
|
|
47
|
+
(re.compile(r'(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})'), '%Y/%m/%d %H:%M:%S'),
|
|
48
|
+
# Syslog: Feb 8 12:34:56
|
|
49
|
+
(re.compile(r'([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})'), '%b %d %H:%M:%S'),
|
|
50
|
+
# Apache/nginx: 08/Feb/2026:12:34:56
|
|
51
|
+
(re.compile(r'(\d{2}/[A-Z][a-z]{2}/\d{4}:\d{2}:\d{2}:\d{2})'), '%d/%b/%Y:%H:%M:%S'),
|
|
52
|
+
# Date only: 2026-02-08
|
|
53
|
+
(re.compile(r'(\d{4}-\d{2}-\d{2})'), '%Y-%m-%d'),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# Field extraction patterns
|
|
57
|
+
FIELD_PATTERNS = {
|
|
58
|
+
'ip': re.compile(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b'),
|
|
59
|
+
'url': re.compile(r'(https?://[^\s"\'<>]+)'),
|
|
60
|
+
'email': re.compile(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})'),
|
|
61
|
+
'status': re.compile(r'\b([1-5]\d{2})\b'),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_level(line):
|
|
66
|
+
"""Detect log level from a line. Returns canonical level or None."""
|
|
67
|
+
for pat in LEVEL_PATTERNS:
|
|
68
|
+
m = pat.search(line)
|
|
69
|
+
if m:
|
|
70
|
+
return LEVEL_CANONICAL.get(m.group(1).upper(), 'INFO')
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_timestamp(line):
|
|
75
|
+
"""Try to extract a datetime from a log line. Returns datetime or None."""
|
|
76
|
+
# Try JSON first
|
|
77
|
+
try:
|
|
78
|
+
obj = json.loads(line)
|
|
79
|
+
for key in ('timestamp', 'time', 'ts', '@timestamp', 'date', 'datetime'):
|
|
80
|
+
if key in obj:
|
|
81
|
+
val = obj[key]
|
|
82
|
+
if isinstance(val, (int, float)):
|
|
83
|
+
return datetime.fromtimestamp(val)
|
|
84
|
+
for pat, fmt in TIMESTAMP_PATTERNS:
|
|
85
|
+
m = pat.search(str(val))
|
|
86
|
+
if m:
|
|
87
|
+
return _parse_ts(m.group(1), fmt)
|
|
88
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
for pat, fmt in TIMESTAMP_PATTERNS:
|
|
92
|
+
m = pat.search(line)
|
|
93
|
+
if m:
|
|
94
|
+
return _parse_ts(m.group(1), fmt)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_ts(s, fmt):
|
|
99
|
+
"""Parse a timestamp string, handling timezone suffixes."""
|
|
100
|
+
# Strip Z or timezone offset for simple parsing
|
|
101
|
+
clean = s.rstrip('Z')
|
|
102
|
+
# Remove colon in timezone offset
|
|
103
|
+
if re.search(r'[+-]\d{2}:\d{2}$', clean):
|
|
104
|
+
clean = clean[:-3] + clean[-2:]
|
|
105
|
+
# Remove timezone offset entirely for strptime
|
|
106
|
+
clean = re.sub(r'[+-]\d{4}$', '', clean)
|
|
107
|
+
# Remove fractional seconds
|
|
108
|
+
clean = re.sub(r'\.\d+', '', clean)
|
|
109
|
+
# Normalize T separator
|
|
110
|
+
clean = clean.replace('T', ' ')
|
|
111
|
+
# Pick appropriate format
|
|
112
|
+
if ' ' in clean and ':' in clean:
|
|
113
|
+
parts = clean.split()
|
|
114
|
+
if '/' in parts[0] and ':' in parts[0]:
|
|
115
|
+
fmt = '%d/%b/%Y:%H:%M:%S'
|
|
116
|
+
elif '-' in parts[0]:
|
|
117
|
+
fmt = '%Y-%m-%d %H:%M:%S'
|
|
118
|
+
elif '/' in parts[0]:
|
|
119
|
+
fmt = '%Y/%m/%d %H:%M:%S'
|
|
120
|
+
elif parts[0][0].isalpha():
|
|
121
|
+
fmt = '%b %d %H:%M:%S'
|
|
122
|
+
else:
|
|
123
|
+
fmt = '%Y-%m-%d %H:%M:%S'
|
|
124
|
+
try:
|
|
125
|
+
return datetime.strptime(clean, fmt)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def extract_fields(line, field):
|
|
131
|
+
"""Extract field values from a line."""
|
|
132
|
+
pat = FIELD_PATTERNS.get(field)
|
|
133
|
+
if not pat:
|
|
134
|
+
return []
|
|
135
|
+
return pat.findall(line)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def detect_format(lines):
|
|
139
|
+
"""Detect log format from sample lines. Returns format name."""
|
|
140
|
+
json_count = 0
|
|
141
|
+
syslog_count = 0
|
|
142
|
+
nginx_count = 0
|
|
143
|
+
apache_count = 0
|
|
144
|
+
|
|
145
|
+
for line in lines[:50]:
|
|
146
|
+
line = line.strip()
|
|
147
|
+
if not line:
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
json.loads(line)
|
|
151
|
+
json_count += 1
|
|
152
|
+
continue
|
|
153
|
+
except (json.JSONDecodeError, ValueError):
|
|
154
|
+
pass
|
|
155
|
+
if re.match(r'[A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2} \S+', line):
|
|
156
|
+
syslog_count += 1
|
|
157
|
+
elif re.match(r'\d+\.\d+\.\d+\.\d+ - .+ \[\d{2}/[A-Z][a-z]{2}/\d{4}', line):
|
|
158
|
+
nginx_count += 1
|
|
159
|
+
elif re.match(r'\d+\.\d+\.\d+\.\d+ .+ \[\d{2}/[A-Z][a-z]{2}/\d{4}', line):
|
|
160
|
+
apache_count += 1
|
|
161
|
+
|
|
162
|
+
total = max(len(lines[:50]), 1)
|
|
163
|
+
if json_count > total * 0.5:
|
|
164
|
+
return 'jsonl'
|
|
165
|
+
if nginx_count > total * 0.3:
|
|
166
|
+
return 'nginx'
|
|
167
|
+
if apache_count > total * 0.3:
|
|
168
|
+
return 'apache'
|
|
169
|
+
if syslog_count > total * 0.3:
|
|
170
|
+
return 'syslog'
|
|
171
|
+
return 'generic'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logpeek-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: View, tail, filter, search, and analyze log files from the command line
|
|
5
|
+
Author-email: Marcus <marcus.builds.things@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/marcusbuildsthings-droid/logpeek
|
|
8
|
+
Project-URL: Repository, https://github.com/marcusbuildsthings-droid/logpeek
|
|
9
|
+
Project-URL: Issues, https://github.com/marcusbuildsthings-droid/logpeek/issues
|
|
10
|
+
Keywords: log,logs,tail,grep,filter,cli,analyzer,syslog,nginx
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: System :: Logging
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: click>=8.0
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# logpeek
|
|
32
|
+
|
|
33
|
+
View, tail, filter, search, and analyze log files from the command line. Think `tail -f` meets `grep` with stats and JSON output.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install logpeek-cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
logpeek view app.log # View with syntax highlighting
|
|
45
|
+
logpeek view app.log -n 50 # Last 50 lines
|
|
46
|
+
logpeek tail app.log # Live tail (like tail -f)
|
|
47
|
+
logpeek filter app.log --level error # Filter by log level
|
|
48
|
+
logpeek filter app.log -l warning -v # Exclude warnings
|
|
49
|
+
logpeek search app.log "pattern" # Regex search
|
|
50
|
+
logpeek search app.log "error" -C 3 # With 3 lines context
|
|
51
|
+
logpeek search app.log "fail" -c # Count matches
|
|
52
|
+
logpeek stats app.log # Log level distribution
|
|
53
|
+
logpeek between app.log --from "2026-02-08" --to "2026-02-09"
|
|
54
|
+
logpeek extract app.log --field ip # Extract IP addresses
|
|
55
|
+
logpeek extract app.log -f url -u # Unique URLs
|
|
56
|
+
logpeek top app.log --field ip -n 10 # Top 10 IPs
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- **Auto-detect log formats**: syslog, nginx, Apache, JSON lines, generic timestamped
|
|
62
|
+
- **Log level detection**: ERROR, WARNING, INFO, DEBUG with colorized output
|
|
63
|
+
- **Regex search**: with context lines (`-A`, `-B`, `-C`) like grep
|
|
64
|
+
- **Time range filtering**: parse timestamps from any common format
|
|
65
|
+
- **Statistics**: level distribution, lines per hour, error rate
|
|
66
|
+
- **Field extraction**: IPs, URLs, emails, HTTP status codes
|
|
67
|
+
- **Top N analysis**: most frequent values for any extractable field
|
|
68
|
+
- **JSON output**: `--json` flag on every command
|
|
69
|
+
- **Stdin support**: pipe logs from other commands
|
|
70
|
+
- **No heavy deps**: just Python + Click
|
|
71
|
+
|
|
72
|
+
## Stdin / Pipelines
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cat /var/log/syslog | logpeek filter --level error
|
|
76
|
+
kubectl logs pod-name | logpeek stats
|
|
77
|
+
docker logs container | logpeek search "timeout" -C 2
|
|
78
|
+
journalctl -u nginx | logpeek top --field ip -n 20
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## JSON Output
|
|
82
|
+
|
|
83
|
+
Every command supports `--json` for machine-readable output:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
logpeek stats app.log --json
|
|
87
|
+
logpeek top app.log -f status --json
|
|
88
|
+
logpeek search app.log "error" --json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## For AI Agents
|
|
92
|
+
|
|
93
|
+
See [SKILL.md](SKILL.md) for structured command reference and usage patterns.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
logpeek/__init__.py,sha256=fkMVvK96phAlLufO3edVS736fGWwox5IPIUfTrDzFD0,72
|
|
2
|
+
logpeek/cli.py,sha256=Wo_FPBTcru6GM0wiSDgTm1yhWpqnejpOy79cdWXUt7k,13892
|
|
3
|
+
logpeek/parser.py,sha256=rHJPl2MuV8g05Rp3-HPb3PZMFA2vgN0S8trjApxmOHY,5586
|
|
4
|
+
logpeek_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9tNBpWq8KGbuJqmeComp40OiNnbvpvsKn1YP26PUtck,1063
|
|
5
|
+
logpeek_cli-0.1.0.dist-info/METADATA,sha256=k6w5wMCn2lT72D9HV3bcXiPF1l5fAeIUDXaQeiBNRZM,3468
|
|
6
|
+
logpeek_cli-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
7
|
+
logpeek_cli-0.1.0.dist-info/entry_points.txt,sha256=Fzt6N43XSkJIHjc1P0hSleoYkFeqnW__qiSvaCcLASQ,45
|
|
8
|
+
logpeek_cli-0.1.0.dist-info/top_level.txt,sha256=D_khlMF3yn0IWm009xRmvjWYZf5EvQch0pWGIUW_Y2Q,8
|
|
9
|
+
logpeek_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcus
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
logpeek
|