From aa7c123c198fd89ab7c8f509b6dd32b9dd867b28 Mon Sep 17 00:00:00 2001 From: Tim Waugh Date: Mon, 12 Oct 2015 16:18:51 +0100 Subject: [PATCH 1/3] New Inclusion class --- journal_brief/cli/main.py | 3 +- journal_brief/filter.py | 101 +++++++++++++++++++++++++++----------- tests/test_filter.py | 37 +++++++++++++- 3 files changed, 111 insertions(+), 30 deletions(-) diff --git a/journal_brief/cli/main.py b/journal_brief/cli/main.py index 08fd6bd..15b7f5e 100644 --- a/journal_brief/cli/main.py +++ b/journal_brief/cli/main.py @@ -96,7 +96,8 @@ def show_stats(self, entries, exclusions): strf = "{FREQ:>10} {EXCLUSION}" print(strf.format(FREQ='FREQUENCY', EXCLUSION='EXCLUSION')) for stat in stats: - print(strf.format(FREQ=stat.hits, EXCLUSION=repr(stat.exclusion))) + print(strf.format(FREQ=stat.hits, + EXCLUSION=repr(dict(stat.exclusion)))) def stream_output(self, stream, formatters, jfilter): try: diff --git a/journal_brief/filter.py b/journal_brief/filter.py index 4fab8d3..43a4797 100644 --- a/journal_brief/filter.py +++ b/journal_brief/filter.py @@ -29,38 +29,87 @@ ExclusionStatistics = namedtuple('ExclusionStatistics', ['hits', 'exclusion']) -class Exclusion(dict): +class FilterRule(dict): """ - str (field) -> list (str values) + A mapping of field names to values that are significant for that field """ - def __init__(self, mapping, comment=None): + def __init__(self, mapping): assert isinstance(mapping, dict) # Make sure everything is interpreted as a string str_mapping = {} - log.debug("new exclusion rule:") for field, matches in mapping.items(): if field == 'PRIORITY': - str_mapping[field] = [PRIORITY_MAP[match] for match in matches] + try: + level = int(PRIORITY_MAP[matches]) + except (AttributeError, TypeError): + str_mapping[field] = [PRIORITY_MAP[match] + for match in matches] + else: + str_mapping[field] = list(range(level + 1)) else: str_mapping[field] = [str(match) for match in matches] - log.debug("%s=%r", field, str_mapping[field]) + super(FilterRule, self).__init__(str_mapping) + + def __str__(self): + return yaml.dump([dict(self)], + indent=2, + default_flow_style=False) + + def value_matches(self, field, index, match, value): + return match == value + + def matches(self, entry): + for field, matches in self.items(): + is_match = False + for index, match in enumerate(matches): + if self.value_matches(field, index, match, entry.get(field)): + is_match = True + break + + if not is_match: + return False + + return True + + +class Inclusion(FilterRule): + """ + Filter rule for including entries + """ + + def __repr__(self): + return "Inclusion(%s)" % super(Inclusion, self).__repr__() + + +class Exclusion(FilterRule): + """ + Filter rule for excluding entries + """ + + def __init__(self, mapping, comment=None): + super(Exclusion, self).__init__(mapping) + + # Make sure everything is interpreted as a string + log.debug("new exclusion rule:") + for field, matches in mapping.items(): + log.debug("%s=%r", field, matches) - super(Exclusion, self).__init__(str_mapping) self.hits = 0 self.regexp = {} # field -> index -> compiled regexp self.comment = comment + def __repr__(self): + return "Exclusion(%s)" % super(Exclusion, self).__repr__() + def __str__(self): ret = '' if self.comment: ret += '# {0}\n'.format(self.comment) - ret += yaml.dump([dict(self)], - indent=2, - default_flow_style=False) + ret += super(Exclusion, self).__str__() return ret def value_matches(self, field, index, match, value): @@ -70,11 +119,15 @@ def value_matches(self, field, index, match, value): log.debug('using cached regexp for %s[%d]:%s', field, index, match) except KeyError: - if match.startswith('/') and match.endswith('/'): - pattern = match[1:-1] - log.debug('compiling pattern %r', pattern) - regexp = re.compile(pattern) - else: + try: + if match.startswith('/') and match.endswith('/'): + pattern = match[1:-1] + log.debug('compiling pattern %r', pattern) + regexp = re.compile(pattern) + else: + regexp = None + log.debug('%r is not a regex', match) + except AttributeError: regexp = None log.debug('%r is not a regex', match) @@ -87,20 +140,12 @@ def value_matches(self, field, index, match, value): return match == value def matches(self, entry): - for field, matches in self.items(): - is_match = False - for index, match in enumerate(matches): - if self.value_matches(field, index, match, entry.get(field)): - is_match = True - log.debug("matched %s[%d]", field, index) - break - - if not is_match: - return False + matched = super(Exclusion, self).matches(entry) + if matched: + log.debug("excluding entry") + self.hits += 1 - log.debug("excluding entry") - self.hits += 1 - return True + return matched class JournalFilter(Iterator): diff --git a/tests/test_filter.py b/tests/test_filter.py index 7af15d7..e22bccf 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -20,7 +20,7 @@ from flexmock import flexmock from io import StringIO from journal_brief import JournalFilter -from journal_brief.filter import Exclusion +from journal_brief.filter import Inclusion, Exclusion import logging from systemd import journal import yaml @@ -29,6 +29,37 @@ logging.basicConfig(level=logging.DEBUG) +class TestInclusion(object): + def test_and(self): + inclusion = Inclusion({'MESSAGE': ['include this'], + 'SYSLOG_IDENTIFIER': ['from this']}) + assert inclusion.matches({'MESSAGE': 'include this', + 'SYSLOG_IDENTIFIER': 'from this', + 'IGNORE': 'ignore this'}) + assert not inclusion.matches({'MESSAGE': 'include this'}) + + def test_or(self): + inclusion = Inclusion({'MESSAGE': ['include this', 'or this']}) + assert inclusion.matches({'MESSAGE': 'include this', + 'IGNORE': 'ignore this'}) + assert not inclusion.matches({'MESSAGE': 'not this', + 'IGNORE': 'ignore this'}) + + def test_and_or(self): + inclusion = Inclusion({'MESSAGE': ['include this', 'or this'], + 'SYSLOG_IDENTIFIER': ['from this']}) + assert inclusion.matches({'MESSAGE': 'include this', + 'SYSLOG_IDENTIFIER': 'from this', + 'IGNORE': 'ignore this'}) + assert not inclusion.matches({'MESSAGE': 'include this', + 'SYSLOG_IDENTIFIER': 'at your peril', + 'IGNORE': 'ignore this'}) + + def test_priority(self): + inclusion = Inclusion({'PRIORITY': 'err'}) + assert inclusion.matches({'PRIORITY': 3}) + + class TestExclusion(object): def test_and(self): exclusion = Exclusion({'MESSAGE': ['exclude this'], @@ -55,6 +86,10 @@ def test_and_or(self): 'SYSLOG_IDENTIFIER': 'at your peril', 'IGNORE': 'ignore this'}) + def test_priority(self): + exclusion = Exclusion({'PRIORITY': 'err'}) + assert exclusion.matches({'PRIORITY': 3}) + def test_str_without_comment(self): excl = {'MESSAGE': ['exclude this']} unyaml = StringIO() From 76646277355b31709540fdf841df3ebe027e52fe Mon Sep 17 00:00:00 2001 From: Tim Waugh Date: Mon, 12 Oct 2015 23:54:53 +0100 Subject: [PATCH 2/3] Alter architecture to allow formatters to specify their filter rules Now JournalFilter takes each entry and passes it to each formatter that wants it. --- journal_brief/cli/main.py | 54 ++++++++++-------- journal_brief/filter.py | 100 +++++++++++++++++++++++++-------- journal_brief/format.py | 7 +++ journal_brief/journal_brief.py | 8 +++ tests/cli/__init__.py | 0 tests/cli/test_main.py | 10 ++-- tests/test_filter.py | 38 ++++++++++--- 7 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 tests/cli/__init__.py diff --git a/journal_brief/cli/main.py b/journal_brief/cli/main.py index 15b7f5e..d9f78fb 100644 --- a/journal_brief/cli/main.py +++ b/journal_brief/cli/main.py @@ -57,6 +57,14 @@ def get(self, key, default_value=None): return value +class NullStream(object): + def write(self, str): + pass + + def flush(self): + pass + + class CLI(object): def __init__(self, args=None): self.args = self.get_args(args or sys.argv[1:]) @@ -88,9 +96,8 @@ def get_args(self, args): cmds.add_parser('stats', help='show statistics') return parser.parse_args(args) - def show_stats(self, entries, exclusions): - jfilter = JournalFilter(entries, exclusions=exclusions) - list(jfilter) + def show_stats(self, jfilter, exclusions): + jfilter.format(NullStream()) stats = jfilter.get_statistics() log.debug("stats: %r", stats) strf = "{FREQ:>10} {EXCLUSION}" @@ -99,15 +106,6 @@ def show_stats(self, entries, exclusions): print(strf.format(FREQ=stat.hits, EXCLUSION=repr(dict(stat.exclusion)))) - def stream_output(self, stream, formatters, jfilter): - try: - for entry in jfilter: - for formatter in formatters: - stream.write(formatter.format(entry)) - finally: - for formatter in formatters: - stream.write(formatter.flush()) - def run(self): if self.config.get('debug'): logging.basicConfig(level=logging.DEBUG) @@ -132,25 +130,37 @@ def run(self): log_level = int(PRIORITY_MAP[priority]) log.debug("priority=%r from args/config", log_level) + if self.args.cmd == 'debrief': + formatters = [get_formatter('config')] + else: + formats = self.config.get('output', 'short').split(',') + formatters = [get_formatter(format) for format in formats] + + default_inclusions = self.config.get('inclusions') + if default_inclusions: + inclusions = default_inclusions[:] + else: + inclusions = [] + + for formatter in formatters: + if formatter.FILTER_INCLUSIONS is not None: + inclusions.extend(formatter.FILTER_INCLUSIONS) + reader = SelectiveReader(this_boot=self.args.b, log_level=log_level, - inclusions=self.config.get('inclusions')) + inclusions=inclusions) with LatestJournalEntries(cursor_file=cursor_file, reader=reader, dry_run=self.args.dry_run, seek_cursor=not self.args.b) as entries: exclusions = self.config.get('exclusions', []) - jfilter = JournalFilter(entries, exclusions=exclusions) + jfilter = JournalFilter(entries, formatters, + default_inclusions=default_inclusions, + default_exclusions=exclusions) if self.args.cmd == 'stats': - self.show_stats(entries, exclusions) + self.show_stats(jfilter, exclusions) else: - if self.args.cmd == 'debrief': - formatters = [get_formatter('config')] - else: - formats = self.config.get('output', 'short').split(',') - formatters = [get_formatter(format) for format in formats] - - self.stream_output(sys.stdout, formatters, jfilter) + jfilter.format(sys.stdout) def run(): diff --git a/journal_brief/filter.py b/journal_brief/filter.py index 43a4797..1a20eaf 100644 --- a/journal_brief/filter.py +++ b/journal_brief/filter.py @@ -16,7 +16,6 @@ ## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. """ -from collections.abc import Iterator from collections import namedtuple from journal_brief.constants import PRIORITY_MAP from logging import getLogger @@ -26,8 +25,13 @@ log = getLogger(__name__) + +# Statistics about an exclusion filter rule ExclusionStatistics = namedtuple('ExclusionStatistics', ['hits', 'exclusion']) +# A set of inclusion and exclusion filter rules +FilterRules = namedtuple('FilterRules', ['inclusions', 'exclusions']) + class FilterRule(dict): """ @@ -148,42 +152,94 @@ def matches(self, entry): return matched -class JournalFilter(Iterator): - """ - Exclude certain journal entries +class JournalFilter(object): + """Apply filter rules to journal entries for a list of formatters - Provide a list of exclusions. Each exclusion is a dict whose keys - are fields which must all match an entry to be excluded. + Provide a list of default filter rules for inclusion and + exclusion. Each filter rule is a dict whose keys are fields which + must all match an entry to be excluded. The dict value for each field is a list of possible match values, any of which may match. - Regular expressions are indicated with '/' at the beginning and - end of the match string. Regular expressions are matched at the - start of the journal field value (i.e. it's a match not a search). + For exclusions, regular expressions are indicated with '/' at the + beginning and end of the match string. Regular expressions are + matched at the start of the journal field value (i.e. it's a match + not a search). + """ - def __init__(self, iterator, exclusions=None): + def __init__(self, + iterator, + formatters, + default_inclusions=None, + default_exclusions=None): """ Constructor :param iterator: iterator, providing journal entries - :param exclusions: list, dicts of str(field) -> [str(match), ...] + :param formatters: list, EntryFormatter instances + :param default_inclusions: list, dicts of field -> values for inclusion + :param default_exclusions: list, dicts of field -> values for exclusion """ super(JournalFilter, self).__init__() self.iterator = iterator - if exclusions: - self.exclusions = [Exclusion(excl) for excl in exclusions] - else: - self.exclusions = [] + self.formatters = formatters + self.filter_rules = {} + + default_inclusions = [Inclusion(incl) + for incl in default_inclusions or []] + self.default_exclusions = [Exclusion(excl) + for excl in default_exclusions or []] + + # Initialise filters + for formatter in formatters: + name = formatter.FORMAT_NAME + if formatter.FILTER_INCLUSIONS or formatter.FILTER_EXCLUSIONS: + inclusions = [Inclusion(incl) + for incl in formatter.FILTER_INCLUSIONS or []] + exclusions = [Exclusions(excl) + for excl in formatter.FILTER_EXCLUSIONS or []] + else: + inclusions = default_inclusions + exclusions = self.default_exclusions - def __next__(self): - for entry in self.iterator: - if not any(exclusion.matches(entry) - for exclusion in self.exclusions): - return entry + rules = FilterRules(inclusions=inclusions, + exclusions=exclusions) + self.filter_rules[name] = rules - raise StopIteration + def format(self, stream): + try: + for entry in self.iterator: + default_excl = None + for formatter in self.formatters: + rules = self.filter_rules[formatter.FORMAT_NAME] + inclusions = rules.inclusions + if inclusions and not any(inclusion.matches(entry) + for inclusion in inclusions): + # Doesn't match an inclusion rule + continue + + if default_excl is None: + # Only match against the default exclusions + # once per message, for efficiency and for + # better statistics gathering + default_excl = any(excl.matches(entry) + for excl in self.default_exclusions) + + exclusions = rules.exclusions + if ((exclusions is self.default_exclusions and + default_excl) or + # This formatter has its own exclusions list + any(exclusion.matches(entry) + for exclusion in exclusions)): + # Matches an exclusion rule + continue + + stream.write(formatter.format(entry)) + finally: + for formatter in self.formatters: + stream.write(formatter.flush()) def get_statistics(self): """ @@ -193,6 +249,6 @@ def get_statistics(self): """ stats = [ExclusionStatistics(excl.hits, excl) - for excl in self.exclusions] + for excl in self.default_exclusions] stats.sort(reverse=True, key=lambda stat: stat.hits) return stats diff --git a/journal_brief/format.py b/journal_brief/format.py index 188b486..62b9145 100644 --- a/journal_brief/format.py +++ b/journal_brief/format.py @@ -50,6 +50,13 @@ def __new__(meta, name, bases, class_dict): class EntryFormatter(object, metaclass=RegisteredFormatter): FORMAT_NAME = 'cat' # for use with get_formatter() + + # Filter rules for journal entries to be processed by this entry + # formatter, or None for the rules listed in the configuration + # file. + FILTER_INCLUSIONS = None + FILTER_EXCLUSIONS = None + def format(self, entry): """ Format a single entry. diff --git a/journal_brief/journal_brief.py b/journal_brief/journal_brief.py index fb0e8ae..26ac0bb 100644 --- a/journal_brief/journal_brief.py +++ b/journal_brief/journal_brief.py @@ -33,6 +33,14 @@ class SelectiveReader(journal.Reader): """ def __init__(self, log_level=None, this_boot=None, inclusions=None): + """ + Constructor + + :param log_level: int, LOG_* priority level + :param this_boot: bool, process messages from this boot + :param inclusions: dict, field -> values, PRIORITY may use value + instead of list + """ super(SelectiveReader, self).__init__() log.debug("setting inclusion filters:") diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index bff51b9..9a56685 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -50,7 +50,7 @@ def test_param_override(self): assert cli.config.get('priority') == 'debug' def test_normal_run(self, capsys): - (flexmock(journal.Reader) + (flexmock(journal.Reader, add_match=None, add_disjunction=None) .should_receive('get_next') .and_return({'__CURSOR': '1', '__REALTIME_TIMESTAMP': datetime.now(), @@ -72,7 +72,7 @@ def test_normal_run(self, capsys): assert len(out.splitlines()) == 2 def test_dry_run(self): - (flexmock(journal.Reader) + (flexmock(journal.Reader, add_match=None, add_disjunction=None) .should_receive('get_next') .and_return({'__CURSOR': '1', '__REALTIME_TIMESTAMP': datetime.now(), @@ -89,6 +89,7 @@ def test_dry_run(self): def test_this_boot(self): final_cursor = '1' + flexmock(journal.Reader, add_match=None, add_disjunction=None) (flexmock(journal.Reader) .should_receive('this_boot') .once()) @@ -111,6 +112,7 @@ def test_this_boot(self): assert cursorfile.read() == final_cursor def test_log_level(self): + flexmock(journal.Reader, add_match=None, add_disjunction=None) (flexmock(journal.Reader) .should_receive('log_level') .with_args(journal.LOG_ERR) @@ -143,7 +145,7 @@ def test_reset(self): assert not os.access(cursorfile.name, os.F_OK) def test_stats(self, capsys): - (flexmock(journal.Reader) + (flexmock(journal.Reader, add_match=None, add_disjunction=None) .should_receive('get_next') .and_return({'__CURSOR': '1', '__REALTIME_TIMESTAMP': datetime.now(), @@ -240,7 +242,7 @@ def test_debrief_no_input(self, capsys): assert not out def test_exclusions_yaml(self, capsys): - (flexmock(journal.Reader) + (flexmock(journal.Reader, add_match=None, add_disjunction=None) .should_receive('get_next') .and_return({'__CURSOR': '1', '__REALTIME_TIMESTAMP': datetime.now(), diff --git a/tests/test_filter.py b/tests/test_filter.py index e22bccf..9f457ba 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -21,6 +21,7 @@ from io import StringIO from journal_brief import JournalFilter from journal_brief.filter import Inclusion, Exclusion +from journal_brief.format import EntryFormatter import logging from systemd import journal import yaml @@ -119,8 +120,13 @@ def test_no_exclusions(self): .and_return(entries[1]) .and_return({})) - jfilter = JournalFilter(journal.Reader()) - assert list(jfilter) == entries + formatter = EntryFormatter() + jfilter = JournalFilter(journal.Reader(), [formatter]) + output = StringIO() + jfilter.format(output) + output.seek(0) + lines = output.read().splitlines() + assert lines == [entry['MESSAGE'] for entry in entries] def test_exclusion(self): entries = [{'MESSAGE': 'exclude this', @@ -149,8 +155,14 @@ def test_exclusion(self): 'and this'], 'SYSLOG_IDENTIFIER': ['from here']}, {'PRIORITY': ['info']}] - jfilter = JournalFilter(journal.Reader(), exclusions=exclusions) - assert list(jfilter) == entries[2:] + formatter = EntryFormatter() + jfilter = JournalFilter(journal.Reader(), [formatter], + default_exclusions=exclusions) + output = StringIO() + jfilter.format(output) + output.seek(0) + lines = output.read().splitlines() + assert lines == [entry['MESSAGE'] for entry in entries[2:]] def test_exclusion_regexp(self): entries = [{'MESSAGE': 'exclude this'}, @@ -168,8 +180,15 @@ def test_exclusion_regexp(self): exclusions = [{'MESSAGE': ['/1/']}, # shouldn't exclude anything {'MESSAGE': ['/exclude th/']}, {'MESSAGE': ['/exclude/']}] - jfilter = JournalFilter(journal.Reader(), exclusions=exclusions) - assert list(jfilter) == [entries[1]] + [entries[3]] + formatter = EntryFormatter() + jfilter = JournalFilter(journal.Reader(), [formatter], + default_exclusions=exclusions) + output = StringIO() + jfilter.format(output) + output.seek(0) + lines = output.read().splitlines() + assert lines == [entry['MESSAGE'] + for entry in [entries[1]] + [entries[3]]] stats = jfilter.get_statistics() for stat in stats: if stat.exclusion['MESSAGE'] == ['/1/']: @@ -184,8 +203,11 @@ def test_statistics(self): .and_return({})) exclusions = [{'MESSAGE': ['exclude']}] - jfilter = JournalFilter(journal.Reader(), exclusions=exclusions) - list(jfilter) + formatter = EntryFormatter() + jfilter = JournalFilter(journal.Reader(), [formatter], + default_exclusions=exclusions) + output = StringIO() + jfilter.format(output) statistics = jfilter.get_statistics() assert len(statistics) == 1 assert statistics[0].hits == 1 From 1a403d1ec976d2577faffe491ef8528eea1c078f Mon Sep 17 00:00:00 2001 From: Tim Waugh Date: Tue, 13 Oct 2015 00:21:09 +0100 Subject: [PATCH 3/3] New 'reboot' output format (fixes #11) New --help-output parameter to collect and display formatter names and documentation. Change the default output format list to 'reboot,short'. --- journal_brief/cli/main.py | 25 ++++++++++++++++++++++- journal_brief/debrief.py | 4 +++- journal_brief/format.py | 43 ++++++++++++++++++++++++++++++++++++++- tests/cli/test_main.py | 8 ++++++++ tests/test_format.py | 9 ++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/journal_brief/cli/main.py b/journal_brief/cli/main.py index d9f78fb..a4871a3 100644 --- a/journal_brief/cli/main.py +++ b/journal_brief/cli/main.py @@ -89,6 +89,9 @@ def get_args(self, args): help = ('output format for journal entries, ' 'comma-separated list from {0}'.format(list_formatters())) parser.add_argument('-o', '--output', metavar='FORMAT', help=help) + parser.add_argument('--help-output', action='store_true', + default=False, + help='display information about output formats') cmds = parser.add_subparsers(dest='cmd') debrief = cmds.add_parser('debrief', help='create exclusions config') @@ -107,6 +110,25 @@ def show_stats(self, jfilter, exclusions): EXCLUSION=repr(dict(stat.exclusion)))) def run(self): + default_output_format = 'reboot,short' + + if self.args.help_output: + print("Available output formats:") + for output in list_formatters(): + print("\n{0}:".format(output)) + formatter = get_formatter(output) + docstring = [line.strip() + for line in formatter.__doc__.splitlines()] + while docstring and not docstring[0]: + del docstring[0] + while docstring and not docstring[-1]: + del docstring[-1] + print('\n'.join([' ' + line for line in docstring])) + + print("\nMultiple output formats can be used at the same time.") + print("The default is '{0}'".format(default_output_format)) + return + if self.config.get('debug'): logging.basicConfig(level=logging.DEBUG) @@ -133,7 +155,8 @@ def run(self): if self.args.cmd == 'debrief': formatters = [get_formatter('config')] else: - formats = self.config.get('output', 'short').split(',') + formats = self.config.get('output', + default_output_format).split(',') formatters = [get_formatter(format) for format in formats] default_inclusions = self.config.get('inclusions') diff --git a/journal_brief/debrief.py b/journal_brief/debrief.py index 5af5b80..fc41886 100644 --- a/journal_brief/debrief.py +++ b/journal_brief/debrief.py @@ -124,7 +124,9 @@ def get_counts(self): class Debriefer(EntryFormatter): """ - Build exclusions list covering all entries. + Build exclusions list covering all entries + + This is the same as using the 'debrief' subcommand. """ FORMAT_NAME = 'config' diff --git a/journal_brief/format.py b/journal_brief/format.py index 62b9145..b5bb7e3 100644 --- a/journal_brief/format.py +++ b/journal_brief/format.py @@ -49,6 +49,10 @@ def __new__(meta, name, bases, class_dict): class EntryFormatter(object, metaclass=RegisteredFormatter): + """ + Only display MESSAGE field + """ + FORMAT_NAME = 'cat' # for use with get_formatter() # Filter rules for journal entries to be processed by this entry @@ -79,7 +83,7 @@ def flush(self): class ShortEntryFormatter(EntryFormatter): """ - Convert a journal entry into a string + Output like a log file """ FORMAT_NAME = 'short' @@ -122,6 +126,10 @@ def format(self, entry): class JSONEntryFormatter(EntryFormatter): + """ + JSON format + """ + FORMAT_NAME = 'json' JSON_DUMPS_KWARGS = {} @@ -151,5 +159,38 @@ def format(self, entry): class JSONPrettyEntryFormatter(JSONEntryFormatter): + """ + Pretty JSON format + """ + FORMAT_NAME = 'json-pretty' JSON_DUMPS_KWARGS = {'indent': 8} + + +class RebootFormatter(EntryFormatter): + """ + Display a message on each reboot + + Only shows reboots between entries that are to be shown. + """ + + FORMAT_NAME = 'reboot' + + def __init__(self, *args, **kwargs): + super(RebootFormatter, self).__init__(*args, **kwargs) + self.this_boot_id = None + + def format(self, entry): + try: + boot_id = entry['_BOOT_ID'] + except KeyError: + return '' + else: + reboot = (self.this_boot_id is not None and + self.this_boot_id != boot_id) + self.this_boot_id = boot_id + + if reboot: + return '-- Reboot --\n' + + return '' diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 9a56685..03a365c 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -345,3 +345,11 @@ def test_multiple_output_formats(self, capsys): del entry['__REALTIME_TIMESTAMP'] del output['__REALTIME_TIMESTAMP'] assert output == entry + + def test_help_output(self, capsys): + cli = CLI(args=['--help-output']) + cli.run() + + (out, err) = capsys.readouterr() + assert not err + assert out diff --git a/tests/test_format.py b/tests/test_format.py index f704b83..6025523 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -73,3 +73,12 @@ def test_format(self, entry, expected): date = 'Jan 01 00:00:00 ' assert formatted.startswith(date) assert formatted[len(date):] == expected + + +class TestRebootEntryFormatter(object): + def test_reboot(self): + formatter = get_formatter('reboot') + assert formatter.format({'_BOOT_ID': '1'}) == '' + assert formatter.format({'_BOOT_ID': '2'}) == '-- Reboot --\n' + assert formatter.format({'_BOOT_ID': '2'}) == '' + assert formatter.flush() == ''