Repository: h2non/pook

Issue:
`HeadersMatcher: 're.Pattern' object has no attribute 'decode'` with pook 1.2.0
Not sure if actionable, but after upgrading from 1.1.1 to 1.2.0, I see this new error on a test where pook can not match on the request headers any more it seems. No additional details or stack trace unfortunately.

```
pook error!

=> Cannot match any mock for the following request:
==================================================
Method: POST
URL: https://foo.bar
Headers: HTTPHeaderDict({})
Body: {}}
==================================================

=> Detailed matching errors:
HeadersMatcher: 're.Pattern' object has no attribute 'decode'
```

The test uses `headers` and `headers_present`, if that helps:

```py
pook.post(
  url,
  reply=200,
  headers={
    "content-type": "application/json"
  },
  headers_present=[
    "foo"
  ],
  response_json={},
)
```

Discussion:

Patch:
diff --git a/pook/assertion.py b/pook/assertion.py
index 493b065..152f1e6 100644
--- a/pook/assertion.py
+++ b/pook/assertion.py
@@ -30,8 +30,6 @@ def equal(x, y):
     """
     return test_case().assertEqual(x, y) or True
 
-    assert x == y
-
 
 def matches(x, y, regex_expr=False):
     """
@@ -52,9 +50,6 @@ def matches(x, y, regex_expr=False):
     # Parse regex expression, if needed
     x = strip_regex(x) if regex_expr and isregex_expr(x) else x
 
-    # Run regex assertion
-    # Retrieve original regex pattern
-    x = x.pattern if isregex(x) else x
     # Assert regular expression via unittest matchers
     return test_case().assertRegex(y, x) or True
 
diff --git a/pook/headers.py b/pook/headers.py
index 26c9d38..0cb1213 100644
--- a/pook/headers.py
+++ b/pook/headers.py
@@ -237,7 +237,7 @@ class HTTPHeaderDict(MutableMapping):
 
 def to_string_value(value):
     """
-    Retrieve a string value for arbitrary header field value.
+    Retrieve a string value for an arbitrary value.
 
     HTTP header values are specified as ASCII strings. However,
     the specificiation also states that non-ASCII bytes should be
@@ -247,15 +247,15 @@ def to_string_value(value):
     obscure the input, like base 64).
 
     Arguments::
-        value (str|bytes):
+        value (mixed):
             The value to cast to ``str``.
 
     Returns::
         str:
             Unicode escaped ``value`` if it was ``bytes``; otherwise,
-            ``value`` is returned.
+            ``value`` is returned, cast through ``str``.
     """
-    if isinstance(value, str):
-        return value
+    if hasattr(value, "decode"):
+        return value.decode("unicode_escape")
 
-    return value.decode('unicode_escape')
+    return str(value)
diff --git a/pook/matchers/headers.py b/pook/matchers/headers.py
index fb76a61..1d1b325 100644
--- a/pook/matchers/headers.py
+++ b/pook/matchers/headers.py
@@ -1,5 +1,6 @@
 from .base import BaseMatcher
 from ..headers import to_string_value
+from ..regex import Pattern
 
 
 class HeadersMatcher(BaseMatcher):
@@ -15,16 +16,41 @@ class HeadersMatcher(BaseMatcher):
     @BaseMatcher.matcher
     def match(self, req):
         for key in self.expectation:
-            # Retrieve value to match
-            # Cast it to a string that can be compared
-            # If it is already a string ``to_string_value`` is a noop
-            value = to_string_value(self.expectation[key])
+            assert key in req.headers, f"Header '{key}' not present"
+
+            expected_value = self.to_comparable_value(self.expectation[key])
 
             # Retrieve header value by key
-            header = req.headers.get(key)
+            actual_value = req.headers.get(key)
+
+            assert not all([
+                expected_value is not None,
+                actual_value is None,
+            ]), (
+                f"Expected a value `{expected_value}` "
+                f"for '{key}' but found `None`"
+            )
 
             # Compare header value
-            if not self.compare(value, header, regex_expr=True):
+            if not self.compare(expected_value, actual_value, regex_expr=True):
                 return False
 
         return True
+
+    def to_comparable_value(self, value):
+        """
+        Return a comparable version of ``value``.
+
+        Arguments:
+            value (mixed): the value to cast.
+
+        Returns:
+            str|re.Pattern|None
+        """
+        if isinstance(value, (str, Pattern)):
+            return value
+
+        if value is None:
+            return value
+
+        return to_string_value(value)
diff --git a/pook/regex.py b/pook/regex.py
index 8ec84c3..2559ebd 100644
--- a/pook/regex.py
+++ b/pook/regex.py
@@ -1,7 +1,10 @@
 import re
