Repository: getsentry/responses

Issue:
regression regarding content_type in 0.10.8
A test started failing on an application when updating our code and testing against the current `responses`.

I traced it back to a regression in the 0.10.8 release. When there is collision between `content_type` and `adding_headers["Content-Type"]` on `responses.RequestsMock.add`

In 0.10.7 and below, the `content_type` argument is ignored if `Content-Type` is provided as a header.

In 0.10.8 and above, the `content_type` argument is prepended to  the headers' `Content-Type` when provided.

### Steps to Reproduce

An executable reproduction is provided below.

pip install "responses==0.10.7"
python {ExampleFile}.py

pip install --upgrade responses
python {ExampleFile}.py

### Expected Result

I did not expect a breaking change.

I would expect a breaking change like this to either:

* be properly listed in the changelog
* raise an exception when there is an overlap in conflicting arguments

When trying to decide workarounds, this was also noted:

When headers are computed, a "," is used to prepend the `content_type` to the `adding_headers["Content-Type"]`. This means one can not strip out "text/html" from a header that has a charset in it, and rely on Responses to upgrade it:

This is invalid:
 
     `{'Content-Type': 'text/html, charset=utf-8'}`

As this is valid 

     `{'Content-Type': 'text/html; charset=utf-8'}`

While I'd love to see the old functionality return, I understand fixing this regression may not be a priority.  Because the Content-Type generated by passing in both arguments is not RFC compliant,  I think a changlog entry is insufficient and this library would need to raise an Exception if `content_type` is specified alongside a "Content-Type" header.  

This was painful to debug.

### Actual Result

Described above.

### Executable Example

```python
# pypi
import responses
import requests

with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
    rsps.add(
        responses.GET,
        "http://example.com/index-1.html",
        body="<html>1</html>",
        content_type="text/html",
    )
    rsps.add(
        responses.GET,
        "http://example.com/index-2.html",
        body="<html>2</html>",
        adding_headers={
            "Content-Type": "text/html2; charset=utf-8",  # html2 to differentiate against html
        },
    )
    rsps.add(
        responses.GET,
        "http://example.com/index-3.html",
        body="<html>3</html>",
        content_type="text/html",
        adding_headers={
            "Content-Type": "text/html2; charset=utf-8",  # html2 to differentiate against html
        },
    )

    r1 = requests.get("http://example.com/index-1.html")
    assert r1.headers["Content-Type"] == "text/html"
    
    r2 = requests.get("http://example.com/index-2.html")
    try:
        assert r2.headers["Content-Type"] == "text/plain, text/html2; charset=utf-8"
        print("r2 REGRESSION")
    except:
        assert r2.headers["Content-Type"] == "text/html2; charset=utf-8"
        print("r2 legacy")

    r3 = requests.get("http://example.com/index-3.html")
    try:
        assert r3.headers["Content-Type"] == "text/html, text/html2; charset=utf-8"
        print("r3 REGRESSION")
    except:
        assert r3.headers["Content-Type"] == "text/html2; charset=utf-8"
        print("r3 legacy")
```

Comments:

Patch:
diff --git a/CHANGES b/CHANGES
index 66c1659..be397ff 100644
--- a/CHANGES
+++ b/CHANGES
@@ -2,7 +2,9 @@
 ------
 
 * Expose `get_registry()` method of `RequestsMock` object. Replaces internal `_get_registry()`.
+* `query_param_matcher` can now accept dictionaries with `int` and `float` values.
 * Added support for `async/await` functions.
+* An error is now raised when both `content_type` and `headers[content-type]` are provided as parameters.
 
 0.18.0
 ------
diff --git a/responses/__init__.py b/responses/__init__.py
index 940b742..12ef01f 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -1,13 +1,11 @@
-from __future__ import absolute_import, print_function, division, unicode_literals
+from http import client
 
-import _io
 import inspect
-from http import client
 from http import cookies
 import json as json_module
 import logging
-import re
 from itertools import groupby
+from re import Pattern
 
 
 from collections import namedtuple
@@ -46,18 +44,14 @@ from urllib.parse import (
     quote,
 )
 
-from io import BytesIO as BufferIO
+from io import BytesIO
+from io import BufferedReader
 
 from unittest import mock as std_mock
 
