Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Json formatter #87

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
*************************
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -428,7 +430,7 @@ Outputs
.. autoclass:: NullOutput

.. autoclass:: ListOutput

.. versionchanged:: 0.4.1
Replace `DequeOutput` with more useful `ListOutput`.

47 changes: 46 additions & 1 deletion tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
21 changes: 21 additions & 0 deletions tests/test_lib_json.py
Original file line number Diff line number Diff line change
@@ -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"}'
)

26 changes: 26 additions & 0 deletions twiggy/formats.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
97 changes: 97 additions & 0 deletions twiggy/lib/json.py
Original file line number Diff line number Diff line change
@@ -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