+import sys
 
-# Little hack to extra the regexp object type at runtime
-retype = type(re.compile(''))
+if sys.version_info < (3, 7):
+    Pattern = type(re.compile(''))
+else:
+    Pattern = re.Pattern
 
 
 def isregex_expr(expr):
@@ -38,7 +41,7 @@ def isregex(value):
     """
     if not value:
         return False
-    return any((isregex_expr(value), isinstance(value, retype)))
+    return any((isregex_expr(value), isinstance(value, Pattern)))
 
 
 def strip_regex(expr):
@@ -52,4 +55,4 @@ def strip_regex(expr):
     Returns:
         str
     """
-    return expr.replace[3:-1] if isregex_expr(expr) else expr
+    return expr[3:-1] if isregex_expr(expr) else expr


Test patch:
diff --git a/tests/unit/matchers/headers_test.py b/tests/unit/matchers/headers_test.py
index c079087..cb4cec3 100644
--- a/tests/unit/matchers/headers_test.py
+++ b/tests/unit/matchers/headers_test.py
@@ -1,15 +1,15 @@
 import pytest
+import re
 
 import pook
 
 
 @pytest.mark.parametrize(
-    ('expected', 'requested', 'should_match'),
+    ('expected', 'requested'),
     (
         pytest.param(
             {'Content-Type': b'application/pdf'},
             {'Content-Type': b'application/pdf'},
-            True,
             id='Matching binary headers'
         ),
         pytest.param(
@@ -21,13 +21,11 @@ import pook
                 'Content-Type': b'application/pdf',
                 'Authentication': 'Bearer 123abc',
             },
-            True,
             id='Matching mixed headers'
         ),
         pytest.param(
             {'Authentication': 'Bearer 123abc'},
             {'Authentication': 'Bearer 123abc'},
-            True,
             id='Matching string headers'
         ),
         pytest.param(
@@ -36,54 +34,130 @@ import pook
                 'Content-Type': b'application/pdf',
                 'Authentication': 'Bearer 123abc',
             },
-            True,
             id='Non-matching asymetric mixed headers'
         ),
         pytest.param(
             {'Content-Type': b'application/pdf'},
             {'Content-Type': 'application/pdf'},
-            True,
             id='Non-matching header types (matcher binary, request string)'
         ),
         pytest.param(
             {'Content-Type': 'application/pdf'},
             {'Content-Type': b'application/pdf'},
-            True,
             id='Non-matching header types (matcher string, request binary)'
         ),
-        pytest.param(
-            {'Content-Type': 'application/pdf'},
-            {'Content-Type': 'application/xml'},
-            False,
-            id='Non-matching values'
-        ),
         pytest.param(
             {'content-type': 'application/pdf'},
             {'Content-Type': 'application/pdf'},
-            True,
             id='Non-matching field name casing'
         ),
         pytest.param(
             {},
             {'Content-Type': 'application/pdf'},
-            True,
             id='Missing matcher header'
         ),