-
-Pattern = re.Pattern
-
-UNSET = object()
-
 Call = namedtuple("Call", ["request", "response"])
-
 _real_send = HTTPAdapter.send
+_UNSET = object()
 
 logger = logging.getLogger("responses")
 
@@ -182,10 +176,10 @@ def _get_url_and_path(url):
 def _handle_body(body):
     if isinstance(body, str):
         body = body.encode("utf-8")
-    if isinstance(body, _io.BufferedReader):
+    if isinstance(body, BufferedReader):
         return body
 
-    data = BufferIO(body)
+    data = BytesIO(body)
 
     def is_closed():
         """
@@ -220,7 +214,6 @@ class BaseResponse(object):
     passthrough = False
     content_type = None
     headers = None
-
     stream = False
 
     def __init__(self, method, url, match_querystring=None, match=()):
@@ -328,7 +321,7 @@ class Response(BaseResponse):
         status=200,
         headers=None,
         stream=None,
-        content_type=UNSET,
+        content_type=_UNSET,
         auto_calculate_content_length=False,
         **kwargs
     ):
@@ -337,10 +330,10 @@ class Response(BaseResponse):
         if json is not None:
             assert not body
             body = json_module.dumps(json)
-            if content_type is UNSET:
+            if content_type is _UNSET:
                 content_type = "application/json"
 
-        if content_type is UNSET:
+        if content_type is _UNSET:
             if isinstance(body, str) and _has_unicode(body):
                 content_type = "text/plain; charset=utf-8"
             else:
@@ -359,7 +352,7 @@ class Response(BaseResponse):
         self.stream = stream
         self.content_type = content_type
         self.auto_calculate_content_length = auto_calculate_content_length
-        super(Response, self).__init__(method, url, **kwargs)
+        super().__init__(method, url, **kwargs)
 
     def get_response(self, request):
         if self.body and isinstance(self.body, Exception):
@@ -371,7 +364,7 @@ class Response(BaseResponse):
 
         if (
             self.auto_calculate_content_length
-            and isinstance(body, BufferIO)
+            and isinstance(body, BytesIO)
             and "Content-Length" not in headers
         ):
             content_length = len(body.getvalue())
@@ -411,7 +404,7 @@ class CallbackResponse(BaseResponse):
             )
         self.stream = stream
         self.content_type = content_type
-        super(CallbackResponse, self).__init__(method, url, **kwargs)
+        super().__init__(method, url, **kwargs)
 
     def get_response(self, request):
         headers = self.get_headers()
@@ -565,6 +558,13 @@ class RequestsMock(object):
 
         if adding_headers is not None:
             kwargs.setdefault("headers", adding_headers)
+        if "content_type" in kwargs and "headers" in kwargs:
+            header_keys = [header.lower() for header in kwargs["headers"]]
+            if "content-type" in header_keys:
+                raise RuntimeError(
+                    "You cannot define both `content_type` and `headers[Content-Type]`."
+                    " Using the `content_type` kwarg is recommended."
+                )
 
         self._registry.add(Response(method=method, url=url, body=body, **kwargs))
 
@@ -580,6 +580,7 @@ class RequestsMock(object):
 
         Regex can be used like:
 
+        >>> import re
         >>> responses.add_passthru(re.compile('https://example.com/\\w+'))
         """
         if not isinstance(prefix, Pattern) and _has_unicode(prefix):
