Repository: itamarst/eliot

Issue:
Testing infrastructure (@capture_logging) can't use custom JSON encoders
If you have a custom JSON encoder, and you try to test your log messages, your tests will fail because the `MemoryLogger` code path always encodes with the plain-vanilla JSON encoder.

Given `FileDestination` supports a custom JSON encoder, this is a problem.

Comments:

Patch:
diff --git a/docs/source/news.rst b/docs/source/news.rst
index 4499e9b..eb9fb0e 100644
--- a/docs/source/news.rst
+++ b/docs/source/news.rst
@@ -1,6 +1,18 @@
 What's New
 ==========
 
+1.13.0
+^^^^^^
+
+Features:
+
+* ``@capture_logging`` and ``MemoryLogger`` now support specifying a custom JSON encoder. By default they now use Eliot's encoder. This means tests can now match the encoding used by a ``FileDestination``.
+* Added support for Python 3.9.
+
+Deprecation:
+
+* Python 3.5 is no longer supported.
+
 1.12.0
 ^^^^^^
 
diff --git a/eliot/_output.py b/eliot/_output.py
index 6f3b0e0..debff7c 100644
--- a/eliot/_output.py
+++ b/eliot/_output.py
@@ -262,8 +262,12 @@ class MemoryLogger(object):
         not mutate this list.
     """
 
-    def __init__(self):
+    def __init__(self, encoder=EliotJSONEncoder):
+        """
+        @param encoder: A JSONEncoder subclass to use when encoding JSON.
+        """
         self._lock = Lock()
+        self._encoder = encoder
         self.reset()
 
     @exclusively
@@ -344,8 +348,7 @@ class MemoryLogger(object):
             serializer.serialize(dictionary)
 
         try:
-            bytesjson.dumps(dictionary)
-            pyjson.dumps(dictionary)
+            pyjson.dumps(dictionary, cls=self._encoder)
         except Exception as e:
             raise TypeError("Message %s doesn't encode to JSON: %s" % (dictionary, e))
 
@@ -462,6 +465,8 @@ def to_file(output_file, encoder=EliotJSONEncoder):
     Add a destination that writes a JSON message per line to the given file.
 
     @param output_file: A file-like object.
+
+    @param encoder: A JSONEncoder subclass to use when encoding JSON.
     """
     Logger._destinations.add(FileDestination(file=output_file, encoder=encoder))
 


Test patch:
diff --git a/docs/source/generating/testing.rst b/docs/source/generating/testing.rst
index cb18bd6..86ce2a8 100644
--- a/docs/source/generating/testing.rst
+++ b/docs/source/generating/testing.rst
@@ -233,6 +233,35 @@ Or we can simplify further by using ``assertHasMessage`` and ``assertHasAction``
             self.assertEqual(servers, [msg.message["server"] for msg in messages])
 
 
+Custom JSON encoding
+--------------------
+
+Just like a ``FileDestination`` can have a custom JSON encoder, so can your tests, so you can validate your messages with that JSON encoder:
+
+.. code-block:: python
+
+   from unittest import TestCase
+   from eliot.json import EliotJSONEncoder
+   from eliot.testing import capture_logging
+
+   class MyClass:
+       def __init__(self, x):
+           self.x = x
+
+   class MyEncoder(EliotJSONEncoder):
+       def default(self, obj):
+           if isinstance(obj, MyClass):
+               return {"x": obj.x}
+           return EliotJSONEncoder.default(self, obj)
+
+   class LoggingTests(TestCase):
+       @capture_logging(None, encoder_=MyEncoder)
+       def test_logging(self, logger):
+           # Logged messages will be validated using MyEncoder....
+           ...
+
+Notice that the hyphen after ``encoder_`` is deliberate: by default keyword arguments are passed to the assertion function (the first argument to ``@capture_logging``) so it's marked this way to indicate it's part of Eliot's API.
+
 Custom testing setup
 --------------------
 