+        pytest.param(
+            {'Content-Type': 'application/pdf'.encode('utf-16')},
+            {'Content-Type': 'application/pdf'.encode('utf-16')},
+            id='Arbitrary field value encoding'
+        ),
+        pytest.param(
+            {'Content-Type': 're/json/'},
+            {'Content-Type': 'application/json'},
+            id="Regex-format str expectation"
+        ),
+        pytest.param(
+            {'Content-Type': re.compile("json", re.I)},
+            {'Content-Type': 'APPLICATION/JSON'},
+            id="Regex pattern expectation",
+        )
+    )
+)
+def test_headers_matcher_matching(expected, requested):
+    mock = pook.get('https://example.com')
+    if expected:
+        mock.headers(expected)
+
+    request = pook.Request()
+    request.url = 'https://example.com'
+    if requested:
+        request.headers = requested
+
+    matched, explanation = mock.match(request)
+    assert matched, explanation
+
+
+@pytest.mark.parametrize(
+    ("expected", "requested", "explanation"),
+    (
         pytest.param(
             {'Content-Type': 'application/pdf'},
             {},
-            False,
-            id='Missing request header'
+            ["HeadersMatcher: Header 'Content-Type' not present"],
+            id='Missing request header str expectation',
         ),
         pytest.param(
-            {'Content-Type': 'application/pdf'.encode('utf-16')},
-            {'Content-Type': 'application/pdf'.encode('utf-16')},
-            True,
-            id='Arbitrary field value encoding'
+            {'Content-Type': b'application/pdf'},
+            {},
+            ["HeadersMatcher: Header 'Content-Type' not present"],
+            id='Missing request header bytes expectation',
+        ),
+        pytest.param(
+            {'Content-Type': 'application/pdf'},
+            {'Content-Type': 'application/xml'},
+            [
+                (
+                    "HeadersMatcher: 'application/pdf' != 'application/xml'\n"
+                    "- application/pdf\n"
+                    "?             ^^^\n"
+                    "+ application/xml\n"
+                    "?             ^^^\n"
+                )
+            ],
+            id='Non-matching values, matching types',
         ),
+        pytest.param(
+            {'Content-Type': 'application/pdf'},
+            {'Content-Type': b'application/xml'},
+            [
+                (
+                    "HeadersMatcher: 'application/pdf' != 'application/xml'\n"
+                    "- application/pdf\n"
+                    "?             ^^^\n"
+                    "+ application/xml\n"
+                    "?             ^^^\n"
+                )
+            ],
+            id='Non-matching values, str expectation byte actual',
+        ),
+        pytest.param(
+            {'Content-Type': b'application/pdf'},
+            {'Content-Type': 'application/xml'},
+            [
+                (
+                    "HeadersMatcher: 'application/pdf' != 'application/xml'\n"
+                    "- application/pdf\n"
+                    "?             ^^^\n"
+                    "+ application/xml\n"
+                    "?             ^^^\n"
+                )
+            ],
+            id='Non-matching values, bytes expectation str actual',
+        ),
+        pytest.param(
+            {'Content-Type': "re/json/"},
+            {'Content-Type': b"application/xml"},
+            [
+                (
+                    "HeadersMatcher: Regex didn't match: 'json' not found in "
+                    "'application/xml'"
+                )
+            ],
+            id='Non-matching values, re-format str expectation',
+        )
     )
 )
-def test_headers_matcher(expected, requested, should_match):
+def test_headers_not_matching(expected, requested, explanation):
     mock = pook.get('https://example.com')
     if expected:
         mock.headers(expected)
@@ -93,5 +167,77 @@ def test_headers_matcher(expected, requested, should_match):
     if requested:
         request.headers = requested
 
+    matched, actual_explanation = mock.match(request)
+    assert not matched
+    assert explanation == actual_explanation
+
+
+@pytest.mark.parametrize(
+    ("required_headers", "requested_headers", "should_match"),
+    (
+        pytest.param(
+            ["content-type", "Authorization"],
+            {
+                "Content-Type": "",
+                "authorization": "Bearer NOT A TOKEN",
+            },
+            True,
+            id="case-insensitive-match-with-empty-value"
+        ),
+        pytest.param(
+            ["content-type", "Authorization"],
+            {
+                "Content-Type": "application/json",
+                "authorization": "Bearer NOT A TOKEN",
+            },
+            True,
+            id="case-insensitive-match-with-non-empty-values"
+        ),
+        pytest.param(
+            ["x-requested-with"],
+            {
+                "content-type": "application/json",
+            },
+            False,
+            id="x-header-missing-with-other-headers"
+        ),
+        pytest.param(
+            ["x-requested-with"],
+            {},
+            False,
+            id="x-header-no-headers",
+        ),
+        pytest.param(
+            ["content-type"],
+            {},
+            False,
+            id="no-headers",
+        ),
+        pytest.param(
+            ["x-requested-with"],
+            {"x-requested-with": "com.example.app"},
+            True,
+            id="x-header-with-value"
+        ),
+        pytest.param(
+            ["x-requested-with"],
+            {"x-requested-with": ""},
+            True,
+            id="x-header-with-empty-value"
+        ),
+    )
+)
+def test_headers_present(required_headers, requested_headers, should_match):
+    mock = pook.get('https://example.com').headers_present(required_headers)
+
+    request = pook.Request()
+    request.url = 'https://example.com'
+    request.headers = requested_headers
+
     matched, explanation = mock.match(request)
     assert matched == should_match, explanation
+
+
+def test_headers_present_empty_headers():
+    with pytest.raises(ValueError):
+        pook.get('https://example.com').headers_present([])