@@ -614,7 +615,6 @@ class RequestsMock(object):
         >>> responses.replace(responses.GET, 'http://example.org', json={'data': 2})
         """
         if isinstance(method_or_response, BaseResponse):
-            url = method_or_response.url
             response = method_or_response
         else:
             response = Response(method=method_or_response, url=url, body=body, **kwargs)
@@ -645,8 +645,6 @@ class RequestsMock(object):
         content_type="text/plain",
         match=(),
     ):
-        # ensure the url has a default path set if the url is a string
-        # url = _ensure_url_default_path(url, match_querystring)
 
         self._registry.add(
             CallbackResponse(
@@ -680,8 +678,8 @@ class RequestsMock(object):
         if func is not None:
             return get_wrapped(func, self)
 
-        def deco_activate(func):
-            return get_wrapped(func, self, registry)
+        def deco_activate(function):
+            return get_wrapped(function, self, registry)
 
         return deco_activate
 
diff --git a/responses/__init__.pyi b/responses/__init__.pyi
index 93e432d..69c1e7c 100644
--- a/responses/__init__.pyi
+++ b/responses/__init__.pyi
@@ -168,6 +168,7 @@ class RequestsMock:
     passthru_prefixes: Tuple[Union[str, Pattern[str]], ...] = ...
     target: Any = ...
     _matches: List[Any]
+    _registry: Any
     def __init__(
         self,
         assert_all_requests_are_fired: bool = ...,
diff --git a/responses/matchers.py b/responses/matchers.py
index 893edc1..d2a041b 100644
--- a/responses/matchers.py
+++ b/responses/matchers.py
@@ -1,12 +1,14 @@
 import json as json_module
+from typing import Dict, Any, Optional, Callable, Tuple, Union, List
 
 from requests import PreparedRequest
 from urllib.parse import parse_qsl, urlparse
-from requests.packages.urllib3.util.url import parse_url
+
+from requests.packages.urllib3.util.url import parse_url  # type: ignore[import]
 from json.decoder import JSONDecodeError
 
 
-def _create_key_val_str(input_dict):
+def _create_key_val_str(input_dict: Union[Dict[Any, Any], Any]) -> str:
     """
     Returns string of format {'key': val, 'key2': val2}
     Function is called recursively for nested dictionaries
@@ -15,7 +17,7 @@ def _create_key_val_str(input_dict):
     :return: (str) reformatted string
     """
 
-    def list_to_str(input_list):
+    def list_to_str(input_list: List[str]) -> str:
         """
         Convert all list items to string.
         Function is called recursively for nested lists
@@ -45,7 +47,7 @@ def _create_key_val_str(input_dict):
     return key_val_str
 
 
-def urlencoded_params_matcher(params):
+def urlencoded_params_matcher(params: Optional[Dict[str, str]]) -> Callable[..., Any]:
     """
     Matches URL encoded data
 
@@ -53,10 +55,10 @@ def urlencoded_params_matcher(params):
     :return: (func) matcher
     """
 
-    def match(request):
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
         request_body = request.body
-        qsl_body = dict(parse_qsl(request_body)) if request_body else {}
+        qsl_body = dict(parse_qsl(request_body)) if request_body else {}  # type: ignore[type-var]
         params_dict = params or {}
         valid = params is None if request_body is None else params_dict == qsl_body
         if not valid:
@@ -69,7 +71,7 @@ def urlencoded_params_matcher(params):
     return match
 
 
-def json_params_matcher(params):
+def json_params_matcher(params: Optional[Dict[str, Any]]) -> Callable[..., Any]:
     """
     Matches JSON encoded data
 
@@ -77,7 +79,7 @@ def json_params_matcher(params):
     :return: (func) matcher
     """
 
-    def match(request):
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
         request_body = request.body
         params_dict = params or {}
@@ -104,27 +106,29 @@ def json_params_matcher(params):
     return match
 
 
-def fragment_identifier_matcher(identifier):
-    def match(request):
+def fragment_identifier_matcher(identifier: Optional[str]) -> Callable[..., Any]:
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
         url_fragment = urlparse(request.url).fragment
         if identifier:
-            url_fragment_qsl = sorted(parse_qsl(url_fragment))
+            url_fragment_qsl = sorted(parse_qsl(url_fragment))  # type: ignore[type-var]
             identifier_qsl = sorted(parse_qsl(identifier))
             valid = identifier_qsl == url_fragment_qsl
         else:
             valid = not url_fragment
 
         if not valid:
