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 ADDED
@@ -0,0 +1,2 @@
1
+ """logpeek - Log file viewer and analyzer CLI."""
2
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ logpeek = logpeek.cli:main
@@ -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