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

Sketch: new testing API #432

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
17 changes: 1 addition & 16 deletions eliot/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down
12 changes: 7 additions & 5 deletions eliot/_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you just use __qualname__?
It's available since Python 3.3.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Python 2.7 is still supported.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not anymore! But when that code was written, it was supported.



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."),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If using __qualname__ is possible you can simply use operator.attrgetter("__qualname__") instead of class_fpqn.

],
"An unexpected exception indicating a bug.",
)
Expand Down
15 changes: 15 additions & 0 deletions eliot/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
30 changes: 30 additions & 0 deletions eliot/pytest.py
Original file line number Diff line number Diff line change
@@ -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"]
20 changes: 18 additions & 2 deletions eliot/testing.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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}.
"""


Expand Down
2 changes: 2 additions & 0 deletions eliot/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Enable pytester, so we can test fixtures/plugins:
pytest_plugins = ["pytester"]
119 changes: 119 additions & 0 deletions eliot/testutil.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down