-            reason = "URL fragment identifier is different: {} doesn't match {}".format(
-                identifier, url_fragment
+            reason = (
+                "URL fragment identifier is different: "  # type: ignore[str-bytes-safe]
+                f"{identifier} doesn't match {url_fragment}"
             )
+
         return valid, reason
 
     return match
 
 
-def query_param_matcher(params):
+def query_param_matcher(params: Optional[Dict[str, Any]]) -> Callable[..., Any]:
     """
     Matcher to match 'params' argument in request
 
@@ -132,11 +136,16 @@ def query_param_matcher(params):
     :return: (func) matcher
     """
 
-    def match(request):
+    params_dict = params or {}
+
+    for k, v in params_dict.items():
+        if isinstance(v, (int, float)):
+            params_dict[k] = str(v)
+
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
-        request_params = request.params
+        request_params = request.params  # type: ignore[attr-defined]
         request_params_dict = request_params or {}
-        params_dict = params or {}
         valid = (
             params is None
             if request_params is None
@@ -154,7 +163,7 @@ def query_param_matcher(params):
     return match
 
 
-def query_string_matcher(query):
+def query_string_matcher(query: Optional[str]) -> Callable[..., Any]:
     """
     Matcher to match query string part of request
 
@@ -162,7 +171,7 @@ def query_string_matcher(query):
     :return: (func) matcher
     """
 
-    def match(request):
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
         data = parse_url(request.url)
         request_query = data.query
@@ -183,7 +192,7 @@ def query_string_matcher(query):
     return match
 
 
-def request_kwargs_matcher(kwargs):
+def request_kwargs_matcher(kwargs: Optional[Dict[str, Any]]) -> Callable[..., Any]:
     """
     Matcher to match keyword arguments provided to request
 
@@ -191,18 +200,17 @@ def request_kwargs_matcher(kwargs):
     :return: (func) matcher
     """
 
-    def match(request):
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
         kwargs_dict = kwargs or {}
         # validate only kwargs that were requested for comparison, skip defaults
-        request_kwargs = {
-            k: v for k, v in request.req_kwargs.items() if k in kwargs_dict
-        }
+        req_kwargs = request.req_kwargs  # type: ignore[attr-defined]
+        request_kwargs = {k: v for k, v in req_kwargs.items() if k in kwargs_dict}
 
         valid = (
             not kwargs_dict
             if not request_kwargs
-            else sorted(kwargs.items()) == sorted(request_kwargs.items())
+            else sorted(kwargs_dict.items()) == sorted(request_kwargs.items())
         )
 
         if not valid:
@@ -215,7 +223,9 @@ def request_kwargs_matcher(kwargs):
     return match
 
 
-def multipart_matcher(files, data=None):
+def multipart_matcher(
+    files: Dict[str, Any], data: Optional[Dict[str, str]] = None
+) -> Callable[..., Any]:
     """
     Matcher to match 'multipart/form-data' content-type.
     This function constructs request body and headers from provided 'data' and 'files'
@@ -229,10 +239,10 @@ def multipart_matcher(files, data=None):
         raise TypeError("files argument cannot be empty")
 
     prepared = PreparedRequest()
-    prepared.headers = {"Content-Type": ""}
+    prepared.headers = {"Content-Type": ""}  # type: ignore[assignment]
     prepared.prepare_body(data=data, files=files)
 
-    def get_boundary(content_type):
+    def get_boundary(content_type: str) -> str:
         """
         Parse 'boundary' value from header.
 
@@ -244,7 +254,7 @@ def multipart_matcher(files, data=None):
 
         return content_type.split("boundary=")[1]
 
-    def match(request):
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = "multipart/form-data doesn't match. "
         if "Content-Type" not in request.headers:
             return False, reason + "Request is missing the 'Content-Type' header"
@@ -261,14 +271,16 @@ def multipart_matcher(files, data=None):
         )
 
         request_body = request.body
-        prepared_body = prepared.body
+        prepared_body = prepared.body or ""
 
         if isinstance(prepared_body, bytes):
             # since headers always come as str, need to convert to bytes
-            prepared_boundary = prepared_boundary.encode("utf-8")
-            request_boundary = request_boundary.encode("utf-8")
+            prepared_boundary = prepared_boundary.encode("utf-8")  # type: ignore[assignment]
+            request_boundary = request_boundary.encode("utf-8")  # type: ignore[assignment]
 
-        prepared_body = prepared_body.replace(prepared_boundary, request_boundary)
+        prepared_body = prepared_body.replace(
+            prepared_boundary, request_boundary  # type: ignore[arg-type]
+        )
 
         headers_valid = prepared_content_type == request_content_type
         if not headers_valid:
@@ -282,8 +294,12 @@ def multipart_matcher(files, data=None):
 
         body_valid = prepared_body == request_body
         if not body_valid:
-            return False, reason + "Request body differs. {} aren't equal {}".format(
-                request_body, prepared_body
+            return (
+                False,
+                reason
+                + "Request body differs. {} aren't equal {}".format(  # type: ignore[str-bytes-safe]
+                    request_body, prepared_body
+                ),
             )
 
         return True, ""
@@ -291,7 +307,9 @@ def multipart_matcher(files, data=None):
     return match
 
 
-def header_matcher(headers, strict_match=False):
+def header_matcher(
+    headers: Dict[str, str], strict_match: bool = False
+) -> Callable[..., Any]:
     """
     Matcher to match 'headers' argument in request using the responses library.
 
@@ -306,8 +324,8 @@ def header_matcher(headers, strict_match=False):
     :return: (func) matcher
     """
 
-    def match(request):
-        request_headers = request.headers or {}
+    def match(request: PreparedRequest) -> Tuple[bool, str]:
+        request_headers: Union[Dict[Any, Any], Any] = request.headers or {}
 
         if not strict_match:
             # filter down to just the headers specified in the matcher
diff --git a/responses/matchers.pyi b/responses/matchers.pyi
deleted file mode 100644
index 188de2e..0000000
--- a/responses/matchers.pyi
+++ /dev/null
@@ -1,44 +0,0 @@
-from typing import (
-    Any,
-    Callable,
-    Optional,
-    Dict,
-)
-
-JSONDecodeError = ValueError
-
-
-def _create_key_val_str(input_dict: Dict[Any, Any]) -> str: ...
-
-def json_params_matcher(
-    params: Optional[Dict[str, Any]]
-) -> Callable[..., Any]: ...
-
-def urlencoded_params_matcher(
-    params: Optional[Dict[str, str]]
-) -> Callable[..., Any]: ...
-
-def query_param_matcher(
-    params: Optional[Dict[str, str]]
-) -> Callable[..., Any]: ...
-
-def query_string_matcher(
-    query: Optional[str]
-) -> Callable[..., Any]: ...
-
-def request_kwargs_matcher(
-    kwargs: Optional[Dict[str, Any]]
-) -> Callable[..., Any]: ...
-
-def multipart_matcher(
-        files: Dict[str, Any], data: Optional[Dict[str, str]] = ...
-) -> Callable[..., Any]: ...
-
-def header_matcher(
-    headers: Dict[str, str],
-    strict_match: bool = ...
-) -> Callable[..., Any]: ...
-
-def fragment_identifier_matcher(
-    identifier: Optional[str]
-) -> Callable[..., Any]: ...


Test patch:
diff --git a/responses/test_matchers.py b/responses/test_matchers.py
index d061d97..937e8c0 100644
--- a/responses/test_matchers.py
+++ b/responses/test_matchers.py
@@ -87,6 +87,23 @@ def test_request_matches_post_params():
     assert_reset()
 
 
+def test_query_params_numbers():
+    @responses.activate
+    def run():
+        expected_query_params = {"float": 5.0, "int": 2}
+        responses.add(
+            responses.GET,
+            "https://example.com/",
+            match=[
+                matchers.query_param_matcher(expected_query_params),
+            ],
+        )
+        requests.get("https://example.com", params=expected_query_params)
+
+    run()
+    assert_reset()
+
+
 def test_request_matches_empty_body():
     def run():
         with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps:
diff --git a/responses/test_responses.py b/responses/test_responses.py
index a6ef490..56dae82 100644
--- a/responses/test_responses.py
+++ b/responses/test_responses.py
@@ -1319,6 +1319,26 @@ def test_legacy_adding_headers():
     assert_reset()
 
 
+def test_legacy_adding_headers_with_content_type():
+    @responses.activate
+    def run():
+        with pytest.raises(RuntimeError) as excinfo:
+            responses.add(
+                responses.GET,
+                "http://example.com",
+                body="test",
+                content_type="text/html",
+                adding_headers={"Content-Type": "text/html; charset=utf-8"},
+            )
+        assert (
+            "You cannot define both `content_type` and `headers[Content-Type]`"
+            in str(excinfo.value)
+        )
+
+    run()
+    assert_reset()
+
+
 def test_auto_calculate_content_length_string_body():
     @responses.activate
     def run():