Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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
------
Expand Down
44 changes: 40 additions & 4 deletions responses/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,47 @@ def query_param_matcher(

"""

params_dict = dict(params) if params else {}
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.

``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.
"""
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 query string.
continue
if isinstance(item, Mapping):
item = list(item)
if isinstance(item, (list, tuple)):
values.extend(_stringify(nested) for nested in item)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested None not stringified

Low Severity

When _normalize flattens a nested sequence, each item goes through _stringify, which only converts int and float. A None inside that nested sequence stays None, while urlencode with doseq emits the string 'None', so the matcher misses a request built from the same params dict.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 264057f. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a relevant gap.

else:
values.append(_stringify(item))
Comment thread
sentry[bot] marked this conversation as resolved.
return values[0] if len(values) == 1 else values

for k, v in params_dict.items():
if isinstance(v, (int, float)):
params_dict[k] = str(v)
params_dict = {k: _normalize(v) for k, v in params.items()} if params else {}

def match(request: PreparedRequest) -> Tuple[bool, str]:
reason = ""
Expand Down
65 changes: 65 additions & 0 deletions responses/tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,71 @@ def run():
assert_reset()


@pytest.mark.parametrize(
"expected_query_params",
[
{"ids": [1, 2]},
{"ids": (1, 2)},
{"ids": ["a", 2], "page": 1},
Comment thread
markstory marked this conversation as resolved.
{"ids": [1, 2], "attrs": {"counter": 3, "favorites": [1, 2]}},
{"ids": [1]},
{"ids": ("a",)},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also have a case for

Suggested change
{"ids": ("a",)},
{"ids": None},

Currently this fails with

E           Request:
E           - GET https://example.com/
E
E           Available matches:
E           - GET https://example.com/ Parameters do not match. {} doesn't match {'ids': None}

],
)
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.
"""

@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.

Expand Down