blob: d3f2ea0218145c74d8bf2371190e61823f85a25c [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:
Neels Hofmeyrf8166882017-05-05 19:48:35 +020057 all_targets = []
58
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020059 do_log_time = None
60 do_log_category = None
61 do_log_level = None
62 do_log_origin = None
63 do_log_traceback = None
64 do_log_src = None
65 origin_width = None
66 origin_fmt = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +020067 all_levels = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020068
69 # redirected by logging test
70 get_time_str = lambda self: time.strftime(self.log_time_fmt)
71
72 # sink that gets each complete logging line
Neels Hofmeyrf8166882017-05-05 19:48:35 +020073 log_write_func = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020074
75 category_levels = None
76
Neels Hofmeyrf8166882017-05-05 19:48:35 +020077 def __init__(self, log_write_func=None):
78 if log_write_func is None:
79 log_write_func = sys.__stdout__.write
80 self.log_write_func = log_write_func
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020081 self.category_levels = {}
82 self.style()
Neels Hofmeyrf8166882017-05-05 19:48:35 +020083 LogTarget.all_targets.append(self)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020084
85 def style(self, time=True, time_fmt=DATEFMT, category=True, level=True, origin=True, origin_width=0, src=True, trace=False):
86 '''
87 set all logging format aspects, to defaults if not passed:
88 time: log timestamps;
89 time_fmt: format of timestamps;
90 category: print the logging category (three letters);
91 level: print the logging level, unless it is L_LOG;
92 origin: print which object(s) the message originated from;
93 origin_width: fill up the origin string with whitespace to this witdh;
94 src: log the source file and line number the log comes from;
95 trace: on exceptions, log the full stack trace;
96 '''
97 self.log_time_fmt = time_fmt
98 self.do_log_time = bool(time)
99 if not self.log_time_fmt:
100 self.do_log_time = False
101 self.do_log_category = bool(category)
102 self.do_log_level = bool(level)
103 self.do_log_origin = bool(origin)
104 self.origin_width = int(origin_width)
105 self.origin_fmt = '{:>%ds}' % self.origin_width
106 self.do_log_src = src
107 self.do_log_traceback = trace
Neels Hofmeyr1a2177c2017-05-06 23:58:46 +0200108 return self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200109
110 def style_change(self, time=None, time_fmt=None, category=None, level=None, origin=None, origin_width=None, src=None, trace=None):
111 'modify only the given aspects of the logging format'
112 self.style(
113 time=(time if time is not None else self.do_log_time),
114 time_fmt=(time_fmt if time_fmt is not None else self.log_time_fmt),
115 category=(category if category is not None else self.do_log_category),
116 level=(level if level is not None else self.do_log_level),
117 origin=(origin if origin is not None else self.do_log_origin),
118 origin_width=(origin_width if origin_width is not None else self.origin_width),
119 src=(src if src is not None else self.do_log_src),
120 trace=(trace if trace is not None else self.do_log_traceback),
121 )
Neels Hofmeyr1a2177c2017-05-06 23:58:46 +0200122 return self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200123
124 def set_level(self, category, level):
125 'set global logging log.L_* level for a given log.C_* category'
126 self.category_levels[category] = level
Neels Hofmeyr1a2177c2017-05-06 23:58:46 +0200127 return self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200128
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200129 def set_all_levels(self, level):
130 self.all_levels = level
Neels Hofmeyr1a2177c2017-05-06 23:58:46 +0200131 return self
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200132
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200133 def is_enabled(self, category, level):
134 if level == L_TRACEBACK:
135 return self.do_log_traceback
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200136 if self.all_levels is not None:
137 is_level = self.all_levels
138 else:
139 is_level = self.category_levels.get(category)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200140 if is_level is None:
141 is_level = L_LOG
142 if level < is_level:
143 return False
144 return True
145
146 def log(self, origin, category, level, src, messages, named_items):
147 if category and len(category) != 3:
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200148 self.log_write_func('WARNING: INVALID LOG SUBSYSTEM %r\n' % category)
149 self.log_write_func('origin=%r category=%r level=%r\n' % (origin, category, level));
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200150
151 if not category:
152 category = C_DEFAULT
153 if not self.is_enabled(category, level):
154 return
155
156 log_pre = []
157 if self.do_log_time:
158 log_pre.append(self.get_time_str())
159
160 if self.do_log_category:
161 log_pre.append(category)
162
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200163 deeper_origins = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200164 if self.do_log_origin:
165 if origin is None:
166 name = '-'
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200167 elif isinstance(origin, Origins):
168 name = origin[-1]
169 if len(origin) > 1:
170 deeper_origins = str(origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200171 elif isinstance(origin, str):
172 name = origin or None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200173 elif hasattr(origin, 'name'):
174 name = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200175 if not name:
176 name = str(origin.__class__.__name__)
177 log_pre.append(self.origin_fmt.format(name))
178
179 if self.do_log_level and level != L_LOG:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200180 loglevel = '%s: ' % (level_str(level) or ('loglevel=' + str(level)))
181 else:
182 loglevel = ''
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200183
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200184 log_line = [compose_message(messages, named_items)]
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200185
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200186 if deeper_origins:
187 log_line.append(' [%s]' % deeper_origins)
188
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200189 if self.do_log_src and src:
190 log_line.append(' [%s]' % str(src))
191
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200192 log_str = '%s%s%s%s' % (' '.join(log_pre),
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200193 ': ' if log_pre else '',
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200194 loglevel,
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200195 ' '.join(log_line))
196
Neels Hofmeyr5356d0a2017-04-10 03:45:30 +0200197 if not log_str.endswith('\n'):
198 log_str = log_str + '\n'
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200199 self.log_write_func(log_str)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200200
Your Name44af3412017-04-13 03:11:59 +0200201 def large_separator(self, *msgs):
202 msg = ' '.join(msgs)
203 if not msg:
204 msg = '------------------------------------------'
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200205 self.log_write_func('------------------------------------------\n'
206 '%s\n'
207 '------------------------------------------\n' % msg)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200208
209def level_str(level):
210 if level == L_TRACEBACK:
211 return L_TRACEBACK
212 if level <= L_DBG:
213 return 'DBG'
214 if level <= L_LOG:
215 return 'LOG'
216 return 'ERR'
217
218def _log_all_targets(origin, category, level, src, messages, named_items=None):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200219 if origin is None:
220 origin = Origin._global_current_origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200221 if isinstance(src, int):
222 src = get_src_from_caller(src + 1)
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200223 for target in LogTarget.all_targets:
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200224 target.log(origin, category, level, src, messages, named_items)
225
Your Name44af3412017-04-13 03:11:59 +0200226def large_separator(*msgs):
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200227 for target in LogTarget.all_targets:
Your Name44af3412017-04-13 03:11:59 +0200228 target.large_separator(*msgs)
229
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200230def get_src_from_caller(levels_up=1):
231 caller = getframeinfo(stack()[levels_up][0])
232 return '%s:%d' % (os.path.basename(caller.filename), caller.lineno)
233
234def get_src_from_tb(tb, levels_up=1):
235 ftb = traceback.extract_tb(tb)
236 f,l,m,c = ftb[-levels_up]
237 f = os.path.basename(f)
238 return '%s:%s: %s' % (f, l, c)
239
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200240def get_line_for_src(src_path):
241 etype, exception, tb = sys.exc_info()
242 if tb:
243 ftb = traceback.extract_tb(tb)
244 for f,l,m,c in ftb:
245 if f.endswith(src_path):
246 return l
247
248 for frame in stack():
249 caller = getframeinfo(frame[0])
250 if caller.filename.endswith(src_path):
251 return caller.lineno
252 return None
253
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200254
255class Origin:
256 '''
257 Base class for all classes that want to log,
258 and to add an origin string to a code path:
259 with log.Origin('my name'):
260 raise Problem()
261 This will log 'my name' as an origin for the Problem.
262 '''
263
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200264 _global_current_origin = None
265 _global_id = None
266
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200267 _log_category = None
268 _src = None
269 _name = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200270 _origin_id = None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200271
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200272 _parent_origin = None
273
274 def __init__(self, *name_items, category=None, **detail_items):
275 self.set_log_category(category)
276 self.set_name(*name_items, **detail_items)
277
278 def set_name(self, *name_items, **detail_items):
279 if name_items:
280 name = '-'.join([str(i) for i in name_items])
281 elif not detail_items:
282 name = self.__class__.__name__
283 else:
284 name = ''
285 if detail_items:
286 details = '(%s)' % (', '.join([("%s=%r" % (k,v))
287 for k,v in sorted(detail_items.items())]))
288 else:
289 details = ''
290 self._name = name + details
291
292 def name(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200293 return self._name or self.__class__.__name__
294
295 __str__ = name
296 __repr__ = name
297
298 def origin_id(self):
299 if not self._origin_id:
300 if not Origin._global_id:
301 Origin._global_id = get_process_id()
302 self._origin_id = '%s-%s' % (self.name(), Origin._global_id)
303 return self._origin_id
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200304
305 def set_log_category(self, category):
306 self._log_category = category
307
308 def _log(self, level, messages, named_items=None, src_levels_up=3, origins=None):
309 src = self._src or src_levels_up
310 origin = origins or self.gather_origins()
311 _log_all_targets(origin, self._log_category, level, src, messages, named_items)
312
313 def dbg(self, *messages, **named_items):
314 self._log(L_DBG, messages, named_items)
315
316 def log(self, *messages, **named_items):
317 self._log(L_LOG, messages, named_items)
318
319 def err(self, *messages, **named_items):
320 self._log(L_ERR, messages, named_items)
321
322 def log_exn(self, exc_info=None):
323 log_exn(self, self._log_category, exc_info)
324
325 def __enter__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200326 if not self.set_child_of(Origin._global_current_origin):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200327 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200328 Origin._global_current_origin = self
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200329
330 def __exit__(self, *exc_info):
331 rc = None
332 if exc_info[0] is not None:
333 rc = exn_add_info(exc_info, self)
334 Origin._global_current_origin, self._parent_origin = self._parent_origin, None
335 return rc
336
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200337 def raise_exn(self, *messages, exn_class=Error, **named_items):
338 with self:
339 raise exn_class(compose_message(messages, named_items))
340
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200341 def redirect_stdout(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200342 return contextlib.redirect_stdout(SafeRedirectStdout(self))
343
344 def gather_origins(self):
345 origins = Origins()
346 origins.add(self)
347 origin = self._parent_origin
348 if origin is None and Origin._global_current_origin is not None:
349 origin = Origin._global_current_origin
350 while origin is not None:
351 origins.add(origin)
352 origin = origin._parent_origin
353 return origins
354
355 def set_child_of(self, parent_origin):
356 # avoid loops
357 if self._parent_origin is not None:
358 return False
359 if parent_origin == self:
360 return False
361 self._parent_origin = parent_origin
362 return True
363
364class LineInfo(Origin):
365 def __init__(self, src_file, *name_items, **detail_items):
366 self.src_file = src_file
367 self.set_name(*name_items, **detail_items)
368
369 def name(self):
370 l = get_line_for_src(self.src_file)
371 if l is not None:
372 return '%s:%s' % (self._name, l)
373 return super().name()
374
375class SafeRedirectStdout:
376 '''
377 To be able to use 'print' in test scripts, this is used to redirect stdout
378 to a test class' log() function. However, it turns out doing that breaks
379 python debugger sessions -- it uses extended features of stdout, and will
380 fail dismally if it finds this wrapper in sys.stdout. Luckily, overriding
381 __getattr__() to return the original sys.__stdout__ attributes for anything
382 else than write() makes the debugger session work nicely again!
383 '''
384 _log_line_buf = None
385
386 def __init__(self, origin):
387 self._origin = origin
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200388
389 def write(self, message):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200390 lines = message.splitlines()
391 if not lines:
392 return
393 if self._log_line_buf:
394 lines[0] = self._log_line_buf + lines[0]
395 self._log_line_buf = None
396 if not message.endswith('\n'):
397 self._log_line_buf = lines[-1]
398 lines = lines[:-1]
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200399 origins = self._origin.gather_origins()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200400 for line in lines:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200401 self._origin._log(L_LOG, (line,), origins=origins)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200402
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200403 def __getattr__(self, name):
404 return sys.__stdout__.__getattribute__(name)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200405
406
407def dbg(origin, category, *messages, **named_items):
408 _log_all_targets(origin, category, L_DBG, 2, messages, named_items)
409
410def log(origin, category, *messages, **named_items):
411 _log_all_targets(origin, category, L_LOG, 2, messages, named_items)
412
413def err(origin, category, *messages, **named_items):
414 _log_all_targets(origin, category, L_ERR, 2, messages, named_items)
415
416def trace(origin, category, exc_info):
417 _log_all_targets(origin, category, L_TRACEBACK, None,
418 traceback.format_exception(*exc_info))
419
420def resolve_category(origin, category):
421 if category is not None:
422 return category
423 if not hasattr(origin, '_log_category'):
424 return None
425 return origin._log_category
426
427def exn_add_info(exc_info, origin, category=None):
428 etype, exception, tb = exc_info
429 if not hasattr(exception, 'origins'):
430 exception.origins = Origins()
431 if not hasattr(exception, 'category'):
432 # only remember the deepest category
433 exception.category = resolve_category(origin, category)
434 if not hasattr(exception, 'src'):
435 exception.src = get_src_from_tb(tb)
436 exception.origins.add(origin)
437 return False
438
439
440
441def log_exn(origin=None, category=None, exc_info=None):
442 if not (exc_info is not None and len(exc_info) == 3):
443 exc_info = sys.exc_info()
444 if not (exc_info is not None and len(exc_info) == 3):
445 raise RuntimeError('invalid call to log_exn() -- no valid exception info')
446
447 etype, exception, tb = exc_info
448
449 # if there are origins recorded with the Exception, prefer that
450 if hasattr(exception, 'origins'):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200451 origin = exception.origins
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200452
453 # if there is a category recorded with the Exception, prefer that
454 if hasattr(exception, 'category'):
455 category = exception.category
456
457 if hasattr(exception, 'msg'):
458 msg = exception.msg
459 else:
460 msg = str(exception)
461
462 if hasattr(exception, 'src'):
463 src = exception.src
464 else:
465 src = 2
466
467 trace(origin, category, exc_info)
468 _log_all_targets(origin, category, L_ERR, src,
469 ('%s:' % str(etype.__name__), msg))
470
471
472class Origins(list):
473 def __init__(self, origin=None):
474 if origin is not None:
475 self.add(origin)
476 def add(self, origin):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200477 if hasattr(origin, 'name'):
478 origin_str = origin.name()
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200479 else:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200480 origin_str = repr(origin)
481 if origin_str is None:
482 raise RuntimeError('origin_str is None for %r' % origin)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200483 self.insert(0, origin_str)
484 def __str__(self):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200485 return '↪'.join(self)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200486
487
488
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200489def set_all_levels(level):
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200490 for target in LogTarget.all_targets:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200491 target.set_all_levels(level)
492
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200493def set_level(category, level):
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200494 for target in LogTarget.all_targets:
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200495 target.set_level(category, level)
496
497def style(**kwargs):
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200498 for target in LogTarget.all_targets:
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200499 target.style(**kwargs)
500
501def style_change(**kwargs):
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200502 for target in LogTarget.all_targets:
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200503 target.style_change(**kwargs)
504
505class TestsTarget(LogTarget):
506 'LogTarget producing deterministic results for regression tests'
Neels Hofmeyrf8166882017-05-05 19:48:35 +0200507 def __init__(self, log_write_func=None):
508 super().__init__(log_write_func)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200509 self.style(time=False, src=False)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200510
511def run_logging_exceptions(func, *func_args, return_on_failure=None, **func_kwargs):
512 try:
513 return func(*func_args, **func_kwargs)
514 except:
515 log_exn()
516 return return_on_failure
517
Neels Hofmeyr2694a9d2017-04-27 19:48:09 +0200518def _compose_named_items(item):
519 'make sure dicts are output sorted, for test expectations'
520 if is_dict(item):
521 return '{%s}' % (', '.join(
522 ['%s=%s' % (k, _compose_named_items(v))
523 for k,v in sorted(item.items())]))
524 return repr(item)
525
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200526def compose_message(messages, named_items):
527 msgs = [str(m) for m in messages]
528
529 if named_items:
530 # unfortunately needs to be sorted to get deterministic results
Neels Hofmeyr2694a9d2017-04-27 19:48:09 +0200531 msgs.append(_compose_named_items(named_items))
Neels Hofmeyr85eb3242017-04-09 22:01:16 +0200532
533 return ' '.join(msgs)
534
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200535# vim: expandtab tabstop=4 shiftwidth=4