Repository: getsentry/responses
Pull request #505 (2022-02-21 03:12:59)

Add logging of passthrus when there's no match
A couple of changes related to passthrus:

`responses.passthru_prefixes` is always empty because it is a value type that is captured when the responses module is loaded. I've fixed this by making passthru_prefixes a mutable list. Of course, this will allow users to add things directly to the list. The downsides of this seem minimal, but I'm happy to fix this in another way if we think it's important to maintain immutability.

I also added some logging of passthrus when there is no match. I found this helpful for debugging.

beliaev-maksim: I am -1 on making prefixes mutable. 
We already had issue with leakage of prefixes

this issue is more related to the PR #497. I would add `responses.passthru_prefixes` to deprecation

please use `responses.mock.passthru_prefixes` instead

aburgel: > I am -1 on making prefixes mutable.

I removed those changes. Now this PR just adds logging of passthrus when there's no match.
beliaev-maksim: @aburgel
Did you follow contribution guide? You can easily set dev environment following it. 

Then, please, run tox and fix coverage
aburgel: @beliaev-maksim I updated a test to get back to 100% coverage

Patch:
diff --git a/CHANGES b/CHANGES
index 50f37ba..6f3cc30 100644
--- a/CHANGES
+++ b/CHANGES
@@ -3,8 +3,11 @@
 
 * Added a registry that provides more strict ordering based on the invocation index.
   See `responses.registries.OrderedRegistry`.
+* Prevent `responses.activate` decorator to leak, if wrapped function called from within another
+  wrapped function. Also, allow calling of above mentioned chain. See #481 for more details.
 * Expose `get_registry()` method of `RequestsMock` object. Replaces internal `_get_registry()`.
 * `query_param_matcher` can now accept dictionaries with `int` and `float` values.
+* Add support for the `loose` version of `query_param_matcher` via named argument `strict_match`.
 * Added support for `async/await` functions.
 * `response_callback` is no longer executed on exceptions raised by failed `Response`s
 * An error is now raised when both `content_type` and `headers[content-type]` are provided as parameters.
diff --git a/README.rst b/README.rst
index 20da43f..55df7d3 100644
--- a/README.rst
+++ b/README.rst
@@ -255,6 +255,9 @@ deprecated argument.
         assert resp.request.url == constructed_url
         assert resp.request.params == params
 
+By default, matcher will validate that all parameters match strictly.
+To validate that only parameters specified in the matcher are present in original request
+use ``strict_match=False``.
 
 Query Parameters as a String
 """"""""""""""""""""""""""""
diff --git a/responses/__init__.py b/responses/__init__.py
index 549e652..97bc709 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -561,6 +561,7 @@ class RequestsMock(object):
         self._registry = FirstMatchRegistry()
         self._calls.reset()
         self.passthru_prefixes = ()
+        self._patcher = None
 
     def add(
         self,
@@ -783,6 +784,11 @@ class RequestsMock(object):
                     m.method, m.url, match_failed_reasons[i]
                 )
 
+            if self.passthru_prefixes:
+                error_msg += "Passthru prefixes:\n"
+                for p in self.passthru_prefixes:
+                    error_msg += "- {}\n".format(p)
+
             response = ConnectionError(error_msg)
             response.request = request
 
@@ -806,6 +812,10 @@ class RequestsMock(object):
         return response
 
     def start(self):
+        if self._patcher:
+            # we must not override value of the _patcher if already applied
+            return
+
         def unbound_on_send(adapter, request, *a, **kwargs):
             return self._on_request(adapter, request, *a, **kwargs)
 
@@ -813,7 +823,10 @@ class RequestsMock(object):
         self._patcher.start()
 
     def stop(self, allow_assert=True):
-        self._patcher.stop()
+        if self._patcher:
+            # prevent stopping unstarted patchers
+            self._patcher.stop()
+
         if not self.assert_all_requests_are_fired:
             return
 
diff --git a/responses/matchers.py b/responses/matchers.py
index 71bb9f2..4f8ebdd 100644
--- a/responses/matchers.py
+++ b/responses/matchers.py
@@ -134,12 +134,26 @@ def fragment_identifier_matcher(identifier: Optional[str]) -> Callable[..., Any]
     return match
 
 
-def query_param_matcher(params: Optional[Dict[str, Any]]) -> Callable[..., Any]:
-    """
-    Matcher to match 'params' argument in request
+def query_param_matcher(
+    params: Optional[Dict[str, Any]], *, strict_match: bool = True
+) -> Callable[..., Any]:
+    """Matcher to match 'params' argument in request.
+
+    Parameters
+    ----------
+    params : dict
+        The same as provided to request or a part of it if used in
+        conjunction with ``strict_match=False``.
+    strict_match : bool, default=True
+        If set to ``True``, validates that all parameters match.
+        If set to ``False``, original request may contain additional parameters.
+
+
+    Returns
+    -------
+    Callable
+        Matcher function.
 
-    :param params: (dict), same as provided to request
-    :return: (func) matcher
     """
 
     params_dict = params or {}
