#!/usr/bin/env python3 """Logtail — log file follower with pattern highlighting.""" import argparse, os, re, sys, time, signal def color(text, code): return f"\033[{code}m{text}\033[0m" if sys.stdout.isatty() else text HIGHLIGHT_MAP = [ (re.compile(r"(?i)\berror\b"), "31;1"), # Bright red (re.compile(r"(?i)\bwarn(ing)?\b"), "33;1"), # Bright yellow (re.compile(r"(?i)\binfo\b"), "32"), # Green (re.compile(r"(?i)\bdebug\b"), "90"), # Gray (re.compile(r"(?i)\bfatal\b"), "41;1"), # White on red (re.compile(r"(?i)\bcritical\b"), "41;1"), (re.compile(r"(?i)\btrace\b"), "36"), # Cyan (re.compile(r"\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}"), "36"), # Timestamps (re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"), "34"), # IPs (re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"), "35"), # UUIDs ] def highlight_line(line): if not sys.stdout.isatty(): return line result = line positions = [] for pattern, code in HIGHLIGHT_MAP: for match in pattern.finditer(line): positions.append((match.start(), match.end(), code)) if not positions: return result positions.sort(key=lambda x: x[0]) # Build result, avoiding overlap merged = [] for start, end, code in positions: if merged and start < merged[-1][1]: continue merged.append((start, end, code)) result_parts = [] last = 0 for start, end, code in merged: result_parts.append(line[last:start]) result_parts.append(color(line[start:end], code)) last = end result_parts.append(line[last:]) return "".join(result_parts) def follow(filepath, pattern=None, invert=False, no_color=False, poll_interval=0.5): try: with open(filepath) as f: f.seek(0, os.SEEK_END) # Start from end while True: line = f.readline() if line: line = line.rstrip("\n\r") if pattern: match = bool(re.search(pattern, line)) if invert: match = not match if not match: continue if no_color: print(line) else: print(highlight_line(line)) else: time.sleep(poll_interval) except KeyboardInterrupt: pass except FileNotFoundError: print(f"Error: File '{filepath}' not found (waiting...)", file=sys.stderr) while not os.path.exists(filepath): time.sleep(poll_interval) # Retry follow(filepath, pattern, invert, no_color, poll_interval) def tail(filepath, lines=10, pattern=None, no_color=False, follow_mode=False): try: with open(filepath) as f: if follow_mode: follow(filepath, pattern, no_color=no_color) return # Read all lines all_lines = f.readlines() if pattern: all_lines = [l for l in all_lines if re.search(pattern, l)] show = all_lines[-lines:] if lines > 0 else all_lines for line in show: line = line.rstrip("\n\r") if no_color: print(line) else: print(highlight_line(line)) except FileNotFoundError: print(f"Error: File '{filepath}' not found", file=sys.stderr) sys.exit(1) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) def main(): parser = argparse.ArgumentParser( description="Logtail — log file viewer with pattern highlighting", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " logtail app.log # Show last 10 lines\n" " logtail -n 50 app.log # Show last 50 lines\n" " logtail -f app.log # Follow log (tail -f)\n" " logtail -f app.log --pattern ERROR # Follow, show only errors\n" " logtail app.log --no-color # Plain text output\n" ), ) parser.add_argument("file", help="Log file to view") parser.add_argument("-n", "--lines", type=int, default=10, help="Number of lines (default: 10)") parser.add_argument("-f", "--follow", action="store_true", help="Follow mode (like tail -f)") parser.add_argument("--pattern", help="Regex pattern to filter") parser.add_argument("--invert", action="store_true", help="Invert pattern match (show non-matching)") parser.add_argument("--no-color", action="store_true", help="Disable color highlighting") parser.add_argument("--poll", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)") parser.add_argument("--version", action="version", version="logtail 1.0.0") args = parser.parse_args() tail(args.file, args.lines, args.pattern, args.no_color, args.follow) if __name__ == "__main__": main()