Repository: getsentry/responses
Pull request #672 (2023-09-22 22:31:23)

removed support for python3.7

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getsentry/responses/pull/672?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Patch has no changes to coverable lines.



:loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa315a7..06c05b0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+        python-version: ['3.8', '3.9', '3.10', '3.11']
         requests-version: ['"requests>=2.0,<3.0"']
         urllib3-version: ['"urllib3<2"', '"urllib3>=2,<3.0"']
 
@@ -48,8 +48,8 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install ${{ matrix.requests-version }} ${{ matrix.urllib3-version }}
         make install-deps
+        pip install ${{ matrix.requests-version }} ${{ matrix.urllib3-version }}
 
     - name: Run Pytest
       run: |
diff --git a/CHANGES b/CHANGES
index 59a5925..3113c85 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,9 +1,13 @@
 0.24.0
 ------
 
+* Added `real_adapter_send` parameter to `RequestsMock` that will allow users to set
+  through which function they would like to send real requests
 * Added support for re.Pattern based header matching.
 * Added support for gzipped response bodies to `json_params_matcher`.
+* Fix `Content-Type` headers issue when the header was duplicated. See #644
 * Moved types-pyyaml dependency to `tests_requires`
+* Removed Python3.7 support
 
 0.23.3
 ------
diff --git a/README.rst b/README.rst
index 7d790eb..92e0040 100644
--- a/README.rst
+++ b/README.rst
@@ -17,7 +17,7 @@ A utility library for mocking out the ``requests`` Python library.
 
 ..  note::
 
-    Responses requires Python 3.7 or newer, and requests >= 2.30.0
+    Responses requires Python 3.8 or newer, and requests >= 2.30.0
 
 
 Table of Contents
diff --git a/responses/__init__.py b/responses/__init__.py
index a89e2d7..78a3a43 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -49,7 +49,8 @@ except ImportError:  # pragma: no cover
 try:
     from requests.packages.urllib3.connection import HTTPHeaderDict
 except ImportError:  # pragma: no cover
-    from urllib3.response import HTTPHeaderDict  # type: ignore[attr-defined]
+    from urllib3.response import HTTPHeaderDict
+
 try:
     from requests.packages.urllib3.util.url import parse_url
 except ImportError:  # pragma: no cover
@@ -84,14 +85,25 @@ if TYPE_CHECKING:  # pragma: no cover
         ) -> models.Response:
             ...
 
-
-# Block of type annotations
-_Body = Union[str, BaseException, "Response", BufferedReader, bytes, None]
-_F = Callable[..., Any]
-_HeaderSet = Optional[Union[Mapping[str, str], List[Tuple[str, str]]]]
-_MatcherIterable = Iterable[Callable[..., Tuple[bool, str]]]
-_HTTPMethodOrResponse = Optional[Union[str, "BaseResponse"]]
-_URLPatternType = Union["Pattern[str]", str]
+    # Block of type annotations
+    _Body = Union[str, BaseException, "Response", BufferedReader, bytes, None]
+    _F = Callable[..., Any]
+    _HeaderSet = Optional[Union[Mapping[str, str], List[Tuple[str, str]]]]
+    _MatcherIterable = Iterable[Callable[..., Tuple[bool, str]]]
+    _HTTPMethodOrResponse = Optional[Union[str, "BaseResponse"]]
+    _URLPatternType = Union["Pattern[str]", str]
+    _HTTPAdapterSend = Callable[
+        [
+            HTTPAdapter,
+            PreparedRequest,
+            bool,
+            float | tuple[float, float] | tuple[float, None] | None,
+            bool | str,
+            bytes | str | tuple[bytes | str, bytes | str] | None,
+            Mapping[str, str] | None,
+        ],
+        models.Response,
+    ]
 
 
 Call = namedtuple("Call", ["request", "response"])
@@ -250,7 +262,7 @@ class CallList(Sequence[Any], Sized):
     def __getitem__(self, idx: Union[int, slice]) -> Union[Call, List[Call]]:
         return self._calls[idx]
 
-    def add(self, request: "PreparedRequest", response: _Body) -> None:
+    def add(self, request: "PreparedRequest", response: "_Body") -> None:
         self._calls.append(Call(request, response))
 
     def reset(self) -> None:
@@ -258,8 +270,8 @@ class CallList(Sequence[Any], Sized):
 
 
 def _ensure_url_default_path(
-    url: _URLPatternType,
-) -> _URLPatternType:
+    url: "_URLPatternType",
+) -> "_URLPatternType":
     """Add empty URL path '/' if doesn't exist.
 
     Examples
@@ -376,7 +388,7 @@ class BaseResponse:
     def __init__(
         self,
         method: str,
-        url: _URLPatternType,
+        url: "_URLPatternType",
         match_querystring: Union[bool, object] = None,
         match: "_MatcherIterable" = (),
         *,
@@ -384,7 +396,7 @@ class BaseResponse:
     ) -> None:
         self.method: str = method
         # ensure the url has a default path set if the url is a string
-        self.url: _URLPatternType = _ensure_url_default_path(url)
+        self.url: "_URLPatternType" = _ensure_url_default_path(url)
 
         if self._should_match_querystring(match_querystring):
             match = tuple(match) + (
@@ -434,7 +446,7 @@ class BaseResponse:
 
         return bool(urlsplit(self.url).query)
 
-    def _url_matches(self, url: _URLPatternType, other: str) -> bool:
+    def _url_matches(self, url: "_URLPatternType", other: str) -> bool:
         """Compares two URLs.
 
         Compares only scheme, netloc and path. If 'url' is a re.Pattern, then checks that
