diff options
author | Jason Madden <jamadden@gmail.com> | 2020-02-10 09:46:04 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-02-10 09:46:04 -0600 |
commit | 447121d0e41e9785251a1e7b1c7f162e4f7f00fd (patch) | |
tree | d078bec878c932d2f7b1c2e87c2b5e0113be5193 | |
parent | d6343eeaa7f67838db9de82974898b80938a7aef (diff) | |
parent | 39229790671a3f85486f26eecfa60fa824755db4 (diff) | |
download | zope-interface-447121d0e41e9785251a1e7b1c7f162e4f7f00fd.tar.gz |
Merge pull request #174 from zopefoundation/issue171
Make verifyObject/Class collect and raise all errors instead of only the first
-rw-r--r-- | .travis.yml | 15 | ||||
-rw-r--r-- | CHANGES.rst | 8 | ||||
-rw-r--r-- | docs/verify.rst | 172 | ||||
-rw-r--r-- | src/zope/interface/exceptions.py | 231 | ||||
-rw-r--r-- | src/zope/interface/tests/test_exceptions.py | 108 | ||||
-rw-r--r-- | src/zope/interface/tests/test_verify.py | 33 | ||||
-rw-r--r-- | src/zope/interface/verify.py | 154 |
7 files changed, 562 insertions, 159 deletions
diff --git a/.travis.yml b/.travis.yml index dd4c3e0..8b5bd09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -82,17 +82,22 @@ before_install: fi install: - - pip install -U pip setuptools - - pip install -U coveralls coverage - - pip install -U -e ".[test]" + # virtualenv 20.0 together with terryfy macOS is broken, at least + # with CPython 2.7.17 (https://github.com/pypa/virtualenv/issues/1555): + # it doesn't install or activate the virtualenv correctly and PATH + # isn't setup right. So try to use `python -m` to workaround that. + - python -m pip install -U pip setuptools + - python -m pip install -U coveralls coverage + - python -m pip install -U -e ".[test]" script: + - which python - python --version - - coverage run -m unittest discover -s src + - python -m coverage run -m unittest discover -s src - python setup.py -q bdist_wheel after_success: - - coveralls + - python -m coveralls - | if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then # macpython 3.5 doesn't support recent TLS protocols which causes twine diff --git a/CHANGES.rst b/CHANGES.rst index b484a99..b21f54d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -108,6 +108,14 @@ .. caution:: This will break consumers (such as doctests) that depended on the exact error messages. +- Make ``verifyObject`` and ``verifyClass`` report all errors, if the + candidate object has multiple detectable violations. Previously they + reported only the first error. See `issue + <https://github.com/zopefoundation/zope.interface/issues/171>`_. + + Like the above, this will break consumers depending on the exact + output of error messages if more than one error is present. + 4.7.1 (2019-11-11) ================== diff --git a/docs/verify.rst b/docs/verify.rst index 6f48218..06a0a0f 100644 --- a/docs/verify.rst +++ b/docs/verify.rst @@ -14,6 +14,82 @@ Verifying objects .. autoexception:: zope.interface.Invalid +Let's demonstrate. We'll begin by defining a simple interface hierarchy +requiring two attributes, and a helper method that will instantiate and verify +that an object provides this interface. + +.. doctest:: + + >>> from zope.interface import Interface, Attribute, implementer + >>> from zope.interface import Invalid + >>> from zope.interface.verify import verifyObject + >>> class IBase(Interface): + ... x = Attribute("The X attribute") + >>> class IFoo(IBase): + ... y = Attribute("The Y attribute") + >>> class Foo(object): + ... pass + >>> def verify_foo(**kwargs): + ... foo = Foo() + ... try: + ... return verifyObject(IFoo, foo, **kwargs) + ... except Invalid as e: + ... print(e) + +If we try to verify an instance of this ``Foo`` class, three errors +will be reported. The declarations (does the object provide ``IFoo``) +are checked, as are the attributes specified in the interface being +validated (and its ancestors). Notice that the interface being +verified is shown, as is the interface where the attribute was +defined. + +.. doctest:: + + >>> verify_foo() + The object <Foo...> has failed to implement interface <...IFoo>: + Does not declaratively implement the interface + The IBase.x attribute was not provided + The IFoo.y attribute was not provided + +If we add the two missing attributes, we still have the error about not +declaring the correct interface. + +.. doctest:: + + >>> Foo.x = Foo.y = 42 + >>> verify_foo() + The object <Foo...> has failed to implement interface <...IFoo>: Does not declaratively implement the interface. + +If we want to only check the structure of the object, without examining +its declarations, we can use the ``tentative`` argument. + +.. doctest:: + + >>> verify_foo(tentative=True) + True + +Of course, we can always mark a particular instance as providing the +desired interface. + +.. doctest:: + + >>> from zope.interface import alsoProvides + >>> foo = Foo() + >>> alsoProvides(foo, IFoo) + >>> verifyObject(IFoo, foo) + True + +If all instances will provide the interface, we can +mark the class as implementing it. + +.. doctest:: + + >>> from zope.interface import classImplements + >>> classImplements(Foo, IFoo) + >>> verify_foo() + True + + Testing for attributes ---------------------- @@ -22,46 +98,44 @@ Attributes of the object, be they defined by its class or added by its .. doctest:: - >>> from zope.interface import Interface, Attribute, implementer - >>> from zope.interface.exceptions import BrokenImplementation - >>> class IFoo(Interface): - ... x = Attribute("The X attribute") - ... y = Attribute("The Y attribute") - >>> @implementer(IFoo) ... class Foo(object): ... x = 1 ... def __init__(self): ... self.y = 2 - >>> from zope.interface.verify import verifyObject >>> verifyObject(IFoo, Foo()) True If either attribute is missing, verification will fail by raising an -exception. (We'll define a helper to make this easier to show.) +exception. .. doctest:: - >>> def verify_foo(): - ... foo = Foo() - ... try: - ... return verifyObject(IFoo, foo) - ... except BrokenImplementation as e: - ... print(e) - >>> @implementer(IFoo) ... class Foo(object): ... x = 1 >>> verify_foo() - The object <Foo...> has failed to implement interface <InterfaceClass ...IFoo>: The IFoo.y attribute was not provided. + The object <Foo...> has failed to implement interface <...IFoo>: The IFoo.y attribute was not provided. >>> @implementer(IFoo) ... class Foo(object): ... def __init__(self): ... self.y = 2 >>> verify_foo() - The object <Foo...> has failed to implement interface <InterfaceClass ...IFoo>: The IFoo.x attribute was not provided. + The object <Foo...> has failed to implement interface <...IFoo>: The IBase.x attribute was not provided. +If both attributes are missing, an exception is raised reporting +both errors. + +.. doctest:: + + >>> @implementer(IFoo) + ... class Foo(object): + ... pass + >>> verify_foo() + The object <Foo ...> has failed to implement interface <...IFoo>: + The IBase.x attribute was not provided + The IFoo.y attribute was not provided If an attribute is implemented as a property that raises an ``AttributeError`` when trying to get its value, the attribute is considered missing: @@ -76,7 +150,7 @@ when trying to get its value, the attribute is considered missing: ... def x(self): ... raise AttributeError >>> verify_foo() - The object <Foo...> has failed to implement interface <InterfaceClass ...IFoo>: The IFoo.x attribute was not provided. + The object <Foo...> has failed to implement interface <...IFoo>: The IFoo.x attribute was not provided. Any other exception raised by a property will propagate to the caller of @@ -122,20 +196,9 @@ that takes one argument. If we don't provide it, we get an error. ... class Foo(object): ... pass >>> verify_foo() - The object <Foo...> has failed to implement interface <InterfaceClass builtins.IFoo>: The IFoo.simple(arg1) attribute was not provided. - -Once they exist, they are checked for compatible signatures. This is a -different type of exception, so we need an updated helper. - -.. doctest:: + The object <Foo...> has failed to implement interface <...IFoo>: The IFoo.simple(arg1) attribute was not provided. - >>> from zope.interface.exceptions import BrokenMethodImplementation - >>> def verify_foo(): - ... foo = Foo() - ... try: - ... return verifyObject(IFoo, foo) - ... except BrokenMethodImplementation as e: - ... print(e) +Once they exist, they are checked to be callable, and for compatible signatures. Not being callable is an error. @@ -143,24 +206,24 @@ Not being callable is an error. >>> Foo.simple = 42 >>> verify_foo() - The object <Foo...> violates its contract in IFoo.simple(arg1): implementation is not a method. + The object <Foo...> has failed to implement interface <...IFoo>: The contract of IFoo.simple(arg1) is violated because '42' is not a method. -Taking too few arguments is an error. +Taking too few arguments is an error. (Recall that the ``self`` +argument is implicit.) .. doctest:: - >>> Foo.simple = lambda: "I take no arguments" + >>> Foo.simple = lambda self: "I take no arguments" >>> verify_foo() - The object <Foo...> violates its contract in IFoo.simple(arg1): implementation doesn't allow enough arguments. + The object <Foo...> has failed to implement interface <...IFoo>: The contract of IFoo.simple(arg1) is violated because '<lambda>()' doesn't allow enough arguments. -Requiring too many arguments is an error. (Recall that the ``self`` -argument is implicit.) +Requiring too many arguments is an error. .. doctest:: >>> Foo.simple = lambda self, a, b: "I require two arguments" >>> verify_foo() - The object <Foo...> violates its contract in IFoo.simple(arg1): implementation requires too many arguments. + The object <Foo...> has failed to implement interface <...IFoo>: The contract of IFoo.simple(arg1) is violated because '<lambda>(a, b)' requires too many arguments. Variable arguments can be used to implement the required number, as can arguments with defaults. @@ -185,7 +248,7 @@ variable keyword arguments, the implementation must also accept them. ... class Foo(object): ... def needs_kwargs(self, a=1, b=2): pass >>> verify_foo() - The object <Foo...> violates its contract in IFoo.needs_kwargs(**kwargs): implementation doesn't support keyword arguments. + The object <Foo...> has failed to implement interface <...IFoo>: The contract of IFoo.needs_kwargs(**kwargs) is violated because 'Foo.needs_kwargs(a=1, b=2)' doesn't support keyword arguments. >>> class IFoo(Interface): ... def needs_varargs(*args): pass @@ -193,15 +256,36 @@ variable keyword arguments, the implementation must also accept them. ... class Foo(object): ... def needs_varargs(self, **kwargs): pass >>> verify_foo() - The object <Foo...> violates its contract in IFoo.needs_varargs(*args): implementation doesn't support variable arguments. + The object <Foo...> has failed to implement interface <...IFoo>: The contract of IFoo.needs_varargs(*args) is violated because 'Foo.needs_varargs(**kwargs)' doesn't support variable arguments. + +Of course, missing attributes are also found and reported, and the +source interface of the missing attribute is included. Similarly, when +the failing method is from a parent class, that is also reported. + +.. doctest:: + + >>> class IBase(Interface): + ... def method(arg1): "Takes one positional argument" + >>> class IFoo(IBase): + ... x = Attribute('The X attribute') + >>> class Base(object): + ... def method(self): "I don't have enough arguments" + >>> @implementer(IFoo) + ... class Foo(Base): + ... pass + >>> verify_foo() + The object <Foo...> has failed to implement interface <...IFoo>: + The contract of IBase.method(arg1) is violated because 'Base.method()' doesn't allow enough arguments + The IFoo.x attribute was not provided Verifying Classes ================= The function `verifyClass` is used to check that a class implements an interface properly, meaning that its instances properly provide the -interface. Most of the same things that `verifyObject` checks can be -checked for classes. +interface. Many of the same things that `verifyObject` checks can be +checked for classes, but certain conditions, such as the presence of +attributes, cannot be verified. .. autofunction:: verifyClass @@ -211,8 +295,8 @@ checked for classes. >>> def verify_foo_class(): ... try: ... return verifyClass(IFoo, Foo) - ... except BrokenMethodImplementation as e: + ... except Invalid as e: ... print(e) >>> verify_foo_class() - The object <class 'Foo'> violates its contract in IFoo.needs_varargs(*args): implementation doesn't support variable arguments. + The object <class 'Foo'> has failed to implement interface <...IFoo>: The contract of IBase.method(arg1) is violated because 'Base.method(self)' doesn't allow enough arguments. 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*. |