Source code for nti.testing.matchers

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Hamcrest matchers for testing.
"""

from collections.abc import Sequence, Mapping

import pprint

import six
from zope.interface.exceptions import Invalid
from zope.interface.verify import verifyObject
from zope.schema import ValidationError
from zope.schema import getValidationErrors


import hamcrest
from hamcrest import assert_that
from hamcrest import has_length
from hamcrest import is_
from hamcrest import is_not
from hamcrest.core.base_description import BaseDescription
from hamcrest.core.base_matcher import BaseMatcher
from hamcrest.library.collection.is_empty import empty

__docformat__ = "restructuredtext en"

__all__ = [
    'has_length',
    'has_attr',
    'is_true',
    'is_false',
    'provides',
    'verifiably_provides',
    'validly_provides',
    'implements',
    'validated_by',
    'not_validated_by',
    'aq_inContextOf',
    'TypeCheckedDict',
]

is_empty = empty # bwc

has_attr = hamcrest.library.has_property

class BoolMatcher(BaseMatcher):
    def __init__(self, value):
        super().__init__()
        self.value = value

    def _matches(self, item):
        return bool(item) == self.value

    def describe_to(self, description):
        description.append_text('object with bool() value ').append(str(self.value))

    def __repr__(self):
        return 'object with bool() value ' + str(self.value)

[docs] def is_true(): """ Matches an object with a true boolean value. """ return BoolMatcher(True)
[docs] def is_false(): """ Matches an object with a false boolean value. """ return BoolMatcher(False)
class Provides(BaseMatcher): def __init__(self, iface): super().__init__() self.iface = iface def _matches(self, item): return self.iface.providedBy(item) def describe_to(self, description): description.append_text('object providing') \ .append(str(self.iface)) def __repr__(self): return 'object providing' + str(self.iface)
[docs] def provides(iface): """ Matches an object that provides the given interface. """ return Provides(iface)
class VerifyProvides(BaseMatcher): def __init__(self, iface): super().__init__() self.iface = iface def _matches(self, item): try: verifyObject(self.iface, item) except Invalid: return False return True def describe_to(self, description): description.append_text('object verifiably providing ').append_description_of(self.iface) def describe_mismatch(self, item, mismatch_description): md = mismatch_description try: verifyObject(self.iface, item) except Invalid as x: # Beginning in zope.interface 5, the Invalid exception subclasses # like BrokenImplementation, DoesNotImplement, etc, all typically # have a much nicer error message than they used to, better than we # were producing. This is especially true now that MultipleInvalid # is a thing. x = str(x).strip() md.append_text("Using class ").append_description_of(type(item)).append_text(' ') if x.startswith('The object '): x = x[len("The object "):] x = 'the object ' + x x = x.replace('\n ', '\n ') md.append_text(x)
[docs] def verifiably_provides(*ifaces): """ Matches if the object verifiably provides the correct interface(s), as defined by :func:`zope.interface.verify.verifyObject`. This means having the right attributes and methods with the right signatures. .. note:: This does **not** test schema compliance. For that (stricter) test, see :func:`validly_provides`. """ if len(ifaces) == 1: return VerifyProvides(ifaces[0]) return hamcrest.all_of(*[VerifyProvides(x) for x in ifaces])
class VerifyValidSchema(BaseMatcher): def __init__(self, iface): super().__init__() self.iface = iface def _matches(self, item): errors = getValidationErrors(self.iface, item) return not errors def describe_to(self, description): description.append_text('object validly providing ').append(str(self.iface)) def describe_mismatch(self, item, mismatch_description): x = None md = mismatch_description md.append_text(str(type(item))) errors = getValidationErrors(self.iface, item) for attr, exc in errors: try: raise exc except ValidationError: md.append_text(' has attribute "') md.append_text(attr) md.append_text('" with error "') md.append_text(repr(exc)) md.append_text('"\n\t ') except Invalid as x: # pragma: no cover md.append_text(str(x))
[docs] def validly_provides(*ifaces): """ Matches if the object verifiably and validly provides the given schema (interface(s)). Verification is done with :mod:`zope.interface` and :func:`verifiably_provides`, while validation is done with :func:`zope.schema.getValidationErrors`. """ if len(ifaces) == 1: the_schema = ifaces[0] return hamcrest.all_of(verifiably_provides(the_schema), VerifyValidSchema(the_schema)) prov = verifiably_provides(*ifaces) valid = [VerifyValidSchema(x) for x in ifaces] return hamcrest.all_of(prov, *valid)
class Implements(BaseMatcher): def __init__(self, iface): super().__init__() self.iface = iface def _matches(self, item): return self.iface.implementedBy(item) def describe_to(self, description): description.append_text('object implementing') description.append_description_of(self.iface)
[docs] def implements(iface): """ Matches if the object implements (is a factory for) the given interface. .. seealso:: :meth:`zope.interface.interfaces.ISpecification.implementedBy` """ return Implements(iface)
class ValidatedBy(BaseMatcher): def __init__(self, field, invalid=Invalid): super().__init__() self.field = field self.invalid = invalid def _matches(self, item): try: self.field.validate(item) except self.invalid: return False return True def describe_to(self, description): description.append_text('data validated by').append_description_of(self.field) def describe_mismatch(self, item, mismatch_description): ex = None try: self.field.validate(item) except self.invalid as e: ex = e mismatch_description.append_description_of(self.field) mismatch_description.append_text(' failed to validate ') mismatch_description.append_description_of(item) mismatch_description.append_text(' with ') mismatch_description.append_description_of(ex)
[docs] def validated_by(field, invalid=Invalid): """ Matches if the data is validated by the given ``IField``. :keyword exception invalid: The types of exceptions that are considered invalid. Anything other than this is allowed to be raised. .. versionchanged:: 2.0.1 Add ``invalid`` and change it from ``Exception`` to :class:`zope.interface.interfaces.Invalid` """ return ValidatedBy(field, invalid=invalid)
[docs] def not_validated_by(field, invalid=Invalid): """ Matches if the data is NOT validated by the given IField. :keyword exception invalid: The types of exceptions that are considered invalid. Anything other than this is allowed to be raised. .. versionchanged:: 2.0.1 Add ``invalid`` and change it from ``Exception`` to :class:`zope.interface.interfaces.Invalid` """ return is_not(validated_by(field, invalid=invalid))
def _aq_inContextOf_NotImplemented(child, parent): # pylint:disable=unused-argument return False try: from Acquisition import aq_inContextOf as _aq_inContextOf except ImportError: # pragma: no cover # acquisition not installed _aq_inContextOf = _aq_inContextOf_NotImplemented class AqInContextOf(BaseMatcher): def __init__(self, parent): super().__init__() self.parent = parent def _matches(self, item): if hasattr(item, 'aq_inContextOf'): # wrappers return item.aq_inContextOf(self.parent) return _aq_inContextOf(item, self.parent) # not wrapped, but maybe __parent__ chain def describe_to(self, description): description.append_text('object in context of ') description.append_description_of(self.parent) def describe_mismatch(self, item, mismatch_description): if _aq_inContextOf is _aq_inContextOf_NotImplemented: mismatch_description.append_text('Acquisition was not installed.') return mismatch_description.append_description_of(item) mismatch_description.append_text(' was not in the context of ') mismatch_description.append_description_of(self.parent) mismatch_description.append_text('; its lineage is ') lineage = [] while item is not None: try: item = item.__parent__ except AttributeError: item = None if item is not None: lineage.append(item) mismatch_description.append_description_of(lineage)
[docs] def aq_inContextOf(parent): """ Matches if the data is in the acquisition context of the given object. """ return AqInContextOf(parent)
# Patch hamcrest for better descriptions of maps (json data) # and sequences if six.PY3: from io import StringIO else: # pragma: no cover from cStringIO import StringIO # pylint:disable=import-error _orig_append_description_of = BaseDescription.append_description_of def _append_description_of_map(self, value): if not hasattr(value, 'describe_to'): if isinstance(value, (Mapping, Sequence)): sio = StringIO() pprint.pprint(value, sio) self.append(sio.getvalue()) return self return _orig_append_description_of(self, value) BaseDescription.append_description_of = _append_description_of_map
[docs] class TypeCheckedDict(dict): """ A dictionary that ensures keys and values are of the required type when set """ def __init__(self, key_class=object, val_class=object, notify=None): dict.__init__(self) self.key_class = key_class self.val_class = val_class self.notify = notify def __setitem__(self, key, val): assert_that(key, is_(self.key_class)) assert_that(val, is_(self.val_class)) dict.__setitem__(self, key, val) if self.notify: self.notify(key, val)