diff --git a/doc/api.rst b/doc/api.rst index 12e420f..ad03a3b 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -173,6 +173,8 @@ Formats a default :class:`.LineFormat` for use in the shell. Same as :data:`.line_format` but uses :data:`.shell_conversion` for :attr:`.fields`. +.. autoclass:: JSONFormat + ************************* Levels ************************* @@ -393,12 +395,12 @@ Outputs Class variable, indicating that locks should be used when running in a synchronous, multithreaded environment. Threadsafe subclasses may disable locking for higher throughput. Defaults to True. .. automethod:: __init__ - + .. versionadded:: 0.4.1 - Add the `close_atexit` parameter. - + Add the `close_atexit` parameter. + .. method:: close - + Finalize the output. The following methods should be implemented by subclasses. @@ -428,7 +430,7 @@ Outputs .. autoclass:: NullOutput .. autoclass:: ListOutput - + .. versionchanged:: 0.4.1 Replace `DequeOutput` with more useful `ListOutput`. diff --git a/tests/test_formats.py b/tests/test_formats.py index b3d79ee..9c3fbbe 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -52,7 +52,7 @@ def test_shell_conversion(self): formats.shell_format.conversion.convert(d) -class FormatTestCase(unittest.TestCase): +class LineFormatTestCase(unittest.TestCase): fields = {'time': when, 'level': levels.INFO, @@ -142,3 +142,48 @@ def test_trace_fold(self): s = fmt(msg) lines = s.split('\n') assert len(lines) == 2 + +class JSONFormatTestCase(unittest.TestCase): + + fields = {'time': when, + 'level': levels.INFO, + 'name': 'mylog', + 'pants': 42, + } + + def test_basic(self): + + fmt = formats.JSONFormat() + + opts = message.Message._default_options.copy() + msg = message.Message(levels.INFO, "I wear {0}", self.fields, opts, ['pants'], {}) + assert fmt(msg) == '2010-10-28T02:15:57Z:INFO:mylog:pants=42|I wear pants\n' + + def test_inline_fields(self): + + fmt = formats.JSONFormat(inline_fields=True) + + opts = message.Message._default_options.copy() + msg = message.Message(levels.INFO, "I wear {0}", self.fields, opts, ['pants'], {}) + assert fmt(msg) == '2010-10-28T02:15:57Z:INFO:mylog:pants=42|I wear pants\n' + + + def test_trace(self): + + fmt = formats.JSONFormat() + + opts = message.Message._default_options.copy() + opts['trace'] = 'error' + + try: + 1 / 0 + except ZeroDivisionError: + msg = message.Message(levels.INFO, "I wear {0}", self.fields, opts, ['pants'], {}) + + s = fmt(msg) + lines = s.split('\n') + assert len(lines) == 6 + for i in lines[1:-1]: + assert i.startswith('TRACE') + + assert lines[0] == '2010-10-28T02:15:57Z:INFO:mylog:pants=42|I wear pants' diff --git a/tests/test_lib_json.py b/tests/test_lib_json.py new file mode 100644 index 0000000..8d0f1c1 --- /dev/null +++ b/tests/test_lib_json.py @@ -0,0 +1,21 @@ +import datetime +import decimal +import json +import re +import unittest + +from twiggy.lib.json import TwiggyJSONEncoder + +class TwiggyJSONEncoderTests(unittest.TestCase): + def test_timedelta(self): + duration = datetime.timedelta(days=1, hours=2, seconds=3) + self.assertEqual( + json.dumps({'duration': duration}, cls=TwiggyJSONEncoder), + '{"duration": "P1DT02H00M03S"}' + ) + duration = datetime.timedelta(0) + self.assertEqual( + json.dumps({'duration': duration}, cls=TwiggyJSONEncoder), + '{"duration": "P0DT00H00M00S"}' + ) + diff --git a/twiggy/formats.py b/twiggy/formats.py index 6c15d5c..a820e00 100644 --- a/twiggy/formats.py +++ b/twiggy/formats.py @@ -1,6 +1,8 @@ import copy +import json from .lib.converter import ConversionTable, Converter +from .lib.json import TwiggyJSONEncoder from .lib import iso8601time #: a default line-oriented converter @@ -72,3 +74,27 @@ def format_fields(self, msg): #: a format for use in the shell - no timestamp shell_format = copy.copy(line_format) shell_format.conversion.get('time').convert_item = lambda k, v: None + +import json + +class JSONFormat(object): + """format messages to JSON. Returns a string. + + The resulting JSON will have keys: `text`, `traceback` (possibly null) and `fields`. + Set `inline_fields` to True to include fields directly instead. + + :ivar bool inline_fields: hoist individual fields to top-level keys. Defaults to False. + :ivar dict kwargs: extra keyword arguments to pass to `json.dumps()` + """ + + def __init__(self, inline_fields=False, **kwargs): + self.inline_fields = inline_fields + self.kwargs = kwargs + + def __call__(self, msg): + # XXX this ignores msg.suppress_newlines. Hmm. + return json.dumps( + {'text': msg.text, + 'traceback': msg.traceback, + **(msg.fields if self.inline_fields else {'fields': msg.fields})}, + cls=TwiggyJSONEncoder, **self.kwargs) diff --git a/twiggy/lib/json.py b/twiggy/lib/json.py new file mode 100644 index 0000000..fb79693 --- /dev/null +++ b/twiggy/lib/json.py @@ -0,0 +1,97 @@ +import datetime +import decimal +import json +import time +import uuid + +from ..levels import LogLevel + +class TwiggyJSONEncoder(json.JSONEncoder): + """ + JSONEncoder subclass that knows how to encode date/time, decimal types, UUIDs and LogLevels. + + Based on DjangoJSONEncoder. + """ + def default(self, o): + if isinstance(o, LogLevel): + return str(o) + elif isinstance(o, (decimal.Decimal, uuid.UUID)): + return str(o) + elif isinstance(o, time.struct_time): + # XXX broken, as we never reach this function (or even encode()), since struct_time is a + # tuple, and JSONEncoder handles it before we get here. Hmm. + + # convert struct_time to datetime + o = datetime.datetime.fromtimestamp(time.mktime(o)) + + # See "Date Time String Format" in the ECMA-262 specification. + if isinstance(o, datetime.datetime): + r = o.isoformat() + if o.microsecond: + r = r[:23] + r[26:] + if r.endswith('+00:00'): + r = r[:-6] + 'Z' + return r + elif isinstance(o, datetime.date): + return o.isoformat() + elif isinstance(o, datetime.time): + if is_aware(o): + raise ValueError("JSON can't represent timezone-aware times.") + r = o.isoformat() + if o.microsecond: + r = r[:12] + return r + elif isinstance(o, datetime.timedelta): + return duration_iso_string(o) + else: + return super().default(o) + + +def _get_duration_components(duration): + days = duration.days + seconds = duration.seconds + microseconds = duration.microseconds + + minutes = seconds // 60 + seconds = seconds % 60 + + hours = minutes // 60 + minutes = minutes % 60 + + return days, hours, minutes, seconds, microseconds + + +def duration_string(duration): + """Version of str(timedelta) which is not English specific.""" + days, hours, minutes, seconds, microseconds = _get_duration_components(duration) + + string = '{:02d}:{:02d}:{:02d}'.format(hours, minutes, seconds) + if days: + string = '{} '.format(days) + string + if microseconds: + string += '.{:06d}'.format(microseconds) + + return string + + +def duration_iso_string(duration): + if duration < datetime.timedelta(0): + sign = '-' + duration *= -1 + else: + sign = '' + + days, hours, minutes, seconds, microseconds = _get_duration_components(duration) + ms = '.{:06d}'.format(microseconds) if microseconds else "" + return '{}P{}DT{:02d}H{:02d}M{:02d}{}S'.format(sign, days, hours, minutes, seconds, ms) + + +def is_aware(value): + """ + Determine if a given datetime.datetime is aware. + The concept is defined in Python's docs: + http://docs.python.org/library/datetime.html#datetime.tzinfo + Assuming value.tzinfo is either None or a proper datetime.tzinfo, + value.utcoffset() implements the appropriate logic. + """ + return value.utcoffset() is not None