diff --git a/eliot/_output.py b/eliot/_output.py index b322ed4..da3bdb4 100644 --- a/eliot/_output.py +++ b/eliot/_output.py @@ -7,7 +7,6 @@ import inspect import json as pyjson from threading import Lock -from functools import wraps from io import IOBase from pyrsistent import PClass, field @@ -17,7 +16,7 @@ from ._traceback import write_traceback, TRACEBACK_MESSAGE from ._message import Message, EXCEPTION_FIELD, MESSAGE_TYPE_FIELD, REASON_FIELD -from ._util import saferepr, safeunicode +from ._util import saferepr, safeunicode, exclusively from .json import EliotJSONEncoder from ._validation import ValidationError @@ -224,20 +223,6 @@ def write(self, dictionary, serializer=None): pass -def exclusively(f): - """ - Decorate a function to make it thread-safe by serializing invocations - using a per-instance lock. - """ - - @wraps(f) - def exclusively_f(self, *a, **kw): - with self._lock: - return f(self, *a, **kw) - - return exclusively_f - - @implementer(ILogger) class MemoryLogger(object): """ diff --git a/eliot/_traceback.py b/eliot/_traceback.py index 08e90a5..9825236 100644 --- a/eliot/_traceback.py +++ b/eliot/_traceback.py @@ -13,16 +13,18 @@ from ._validation import MessageType, Field from ._errors import _error_extraction + +def class_fqpn(typ): + """Convert a class to it's fully-qualified name.""" + return "%s.%s" % (typ.__module__, typ.__name__) + + TRACEBACK_MESSAGE = MessageType( "eliot:traceback", [ Field(REASON_FIELD, safeunicode, "The exception's value."), Field("traceback", safeunicode, "The traceback."), - Field( - EXCEPTION_FIELD, - lambda typ: "%s.%s" % (typ.__module__, typ.__name__), - "The exception type's FQPN.", - ), + Field(EXCEPTION_FIELD, class_fqpn, "The exception type's FQPN."), ], "An unexpected exception indicating a bug.", ) diff --git a/eliot/_util.py b/eliot/_util.py index 38768c4..02a59b2 100644 --- a/eliot/_util.py +++ b/eliot/_util.py @@ -8,6 +8,7 @@ from types import ModuleType from six import exec_, text_type as unicode, PY3 +from boltons.functools import wraps def safeunicode(o): @@ -68,3 +69,17 @@ def load_module(name, original_module): source = f.read() exec_(source, module.__dict__, module.__dict__) return module + + +def exclusively(f): + """ + Decorate a function to make it thread-safe by serializing invocations + using a per-instance lock. + """ + + @wraps(f) + def exclusively_f(self, *a, **kw): + with self._lock: + return f(self, *a, **kw) + + return exclusively_f diff --git a/eliot/pytest.py b/eliot/pytest.py new file mode 100644 index 0000000..3d183fa --- /dev/null +++ b/eliot/pytest.py @@ -0,0 +1,30 @@ +"""Plugins for py.test.""" + +import json + +import pytest + +from .testutil import _capture_logs +from .json import EliotJSONEncoder + + +@pytest.fixture +def eliot_logs(request): + """ + Capture log messages for the duration of the test. + + 1. The fixture object is a L{eliot.testutil.TestingDestination}. + + 2. All messages logged during the test are validated at the end of + the test. + + 3. Any unflushed logged tracebacks will cause the test to fail. If you + expect a particular tracekbac, you can flush it by calling + C{remove_expected_tracebacks} on the C{TestingDestination} instance. + """ + + def logs_for_pyttest(encode=EliotJSONEncoder().encode, decode=json.loads): + return _capture_logs(request.addfinalizer, encode, decode) + + +__all__ = ["eliot_logs"] diff --git a/eliot/testing.py b/eliot/testing.py index 4e0ba2c..6507f8d 100644 --- a/eliot/testing.py +++ b/eliot/testing.py @@ -1,5 +1,7 @@ """ Utilities to aid unit testing L{eliot} and code that uses it. + +DEPRECATED. Use L{eliot.testutil} instead. """ from __future__ import unicode_literals @@ -19,11 +21,25 @@ ) from ._message import MESSAGE_TYPE_FIELD, TASK_LEVEL_FIELD, TASK_UUID_FIELD from ._output import MemoryLogger +from ._util import exclusively from . import _output + COMPLETED_STATUSES = (FAILED_STATUS, SUCCEEDED_STATUS) +__all__ = [ + "assertHasAction", + "assertHasMessage", + "assertContainsFields", + "MemoryLogger", + "LoggedAction", + "LoggedMessage", + "capture_logging", + "UnflushedTracebacks", +] + + def issuperset(a, b): """ Use L{assertContainsFields} instead. @@ -262,11 +278,11 @@ def of_type(klass, messages, messageType): class UnflushedTracebacks(Exception): """ - The L{MemoryLogger} had some tracebacks logged which were not flushed. + A test had some tracebacks logged which were not flushed. This means either your code has a bug and logged an unexpected traceback. If you expected the traceback then you will need to flush it - using L{MemoryLogger.flushTracebacks}. + using C{flush_tracebacks}. """ diff --git a/eliot/tests/conftest.py b/eliot/tests/conftest.py new file mode 100644 index 0000000..1df0963 --- /dev/null +++ b/eliot/tests/conftest.py @@ -0,0 +1,2 @@ +# Enable pytester, so we can test fixtures/plugins: +pytest_plugins = ["pytester"] diff --git a/eliot/testutil.py b/eliot/testutil.py new file mode 100644 index 0000000..cf08f72 --- /dev/null +++ b/eliot/testutil.py @@ -0,0 +1,119 @@ +"""Utilities for testing.""" + +import json +from unittest import TestCase +from typing import Type + +from .json import EliotJSONEncoder +from ._message import MESSAGE_TYPE_FIELD +from ._traceback import REASON_FIELD, class_fqpn +from ._util import exclusively + + +__all__ = ["TestingDestination", "UnexpectedTracebacks", "logs_for_pyunit"] + + +class UnexpectedTracebacks(Exception): + """ + A test had some tracebacks logged which were not marked as expected. + + If you expected the traceback then you will need to flush it using + C{TestingDestination.flush_tracebacks}. + """ + + +class TestingDestination: + """ + A destination that stores messages for testing purposes. + + Unexpected tracebacks are considered errors (your code logging a traceback + typically indicates a bug), so you will need to remove expected tracebacks + by calling C{remove_expected_tracebacks}. + """ + + def __init__(self, encode, decode): + """ + @param encode: Take an unserialized message, serialize it. + @param decode: Take an serialized message, deserialize it. + """ + self.messages = [] + self._traceback_messages = [] + self._encode = encode + self._decode = decode + + @exclusively + def write(self, message): + if message.get(MESSAGE_TYPE_FIELD) == "eliot:traceback": + self._traceback_messages.append(message) + self.messages.append(self._decode(self._encode(message))) + + @exclusively + def remove_expected_tracebacks(self, exceptionType: Type[Exception]): + """ + Remove all logged tracebacks whose exception is of the given type. + + This means they are expected tracebacks and should not cause the test + to fail. + + @param exceptionType: A subclass of L{Exception}. + + @return: C{list} of flushed messages. + """ + result = [] + remaining = [] + for message in self._traceback_messages: + if message[REASON_FIELD] == class_fqpn(exceptionType): + result.append(message) + else: + remaining.append(message) + self.traceback_messages = remaining + return result + + def _ensure_no_bad_messages(self): + """ + Raise L{UnexpectedTracebacks} if there are any unexpected tracebacks. + + Raise L{ValueError} if there are serialization failures from the Eliot + type system, or serialization errors from the encoder/decoder + (typically JSON). + + If you expect a traceback to be logged, remove it using + C{remove_expected_tracebacks}. + """ + if self._traceback_messages: + raise UnexpectedTracebacks(self._traceback_messages) + serialization_failures = [ + m + for m in self.messages + if m.get(MESSAGE_TYPE_FIELD) + in ("eliot:serialization_failure", "eliot:destination_failure") + ] + if serialization_failures: + raise ValueError(serialization_failures) + + +def _capture_logs(addfinalizer, encode, decode): + test_dest = TestingDestination(encode, decode) + from . import add_destinations, remove_destination + + add_destinations(test_dest) + addfinalizer(remove_destination, test_dest) + addfinalizer(test_dest._ensure_no_bad_messages) + + return test_dest + + +def logs_for_pyunit( + test_case: TestCase, encode=EliotJSONEncoder().encode, decode=json.loads +) -> TestingDestination: + """Capture the logs for a C{unittest.TestCase}. + + 1. Captures all log messages. + + 2. At the end of the test, raises an exception if there are any + unexpected tracebacks, or any of the messages couldn't be + serialized. + + @returns: The L{TestingDestination} that will capture the log messages. + """ + return _capture_logs(test_case.addCleanup, encode, decode) diff --git a/setup.py b/setup.py index e6fe980..50e6d84 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ def read(path): setup( classifiers=[ "Intended Audience :: Developers", + "Framework :: Pytest", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", @@ -67,7 +68,9 @@ def read(path): "black", ], }, - entry_points={"console_scripts": ["eliot-prettyprint = eliot.prettyprint:_main"]}, + entry_points={"console_scripts": ["eliot-prettyprint = eliot.prettyprint:_main"], + "pytest11": ["eliot = eliot.pytest"]}, + }, keywords="logging", license="Apache 2.0", packages=["eliot", "eliot.tests"],