blob: b581e2cbcb1300811e8554ef04ff1e21da8497ad [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
Neels Hofmeyr2694a9d2017-04-27 19:48:09 +020027from .util import is_dict
28
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020029L_ERR = 30
30L_LOG = 20
31L_DBG = 10
32L_TRACEBACK = 'TRACEBACK'
33
Neels Hofmeyr3531a192017-03-28 14:30:28 +020034LEVEL_STRS = {
35 'err': L_ERR,
36 'log': L_LOG,
37 'dbg': L_DBG,
38 }
39
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020040C_NET = 'net'
41C_RUN = 'run'
42C_TST = 'tst'
43C_CNF = 'cnf'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020044C_BUS = 'bus'
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020045C_DEFAULT = '---'
46
47LONG_DATEFMT = '%Y-%m-%d_%H:%M:%S'
48DATEFMT = '%H:%M:%S'
49
Neels Hofmeyr3531a192017-03-28 14:30:28 +020050# may be overridden by regression tests
51get_process_id = lambda: '%d-%d' % (os.getpid(), time.time())
52
Neels Hofmeyr85eb3242017-04-09 22:01:16 +020053class Error(Exception):
54 pass
55
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020056class LogTarget:
57 do_log_time = None
58 do_log_category = None
59 do_log_level = None
60 do_log_origin = None
61 do_log_traceback = None
62 do_log_src = None
63 origin_width = None
64 origin_fmt = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +020065 all_levels = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020066
67 # redirected by logging test
68 get_time_str = lambda self: time.strftime(self.log_time_fmt)
69
70 # sink that gets each complete logging line
Neels Hofmeyr5356d0a2017-04-10 03:45:30 +020071 log_sink = sys.__stdout__.write
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020072
73 category_levels = None
74
75 def __init__(self):
76 self.category_levels = {}
77 self.style()
78
79 def style(self, time=True, time_fmt=DATEFMT, category=True, level=True, origin=True, origin_width=0, src=True, trace=False):
80 '''
81 set all logging format aspects, to defaults if not passed:
82 time: log timestamps;
83 time_fmt: format of timestamps;
84 category: print the logging category (three letters);
85 level: print the logging level, unless it is L_LOG;
86 origin: print which object(s) the message originated from;
87 origin_width: fill up the origin string with whitespace to this witdh;
88 src: log the source file and line number the log comes from;
89 trace: on exceptions, log the full stack trace;
90 '''
91 self.log_time_fmt = time_fmt
92 self.do_log_time = bool(time)
93 if not self.log_time_fmt:
94 self.do_log_time = False
95 self.do_log_category = bool(category)
96 self.do_log_level = bool(level)
97 self.do_log_origin = bool(origin)
98 self.origin_width = int(origin_width)
99 self.origin_fmt = '{:>%ds}' % self.origin_width
100 self.do_log_src = src
101 self.do_log_traceback = trace
102
103 def style_change(self, time=None, time_fmt=None, category=None, level=None, origin=None, origin_width=None, src=None, trace=None):
104 'modify only the given aspects of the logging format'
105 self.style(
106 time=(time if time is not None else self.do_log_time),
107 time_fmt=(time_fmt if time_fmt is not None else self.log_time_fmt),
108 category=(category if category is not None else self.do_log_category),
109 level=(level if level is not None else self.do_log_level),
110 origin=(origin if origin is not None else self.do_log_origin),
111 origin_width=(origin_width if origin_width is not None else self.origin_width),
112 src=(src if src is not None else self.do_log_src),
113 trace=(trace if trace is not None else self.do_log_traceback),
114 )
115
116 def set_level(self, category, level):
117 'set global logging log.L_* level for a given log.C_* category'
118 self.category_levels[category] = level
119
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200120 def set_all_levels(self, level):
121 self.all_levels = level
122
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200123 def is_enabled(self, category, level):
124 if level == L_TRACEBACK:
125 return self.do_log_traceback
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200126 if self.all_levels is not None:
127 is_level = self.all_levels
128 else:
129 is_level = self.category_levels.get(category)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200130 if is_level is None:
131 is_level = L_LOG
132 if level < is_level:
133 return False
134 return True
135
136 def log(self, origin, category, level, src, messages, named_items):
137 if category and len(category) != 3:
138 self.log_sink('WARNING: INVALID LOG SUBSYSTEM %r\n' % category)
139 self.log_sink('origin=%r category=%r level=%r\n' % (origin, category, level));
140
141 if not category:
142 category = C_DEFAULT
143 if not self.is_enabled(category, level):
144 return
145
146 log_pre = []
147 if self.do_log_time:
148 log_pre.append(self.get_time_str())
149
150 if self.do_log_category:
151 log_pre.append(category)
152
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200153 deeper_origins = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200154 if self.do_log_origin:
155 if origin is None:
156 name = '-'
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200157 elif isinstance(origin, Origins):
158 name = origin[-1]
159 if len(origin) > 1:
160 deeper_origins = str(origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200161 elif isinstance(origin, str):
162 name = origin or None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200163 elif hasattr(origin, 'name'):
164 name = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200165 if not name:
166 name = str(origin.__class__.__name__)
167 log_pre.append(self.origin_fmt.format(name))
168
169 if self.do_log_level and level != L_LOG:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200170 loglevel = '%s: ' % (level_str(level) or ('loglevel=' + str(level)))
171 else:
172 loglevel = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200173
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200174 log_line = [compose_message(messages, named_items)]
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200175
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200176 if deeper_origins:
177 log_line.append(' [%s]' % deeper_origins)
178
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200179 if self.do_log_src and src:
180 log_line.append(' [%s]' % str(src))
181
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200182 log_str = '%s%s%s%s' % (' '.join(log_pre),
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200183 ': ' if log_pre else '',
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200184 loglevel,
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200185 ' '.join(log_line))
186
Neels Hofmeyr5356d0a2017-04-10 03:45:30 +0200187 if not log_str.endswith('\n'):
188 log_str = log_str + '\n'
189 self.log_sink(log_str)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200190
Your Name44af3412017-04-13 03:11:59 +0200191 def large_separator(self, *msgs):
192 msg = ' '.join(msgs)
193 if not msg:
194 msg = '------------------------------------------'
195 self.log_sink('------------------------------------------\n'
196 '%s\n'
197 '------------------------------------------\n' % msg)
198
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200199targets = [ LogTarget() ]
200
201def level_str(level):
202 if level == L_TRACEBACK:
203 return L_TRACEBACK
204 if level <= L_DBG:
205 return 'DBG'
206 if level <= L_LOG:
207 return 'LOG'
208 return 'ERR'
209
210def _log_all_targets(origin, category, level, src, messages, named_items=None):
211 global targets
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200212
213 if origin is None:
214 origin = Origin._global_current_origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200215 if isinstance(src, int):
216 src = get_src_from_caller(src + 1)
217 for target in targets:
218 target.log(origin, category, level, src, messages, named_items)
219
Your Name44af3412017-04-13 03:11:59 +0200220def large_separator(*msgs):
221 for target in targets:
222 target.large_separator(*msgs)
223
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200224def get_src_from_caller(levels_up=1):
225 caller = getframeinfo(stack()[levels_up][0])
226 return '%s:%d' % (os.path.basename(caller.filename), caller.lineno)
227
228def get_src_from_tb(tb, levels_up=1):
229 ftb = traceback.extract_tb(tb)
230 f,l,m,c = ftb[-levels_up]
231 f = os.path.basename(f)
232 return '%s:%s: %s' % (f, l, c)
233
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200234def get_line_for_src(src_path):
235 etype, exception, tb = sys.exc_info()
236 if tb:
237 ftb = traceback.extract_tb(tb)
238 for f,l,m,c in ftb:
239 if f.endswith(src_path):
240 return l
241
242 for frame in stack():
243 caller = getframeinfo(frame[0])
244 if caller.filename.endswith(src_path):
245 return caller.lineno
246 return None
247
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200248
249class Origin:
250 '''
251 Base class for all classes that want to log,
252 and to add an origin string to a code path:
253 with log.Origin('my name'):
254 raise Problem()
255 This will log 'my name' as an origin for the Problem.
256 '''
257
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200258 _global_current_origin = None
259 _global_id = None
260
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200261 _log_category = None
262 _src = None
263 _name = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200264 _origin_id = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200265
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200266 _parent_origin = None
267
268 def __init__(self, *name_items, category=None, **detail_items):
269 self.set_log_category(category)
270 self.set_name(*name_items, **detail_items)
271
272 def set_name(self, *name_items, **detail_items):
273 if name_items:
274 name = '-'.join([str(i) for i in name_items])
275 elif not detail_items:
276 name = self.__class__.__name__
277 else:
278 name = ''
279 if detail_items:
280 details = '(%s)' % (', '.join([("%s=%r" % (k,v))
281 for k,v in sorted(detail_items.items())]))
282 else:
283 details = ''
284 self._name = name + details
285
286 def name(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200287 return self._name or self.__class__.__name__
288
289 __str__ = name
290 __repr__ = name
291
292 def origin_id(self):
293 if not self._origin_id:
294 if not Origin._global_id:
295 Origin._global_id = get_process_id()
296 self._origin_id = '%s-%s' % (self.name(), Origin._global_id)
297 return self._origin_id
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200298
299 def set_log_category(self, category):
300 self._log_category = category
301
302 def _log(self, level, messages, named_items=None, src_levels_up=3, origins=None):
303 src = self._src or src_levels_up
304 origin = origins or self.gather_origins()
305 _log_all_targets(origin, self._log_category, level, src, messages, named_items)
306
307 def dbg(self, *messages, **named_items):
308 self._log(L_DBG, messages, named_items)
309
310 def log(self, *messages, **named_items):
311 self._log(L_LOG, messages, named_items)
312
313 def err(self, *messages, **named_items):
314 self._log(L_ERR, messages, named_items)
315
316 def log_exn(self, exc_info=None):
317 log_exn(self, self._log_category, exc_info)
318
319 def __enter__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200320 if not self.set_child_of(Origin._global_current_origin):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200321 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200322 Origin._global_current_origin = self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200323
324 def __exit__(self, *exc_info):
325 rc = None
326 if exc_info[0] is not None:
327 rc = exn_add_info(exc_info, self)
328 Origin._global_current_origin, self._parent_origin = self._parent_origin, None
329 return rc
330
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200331 def raise_exn(self, *messages, exn_class=Error, **named_items):
332 with self:
333 raise exn_class(compose_message(messages, named_items))
334
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200335 def redirect_stdout(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200336 return contextlib.redirect_stdout(SafeRedirectStdout(self))
337
338 def gather_origins(self):
339 origins = Origins()
340 origins.add(self)
341 origin = self._parent_origin
342 if origin is None and Origin._global_current_origin is not None:
343 origin = Origin._global_current_origin
344 while origin is not None:
345 origins.add(origin)
346 origin = origin._parent_origin
347 return origins
348
349 def set_child_of(self, parent_origin):
350 # avoid loops
351 if self._parent_origin is not None:
352 return False
353 if parent_origin == self:
354 return False
355 self._parent_origin = parent_origin
356 return True
357
358class LineInfo(Origin):
359 def __init__(self, src_file, *name_items, **detail_items):
360 self.src_file = src_file
361 self.set_name(*name_items, **detail_items)
362
363 def name(self):
364 l = get_line_for_src(self.src_file)
365 if l is not None:
366 return '%s:%s' % (self._name, l)
367 return super().name()
368
369class SafeRedirectStdout:
370 '''
371 To be able to use 'print' in test scripts, this is used to redirect stdout
372 to a test class' log() function. However, it turns out doing that breaks
373 python debugger sessions -- it uses extended features of stdout, and will
374 fail dismally if it finds this wrapper in sys.stdout. Luckily, overriding
375 __getattr__() to return the original sys.__stdout__ attributes for anything
376 else than write() makes the debugger session work nicely again!
377 '''
378 _log_line_buf = None
379
380 def __init__(self, origin):
381 self._origin = origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200382
383 def write(self, message):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200384 lines = message.splitlines()
385 if not lines:
386 return
387 if self._log_line_buf:
388 lines[0] = self._log_line_buf + lines[0]
389 self._log_line_buf = None
390 if not message.endswith('\n'):
391 self._log_line_buf = lines[-1]
392 lines = lines[:-1]
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200393 origins = self._origin.gather_origins()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200394 for line in lines:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200395 self._origin._log(L_LOG, (line,), origins=origins)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200396
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200397 def __getattr__(self, name):
398 return sys.__stdout__.__getattribute__(name)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200399
400
401def dbg(origin, category, *messages, **named_items):
402 _log_all_targets(origin, category, L_DBG, 2, messages, named_items)
403
404def log(origin, category, *messages, **named_items):
405 _log_all_targets(origin, category, L_LOG, 2, messages, named_items)
406
407def err(origin, category, *messages, **named_items):
408 _log_all_targets(origin, category, L_ERR, 2, messages, named_items)
409
410def trace(origin, category, exc_info):
411 _log_all_targets(origin, category, L_TRACEBACK, None,
412 traceback.format_exception(*exc_info))
413
414def resolve_category(origin, category):
415 if category is not None:
416 return category
417 if not hasattr(origin, '_log_category'):
418 return None
419 return origin._log_category
420
421def exn_add_info(exc_info, origin, category=None):
422 etype, exception, tb = exc_info
423 if not hasattr(exception, 'origins'):
424 exception.origins = Origins()
425 if not hasattr(exception, 'category'):
426 # only remember the deepest category
427 exception.category = resolve_category(origin, category)
428 if not hasattr(exception, 'src'):
429 exception.src = get_src_from_tb(tb)
430 exception.origins.add(origin)
431 return False
432
433
434
435def log_exn(origin=None, category=None, exc_info=None):
436 if not (exc_info is not None and len(exc_info) == 3):
437 exc_info = sys.exc_info()
438 if not (exc_info is not None and len(exc_info) == 3):
439 raise RuntimeError('invalid call to log_exn() -- no valid exception info')
440
441 etype, exception, tb = exc_info
442
443 # if there are origins recorded with the Exception, prefer that
444 if hasattr(exception, 'origins'):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200445 origin = exception.origins
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200446
447 # if there is a category recorded with the Exception, prefer that
448 if hasattr(exception, 'category'):
449 category = exception.category
450
451 if hasattr(exception, 'msg'):
452 msg = exception.msg
453 else:
454 msg = str(exception)
455
456 if hasattr(exception, 'src'):
457 src = exception.src
458 else:
459 src = 2
460
461 trace(origin, category, exc_info)
462 _log_all_targets(origin, category, L_ERR, src,
463 ('%s:' % str(etype.__name__), msg))
464
465
466class Origins(list):
467 def __init__(self, origin=None):
468 if origin is not None:
469 self.add(origin)
470 def add(self, origin):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200471 if hasattr(origin, 'name'):
472 origin_str = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200473 else:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200474 origin_str = repr(origin)
475 if origin_str is None:
476 raise RuntimeError('origin_str is None for %r' % origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200477 self.insert(0, origin_str)
478 def __str__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200479 return '↪'.join(self)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200480
481
482
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200483def set_all_levels(level):
484 global targets
485 for target in targets:
486 target.set_all_levels(level)
487
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200488def set_level(category, level):
489 global targets
490 for target in targets:
491 target.set_level(category, level)
492
493def style(**kwargs):
494 global targets
495 for target in targets:
496 target.style(**kwargs)
497
498def style_change(**kwargs):
499 global targets
500 for target in targets:
501 target.style_change(**kwargs)
502
503class TestsTarget(LogTarget):
504 'LogTarget producing deterministic results for regression tests'
505 def __init__(self, out=sys.stdout):
506 super().__init__()
507 self.style(time=False, src=False)
508 self.log_sink = out.write
509
510def run_logging_exceptions(func, *func_args, return_on_failure=None, **func_kwargs):
511 try:
512 return func(*func_args, **func_kwargs)
513 except:
514 log_exn()
515 return return_on_failure
516
Neels Hofmeyr2694a9d2017-04-27 19:48:09 +0200517def _compose_named_items(item):
518 'make sure dicts are output sorted, for test expectations'
519 if is_dict(item):
520 return '{%s}' % (', '.join(
521 ['%s=%s' % (k, _compose_named_items(v))
522 for k,v in sorted(item.items())]))
523 return repr(item)
524
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200525def compose_message(messages, named_items):
526 msgs = [str(m) for m in messages]
527
528 if named_items:
529 # unfortunately needs to be sorted to get deterministic results
Neels Hofmeyr2694a9d2017-04-27 19:48:09 +0200530 msgs.append(_compose_named_items(named_items))
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200531
532 return ' '.join(msgs)
533
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200534# vim: expandtab tabstop=4 shiftwidth=4