-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathlogmux.py
157 lines (113 loc) · 4.82 KB
/
logmux.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""
Tail multiple log files and label their lines.
"""
from contextlib import contextmanager, ExitStack
from collections import namedtuple
from urllib.parse import urlparse, parse_qsl
import click
import select
import subprocess
import itertools
import fcntl
import os
LogFile = namedtuple("LogFile", "pipe config")
LogFileConfig = namedtuple("LogFileConfig", "path color label")
COLORS = """
red green yellow blue magenta cyan white black bright_red bright_green
bright_yellow bright_blue bright_magenta bright_cyan bright_white
bright_black
""".strip().split()
@click.command()
@click.argument("logpath", nargs=-1)
@click.option("-l", "--list-colors", is_flag=True, default=False)
@click.option("--colorize-labels/--no-colorize-labels", default=True)
@click.option("--verbose/--quiet", default=False)
def main(logpath, list_colors=False, colorize_labels=True, verbose=False):
"""
Tail all LOGPATH(s), label their lines and output them in one stream.
Each path can be configured using query arguments, e.g.:
logmux logs/test.log configured.log?label=custom&color=red
The availalbe configuration options for each path are:
label: The label to prepend to each line. Defaults to the name of the
log file without its extension.
color: Color of the label. Add "-l" to list the available colors.
"""
if list_colors:
click.echo("The following colors are available:", err=True)
for color in COLORS:
click.secho(" " + color, fg=color, err=True)
return
logfiles = {}
with ExitStack() as stack:
for path in logpath:
try:
config = logfileconfig(path)
except ValueError as err:
click.echo(f'Configuration error at "{path}": {err}', err=True)
return
pipe = stack.enter_context(tail(config.path))
logfiles[pipe.fileno()] = LogFile(pipe=pipe, config=config)
if colorize_labels:
# assign a color to each log file that had none configured
colors = iter(itertools.cycle(COLORS))
for key, logfile in logfiles.items():
if not logfile.config.color:
config = logfile.config._replace(color=next(colors))
logfiles[key] = logfile._replace(config=config)
labels = (logfile.config.label for logfile in logfiles.values())
maxlabel = max(map(len, labels), default=1)
poll = select.poll()
for logfile in logfiles.values():
poll.register(logfile.pipe)
if verbose:
click.echo(f"Tailing {len(logfiles)} file(s)...", err=True)
while True:
for fd, event in poll.poll():
logfile = logfiles[fd]
if event == select.EPOLLIN:
label = (logfile.config.label + ":").ljust(maxlabel + 2)
while True:
line = logfile.pipe.readline()
if not line:
break
if logfile.config.color:
click.secho(label, fg=logfile.config.color, nl=False)
else:
click.echo(label, nl=False)
click.echo(line, nl=False)
elif verbose:
msg = f'Got unexpected event {event} on "{logfile.config.path}".'
click.echo(msg, err=True)
@contextmanager
def tail(path):
"Returns a file-like object that tails the file at the given path."
command = ["stdbuf", "-oL", "tail", "-F", "--lines=0", path]
with subprocess.Popen(command, stdout=subprocess.PIPE) as process:
set_nonblocking(process.stdout)
yield process.stdout
def set_nonblocking(fd):
"Set a file descriptior or file-like object to be non-blocking."
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
def logfileconfig(path):
"Parse a log path with optional configuration in query arguments."
result = urlparse(path)
if any([result.scheme, result.netloc, result.params, result.fragment]):
raise ValueError("Only regular files are supported.")
label = result.path.split("/")[-1].split(".")[0]
color = None
for option, value in parse_qsl(result.query):
if option == "color":
if value not in COLORS:
raise ValueError(
f'Invalid color: "{value}".'
' Add "-l" to list the available colors.'
)
color = value
elif option == "label":
label = value
else:
raise ValueError(f'Invalid option: "{option}".' ' Use "label" or "color".')
return LogFileConfig(path=result.path, color=color, label=label)
if __name__ == "__main__":
main()