Skip to content

Commit c437b99

Browse files
authored
Fix query-string: accept array/bracketed keys and trailing & (#586)
* Initial plan * Fix query-string: accept array/bracketed keys and trailing & - Replace character-whitelist regex with structural pair validation - Accept exactly one trailing '&' (backward-compatible) - Add tests for array-style and bracketed keys (both entry points) * Parse bracket/array keys into nested dicts and lists - Add _parse_bracket_notation to convert parse_qs output: - a[]=1&a[]=2 -> {"a": ["1", "2"]} - user[name]=joe&user[age]=42 -> {"user": {"name": "joe", "age": "42"}} - Update tests to use the correct expected results per owner feedback * Fix mypy type annotations in _parse_bracket_notation Use dict[str, Any] instead of bare dict for return type and result variable * Add tests for trailing & and flat=False to cover lines 71 and 74 * Fix flat=False to not create lists for non-bracket keys * Clarify docstring: flat param does not affect non-bracket keys --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent f99b35a commit c437b99

2 files changed

Lines changed: 87 additions & 6 deletions

File tree

benedict/serializers/query_string.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,53 @@ def __init__(self) -> None:
2626
],
2727
)
2828

29+
@staticmethod
30+
def _parse_bracket_notation(
31+
data: dict[str, list[str]],
32+
) -> dict[str, Any]:
33+
"""Convert parse_qs output into nested dicts / lists for bracket keys.
34+
35+
- ``a[]=1&a[]=2`` → ``{"a": ["1", "2"]}``
36+
- ``user[name]=joe`` → ``{"user": {"name": "joe"}}``
37+
- Regular keys (no brackets) always return a scalar string value;
38+
the *flat* parameter on :meth:`decode` does not affect them.
39+
"""
40+
_array_re = re.compile(r"^([^\[]+)\[\]$")
41+
_nested_re = re.compile(r"^([^\[]+)\[([^\]]+)\]$")
42+
result: dict[str, Any] = {}
43+
for key, values in data.items():
44+
m_array = _array_re.match(key)
45+
m_nested = _nested_re.match(key)
46+
if m_array:
47+
base = m_array.group(1)
48+
result[base] = values
49+
elif m_nested:
50+
parent, child = m_nested.group(1), m_nested.group(2)
51+
if parent not in result or not isinstance(result[parent], dict):
52+
result[parent] = {}
53+
result[parent][child] = values[0] if len(values) == 1 else values
54+
else:
55+
result[key] = values[0]
56+
return result
57+
2958
@override
3059
def decode( # type: ignore[override]
3160
self, s: str, flat: bool = True
3261
) -> dict[str, str] | dict[str, list[str]]:
33-
qs_re = r"(?:([\w\-\%\+\.\|]+\=[\w\-\%\+\.\|]*)+(?:[\&]{1})?)+"
34-
qs_pattern = re.compile(qs_re)
35-
if qs_pattern.match(s):
62+
# A query string is a sequence of "key=value" pairs joined by "&".
63+
# Each key must be non-empty and free of whitespace, "=" and "&";
64+
# each value must be free of whitespace and "&" (spaces are encoded
65+
# as "+" or "%20"). This accepts real-world keys such as array-style
66+
# "a[]" / "user[name]" while still rejecting other formats (TOML, YAML,
67+
# JSON, XML), plain text and URLs.
68+
pair_re = re.compile(r"^[^\s=&]+=[^\s&]*$")
69+
pairs = s.split("&")
70+
# Allow exactly one trailing empty segment (trailing "&" is valid)
71+
if pairs and not pairs[-1]:
72+
pairs = pairs[:-1]
73+
if all(pair_re.match(pair) for pair in pairs):
3674
data = parse_qs(s)
37-
if flat:
38-
return {key: value[0] for key, value in data.items()}
39-
return data
75+
return self._parse_bracket_notation(data)
4076
raise ValueError(f"Invalid query string: {s}")
4177

4278
@override

tests/dicts/io/test_io_dict_query_string.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,51 @@ def test_from_query_string_with_valid_data(self) -> None:
2626
self.assertTrue(isinstance(d, dict))
2727
self.assertEqual(d, r)
2828

29+
def test_from_query_string_with_array_style_keys(self) -> None:
30+
# array-style keys (PHP / HTML form syntax) are valid query strings
31+
s = "a[]=1&a[]=2"
32+
r = {"a": ["1", "2"]}
33+
# static method
34+
d = IODict.from_query_string(s)
35+
self.assertTrue(isinstance(d, dict))
36+
self.assertEqual(d, r)
37+
# constructor
38+
d = IODict(s, format="query_string")
39+
self.assertTrue(isinstance(d, dict))
40+
self.assertEqual(d, r)
41+
42+
def test_from_query_string_with_bracketed_keys(self) -> None:
43+
s = "user[name]=joe&user[age]=42"
44+
r = {"user": {"name": "joe", "age": "42"}}
45+
# static method
46+
d = IODict.from_query_string(s)
47+
self.assertTrue(isinstance(d, dict))
48+
self.assertEqual(d, r)
49+
50+
def test_from_query_string_with_trailing_ampersand(self) -> None:
51+
s = "a=1&"
52+
r = {"a": "1"}
53+
# static method
54+
d = IODict.from_query_string(s)
55+
self.assertTrue(isinstance(d, dict))
56+
self.assertEqual(d, r)
57+
# constructor
58+
d = IODict(s, format="query_string")
59+
self.assertTrue(isinstance(d, dict))
60+
self.assertEqual(d, r)
61+
62+
def test_from_query_string_with_valid_data_flat_false(self) -> None:
63+
s = "a=1&b=2"
64+
r = {"a": "1", "b": "2"}
65+
# static method
66+
d = IODict.from_query_string(s, flat=False)
67+
self.assertTrue(isinstance(d, dict))
68+
self.assertEqual(d, r)
69+
# constructor
70+
d = IODict(s, format="query_string", flat=False)
71+
self.assertTrue(isinstance(d, dict))
72+
self.assertEqual(d, r)
73+
2974
def test_from_query_string_with_invalid_data(self) -> None:
3075
s = "Lorem ipsum est in ea occaecat nisi officia."
3176
# static method

0 commit comments

Comments
 (0)