Repository: getsentry/responses

Issue:
Partial query_param_matcher
Currently it seems that the query_param_matcher, matcher, only performs a match when **ALL** query parameters are matched.

Is it possible to add an option to the function to allow for `loose` (probably a much better word for that) matching, where a match against any of the query parameters results in the tuple returning true?






Comments:

Patch:
diff --git a/CHANGES b/CHANGES
index 50f37ba..f498e82 100644
--- a/CHANGES
+++ b/CHANGES
@@ -5,6 +5,7 @@
   See `responses.registries.OrderedRegistry`.
 * 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 476371d..55df7d3 100644
--- a/README.rst
+++ b/README.rst
@@ -7,6 +7,9 @@ Responses
 .. image:: https://img.shields.io/pypi/pyversions/responses.svg
     :target: https://pypi.org/project/responses/
 
+.. image:: https://img.shields.io/pypi/dm/responses
+   :target: https://pypi.python.org/pypi/responses/
+
 .. image:: https://codecov.io/gh/getsentry/responses/branch/master/graph/badge.svg
     :target: https://codecov.io/gh/getsentry/responses/
 
@@ -252,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/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: