Repository: hamcrest/PyHamcrest

Issue:
How to match exception properties?
I have a webapp with helper methods that raises `bottle.HTTPError` and I want to ensure that a raised exception has a `status_code` property, but it seems the calling/raises pair cannot directly assert this? E.g. something like
```
assert_that(
    calling(helper).with_args(broken_input),
    raises(HTTPError, has_properties(status_code=500))
)
```
Am I missing something obvious or is there no way to match structured details of an exception short of manually catching the exception first? 

If this is indeed the case, would you be amenable to a PR implementing `raises(HTTPError, matching=has_properties(status_code=500))` or perhaps `raises(HTTPError).matching(has_properties(status_code=500))`?

Comments:

Patch:
diff --git a/src/hamcrest/core/core/raises.py b/src/hamcrest/core/core/raises.py
index efe9e6c..67ec46c 100644
--- a/src/hamcrest/core/core/raises.py
+++ b/src/hamcrest/core/core/raises.py
@@ -13,8 +13,11 @@ __license__ = "BSD, see License.txt"
 
 
 class Raises(BaseMatcher[Callable[..., Any]]):
-    def __init__(self, expected: Exception, pattern: Optional[str] = None) -> None:
+    def __init__(
+        self, expected: Exception, pattern: Optional[str] = None, matching: Optional[Matcher] = None
+    ) -> None:
         self.pattern = pattern
+        self.matcher = matching
         self.expected = expected
         self.actual = None  # type: Optional[BaseException]
         self.function = None  # type: Optional[Callable[..., Any]]
@@ -35,7 +38,11 @@ class Raises(BaseMatcher[Callable[..., Any]]):
 
             if isinstance(self.actual, cast(type, self.expected)):
                 if self.pattern is not None:
-                    return re.search(self.pattern, str(self.actual)) is not None
+                    if re.search(self.pattern, str(self.actual)) is None:
+                        return False
+                if self.matcher is not None:
+                    if not self.matcher.matches(self.actual):
+                        return False
                 return True
         return False
 
@@ -55,12 +62,17 @@ class Raises(BaseMatcher[Callable[..., Any]]):
 
         if self.actual is None:
             description.append_text("No exception raised.")
-        elif isinstance(self.actual, cast(type, self.expected)) and self.pattern is not None:
-            description.append_text(
-                'Correct assertion type raised, but the expected pattern ("%s") not found.'
-                % self.pattern
-            )
-            description.append_text('\n          message was: "%s"' % str(self.actual))
+        elif isinstance(self.actual, cast(type, self.expected)):
+            if self.pattern is not None or self.matcher is not None:
+                description.append_text("Correct assertion type raised, but ")
+                if self.pattern is not None:
+                    description.append_text('the expected pattern ("%s") ' % self.pattern)
+                if self.pattern is not None and self.matcher is not None:
+                    description.append_text("and ")
+                if self.matcher is not None:
+                    description.append_description_of(self.matcher)
+                    description.append_text(" ")
+                description.append_text('not found. Exception message was: "%s"' % str(self.actual))
         else:
             description.append_text(
                 "%r of type %s was raised instead" % (self.actual, type(self.actual))
@@ -73,11 +85,12 @@ class Raises(BaseMatcher[Callable[..., Any]]):
         )
 
 
-def raises(exception: Exception, pattern=None) -> Matcher[Callable[..., Any]]:
+def raises(exception: Exception, pattern=None, matching=None) -> Matcher[Callable[..., Any]]:
     """Matches if the called function raised the expected exception.
 
     :param exception:  The class of the expected exception
     :param pattern:    Optional regular expression to match exception message.
+    :param matching:   Optional Hamcrest matchers to apply to the exception.
 
     Expects the actual to be wrapped by using :py:func:`~hamcrest.core.core.raises.calling`,
     or a callable taking no arguments.
@@ -88,8 +101,12 @@ def raises(exception: Exception, pattern=None) -> Matcher[Callable[..., Any]]:
 
         assert_that(calling(int).with_args('q'), raises(TypeError))
         assert_that(calling(parse, broken_input), raises(ValueError))
+        assert_that(
+            calling(valid_user, bad_json),
+            raises(HTTPError, matching=has_properties(status_code=500)
+        )
     """
-    return Raises(exception, pattern)
+    return Raises(exception, pattern, matching)
 
 
 class DeferredCallable(object):


Test patch:
diff --git a/tests/hamcrest_unit_test/core/raises_test.py b/tests/hamcrest_unit_test/core/raises_test.py
index e4fee61..519b7a2 100644
--- a/tests/hamcrest_unit_test/core/raises_test.py
+++ b/tests/hamcrest_unit_test/core/raises_test.py
@@ -2,7 +2,7 @@ import sys
 import unittest
 
 import pytest
-from hamcrest import not_
+from hamcrest import has_properties, not_
 from hamcrest.core.core.raises import calling, raises
 from hamcrest_unit_test.matcher_test import MatcherTest, assert_mismatch_description
 
@@ -28,6 +28,13 @@ def raise_baseException(*args, **kwargs):
     raise SystemExit(str(args) + str(kwargs))
 
 
+def raise_exception_with_properties(**kwargs):
+    err = AssertionError("boom")
+    for k, v in kwargs.items():
+        setattr(err, k, v)
+    raise err
+
+
 class RaisesTest(MatcherTest):
     def testMatchesIfFunctionRaisesTheExactExceptionExpected(self):
         self.assert_matches("Right exception", raises(AssertionError), calling(raise_exception))
@@ -72,6 +79,11 @@ class RaisesTest(MatcherTest):
         self.assert_does_not_match(
             "Bad regex", raises(AssertionError, "Phrase not found"), calling(raise_exception)
         )
+        self.assert_mismatch_description(
+            '''Correct assertion type raised, but the expected pattern ("Phrase not found") not found. Exception message was: "(){}"''',
+            raises(AssertionError, "Phrase not found"),
+            calling(raise_exception),
+        )
 
     def testMatchesRegularExpressionToStringifiedException(self):
         self.assert_matches(
@@ -86,6 +98,37 @@ class RaisesTest(MatcherTest):
             calling(raise_exception).with_args(3, 1, 4),
         )
 
+    def testMachesIfRaisedExceptionMatchesAdditionalMatchers(self):
+        self.assert_matches(
+            "Properties",
+            raises(AssertionError, matching=has_properties(prip="prop")),
+            calling(raise_exception_with_properties).with_args(prip="prop"),
+        )
+
+    def testDoesNotMatchIfAdditionalMatchersDoesNotMatch(self):
+        self.assert_does_not_match(
+            "Bad properties",
+            raises(AssertionError, matching=has_properties(prop="prip")),
+            calling(raise_exception_with_properties).with_args(prip="prop"),
+        )
+        self.assert_mismatch_description(
+            '''Correct assertion type raised, but an object with a property 'prop' matching 'prip' not found. Exception message was: "boom"''',
+            raises(AssertionError, matching=has_properties(prop="prip")),
+            calling(raise_exception_with_properties).with_args(prip="prop"),
+        )
+
+    def testDoesNotMatchIfNeitherPatternOrMatcherMatch(self):
+        self.assert_does_not_match(
+            "Bad pattern and properties",
+            raises(AssertionError, pattern="asdf", matching=has_properties(prop="prip")),
+            calling(raise_exception_with_properties).with_args(prip="prop"),
+        )
+        self.assert_mismatch_description(
+            '''Correct assertion type raised, but the expected pattern ("asdf") and an object with a property 'prop' matching 'prip' not found. Exception message was: "boom"''',
+            raises(AssertionError, pattern="asdf", matching=has_properties(prop="prip")),
+            calling(raise_exception_with_properties).with_args(prip="prop"),
+        )
+
     def testDescribeMismatchWillCallItemIfNotTheOriginalMatch(self):
         function = Callable()
         matcher = raises(AssertionError)