summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJason Madden <jamadden@gmail.com>2020-02-10 09:46:04 -0600
committerGitHub <noreply@github.com>2020-02-10 09:46:04 -0600
commit447121d0e41e9785251a1e7b1c7f162e4f7f00fd (patch)
treed078bec878c932d2f7b1c2e87c2b5e0113be5193 /src
parentd6343eeaa7f67838db9de82974898b80938a7aef (diff)
parent39229790671a3f85486f26eecfa60fa824755db4 (diff)
downloadzope-interface-447121d0e41e9785251a1e7b1c7f162e4f7f00fd.tar.gz
Merge pull request #174 from zopefoundation/issue171
Make verifyObject/Class collect and raise all errors instead of only the first
Diffstat (limited to 'src')
-rw-r--r--src/zope/interface/exceptions.py231
-rw-r--r--src/zope/interface/tests/test_exceptions.py108
-rw-r--r--src/zope/interface/tests/test_verify.py33
-rw-r--r--src/zope/interface/verify.py154
4 files changed, 416 insertions, 110 deletions
diff --git a/src/zope/interface/exceptions.py b/src/zope/interface/exceptions.py
index 8d21f33..2f3758b 100644
--- a/src/zope/interface/exceptions.py
+++ b/src/zope/interface/exceptions.py
@@ -20,6 +20,7 @@ __all__ = [
'DoesNotImplement',
'BrokenImplementation',
'BrokenMethodImplementation',
+ 'MultipleInvalid',
# Other
'BadImplements',
'InvalidInterface',
@@ -29,19 +30,94 @@ class Invalid(Exception):
"""A specification is violated
"""
-_NotGiven = object()
-class _TargetMixin(object):
- target = _NotGiven
+class _TargetInvalid(Invalid):
+ # Internal use. Subclass this when you're describing
+ # a particular target object that's invalid according
+ # to a specific interface.
+ #
+ # For backwards compatibility, the *target* and *interface* are
+ # optional, and the signatures are inconsistent in their ordering.
+ #
+ # We deal with the inconsistency in ordering by defining the index
+ # of the two values in ``self.args``. *target* uses a marker object to
+ # distinguish "not given" from "given, but None", because the latter
+ # can be a value that gets passed to validation. For this reason, it must
+ # always be the last argument (we detect absense by the ``IndexError``).
+
+ _IX_INTERFACE = 0
+ _IX_TARGET = 1
+ # The exception to catch when indexing self.args indicating that
+ # an argument was not given. If all arguments are expected,
+ # a subclass should set this to ().
+ _NOT_GIVEN_CATCH = IndexError
+ _NOT_GIVEN = '<Not Given>'
+
+ def _get_arg_or_default(self, ix, default=None):
+ try:
+ return self.args[ix] # pylint:disable=unsubscriptable-object
+ except self._NOT_GIVEN_CATCH:
+ return default
+
+ @property
+ def interface(self):
+ return self._get_arg_or_default(self._IX_INTERFACE)
+
+ @property
+ def target(self):
+ return self._get_arg_or_default(self._IX_TARGET, self._NOT_GIVEN)
+
+ ###
+ # str
+ #
+ # The ``__str__`` of self is implemented by concatenating (%s), in order,
+ # these properties (none of which should have leading or trailing
+ # whitespace):
+ #
+ # - self._str_subject
+ # Begin the message, including a description of the target.
+ # - self._str_description
+ # Provide a general description of the type of error, including
+ # the interface name if possible and relevant.
+ # - self._str_conjunction
+ # Join the description to the details. Defaults to ": ".
+ # - self._str_details
+ # Provide details about how this particular instance of the error.
+ # - self._str_trailer
+ # End the message. Usually just a period.
+ ###
@property
- def _prefix(self):
- if self.target is _NotGiven:
+ def _str_subject(self):
+ target = self.target
+ if target is self._NOT_GIVEN:
return "An object"
- return "The object %r" % (self.target,)
+ return "The object %r" % (target,)
+
+ @property
+ def _str_description(self):
+ return "has failed to implement interface %s" % (
+ self.interface or '<Unknown>'
+ )
+
+ _str_conjunction = ": "
+ _str_details = "<unknown>"
+ _str_trailer = '.'
+
+ def __str__(self):
+ return "%s %s%s%s%s" % (
+ self._str_subject,
+ self._str_description,
+ self._str_conjunction,
+ self._str_details,
+ self._str_trailer
+ )
-class DoesNotImplement(Invalid, _TargetMixin):
+
+class DoesNotImplement(_TargetInvalid):
"""
+ DoesNotImplement(interface[, target])
+
The *target* (optional) does not implement the *interface*.
.. versionchanged:: 5.0.0
@@ -49,19 +125,13 @@ class DoesNotImplement(Invalid, _TargetMixin):
string value of this object accordingly.
"""
- def __init__(self, interface, target=_NotGiven):
- Invalid.__init__(self)
- self.interface = interface
- self.target = target
+ _str_details = "Does not declaratively implement the interface"
- def __str__(self):
- return "%s does not implement the interface %s." % (
- self._prefix,
- self.interface
- )
-class BrokenImplementation(Invalid, _TargetMixin):
+class BrokenImplementation(_TargetInvalid):
"""
+ BrokenImplementation(interface, name[, target])
+
The *target* (optional) is missing the attribute *name*.
.. versionchanged:: 5.0.0
@@ -71,45 +141,128 @@ class BrokenImplementation(Invalid, _TargetMixin):
The *name* can either be a simple string or a ``Attribute`` object.
"""
- def __init__(self, interface, name, target=_NotGiven):
- Invalid.__init__(self)
- self.interface = interface
- self.name = name
- self.target = target
+ _IX_NAME = _TargetInvalid._IX_INTERFACE + 1
+ _IX_TARGET = _IX_NAME + 1
- def __str__(self):
- return "%s has failed to implement interface %s: The %s attribute was not provided." % (
- self._prefix,
- self.interface,
+ @property
+ def name(self):
+ return self.args[1] # pylint:disable=unsubscriptable-object
+
+ @property
+ def _str_details(self):
+ return "The %s attribute was not provided" % (
repr(self.name) if isinstance(self.name, str) else self.name
)
-class BrokenMethodImplementation(Invalid, _TargetMixin):
+
+class BrokenMethodImplementation(_TargetInvalid):
"""
- The *target* (optional) has a *method* that violates
+ BrokenMethodImplementation(method, message[, implementation, interface, target])
+
+ The *target* (optional) has a *method* in *implementation* that violates
its contract in a way described by *mess*.
.. versionchanged:: 5.0.0
- Add the *target* argument and attribute, and change the resulting
- string value of this object accordingly.
+ Add the *interface* and *target* argument and attribute,
+ and change the resulting string value of this object accordingly.
The *method* can either be a simple string or a ``Method`` object.
+
+ .. versionchanged:: 5.0.0
+ If *implementation* is given, then the *message* will have the
+ string "implementation" replaced with an short but informative
+ representation of *implementation*.
+
"""
- def __init__(self, method, mess, target=_NotGiven):
- Invalid.__init__(self)
- self.method = method
- self.mess = mess
- self.target = target
+ _IX_IMPL = 2
+ _IX_INTERFACE = _IX_IMPL + 1
+ _IX_TARGET = _IX_INTERFACE + 1
- def __str__(self):
- return "%s violates its contract in %s: %s." % (
- self._prefix,
+ @property
+ def method(self):
+ return self.args[0] # pylint:disable=unsubscriptable-object
+
+ @property
+ def mess(self):
+ return self.args[1] # pylint:disable=unsubscriptable-object
+
+ @staticmethod
+ def __implementation_str(impl):
+ # It could be a callable or some arbitrary object, we don't
+ # know yet.
+ import inspect # Inspect is a heavy-weight dependency, lots of imports
+ try:
+ sig = inspect.signature
+ formatsig = str
+ except AttributeError:
+ sig = inspect.getargspec
+ f = inspect.formatargspec
+ formatsig = lambda sig: f(*sig) # pylint:disable=deprecated-method
+
+ try:
+ sig = sig(impl)
+ except (ValueError, TypeError):
+ # Unable to introspect. Darn.
+ # This could be a non-callable, or a particular builtin,
+ # or a bound method that doesn't even accept 'self', e.g.,
+ # ``Class.method = lambda: None; Class().method``
+ return repr(impl)
+
+ try:
+ name = impl.__qualname__
+ except AttributeError:
+ name = impl.__name__
+
+ return name + formatsig(sig)
+
+ @property
+ def _str_details(self):
+ impl = self._get_arg_or_default(self._IX_IMPL, self._NOT_GIVEN)
+ message = self.mess
+ if impl is not self._NOT_GIVEN and 'implementation' in message:
+ message = message.replace("implementation", '%r')
+ message = message % (self.__implementation_str(impl),)
+
+ return 'The contract of %s is violated because %s' % (
repr(self.method) if isinstance(self.method, str) else self.method,
- self.mess
+ message,
)
+class MultipleInvalid(_TargetInvalid):
+ """
+ The *target* has failed to implement the *interface* in
+ multiple ways.
+
+ The failures are described by *exceptions*, a collection of
+ other `Invalid` instances.
+
+ .. versionadded:: 5.0
+ """
+
+ _NOT_GIVEN_CATCH = ()
+
+ def __init__(self, interface, target, exceptions):
+ super(MultipleInvalid, self).__init__(interface, target, tuple(exceptions))
+
+ @property
+ def exceptions(self):
+ return self.args[2] # pylint:disable=unsubscriptable-object
+
+ @property
+ def _str_details(self):
+ # It would be nice to use tabs here, but that
+ # is hard to represent in doctests.
+ return '\n ' + '\n '.join(
+ x._str_details.strip() if isinstance(x, _TargetInvalid) else str(x)
+ for x in self.exceptions
+ )
+
+ _str_conjunction = ':' # We don't want a trailing space, messes up doctests
+ _str_trailer = ''
+
+
class InvalidInterface(Exception):
"""The interface has invalid contents
"""
diff --git a/src/zope/interface/tests/test_exceptions.py b/src/zope/interface/tests/test_exceptions.py
index 34ee071..a55f522 100644
--- a/src/zope/interface/tests/test_exceptions.py
+++ b/src/zope/interface/tests/test_exceptions.py
@@ -35,15 +35,19 @@ class DoesNotImplementTests(unittest.TestCase):
dni = self._makeOne()
self.assertEqual(
str(dni),
- 'An object does not implement the interface '
- '<InterfaceClass zope.interface.tests.test_exceptions.IDummy>.')
+ "An object has failed to implement interface "
+ "<InterfaceClass zope.interface.tests.test_exceptions.IDummy>: "
+ "Does not declaratively implement the interface."
+ )
def test___str__w_candidate(self):
dni = self._makeOne('candidate')
self.assertEqual(
str(dni),
- 'The object \'candidate\' does not implement the interface '
- '<InterfaceClass zope.interface.tests.test_exceptions.IDummy>.')
+ "The object 'candidate' has failed to implement interface "
+ "<InterfaceClass zope.interface.tests.test_exceptions.IDummy>: "
+ "Does not declaratively implement the interface."
+ )
class BrokenImplementationTests(unittest.TestCase):
@@ -72,23 +76,109 @@ class BrokenImplementationTests(unittest.TestCase):
'<InterfaceClass zope.interface.tests.test_exceptions.IDummy>: '
"The 'missing' attribute was not provided.")
+
+def broken_function():
+ """
+ This is a global function with a simple argument list.
+
+ It exists to be able to report the same information when
+ formatting signatures under Python 2 and Python 3.
+ """
+
+
class BrokenMethodImplementationTests(unittest.TestCase):
def _getTargetClass(self):
from zope.interface.exceptions import BrokenMethodImplementation
return BrokenMethodImplementation
+ message = 'I said so'
+
def _makeOne(self, *args):
- return self._getTargetClass()('aMethod', 'I said so', *args)
+ return self._getTargetClass()('aMethod', self.message, *args)
def test___str__(self):
dni = self._makeOne()
self.assertEqual(
str(dni),
- "An object violates its contract in 'aMethod': I said so.")
+ "An object has failed to implement interface <Unknown>: "
+ "The contract of 'aMethod' is violated because I said so."
+ )
- def test___str__w_candidate(self):
- dni = self._makeOne('candidate')
+ def test___str__w_candidate_no_implementation(self):
+ dni = self._makeOne('some_function', '<IFoo>', 'candidate')
self.assertEqual(
str(dni),
- "The object 'candidate' violates its contract in 'aMethod': I said so.")
+ "The object 'candidate' has failed to implement interface <IFoo>: "
+ "The contract of 'aMethod' is violated because I said so."
+ )
+
+ def test___str__w_candidate_w_implementation(self):
+ self.message = 'implementation is wonky'
+ dni = self._makeOne(broken_function, '<IFoo>', 'candidate')
+ self.assertEqual(
+ str(dni),
+ "The object 'candidate' has failed to implement interface <IFoo>: "
+ "The contract of 'aMethod' is violated because "
+ "'broken_function()' is wonky."
+ )
+
+ def test___str__w_candidate_w_implementation_not_callable(self):
+ self.message = 'implementation is not callable'
+ dni = self._makeOne(42, '<IFoo>', 'candidate')
+ self.assertEqual(
+ str(dni),
+ "The object 'candidate' has failed to implement interface <IFoo>: "
+ "The contract of 'aMethod' is violated because "
+ "'42' is not callable."
+ )
+
+ def test___repr__w_candidate(self):
+ dni = self._makeOne(None, 'candidate')
+ self.assertEqual(
+ repr(dni),
+ "BrokenMethodImplementation('aMethod', 'I said so', None, 'candidate')"
+ )
+
+
+class MultipleInvalidTests(unittest.TestCase):
+
+ def _getTargetClass(self):
+ from zope.interface.exceptions import MultipleInvalid
+ return MultipleInvalid
+
+ def _makeOne(self, excs):
+ iface = _makeIface()
+ return self._getTargetClass()(iface, 'target', excs)
+
+ def test__str__(self):
+ from zope.interface.exceptions import BrokenMethodImplementation
+ excs = [
+ BrokenMethodImplementation('aMethod', 'I said so'),
+ Exception("Regular exception")
+ ]
+ dni = self._makeOne(excs)
+ self.assertEqual(
+ str(dni),
+ "The object 'target' has failed to implement interface "
+ "<InterfaceClass zope.interface.tests.test_exceptions.IDummy>:\n"
+ " The contract of 'aMethod' is violated because I said so\n"
+ " Regular exception"
+ )
+
+ def test__repr__(self):
+ from zope.interface.exceptions import BrokenMethodImplementation
+ excs = [
+ BrokenMethodImplementation('aMethod', 'I said so'),
+ # Use multiple arguments to normalize repr; versions of Python
+ # prior to 3.7 add a trailing comma if there's just one.
+ Exception("Regular", "exception")
+ ]
+ dni = self._makeOne(excs)
+ self.assertEqual(
+ repr(dni),
+ "MultipleInvalid(<InterfaceClass zope.interface.tests.test_exceptions.IDummy>,"
+ " 'target',"
+ " (BrokenMethodImplementation('aMethod', 'I said so'),"
+ " Exception('Regular', 'exception')))"
+ )
diff --git a/src/zope/interface/tests/test_verify.py b/src/zope/interface/tests/test_verify.py
index 65e390a..3a68d20 100644
--- a/src/zope/interface/tests/test_verify.py
+++ b/src/zope/interface/tests/test_verify.py
@@ -554,6 +554,39 @@ class Test_verifyClass(unittest.TestCase):
self._callFUT(IReadSequence, tuple, tentative=True)
+ def test_multiple_invalid(self):
+ from zope.interface.exceptions import MultipleInvalid
+ from zope.interface.exceptions import DoesNotImplement
+ from zope.interface.exceptions import BrokenImplementation
+ from zope.interface import Interface
+ from zope.interface import classImplements
+
+ class ISeveralMethods(Interface):
+ def meth1(arg1):
+ "Method 1"
+ def meth2(arg1):
+ "Method 2"
+
+ class SeveralMethods(object):
+ pass
+
+ with self.assertRaises(MultipleInvalid) as exc:
+ self._callFUT(ISeveralMethods, SeveralMethods)
+
+ ex = exc.exception
+ self.assertEqual(3, len(ex.exceptions))
+ self.assertIsInstance(ex.exceptions[0], DoesNotImplement)
+ self.assertIsInstance(ex.exceptions[1], BrokenImplementation)
+ self.assertIsInstance(ex.exceptions[2], BrokenImplementation)
+
+ # If everything else is correct, only the single error is raised without
+ # the wrapper.
+ classImplements(SeveralMethods, ISeveralMethods)
+ SeveralMethods.meth1 = lambda self, arg1: "Hi"
+
+ with self.assertRaises(BrokenImplementation):
+ self._callFUT(ISeveralMethods, SeveralMethods)
+
class Test_verifyObject(Test_verifyClass):
@classmethod
diff --git a/src/zope/interface/verify.py b/src/zope/interface/verify.py
index a8dd952..0a64aeb 100644
--- a/src/zope/interface/verify.py
+++ b/src/zope/interface/verify.py
@@ -21,8 +21,12 @@ from types import MethodType
from zope.interface._compat import PYPY2
-from zope.interface.exceptions import BrokenImplementation, DoesNotImplement
+from zope.interface.exceptions import BrokenImplementation
from zope.interface.exceptions import BrokenMethodImplementation
+from zope.interface.exceptions import DoesNotImplement
+from zope.interface.exceptions import Invalid
+from zope.interface.exceptions import MultipleInvalid
+
from zope.interface.interface import fromMethod, fromFunction, Method
__all__ = [
@@ -55,8 +59,16 @@ def _verify(iface, candidate, tentative=False, vtype=None):
- Making sure the candidate defines all the necessary attributes
+ :return bool: Returns a true value if everything that could be
+ checked passed.
:raises zope.interface.Invalid: If any of the previous
conditions does not hold.
+
+ .. versionchanged:: 5.0
+ If multiple methods or attributes are invalid, all such errors
+ are collected and reported. Previously, only the first error was reported.
+ As a special case, if only one such error is present, it is raised
+ alone, like before.
"""
if vtype == 'c':
@@ -64,74 +76,92 @@ def _verify(iface, candidate, tentative=False, vtype=None):
else:
tester = iface.providedBy
+ excs = []
if not tentative and not tester(candidate):
- raise DoesNotImplement(iface)
+ excs.append(DoesNotImplement(iface, candidate))
- # Here the `desc` is either an `Attribute` or `Method` instance
for name, desc in iface.namesAndDescriptions(all=True):
try:
- attr = getattr(candidate, name)
- except AttributeError:
- if (not isinstance(desc, Method)) and vtype == 'c':
- # We can't verify non-methods on classes, since the
- # class may provide attrs in it's __init__.
- continue
-
- raise BrokenImplementation(iface, desc, candidate)
-
- if not isinstance(desc, Method):
- # If it's not a method, there's nothing else we can test
- continue
-
- if inspect.ismethoddescriptor(attr) or inspect.isbuiltin(attr):
- # The first case is what you get for things like ``dict.pop``
- # on CPython (e.g., ``verifyClass(IFullMapping, dict))``). The
- # second case is what you get for things like ``dict().pop`` on
- # CPython (e.g., ``verifyObject(IFullMapping, dict()))``.
- # In neither case can we get a signature, so there's nothing
- # to verify. Even the inspect module gives up and raises
- # ValueError: no signature found. The ``__text_signature__`` attribute
- # isn't typically populated either.
- #
- # Note that on PyPy 2 or 3 (up through 7.3 at least), these are
- # not true for things like ``dict.pop`` (but might be true for C extensions?)
- continue
-
- if isinstance(attr, FunctionType):
- if sys.version_info[0] >= 3 and isinstance(candidate, type) and vtype == 'c':
- # This is an "unbound method" in Python 3.
- # Only unwrap this if we're verifying implementedBy;
- # otherwise we can unwrap @staticmethod on classes that directly
- # provide an interface.
- meth = fromFunction(attr, iface, name=name,
- imlevel=1)
- else:
- # Nope, just a normal function
- meth = fromFunction(attr, iface, name=name)
- elif (isinstance(attr, MethodTypes)
- and type(attr.__func__) is FunctionType):
- meth = fromMethod(attr, iface, name)
- elif isinstance(attr, property) and vtype == 'c':
- # We without an instance we cannot be sure it's not a
- # callable.
- continue
- else:
- if not callable(attr):
- raise BrokenMethodImplementation(desc, "implementation is not a method", candidate)
- # sigh, it's callable, but we don't know how to introspect it, so
- # we have to give it a pass.
- continue
-
- # Make sure that the required and implemented method signatures are
- # the same.
- mess = _incompat(desc.getSignatureInfo(), meth.getSignatureInfo())
- if mess:
- if PYPY2 and _pypy2_false_positive(mess, candidate, vtype):
- continue
- raise BrokenMethodImplementation(desc, mess, candidate)
+ _verify_element(iface, name, desc, candidate, vtype)
+ except Invalid as e:
+ excs.append(e)
+
+ if excs:
+ if len(excs) == 1:
+ raise excs[0]
+ raise MultipleInvalid(iface, candidate, excs)
return True
+def _verify_element(iface, name, desc, candidate, vtype):
+ # Here the `desc` is either an `Attribute` or `Method` instance
+ try:
+ attr = getattr(candidate, name)
+ except AttributeError:
+ if (not isinstance(desc, Method)) and vtype == 'c':
+ # We can't verify non-methods on classes, since the
+ # class may provide attrs in it's __init__.
+ return
+ # TODO: On Python 3, this should use ``raise...from``
+ raise BrokenImplementation(iface, desc, candidate)
+
+ if not isinstance(desc, Method):
+ # If it's not a method, there's nothing else we can test
+ return
+
+ if inspect.ismethoddescriptor(attr) or inspect.isbuiltin(attr):
+ # The first case is what you get for things like ``dict.pop``
+ # on CPython (e.g., ``verifyClass(IFullMapping, dict))``). The
+ # second case is what you get for things like ``dict().pop`` on
+ # CPython (e.g., ``verifyObject(IFullMapping, dict()))``.
+ # In neither case can we get a signature, so there's nothing
+ # to verify. Even the inspect module gives up and raises
+ # ValueError: no signature found. The ``__text_signature__`` attribute
+ # isn't typically populated either.
+ #
+ # Note that on PyPy 2 or 3 (up through 7.3 at least), these are
+ # not true for things like ``dict.pop`` (but might be true for C extensions?)
+ return
+
+ if isinstance(attr, FunctionType):
+ if sys.version_info[0] >= 3 and isinstance(candidate, type) and vtype == 'c':
+ # This is an "unbound method" in Python 3.
+ # Only unwrap this if we're verifying implementedBy;
+ # otherwise we can unwrap @staticmethod on classes that directly
+ # provide an interface.
+ meth = fromFunction(attr, iface, name=name,
+ imlevel=1)
+ else:
+ # Nope, just a normal function
+ meth = fromFunction(attr, iface, name=name)
+ elif (isinstance(attr, MethodTypes)
+ and type(attr.__func__) is FunctionType):
+ meth = fromMethod(attr, iface, name)
+ elif isinstance(attr, property) and vtype == 'c':
+ # Without an instance we cannot be sure it's not a
+ # callable.
+ # TODO: This should probably check inspect.isdatadescriptor(),
+ # a more general form than ``property``
+ return
+
+ else:
+ if not callable(attr):
+ raise BrokenMethodImplementation(desc, "implementation is not a method",
+ attr, iface, candidate)
+ # sigh, it's callable, but we don't know how to introspect it, so
+ # we have to give it a pass.
+ return
+
+ # Make sure that the required and implemented method signatures are
+ # the same.
+ mess = _incompat(desc.getSignatureInfo(), meth.getSignatureInfo())
+ if mess:
+ if PYPY2 and _pypy2_false_positive(mess, candidate, vtype):
+ return
+ raise BrokenMethodImplementation(desc, mess, attr, iface, candidate)
+
+
+
def verifyClass(iface, candidate, tentative=False):
"""
Verify that the *candidate* might correctly provide *iface*.