@@ -152,17 +166,24 @@ def query_param_matcher(params: Optional[Dict[str, Any]]) -> Callable[..., Any]:
         reason = ""
         request_params = request.params  # type: ignore[attr-defined]
         request_params_dict = request_params or {}
-        valid = (
-            params is None
-            if request_params is None
-            else params_dict == request_params_dict
-        )
+
+        if not strict_match:
+            # filter down to just the params specified in the matcher
+            request_params_dict = {
+                k: v for k, v in request_params_dict.items() if k in params_dict
+            }
+
+        valid = sorted(params_dict.items()) == sorted(request_params_dict.items())
 
         if not valid:
             reason = "Parameters do not match. {} doesn't match {}".format(
                 _create_key_val_str(request_params_dict),
                 _create_key_val_str(params_dict),
             )
+            if not strict_match:
+                reason += (
+                    "\nYou can use `strict_match=True` to do a strict parameters check."
+                )
 
         return valid, reason
 


Test patch:
diff --git a/responses/test_matchers.py b/responses/test_matchers.py
index 544f5de..80b831b 100644
--- a/responses/test_matchers.py
+++ b/responses/test_matchers.py
@@ -103,6 +103,52 @@ def test_query_params_numbers():
     assert_reset()
 
 
+def test_query_param_matcher_loose():
+    @responses.activate
+    def run():
+        expected_query_params = {"only_one_param": "test"}
+        responses.add(
+            responses.GET,
+            "https://example.com/",
+            match=[
+                matchers.query_param_matcher(expected_query_params, strict_match=False),
+            ],
+        )
+        requests.get(
+            "https://example.com", params={"only_one_param": "test", "second": "param"}
+        )
+
+    run()
+    assert_reset()
+
+
+def test_query_param_matcher_loose_fail():
+    @responses.activate
+    def run():
+        expected_query_params = {"does_not_exist": "test"}
+        responses.add(
+            responses.GET,
+            "https://example.com/",
+            match=[
+                matchers.query_param_matcher(expected_query_params, strict_match=False),
+            ],
+        )
+        with pytest.raises(ConnectionError) as exc:
+            requests.get(
+                "https://example.com",
+                params={"only_one_param": "test", "second": "param"},
+            )
+
+        assert (
+            "- GET https://example.com/ Parameters do not match. {} doesn't"
+            " match {does_not_exist: test}\n"
+            "You can use `strict_match=True` to do a strict parameters check."
+        ) in str(exc.value)
+
+    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 91c167a..7b12d37 100644
--- a/responses/test_responses.py
+++ b/responses/test_responses.py
@@ -1817,6 +1817,7 @@ def test_fail_request_error():
         with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
             rsps.add("POST", "http://example1.com")
             rsps.add("GET", "http://example.com")
+            rsps.add_passthru("http://other.example.com")
 
             with pytest.raises(ConnectionError) as excinfo:
                 requests.post("http://example.com", data={"id": "bad"})
@@ -1824,6 +1825,7 @@ def test_fail_request_error():
             msg = str(excinfo.value)
             assert "- POST http://example1.com/ URL does not match" in msg
             assert "- GET http://example.com/ Method does not match" in msg
+            assert "Passthru prefixes:\n- http://other.example.com" in msg
 
     run()
     assert_reset()
@@ -1983,3 +1985,38 @@ async def test_async_calls():
 
     await run()
     assert_reset()
+
+
+class TestMultipleWrappers:
+    """Test to validate that multiple decorators could be applied.
+
+    Ensures that we can call one function that is wrapped with
+    ``responses.activate`` decorator from within another wrapped function.
+
+    Validates that mock patch is not leaked to other tests.
+    For more detail refer to https://github.com/getsentry/responses/issues/481
+    """
+
+    @responses.activate
+    def test_wrapped(self):
+        responses.add(responses.GET, "http://example.com/1", body="Hello 1")
+        assert b"Hello 1" == requests.get("http://example.com/1").content
+
+    @responses.activate
+    def test_call_another_wrapped_function(self):
+        self.test_wrapped()
+
+    def test_mock_not_leaked(self, httpserver):
+        """
+        Validate that ``responses.activate`` does not leak to unpatched test.
+
+        Parameters
+        ----------
+        httpserver : ContentServer
+            Mock real HTTP server
+
+        """
+        httpserver.serve_content("OK", code=969, headers={"Content-Type": "text/plain"})
+
+        response = requests.get(httpserver.url)
+        assert response.status_code == 969