diff --git a/CHANGES b/CHANGES index fcab1798..1acb978a 100644 --- a/CHANGES +++ b/CHANGES @@ -11,6 +11,10 @@ Unreleased include the ``params`` and ``req_kwargs`` attributes added by ``responses``. * Fixed non-strict `json_params_matcher` raising `TypeError` when an actual nested object is compared with an expected scalar value. +* Fixed `query_param_matcher` rejecting sequence-valued params such as + ``{"ids": [1, 2]}``, which `requests` sends as a repeated key. Numeric items + are converted to strings, a nested sequence is flattened and a ``None`` + element is dropped, matching how `requests` builds the query string. See #816 0.26.3 ------ diff --git a/responses/matchers.py b/responses/matchers.py index 15df1884..f9540cdd 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -224,11 +224,85 @@ def query_param_matcher( """ - params_dict = dict(params) if params else {} - - for k, v in params_dict.items(): - if isinstance(v, (int, float)): - params_dict[k] = str(v) + # A params value that produces no query string at all, which is not the + # same as one that produces an empty value. + _DROP = object() + + def _stringify(item: Any) -> Any: + """Render one value the way it comes back out of the query string. + + Everything reaching here is already a scalar as far as the query string + is concerned, and ``responses`` reads the query string back as text, so + the comparison has to be against text. ``bytes`` are decoded rather + than ``str()``-ed, because ``urlencode`` percent-encodes the bytes + themselves and ``b"x"`` comes back as ``"x"`` rather than ``"b'x'"``. + """ + if isinstance(item, str): + return item + if isinstance(item, bytes): + return item.decode() + return str(item) + + def _normalize(value: Any) -> Any: + """Present a ``params`` value the way the request's query string parses. + + ``responses`` reads the query string back as strings and groups a + repeated key into a list, collapsing a key that appears once to a bare + value. Sequences here are normalized the same way so that the dict + given to ``requests`` can be reused verbatim as the expectation. + + This mirrors how ``requests`` builds the query string: it iterates a + sequence value once, drops a ``None`` element, and then hands the + result to ``urlencode(..., doseq=True)``, which splices a nested + sequence in one further level. Anything deeper is url-encoded by + ``requests`` as its ``str()`` and is left alone here. + + A mapping is iterated too, which yields its keys, so + ``{"attrs": {"counter": 3}}`` is sent as ``attrs=counter`` and the + values never reach the query string. It is treated here as the + sequence of its keys for the same reason. + + A value that contributes nothing to the query string returns ``_DROP`` + so the key can be removed, because ``requests`` omits the key entirely + rather than sending it empty. That covers ``None``, an empty sequence + and a sequence of nothing but ``None``. An empty string is not one of + these: ``{"a": ""}`` is sent as ``a=``, and ``0`` and ``False`` are + sent as themselves, so emptiness here is never truthiness. + """ + if value is None: + return _DROP + if isinstance(value, Mapping): + value = list(value) + if not isinstance(value, (list, tuple)): + return _stringify(value) + + values: List[Any] = [] + for item in value: + if item is None: + # ``requests`` omits a ``None`` element from the sequence it + # iterates. One level deeper is a different rule: that splice is + # done by ``urlencode``, which renders a ``None`` there as the + # string ``"None"``, so only this level drops it. + continue + if isinstance(item, Mapping): + item = list(item) + if isinstance(item, (list, tuple)): + values.extend(_stringify(nested) for nested in item) + else: + values.append(_stringify(item)) + if not values: + return _DROP + return values[0] if len(values) == 1 else values + + params_dict = ( + { + k: normalized + for k, v in params.items() + if (normalized := _normalize(v)) is not _DROP + } + if params + else {} + ) def match(request: PreparedRequest) -> Tuple[bool, str]: reason = "" diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 4f67810c..3dffebc0 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -464,6 +464,119 @@ def run(): assert_reset() +@pytest.mark.parametrize( + "expected_query_params", + [ + {"ids": None}, + {"ids": []}, + {"ids": [None]}, + {"ids": [None, None]}, + {"attrs": {}}, + {"a": "", "ids": None}, + {"a": 0}, + {"a": False}, + ], +) +def test_query_params_sending_nothing(expected_query_params): # type: ignore[misc] + """A ``params`` value can contribute nothing to the query string. + + ``requests`` omits the key outright for ``None`` and for a sequence that + yields no elements, rather than sending it with an empty value, so the + matcher has to drop it as well. Emptiness here is not truthiness: ``""`` + is sent as ``a=``, and ``0`` and ``False`` are sent as themselves. + """ + + @responses.activate + def run(): + 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() + + +@pytest.mark.parametrize( + "expected_query_params", + [ + {"ids": [1, 2]}, + {"ids": (1, 2)}, + {"ids": ["a", 2], "page": 1}, + {"ids": [1, 2], "attrs": {"counter": 3, "favorites": [1, 2]}}, + {"ids": [1]}, + {"ids": ("a",)}, + {"ids": [b"x"]}, + {"ids": [{"k": 1}]}, + {"ids": [[1, [2, 3]]]}, + {"ids": [[None, 1]]}, + {"ids": [[b"x", 1]]}, + {"ids": [[{"k": 1}]]}, + ], +) +def test_query_params_sequences(expected_query_params): # type: ignore[misc] + """A ``params`` value may be a sequence, which sends the key repeatedly. + + A mapping counts as one: ``requests`` iterates it, so only its keys are + sent. + + One level deeper is a different rule, because that splice is done by + ``urlencode`` rather than by ``requests``: a nested sequence is spliced in, + anything left below that is sent as its ``str()``, and a ``None`` there + becomes the string ``"None"`` instead of being dropped. + """ + + @responses.activate + def run(): + 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() + + +@pytest.mark.parametrize( + "expected_query_params", + [ + {"ids": [[1, 2], [3]]}, + {"ids": [[1, 2]], "page": 1}, + {"ids": [1, None]}, + {"ids": [None, "a", 2]}, + ], +) +def test_query_params_nested_sequences(expected_query_params): # type: ignore[misc] + """``requests`` flattens a nested sequence and drops a ``None`` element. + + ``urlencode(..., doseq=True)`` splices a sequence inside a sequence value + into the query string one level deep, and ``requests`` omits a ``None`` + element entirely, so both are normalized the same way here. + """ + + @responses.activate + def run(): + 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_query_param_matcher_does_not_mutate_input(): """query_param_matcher must not modify the caller's params dict.