From 2aa7f37f7afa8d70bed0fbc209a5fe1626deefde Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Sun, 30 Aug 2026 10:22:09 +0300 Subject: [PATCH 1/4] fix: match sequence-valued params in query_param_matcher query_param_matcher converted int/float values to strings only at the top level of the params mapping, so a list- or tuple-valued param never matched. responses parses the request's query string into strings and groups a repeated key into a list (collapsing a key seen once to a bare value), so {"ids": [1, 2]} was compared against {'ids': ['1', '2']} and failed, as did any tuple and any single-item sequence. Normalize sequence values the same way the request parser produces them. An empty sequence still does not match a request that omits the key. --- CHANGES | 2 ++ responses/matchers.py | 21 +++++++++++++++++---- responses/tests/test_matchers.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index fcab1798..147c1ec1 100644 --- a/CHANGES +++ b/CHANGES @@ -11,6 +11,8 @@ 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. See #816 0.26.3 ------ diff --git a/responses/matchers.py b/responses/matchers.py index 15df1884..7a934f36 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -224,11 +224,24 @@ def query_param_matcher( """ - params_dict = dict(params) if params else {} + def _normalize(value: Any) -> Any: + """Present a ``params`` value the way the request's query string parses. - for k, v in params_dict.items(): - if isinstance(v, (int, float)): - params_dict[k] = str(v) + ``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. + """ + if isinstance(value, (list, tuple)): + values = [ + str(item) if isinstance(item, (int, float)) else item for item in value + ] + return values[0] if len(values) == 1 else values + if isinstance(value, (int, float)): + return str(value) + return value + + params_dict = {k: _normalize(v) for k, v in params.items()} 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..0e9add91 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -464,6 +464,34 @@ def run(): assert_reset() +@pytest.mark.parametrize( + "expected_query_params", + [ + {"ids": [1, 2]}, + {"ids": (1, 2)}, + {"ids": ["a", 2], "page": 1}, + {"ids": [1]}, + {"ids": ("a",)}, + ], +) +def test_query_params_sequences(expected_query_params): # type: ignore[misc] + """A ``params`` value may be a sequence, which sends the key repeatedly.""" + + @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. From 19c43163432d38fd4f2a18062fb2fde0c8eb99e1 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Fri, 4 Sep 2026 14:58:04 +0300 Subject: [PATCH 2/4] fix: flatten nested sequences in query_param_matcher requests builds the query string by iterating a sequence value once and then handing the result to urlencode(..., doseq=True), which splices a sequence nested inside that value in one further level. It also drops a None element. So {"ids": [[1, 2], [3]]} is sent as ids=1&ids=2&ids=3 and {"ids": [1, None]} as ids=1, neither of which the matcher's expectation reproduced. Mirror both rules. Nesting deeper than one level is url-encoded by requests as its str(), and a dict value is emitted by requests as its keys alone (the values never reach the wire), so neither is normalized here. Co-Authored-By: Claude --- CHANGES | 4 +++- responses/matchers.py | 30 ++++++++++++++++++++++-------- responses/tests/test_matchers.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGES b/CHANGES index 147c1ec1..1acb978a 100644 --- a/CHANGES +++ b/CHANGES @@ -12,7 +12,9 @@ Unreleased * 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. See #816 + ``{"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 7a934f36..b5661eed 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -224,6 +224,9 @@ def query_param_matcher( """ + def _stringify(item: Any) -> Any: + return str(item) if isinstance(item, (int, float)) else item + def _normalize(value: Any) -> Any: """Present a ``params`` value the way the request's query string parses. @@ -231,15 +234,26 @@ def _normalize(value: Any) -> Any: 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. """ - if isinstance(value, (list, tuple)): - values = [ - str(item) if isinstance(item, (int, float)) else item for item in value - ] - return values[0] if len(values) == 1 else values - if isinstance(value, (int, float)): - return str(value) - return 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 query string. + continue + if isinstance(item, (list, tuple)): + values.extend(_stringify(nested) for nested in item) + else: + values.append(_stringify(item)) + return values[0] if len(values) == 1 else values params_dict = {k: _normalize(v) for k, v in params.items()} if params else {} diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 0e9add91..3b553382 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -492,6 +492,38 @@ def 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. From aa6550c4e51079ac41b04f067d25c9516f267762 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Thu, 10 Sep 2026 02:59:39 +0200 Subject: [PATCH 3/4] fix: normalize a mapping params value to its keys requests hands params to urlencode(..., doseq=True), which iterates the value. Iterating a mapping yields its keys, so {"attrs": {"counter": 3}} goes out as attrs=counter and the values never reach the query string. _normalize left a mapping alone, so the expectation kept the mapping while the request carried its keys and the two could not match. A mapping is now treated as the sequence of its keys, at the top level and nested inside a sequence, which is what requests does with it. --- responses/matchers.py | 9 +++++++++ responses/tests/test_matchers.py | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/responses/matchers.py b/responses/matchers.py index b5661eed..264a9584 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -240,7 +240,14 @@ def _normalize(value: Any) -> Any: 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. """ + if isinstance(value, Mapping): + value = list(value) if not isinstance(value, (list, tuple)): return _stringify(value) @@ -249,6 +256,8 @@ def _normalize(value: Any) -> Any: if item is None: # ``requests`` omits a ``None`` element from the query string. continue + if isinstance(item, Mapping): + item = list(item) if isinstance(item, (list, tuple)): values.extend(_stringify(nested) for nested in item) else: diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 3b553382..743d1ec2 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -470,12 +470,17 @@ def run(): {"ids": [1, 2]}, {"ids": (1, 2)}, {"ids": ["a", 2], "page": 1}, + {"ids": [1, 2], "attrs": {"counter": 3, "favorites": [1, 2]}}, {"ids": [1]}, {"ids": ("a",)}, ], ) def test_query_params_sequences(expected_query_params): # type: ignore[misc] - """A ``params`` value may be a sequence, which sends the key repeatedly.""" + """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. + """ @responses.activate def run(): From f5ce59d2d2818db8a81d57a523a453bace5071d9 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Fri, 30 Oct 2026 18:04:39 -0400 Subject: [PATCH 4/4] fix: drop a params key that sends no query string Review asked whether `{"ids": None}` should match, and it should: `requests` omits a `None`-valued key from the query string outright rather than sending it empty, so the matcher has to drop it too. The same holds for an empty sequence, a sequence of nothing but `None`, and an empty mapping. Emptiness here is not truthiness, since `""` is sent as `a=` and `0` and `False` are sent as themselves. `_stringify` now renders every value the way it comes back out of the query string. `bytes` are decoded rather than `str()`-ed, because `urlencode` percent-encodes the bytes and `b"x"` returns as `"x"`. That also fixes the level below a spliced sequence, where anything left is sent as its `str()`. On recursing further: `requests` iterates a sequence value once and hands the result to `urlencode(doseq=True)`, which splices one more level and `str()`s whatever is below. Recursing past that would make the matcher accept shapes `requests` cannot send, so the tests pin the boundary instead. Note the asymmetry those two levels have: `requests` drops a `None` in the sequence it iterates, while `urlencode` renders a `None` one level deeper as the string "None". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- responses/matchers.py | 44 +++++++++++++++++++++++++++-- responses/tests/test_matchers.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/responses/matchers.py b/responses/matchers.py index 264a9584..f9540cdd 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -224,8 +224,24 @@ def query_param_matcher( """ + # 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: - return str(item) if isinstance(item, (int, float)) else item + """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. @@ -245,7 +261,16 @@ def _normalize(value: Any) -> Any: ``{"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)): @@ -254,7 +279,10 @@ def _normalize(value: Any) -> Any: values: List[Any] = [] for item in value: if item is None: - # ``requests`` omits a ``None`` element from the query string. + # ``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) @@ -262,9 +290,19 @@ def _normalize(value: Any) -> Any: 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: _normalize(v) for k, v in params.items()} if params else {} + 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 743d1ec2..3dffebc0 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -464,6 +464,43 @@ 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", [ @@ -473,6 +510,12 @@ def run(): {"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] @@ -480,6 +523,11 @@ def test_query_params_sequences(expected_query_params): # type: ignore[misc] 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