diff --git a/eliot/testing.py b/eliot/testing.py
index 4e0ba2c..7fdcba4 100644
--- a/eliot/testing.py
+++ b/eliot/testing.py
@@ -20,6 +20,7 @@ from ._action import (
 from ._message import MESSAGE_TYPE_FIELD, TASK_LEVEL_FIELD, TASK_UUID_FIELD
 from ._output import MemoryLogger
 from . import _output
+from .json import EliotJSONEncoder
 
 COMPLETED_STATUSES = (FAILED_STATUS, SUCCEEDED_STATUS)
 
@@ -298,7 +299,9 @@ def swap_logger(logger):
     return previous_logger
 
 
-def validateLogging(assertion, *assertionArgs, **assertionKwargs):
+def validateLogging(
+    assertion, *assertionArgs, encoder_=EliotJSONEncoder, **assertionKwargs
+):
     """
     Decorator factory for L{unittest.TestCase} methods to add logging
     validation.
@@ -330,6 +333,8 @@ def validateLogging(assertion, *assertionArgs, **assertionKwargs):
 
     @param assertionKwargs: Additional keyword arguments to pass to
         C{assertion}.
+
+    @param encoder_: C{json.JSONEncoder} subclass to use when validating JSON.
     """
 
     def decorator(function):
@@ -337,7 +342,7 @@ def validateLogging(assertion, *assertionArgs, **assertionKwargs):
         def wrapper(self, *args, **kwargs):
             skipped = False
 
-            kwargs["logger"] = logger = MemoryLogger()
+            kwargs["logger"] = logger = MemoryLogger(encoder=encoder_)
             self.addCleanup(check_for_errors, logger)
             # TestCase runs cleanups in reverse order, and we want this to
             # run *before* tracebacks are checked:
@@ -361,7 +366,9 @@ def validateLogging(assertion, *assertionArgs, **assertionKwargs):
 validate_logging = validateLogging
 
 
-def capture_logging(assertion, *assertionArgs, **assertionKwargs):
+def capture_logging(
+    assertion, *assertionArgs, encoder_=EliotJSONEncoder, **assertionKwargs
+):
     """
     Capture and validate all logging that doesn't specify a L{Logger}.
 
@@ -369,7 +376,9 @@ def capture_logging(assertion, *assertionArgs, **assertionKwargs):
     """
 
     def decorator(function):
-        @validate_logging(assertion, *assertionArgs, **assertionKwargs)
+        @validate_logging(
+            assertion, *assertionArgs, encoder_=encoder_, **assertionKwargs
+        )
         @wraps(function)
         def wrapper(self, *args, **kwargs):
             logger = kwargs["logger"]
diff --git a/eliot/tests/common.py b/eliot/tests/common.py
index 6e26b26..7aa042b 100644
--- a/eliot/tests/common.py
+++ b/eliot/tests/common.py
@@ -3,6 +3,20 @@ Common testing infrastructure.
 """
 
 from io import BytesIO
+from json import JSONEncoder
+
+
+class CustomObject(object):
+    """Gets encoded to JSON."""
+
+
+class CustomJSONEncoder(JSONEncoder):
+    """JSONEncoder that knows about L{CustomObject}."""
+
+    def default(self, o):
+        if isinstance(o, CustomObject):
+            return "CUSTOM!"
+        return JSONEncoder.default(self, o)
 
 
 class FakeSys(object):
diff --git a/eliot/tests/test_output.py b/eliot/tests/test_output.py
index fedad15..6b608c6 100644
--- a/eliot/tests/test_output.py
+++ b/eliot/tests/test_output.py
@@ -32,6 +32,7 @@ from .._output import (
 from .._validation import ValidationError, Field, _MessageSerializer
 from .._traceback import write_traceback
 from ..testing import assertContainsFields
+from .common import CustomObject, CustomJSONEncoder
 
 
 class MemoryLoggerTests(TestCase):
@@ -122,6 +123,27 @@ class MemoryLoggerTests(TestCase):
         )
         self.assertRaises(TypeError, logger.validate)
 
+    @skipUnless(np, "NumPy is not installed.")
+    def test_EliotJSONEncoder(self):
+        """
+        L{MemoryLogger.validate} uses the EliotJSONEncoder by default to do
+        encoding testing.
+        """
+        logger = MemoryLogger()
+        logger.write({"message_type": "type", "foo": np.uint64(12)}, None)
+        logger.validate()
+
+    def test_JSON_custom_encoder(self):
+        """
+        L{MemoryLogger.validate} will use a custom JSON encoder if one was given.
+        """
+        logger = MemoryLogger(encoder=CustomJSONEncoder)
+        logger.write(
+            {"message_type": "type", "custom": CustomObject()},
+            None,
+        )
+        logger.validate()
+
     def test_serialize(self):
         """
         L{MemoryLogger.serialize} returns a list of serialized versions of the
diff --git a/eliot/tests/test_testing.py b/eliot/tests/test_testing.py
index e507607..8df9874 100644
--- a/eliot/tests/test_testing.py
+++ b/eliot/tests/test_testing.py
@@ -4,7 +4,12 @@ Tests for L{eliot.testing}.
 
 from __future__ import unicode_literals
 
-from unittest import SkipTest, TestResult, TestCase
+from unittest import SkipTest, TestResult, TestCase, skipUnless
+
+try:
+    import numpy as np
+except ImportError:
+    np = None
 
 from ..testing import (
     issuperset,
@@ -25,7 +30,8 @@ from .._action import start_action
 from .._message import Message
 from .._validation import ActionType, MessageType, ValidationError, Field
 from .._traceback import write_traceback
-from .. import add_destination, remove_destination, _output
+from .. import add_destination, remove_destination, _output, log_message
+from .common import CustomObject, CustomJSONEncoder
 
 
 class IsSuperSetTests(TestCase):
@@ -740,6 +746,28 @@ class CaptureLoggingTests(ValidateLoggingTestsMixin, TestCase):
         )
 
 
+class JSONEncodingTests(TestCase):
+    """Tests for L{capture_logging} JSON encoder support."""
+
+    @skipUnless(np, "NumPy is not installed.")
+    @capture_logging(None)
+    def test_default_JSON_encoder(self, logger):
+        """
+        L{capture_logging} validates using L{EliotJSONEncoder} by default.
+        """
+        # Default JSON encoder can't handle NumPy:
+        log_message(message_type="hello", number=np.uint32(12))
+
+    @capture_logging(None, encoder_=CustomJSONEncoder)
+    def test_custom_JSON_encoder(self, logger):
+        """
+        L{capture_logging} can be called with a custom JSON encoder, which is then
+        used for validation.
+        """
+        # Default JSON encoder can't handle this custom object:
+        log_message(message_type="hello", object=CustomObject())
+
+
 MESSAGE1 = MessageType(
     "message1", [Field.forTypes("x", [int], "A number")], "A message for testing."
 )