blob: 2ad82aa4c62e18b593b6e802a6875d3d982ecc93 [file] [log] [blame]
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02001# osmo_gsm_tester: global logging
2#
3# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
4#
5# Author: Neels Hofmeyr <neels@hofmeyr.de>
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU Affero General Public License as
9# published by the Free Software Foundation, either version 3 of the
10# License, or (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Affero General Public License for more details.
16#
17# You should have received a copy of the GNU Affero General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20import os
21import sys
22import time
23import traceback
24import contextlib
25from inspect import getframeinfo, stack
26
27L_ERR = 30
28L_LOG = 20
29L_DBG = 10
30L_TRACEBACK = 'TRACEBACK'
31
Neels Hofmeyr3531a192017-03-28 14:30:28 +020032LEVEL_STRS = {
33 'err': L_ERR,
34 'log': L_LOG,
35 'dbg': L_DBG,
36 }
37
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020038C_NET = 'net'
39C_RUN = 'run'
40C_TST = 'tst'
41C_CNF = 'cnf'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020042C_BUS = 'bus'
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020043C_DEFAULT = '---'
44
45LONG_DATEFMT = '%Y-%m-%d_%H:%M:%S'
46DATEFMT = '%H:%M:%S'
47
Neels Hofmeyr3531a192017-03-28 14:30:28 +020048# may be overridden by regression tests
49get_process_id = lambda: '%d-%d' % (os.getpid(), time.time())
50
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020051class LogTarget:
52 do_log_time = None
53 do_log_category = None
54 do_log_level = None
55 do_log_origin = None
56 do_log_traceback = None
57 do_log_src = None
58 origin_width = None
59 origin_fmt = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +020060 all_levels = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020061
62 # redirected by logging test
63 get_time_str = lambda self: time.strftime(self.log_time_fmt)
64
65 # sink that gets each complete logging line
66 log_sink = sys.stderr.write
67
68 category_levels = None
69
70 def __init__(self):
71 self.category_levels = {}
72 self.style()
73
74 def style(self, time=True, time_fmt=DATEFMT, category=True, level=True, origin=True, origin_width=0, src=True, trace=False):
75 '''
76 set all logging format aspects, to defaults if not passed:
77 time: log timestamps;
78 time_fmt: format of timestamps;
79 category: print the logging category (three letters);
80 level: print the logging level, unless it is L_LOG;
81 origin: print which object(s) the message originated from;
82 origin_width: fill up the origin string with whitespace to this witdh;
83 src: log the source file and line number the log comes from;
84 trace: on exceptions, log the full stack trace;
85 '''
86 self.log_time_fmt = time_fmt
87 self.do_log_time = bool(time)
88 if not self.log_time_fmt:
89 self.do_log_time = False
90 self.do_log_category = bool(category)
91 self.do_log_level = bool(level)
92 self.do_log_origin = bool(origin)
93 self.origin_width = int(origin_width)
94 self.origin_fmt = '{:>%ds}' % self.origin_width
95 self.do_log_src = src
96 self.do_log_traceback = trace
97
98 def style_change(self, time=None, time_fmt=None, category=None, level=None, origin=None, origin_width=None, src=None, trace=None):
99 'modify only the given aspects of the logging format'
100 self.style(
101 time=(time if time is not None else self.do_log_time),
102 time_fmt=(time_fmt if time_fmt is not None else self.log_time_fmt),
103 category=(category if category is not None else self.do_log_category),
104 level=(level if level is not None else self.do_log_level),
105 origin=(origin if origin is not None else self.do_log_origin),
106 origin_width=(origin_width if origin_width is not None else self.origin_width),
107 src=(src if src is not None else self.do_log_src),
108 trace=(trace if trace is not None else self.do_log_traceback),
109 )
110
111 def set_level(self, category, level):
112 'set global logging log.L_* level for a given log.C_* category'
113 self.category_levels[category] = level
114
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200115 def set_all_levels(self, level):
116 self.all_levels = level
117
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200118 def is_enabled(self, category, level):
119 if level == L_TRACEBACK:
120 return self.do_log_traceback
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200121 if self.all_levels is not None:
122 is_level = self.all_levels
123 else:
124 is_level = self.category_levels.get(category)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200125 if is_level is None:
126 is_level = L_LOG
127 if level < is_level:
128 return False
129 return True
130
131 def log(self, origin, category, level, src, messages, named_items):
132 if category and len(category) != 3:
133 self.log_sink('WARNING: INVALID LOG SUBSYSTEM %r\n' % category)
134 self.log_sink('origin=%r category=%r level=%r\n' % (origin, category, level));
135
136 if not category:
137 category = C_DEFAULT
138 if not self.is_enabled(category, level):
139 return
140
141 log_pre = []
142 if self.do_log_time:
143 log_pre.append(self.get_time_str())
144
145 if self.do_log_category:
146 log_pre.append(category)
147
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200148 deeper_origins = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200149 if self.do_log_origin:
150 if origin is None:
151 name = '-'
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200152 elif isinstance(origin, Origins):
153 name = origin[-1]
154 if len(origin) > 1:
155 deeper_origins = str(origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200156 elif isinstance(origin, str):
157 name = origin or None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200158 elif hasattr(origin, 'name'):
159 name = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200160 if not name:
161 name = str(origin.__class__.__name__)
162 log_pre.append(self.origin_fmt.format(name))
163
164 if self.do_log_level and level != L_LOG:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200165 loglevel = '%s: ' % (level_str(level) or ('loglevel=' + str(level)))
166 else:
167 loglevel = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200168
169 log_line = [str(m) for m in messages]
170
171 if named_items:
172 # unfortunately needs to be sorted to get deterministic results
173 log_line.append('{%s}' %
174 (', '.join(['%s=%r' % (k,v)
175 for k,v in sorted(named_items.items())])))
176
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200177 if deeper_origins:
178 log_line.append(' [%s]' % deeper_origins)
179
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200180 if self.do_log_src and src:
181 log_line.append(' [%s]' % str(src))
182
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200183 log_str = '%s%s%s%s' % (' '.join(log_pre),
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200184 ': ' if log_pre else '',
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200185 loglevel,
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200186 ' '.join(log_line))
187
188 self.log_sink(log_str.strip() + '\n')
189
190
191targets = [ LogTarget() ]
192
193def level_str(level):
194 if level == L_TRACEBACK:
195 return L_TRACEBACK
196 if level <= L_DBG:
197 return 'DBG'
198 if level <= L_LOG:
199 return 'LOG'
200 return 'ERR'
201
202def _log_all_targets(origin, category, level, src, messages, named_items=None):
203 global targets
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200204
205 if origin is None:
206 origin = Origin._global_current_origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200207 if isinstance(src, int):
208 src = get_src_from_caller(src + 1)
209 for target in targets:
210 target.log(origin, category, level, src, messages, named_items)
211
212def get_src_from_caller(levels_up=1):
213 caller = getframeinfo(stack()[levels_up][0])
214 return '%s:%d' % (os.path.basename(caller.filename), caller.lineno)
215
216def get_src_from_tb(tb, levels_up=1):
217 ftb = traceback.extract_tb(tb)
218 f,l,m,c = ftb[-levels_up]
219 f = os.path.basename(f)
220 return '%s:%s: %s' % (f, l, c)
221
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200222def get_line_for_src(src_path):
223 etype, exception, tb = sys.exc_info()
224 if tb:
225 ftb = traceback.extract_tb(tb)
226 for f,l,m,c in ftb:
227 if f.endswith(src_path):
228 return l
229
230 for frame in stack():
231 caller = getframeinfo(frame[0])
232 if caller.filename.endswith(src_path):
233 return caller.lineno
234 return None
235
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200236
237class Origin:
238 '''
239 Base class for all classes that want to log,
240 and to add an origin string to a code path:
241 with log.Origin('my name'):
242 raise Problem()
243 This will log 'my name' as an origin for the Problem.
244 '''
245
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200246 _global_current_origin = None
247 _global_id = None
248
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200249 _log_category = None
250 _src = None
251 _name = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200252 _origin_id = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200253
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200254 _parent_origin = None
255
256 def __init__(self, *name_items, category=None, **detail_items):
257 self.set_log_category(category)
258 self.set_name(*name_items, **detail_items)
259
260 def set_name(self, *name_items, **detail_items):
261 if name_items:
262 name = '-'.join([str(i) for i in name_items])
263 elif not detail_items:
264 name = self.__class__.__name__
265 else:
266 name = ''
267 if detail_items:
268 details = '(%s)' % (', '.join([("%s=%r" % (k,v))
269 for k,v in sorted(detail_items.items())]))
270 else:
271 details = ''
272 self._name = name + details
273
274 def name(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200275 return self._name or self.__class__.__name__
276
277 __str__ = name
278 __repr__ = name
279
280 def origin_id(self):
281 if not self._origin_id:
282 if not Origin._global_id:
283 Origin._global_id = get_process_id()
284 self._origin_id = '%s-%s' % (self.name(), Origin._global_id)
285 return self._origin_id
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200286
287 def set_log_category(self, category):
288 self._log_category = category
289
290 def _log(self, level, messages, named_items=None, src_levels_up=3, origins=None):
291 src = self._src or src_levels_up
292 origin = origins or self.gather_origins()
293 _log_all_targets(origin, self._log_category, level, src, messages, named_items)
294
295 def dbg(self, *messages, **named_items):
296 self._log(L_DBG, messages, named_items)
297
298 def log(self, *messages, **named_items):
299 self._log(L_LOG, messages, named_items)
300
301 def err(self, *messages, **named_items):
302 self._log(L_ERR, messages, named_items)
303
304 def log_exn(self, exc_info=None):
305 log_exn(self, self._log_category, exc_info)
306
307 def __enter__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200308 if not self.set_child_of(Origin._global_current_origin):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200309 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200310 Origin._global_current_origin = self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200311
312 def __exit__(self, *exc_info):
313 rc = None
314 if exc_info[0] is not None:
315 rc = exn_add_info(exc_info, self)
316 Origin._global_current_origin, self._parent_origin = self._parent_origin, None
317 return rc
318
319 def redirect_stdout(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200320 return contextlib.redirect_stdout(SafeRedirectStdout(self))
321
322 def gather_origins(self):
323 origins = Origins()
324 origins.add(self)
325 origin = self._parent_origin
326 if origin is None and Origin._global_current_origin is not None:
327 origin = Origin._global_current_origin
328 while origin is not None:
329 origins.add(origin)
330 origin = origin._parent_origin
331 return origins
332
333 def set_child_of(self, parent_origin):
334 # avoid loops
335 if self._parent_origin is not None:
336 return False
337 if parent_origin == self:
338 return False
339 self._parent_origin = parent_origin
340 return True
341
342class LineInfo(Origin):
343 def __init__(self, src_file, *name_items, **detail_items):
344 self.src_file = src_file
345 self.set_name(*name_items, **detail_items)
346
347 def name(self):
348 l = get_line_for_src(self.src_file)
349 if l is not None:
350 return '%s:%s' % (self._name, l)
351 return super().name()
352
353class SafeRedirectStdout:
354 '''
355 To be able to use 'print' in test scripts, this is used to redirect stdout
356 to a test class' log() function. However, it turns out doing that breaks
357 python debugger sessions -- it uses extended features of stdout, and will
358 fail dismally if it finds this wrapper in sys.stdout. Luckily, overriding
359 __getattr__() to return the original sys.__stdout__ attributes for anything
360 else than write() makes the debugger session work nicely again!
361 '''
362 _log_line_buf = None
363
364 def __init__(self, origin):
365 self._origin = origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200366
367 def write(self, message):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200368 lines = message.splitlines()
369 if not lines:
370 return
371 if self._log_line_buf:
372 lines[0] = self._log_line_buf + lines[0]
373 self._log_line_buf = None
374 if not message.endswith('\n'):
375 self._log_line_buf = lines[-1]
376 lines = lines[:-1]
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200377 origins = self._origin.gather_origins()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200378 for line in lines:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200379 self._origin._log(L_LOG, (line,), origins=origins)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200380
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200381 def __getattr__(self, name):
382 return sys.__stdout__.__getattribute__(name)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200383
384
385def dbg(origin, category, *messages, **named_items):
386 _log_all_targets(origin, category, L_DBG, 2, messages, named_items)
387
388def log(origin, category, *messages, **named_items):
389 _log_all_targets(origin, category, L_LOG, 2, messages, named_items)
390
391def err(origin, category, *messages, **named_items):
392 _log_all_targets(origin, category, L_ERR, 2, messages, named_items)
393
394def trace(origin, category, exc_info):
395 _log_all_targets(origin, category, L_TRACEBACK, None,
396 traceback.format_exception(*exc_info))
397
398def resolve_category(origin, category):
399 if category is not None:
400 return category
401 if not hasattr(origin, '_log_category'):
402 return None
403 return origin._log_category
404
405def exn_add_info(exc_info, origin, category=None):
406 etype, exception, tb = exc_info
407 if not hasattr(exception, 'origins'):
408 exception.origins = Origins()
409 if not hasattr(exception, 'category'):
410 # only remember the deepest category
411 exception.category = resolve_category(origin, category)
412 if not hasattr(exception, 'src'):
413 exception.src = get_src_from_tb(tb)
414 exception.origins.add(origin)
415 return False
416
417
418
419def log_exn(origin=None, category=None, exc_info=None):
420 if not (exc_info is not None and len(exc_info) == 3):
421 exc_info = sys.exc_info()
422 if not (exc_info is not None and len(exc_info) == 3):
423 raise RuntimeError('invalid call to log_exn() -- no valid exception info')
424
425 etype, exception, tb = exc_info
426
427 # if there are origins recorded with the Exception, prefer that
428 if hasattr(exception, 'origins'):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200429 origin = exception.origins
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200430
431 # if there is a category recorded with the Exception, prefer that
432 if hasattr(exception, 'category'):
433 category = exception.category
434
435 if hasattr(exception, 'msg'):
436 msg = exception.msg
437 else:
438 msg = str(exception)
439
440 if hasattr(exception, 'src'):
441 src = exception.src
442 else:
443 src = 2
444
445 trace(origin, category, exc_info)
446 _log_all_targets(origin, category, L_ERR, src,
447 ('%s:' % str(etype.__name__), msg))
448
449
450class Origins(list):
451 def __init__(self, origin=None):
452 if origin is not None:
453 self.add(origin)
454 def add(self, origin):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200455 if hasattr(origin, 'name'):
456 origin_str = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200457 else:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200458 origin_str = repr(origin)
459 if origin_str is None:
460 raise RuntimeError('origin_str is None for %r' % origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200461 self.insert(0, origin_str)
462 def __str__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200463 return '↪'.join(self)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200464
465
466
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200467def set_all_levels(level):
468 global targets
469 for target in targets:
470 target.set_all_levels(level)
471
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200472def set_level(category, level):
473 global targets
474 for target in targets:
475 target.set_level(category, level)
476
477def style(**kwargs):
478 global targets
479 for target in targets:
480 target.style(**kwargs)
481
482def style_change(**kwargs):
483 global targets
484 for target in targets:
485 target.style_change(**kwargs)
486
487class TestsTarget(LogTarget):
488 'LogTarget producing deterministic results for regression tests'
489 def __init__(self, out=sys.stdout):
490 super().__init__()
491 self.style(time=False, src=False)
492 self.log_sink = out.write
493
494def run_logging_exceptions(func, *func_args, return_on_failure=None, **func_kwargs):
495 try:
496 return func(*func_args, **func_kwargs)
497 except:
498 log_exn()
499 return return_on_failure
500
501# vim: expandtab tabstop=4 shiftwidth=4