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
84 changes: 79 additions & 5 deletions responses/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-UTF-8 bytes crash the matcher

Low Severity

_stringify decodes bytes with strict UTF-8, so a non-UTF-8 sequence value raises UnicodeDecodeError while the matcher is being built. parse_qsl uses errors='replace' on the same bytes after urlencode, so a real request would parse instead of crashing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5ce59d. Configure here.

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)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
else:
values.append(_stringify(item))
Comment thread
sentry[bot] marked this conversation as resolved.
Resolved
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 = ""
Expand Down
113 changes: 113 additions & 0 deletions responses/tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Comment thread
markstory marked this conversation as resolved.
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}

{"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.

Expand Down