@@ -479,10 +491,17 @@ class BaseResponse:
 
     def get_headers(self) -> HTTPHeaderDict:
         headers = HTTPHeaderDict()  # Duplicate headers are legal
-        if self.content_type is not None:
+
+        # Add Content-Type if it exists and is not already in headers
+        if self.content_type and (
+            not self.headers or "Content-Type" not in self.headers
+        ):
             headers["Content-Type"] = self.content_type
+
+        # Extend headers if they exist
         if self.headers:
             headers.extend(self.headers)
+
         return headers
 
     def get_response(self, request: "PreparedRequest") -> HTTPResponse:
@@ -532,8 +551,8 @@ class Response(BaseResponse):
     def __init__(
         self,
         method: str,
-        url: _URLPatternType,
-        body: _Body = "",
+        url: "_URLPatternType",
+        body: "_Body" = "",
         json: Optional[Any] = None,
         status: int = 200,
         headers: Optional[Mapping[str, str]] = None,
@@ -556,7 +575,7 @@ class Response(BaseResponse):
             else:
                 content_type = "text/plain"
 
-        self.body: _Body = body
+        self.body: "_Body" = body
         self.status: int = status
         self.headers: Optional[Mapping[str, str]] = headers
 
@@ -608,7 +627,7 @@ class CallbackResponse(BaseResponse):
     def __init__(
         self,
         method: str,
-        url: _URLPatternType,
+        url: "_URLPatternType",
         callback: Callable[[Any], Any],
         stream: Optional[bool] = None,
         content_type: Optional[str] = "text/plain",
@@ -678,6 +697,8 @@ class RequestsMock:
         passthru_prefixes: Tuple[str, ...] = (),
         target: str = "requests.adapters.HTTPAdapter.send",
         registry: Type[FirstMatchRegistry] = FirstMatchRegistry,
+        *,
+        real_adapter_send: "_HTTPAdapterSend" = _real_send,
     ) -> None:
         self._calls: CallList = CallList()
         self.reset()
@@ -688,6 +709,7 @@ class RequestsMock:
         self.target: str = target
         self._patcher: Optional["_mock_patcher[Any]"] = None
         self._thread_lock = _ThreadingLock()
+        self._real_send = real_adapter_send
 
     def get_registry(self) -> FirstMatchRegistry:
         """Returns current registry instance with responses.
@@ -726,10 +748,10 @@ class RequestsMock:
 
     def add(
         self,
-        method: _HTTPMethodOrResponse = None,
+        method: "_HTTPMethodOrResponse" = None,
         url: "Optional[_URLPatternType]" = None,
-        body: _Body = "",
-        adding_headers: _HeaderSet = None,
+        body: "_Body" = "",
+        adding_headers: "_HeaderSet" = None,
         *args: Any,
         **kwargs: Any,
     ) -> BaseResponse:
@@ -808,7 +830,7 @@ class RequestsMock:
                 auto_calculate_content_length=rsp["auto_calculate_content_length"],
             )
 
-    def add_passthru(self, prefix: _URLPatternType) -> None:
+    def add_passthru(self, prefix: "_URLPatternType") -> None:
         """
         Register a URL prefix or regex to passthru any non-matching mock requests to.
 
@@ -829,7 +851,7 @@ class RequestsMock:
 
     def remove(
         self,
-        method_or_response: _HTTPMethodOrResponse = None,
+        method_or_response: "_HTTPMethodOrResponse" = None,
         url: "Optional[_URLPatternType]" = None,
     ) -> List[BaseResponse]:
         """
@@ -852,9 +874,9 @@ class RequestsMock:
 
     def replace(
         self,
-        method_or_response: _HTTPMethodOrResponse = None,
+        method_or_response: "_HTTPMethodOrResponse" = None,
         url: "Optional[_URLPatternType]" = None,
-        body: _Body = "",
+        body: "_Body" = "",
         *args: Any,
         **kwargs: Any,
     ) -> BaseResponse:
@@ -878,9 +900,9 @@ class RequestsMock:
 
     def upsert(
         self,
-        method_or_response: _HTTPMethodOrResponse = None,
+        method_or_response: "_HTTPMethodOrResponse" = None,
         url: "Optional[_URLPatternType]" = None,
-        body: _Body = "",
+        body: "_Body" = "",
         *args: Any,
         **kwargs: Any,
     ) -> BaseResponse:
@@ -901,9 +923,10 @@ class RequestsMock:
     def add_callback(
         self,
         method: str,
-        url: _URLPatternType,
+        url: "_URLPatternType",
         callback: Callable[
-            ["PreparedRequest"], Union[Exception, Tuple[int, Mapping[str, str], _Body]]
+            ["PreparedRequest"],
+            Union[Exception, Tuple[int, Mapping[str, str], "_Body"]],
         ],
         match_querystring: Union[bool, FalseBool] = FalseBool(),
         content_type: Optional[str] = "text/plain",
@@ -940,7 +963,7 @@ class RequestsMock:
         return success
 
     @overload
-    def activate(self, func: _F = ...) -> _F:
+    def activate(self, func: "_F" = ...) -> "_F":
         """Overload for scenario when 'responses.activate' is used."""
 
     @overload
@@ -958,15 +981,15 @@ class RequestsMock:
 
     def activate(
         self,
-        func: Optional[_F] = None,
+        func: Optional["_F"] = None,
         *,
         registry: Optional[Type[Any]] = None,
         assert_all_requests_are_fired: bool = False,
-    ) -> Union[Callable[["_F"], "_F"], _F]:
+    ) -> Union[Callable[["_F"], "_F"], "_F"]:
         if func is not None:
             return get_wrapped(func, self)
 
-        def deco_activate(function: _F) -> Callable[..., Any]:
+        def deco_activate(function: "_F") -> Callable[..., Any]:
             return get_wrapped(
                 function,
                 self,
@@ -1028,7 +1051,7 @@ class RequestsMock:
                 ]
             ):
                 logger.info("request.allowed-passthru", extra={"url": request_url})
-                return _real_send(adapter, request, **kwargs)
+                return self._real_send(adapter, request, **kwargs)  # type: ignore
 
             error_msg = (
                 "Connection refused by Responses - the call doesn't "
@@ -1055,7 +1078,7 @@ class RequestsMock:
 
         if match.passthrough:
             logger.info("request.passthrough-response", extra={"url": request_url})
-            response = _real_send(adapter, request, **kwargs)  # type: ignore[assignment]
+            response = self._real_send(adapter, request, **kwargs)  # type: ignore
         else:
             try:
                 response = adapter.build_response(  # type: ignore[no-untyped-call]
diff --git a/setup.py b/setup.py
index f800e0c..88c8ad2 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,6 @@ install_requires = [
     "requests>=2.30.0,<3.0",
     "urllib3>=1.25.10,<3.0",
     "pyyaml",
-    "typing_extensions; python_version < '3.8'",
 ]
 
 tests_require = [
@@ -77,7 +76,7 @@ setup(
     long_description_content_type="text/x-rst",
     packages=["responses"],
     zip_safe=False,
-    python_requires=">=3.7",
+    python_requires=">=3.8",
     install_requires=install_requires,
     extras_require=extras_require,
     tests_require=tests_require,
@@ -90,7 +89,6 @@ setup(
         "Operating System :: OS Independent",
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
diff --git a/tox.ini b/tox.ini
index 16eb90c..19ac6b0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py37,py38,py39,py310,py311,mypy,precom
+envlist = py38,py39,py310,py311,mypy,precom
 
 [testenv]
 extras = tests


Test patch:
diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py
index 2ae3d65..fcdb62b 100644
--- a/responses/tests/test_responses.py
+++ b/responses/tests/test_responses.py
@@ -1330,6 +1330,29 @@ def test_headers():
     assert_reset()
 
 
+def test_headers_deduplicated_content_type():
+    """Test to ensure that we do not have two values for `content-type`.
+
+    For more details see https://github.com/getsentry/responses/issues/644
+    """
+
+    @responses.activate
+    def run():
+        responses.get(
+            "https://example.org/",
+            json={},
+            headers={"Content-Type": "application/json"},
+        )
+        responses.start()
+
+        resp = requests.get("https://example.org/")
+
+        assert resp.headers["Content-Type"] == "application/json"
+
+    run()
+    assert_reset()
+
+
 def test_content_length_error(monkeypatch):
     """
     Currently 'requests' does not enforce content length validation,
@@ -1838,6 +1861,34 @@ class TestPassthru:
         run()
         assert_reset()
 
+    def test_real_send_argument(self):
+        def run():
+            # the following mock will serve to catch the real send request from another mock and
+            # will "donate" `unbound_on_send` method
+            mock_to_catch_real_send = responses.RequestsMock(
+                assert_all_requests_are_fired=True
+            )
+            mock_to_catch_real_send.post(
+                "http://send-this-request-through.com", status=500
+            )
+
+            with responses.RequestsMock(
+                assert_all_requests_are_fired=True,
+                real_adapter_send=mock_to_catch_real_send.unbound_on_send(),
+            ) as r_mock:
+                r_mock.add_passthru("http://send-this-request-through.com")
+
+                r_mock.add(responses.POST, "https://example.org", status=200)
+
+                response = requests.post("https://example.org")
+                assert response.status_code == 200
+
+                response = requests.post("http://send-this-request-through.com")
+                assert response.status_code == 500
+
+        run()
+        assert_reset()
+
 
 def test_method_named_param():
     @responses.activate