blob: f56d2c9c98fafd8fb3a4beebfd042b257a7edbb3 [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 Hofmeyr85eb3242017-04-09 22:01:16 +020051class Error(Exception):
52 pass
53
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020054class LogTarget:
55 do_log_time = None
56 do_log_category = None
57 do_log_level = None
58 do_log_origin = None
59 do_log_traceback = None
60 do_log_src = None
61 origin_width = None
62 origin_fmt = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +020063 all_levels = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020064
65 # redirected by logging test
66 get_time_str = lambda self: time.strftime(self.log_time_fmt)
67
68 # sink that gets each complete logging line
Neels Hofmeyr5356d0a2017-04-10 03:45:30 +020069 log_sink = sys.__stdout__.write
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020070
71 category_levels = None
72
73 def __init__(self):
74 self.category_levels = {}
75 self.style()
76
77 def style(self, time=True, time_fmt=DATEFMT, category=True, level=True, origin=True, origin_width=0, src=True, trace=False):
78 '''
79 set all logging format aspects, to defaults if not passed:
80 time: log timestamps;
81 time_fmt: format of timestamps;
82 category: print the logging category (three letters);
83 level: print the logging level, unless it is L_LOG;
84 origin: print which object(s) the message originated from;
85 origin_width: fill up the origin string with whitespace to this witdh;
86 src: log the source file and line number the log comes from;
87 trace: on exceptions, log the full stack trace;
88 '''
89 self.log_time_fmt = time_fmt
90 self.do_log_time = bool(time)
91 if not self.log_time_fmt:
92 self.do_log_time = False
93 self.do_log_category = bool(category)
94 self.do_log_level = bool(level)
95 self.do_log_origin = bool(origin)
96 self.origin_width = int(origin_width)
97 self.origin_fmt = '{:>%ds}' % self.origin_width
98 self.do_log_src = src
99 self.do_log_traceback = trace
100
101 def style_change(self, time=None, time_fmt=None, category=None, level=None, origin=None, origin_width=None, src=None, trace=None):
102 'modify only the given aspects of the logging format'
103 self.style(
104 time=(time if time is not None else self.do_log_time),
105 time_fmt=(time_fmt if time_fmt is not None else self.log_time_fmt),
106 category=(category if category is not None else self.do_log_category),
107 level=(level if level is not None else self.do_log_level),
108 origin=(origin if origin is not None else self.do_log_origin),
109 origin_width=(origin_width if origin_width is not None else self.origin_width),
110 src=(src if src is not None else self.do_log_src),
111 trace=(trace if trace is not None else self.do_log_traceback),
112 )
113
114 def set_level(self, category, level):
115 'set global logging log.L_* level for a given log.C_* category'
116 self.category_levels[category] = level
117
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200118 def set_all_levels(self, level):
119 self.all_levels = level
120
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200121 def is_enabled(self, category, level):
122 if level == L_TRACEBACK:
123 return self.do_log_traceback
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200124 if self.all_levels is not None:
125 is_level = self.all_levels
126 else:
127 is_level = self.category_levels.get(category)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200128 if is_level is None:
129 is_level = L_LOG
130 if level < is_level:
131 return False
132 return True
133
134 def log(self, origin, category, level, src, messages, named_items):
135 if category and len(category) != 3:
136 self.log_sink('WARNING: INVALID LOG SUBSYSTEM %r\n' % category)
137 self.log_sink('origin=%r category=%r level=%r\n' % (origin, category, level));
138
139 if not category:
140 category = C_DEFAULT
141 if not self.is_enabled(category, level):
142 return
143
144 log_pre = []
145 if self.do_log_time:
146 log_pre.append(self.get_time_str())
147
148 if self.do_log_category:
149 log_pre.append(category)
150
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200151 deeper_origins = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200152 if self.do_log_origin:
153 if origin is None:
154 name = '-'
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200155 elif isinstance(origin, Origins):
156 name = origin[-1]
157 if len(origin) > 1:
158 deeper_origins = str(origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200159 elif isinstance(origin, str):
160 name = origin or None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200161 elif hasattr(origin, 'name'):
162 name = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200163 if not name:
164 name = str(origin.__class__.__name__)
165 log_pre.append(self.origin_fmt.format(name))
166
167 if self.do_log_level and level != L_LOG:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200168 loglevel = '%s: ' % (level_str(level) or ('loglevel=' + str(level)))
169 else:
170 loglevel = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200171
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200172 log_line = [compose_message(messages, named_items)]
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200173
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200174 if deeper_origins:
175 log_line.append(' [%s]' % deeper_origins)
176
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200177 if self.do_log_src and src:
178 log_line.append(' [%s]' % str(src))
179
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200180 log_str = '%s%s%s%s' % (' '.join(log_pre),
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200181 ': ' if log_pre else '',
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200182 loglevel,
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200183 ' '.join(log_line))
184
Neels Hofmeyr5356d0a2017-04-10 03:45:30 +0200185 if not log_str.endswith('\n'):
186 log_str = log_str + '\n'
187 self.log_sink(log_str)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200188
189targets = [ LogTarget() ]
190
191def level_str(level):
192 if level == L_TRACEBACK:
193 return L_TRACEBACK
194 if level <= L_DBG:
195 return 'DBG'
196 if level <= L_LOG:
197 return 'LOG'
198 return 'ERR'
199
200def _log_all_targets(origin, category, level, src, messages, named_items=None):
201 global targets
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200202
203 if origin is None:
204 origin = Origin._global_current_origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200205 if isinstance(src, int):
206 src = get_src_from_caller(src + 1)
207 for target in targets:
208 target.log(origin, category, level, src, messages, named_items)
209
210def get_src_from_caller(levels_up=1):
211 caller = getframeinfo(stack()[levels_up][0])
212 return '%s:%d' % (os.path.basename(caller.filename), caller.lineno)
213
214def get_src_from_tb(tb, levels_up=1):
215 ftb = traceback.extract_tb(tb)
216 f,l,m,c = ftb[-levels_up]
217 f = os.path.basename(f)
218 return '%s:%s: %s' % (f, l, c)
219
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200220def get_line_for_src(src_path):
221 etype, exception, tb = sys.exc_info()
222 if tb:
223 ftb = traceback.extract_tb(tb)
224 for f,l,m,c in ftb:
225 if f.endswith(src_path):
226 return l
227
228 for frame in stack():
229 caller = getframeinfo(frame[0])
230 if caller.filename.endswith(src_path):
231 return caller.lineno
232 return None
233
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200234
235class Origin:
236 '''
237 Base class for all classes that want to log,
238 and to add an origin string to a code path:
239 with log.Origin('my name'):
240 raise Problem()
241 This will log 'my name' as an origin for the Problem.
242 '''
243
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200244 _global_current_origin = None
245 _global_id = None
246
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200247 _log_category = None
248 _src = None
249 _name = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200250 _origin_id = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200251
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200252 _parent_origin = None
253
254 def __init__(self, *name_items, category=None, **detail_items):
255 self.set_log_category(category)
256 self.set_name(*name_items, **detail_items)
257
258 def set_name(self, *name_items, **detail_items):
259 if name_items:
260 name = '-'.join([str(i) for i in name_items])
261 elif not detail_items:
262 name = self.__class__.__name__
263 else:
264 name = ''
265 if detail_items:
266 details = '(%s)' % (', '.join([("%s=%r" % (k,v))
267 for k,v in sorted(detail_items.items())]))
268 else:
269 details = ''
270 self._name = name + details
271
272 def name(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200273 return self._name or self.__class__.__name__
274
275 __str__ = name
276 __repr__ = name
277
278 def origin_id(self):
279 if not self._origin_id:
280 if not Origin._global_id:
281 Origin._global_id = get_process_id()
282 self._origin_id = '%s-%s' % (self.name(), Origin._global_id)
283 return self._origin_id
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200284
285 def set_log_category(self, category):
286 self._log_category = category
287
288 def _log(self, level, messages, named_items=None, src_levels_up=3, origins=None):
289 src = self._src or src_levels_up
290 origin = origins or self.gather_origins()
291 _log_all_targets(origin, self._log_category, level, src, messages, named_items)
292
293 def dbg(self, *messages, **named_items):
294 self._log(L_DBG, messages, named_items)
295
296 def log(self, *messages, **named_items):
297 self._log(L_LOG, messages, named_items)
298
299 def err(self, *messages, **named_items):
300 self._log(L_ERR, messages, named_items)
301
302 def log_exn(self, exc_info=None):
303 log_exn(self, self._log_category, exc_info)
304
305 def __enter__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200306 if not self.set_child_of(Origin._global_current_origin):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200307 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200308 Origin._global_current_origin = self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200309
310 def __exit__(self, *exc_info):
311 rc = None
312 if exc_info[0] is not None:
313 rc = exn_add_info(exc_info, self)
314 Origin._global_current_origin, self._parent_origin = self._parent_origin, None
315 return rc
316
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200317 def raise_exn(self, *messages, exn_class=Error, **named_items):
318 with self:
319 raise exn_class(compose_message(messages, named_items))
320
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200321 def redirect_stdout(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200322 return contextlib.redirect_stdout(SafeRedirectStdout(self))
323
324 def gather_origins(self):
325 origins = Origins()
326 origins.add(self)
327 origin = self._parent_origin
328 if origin is None and Origin._global_current_origin is not None:
329 origin = Origin._global_current_origin
330 while origin is not None:
331 origins.add(origin)
332 origin = origin._parent_origin
333 return origins
334
335 def set_child_of(self, parent_origin):
336 # avoid loops
337 if self._parent_origin is not None:
338 return False
339 if parent_origin == self:
340 return False
341 self._parent_origin = parent_origin
342 return True
343
344class LineInfo(Origin):
345 def __init__(self, src_file, *name_items, **detail_items):
346 self.src_file = src_file
347 self.set_name(*name_items, **detail_items)
348
349 def name(self):
350 l = get_line_for_src(self.src_file)
351 if l is not None:
352 return '%s:%s' % (self._name, l)
353 return super().name()
354
355class SafeRedirectStdout:
356 '''
357 To be able to use 'print' in test scripts, this is used to redirect stdout
358 to a test class' log() function. However, it turns out doing that breaks
359 python debugger sessions -- it uses extended features of stdout, and will
360 fail dismally if it finds this wrapper in sys.stdout. Luckily, overriding
361 __getattr__() to return the original sys.__stdout__ attributes for anything
362 else than write() makes the debugger session work nicely again!
363 '''
364 _log_line_buf = None
365
366 def __init__(self, origin):
367 self._origin = origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200368
369 def write(self, message):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200370 lines = message.splitlines()
371 if not lines:
372 return
373 if self._log_line_buf:
374 lines[0] = self._log_line_buf + lines[0]
375 self._log_line_buf = None
376 if not message.endswith('\n'):
377 self._log_line_buf = lines[-1]
378 lines = lines[:-1]
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200379 origins = self._origin.gather_origins()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200380 for line in lines:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200381 self._origin._log(L_LOG, (line,), origins=origins)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200382
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200383 def __getattr__(self, name):
384 return sys.__stdout__.__getattribute__(name)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200385
386
387def dbg(origin, category, *messages, **named_items):
388 _log_all_targets(origin, category, L_DBG, 2, messages, named_items)
389
390def log(origin, category, *messages, **named_items):
391 _log_all_targets(origin, category, L_LOG, 2, messages, named_items)
392
393def err(origin, category, *messages, **named_items):
394 _log_all_targets(origin, category, L_ERR, 2, messages, named_items)
395
396def trace(origin, category, exc_info):
397 _log_all_targets(origin, category, L_TRACEBACK, None,
398 traceback.format_exception(*exc_info))
399
400def resolve_category(origin, category):
401 if category is not None:
402 return category
403 if not hasattr(origin, '_log_category'):
404 return None
405 return origin._log_category
406
407def exn_add_info(exc_info, origin, category=None):
408 etype, exception, tb = exc_info
409 if not hasattr(exception, 'origins'):
410 exception.origins = Origins()
411 if not hasattr(exception, 'category'):
412 # only remember the deepest category
413 exception.category = resolve_category(origin, category)
414 if not hasattr(exception, 'src'):
415 exception.src = get_src_from_tb(tb)
416 exception.origins.add(origin)
417 return False
418
419
420
421def log_exn(origin=None, category=None, exc_info=None):
422 if not (exc_info is not None and len(exc_info) == 3):
423 exc_info = sys.exc_info()
424 if not (exc_info is not None and len(exc_info) == 3):
425 raise RuntimeError('invalid call to log_exn() -- no valid exception info')
426
427 etype, exception, tb = exc_info
428
429 # if there are origins recorded with the Exception, prefer that
430 if hasattr(exception, 'origins'):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200431 origin = exception.origins
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200432
433 # if there is a category recorded with the Exception, prefer that
434 if hasattr(exception, 'category'):
435 category = exception.category
436
437 if hasattr(exception, 'msg'):
438 msg = exception.msg
439 else:
440 msg = str(exception)
441
442 if hasattr(exception, 'src'):
443 src = exception.src
444 else:
445 src = 2
446
447 trace(origin, category, exc_info)
448 _log_all_targets(origin, category, L_ERR, src,
449 ('%s:' % str(etype.__name__), msg))
450
451
452class Origins(list):
453 def __init__(self, origin=None):
454 if origin is not None:
455 self.add(origin)
456 def add(self, origin):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200457 if hasattr(origin, 'name'):
458 origin_str = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200459 else:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200460 origin_str = repr(origin)
461 if origin_str is None:
462 raise RuntimeError('origin_str is None for %r' % origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200463 self.insert(0, origin_str)
464 def __str__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200465 return '↪'.join(self)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200466
467
468
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200469def set_all_levels(level):
470 global targets
471 for target in targets:
472 target.set_all_levels(level)
473
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200474def set_level(category, level):
475 global targets
476 for target in targets:
477 target.set_level(category, level)
478
479def style(**kwargs):
480 global targets
481 for target in targets:
482 target.style(**kwargs)
483
484def style_change(**kwargs):
485 global targets
486 for target in targets:
487 target.style_change(**kwargs)
488
489class TestsTarget(LogTarget):
490 'LogTarget producing deterministic results for regression tests'
491 def __init__(self, out=sys.stdout):
492 super().__init__()
493 self.style(time=False, src=False)
494 self.log_sink = out.write
495
496def run_logging_exceptions(func, *func_args, return_on_failure=None, **func_kwargs):
497 try:
498 return func(*func_args, **func_kwargs)
499 except:
500 log_exn()
501 return return_on_failure
502
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200503def compose_message(messages, named_items):
504 msgs = [str(m) for m in messages]
505
506 if named_items:
507 # unfortunately needs to be sorted to get deterministic results
508 msgs.append('{%s}' %
509 (', '.join(['%s=%r' % (k,v)
510 for k,v in sorted(named_items.items())])))
511
512 return ' '.join(msgs)
513
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200514# vim: expandtab tabstop=4 shiftwidth=4