Skip to content

Commit c92e380

Browse files
authored
Merge pull request #443 from Lonami/quote_list
2 parents d0a30d7 + 434c3cb commit c92e380

File tree

4 files changed

+87
-12
lines changed

4 files changed

+87
-12
lines changed

CHANGES/443.feature

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Allow use of sequences such as :class:`list` and :class:`tuple` in the values
2+
of a mapping such as :class:`dict` to represent that a key has many values:
3+
4+
url = URL("http://example.com")
5+
assert url.with_query({"a": [1, 2]}) == URL("http://example.com/?a=1&a=2")

docs/api.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,9 @@ section generates a new *URL* instance.
547547

548548
The library accepts :class:`str` and :class:`int` as query argument values.
549549

550+
If a mapping such as :class:`dict` is used, the values may also be
551+
:class:`list` or :class:`tuple` to represent a key has many values.
552+
550553
Please see :ref:`yarl-bools-support` for the reason why :class:`bool` is not
551554
supported out-of-the-box.
552555

@@ -556,6 +559,8 @@ section generates a new *URL* instance.
556559
URL('http://example.com/path?c=d')
557560
>>> URL('http://example.com/path?a=b').with_query({'c': 'd'})
558561
URL('http://example.com/path?c=d')
562+
>>> URL('http://example.com/path?a=b').with_query({'c': [1, 2]})
563+
URL('http://example.com/path?c=1&c=2')
559564
>>> URL('http://example.com/path?a=b').with_query({'кл': 'зн'})
560565
URL('http://example.com/path?%D0%BA%D0%BB=%D0%B7%D0%BD')
561566
>>> URL('http://example.com/path?a=b').with_query(None)
@@ -591,6 +596,9 @@ section generates a new *URL* instance.
591596

592597
The library accepts :class:`str` and :class:`int` as query argument values.
593598

599+
If a mapping such as :class:`dict` is used, the values may also be
600+
:class:`list` or :class:`tuple` to represent a key has many values.
601+
594602
Please see :ref:`yarl-bools-support` for the reason why :class:`bool` is not
595603
supported out-of-the-box.
596604

@@ -600,6 +608,8 @@ section generates a new *URL* instance.
600608
URL('http://example.com/path?a=b&c=d')
601609
>>> URL('http://example.com/path?a=b').update_query({'c': 'd'})
602610
URL('http://example.com/path?a=b&c=d')
611+
>>> URL('http://example.com/path?a=b').update_query({'c': [1, 2]})
612+
URL('http://example.com/path?a=b&c=1&c=2')
603613
>>> URL('http://example.com/path?a=b').update_query({'кл': 'зн'})
604614
URL('http://example.com/path?a=b&%D0%BA%D0%BB=%D0%B7%D0%BD')
605615
>>> URL('http://example.com/path?a=b&b=1').update_query(b='2')

tests/test_update_query.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,44 @@ def test_with_query_list_int():
117117
assert str(url.with_query([("a", 1)])) == "http://example.com/?a=1"
118118

119119

120+
@pytest.mark.parametrize(
121+
("query", "expected"),
122+
[
123+
pytest.param({"a": []}, "", id="empty list"),
124+
pytest.param({"a": ()}, "", id="empty tuple"),
125+
pytest.param({"a": [1]}, "/?a=1", id="single list"),
126+
pytest.param({"a": (1,)}, "/?a=1", id="single tuple"),
127+
pytest.param({"a": [1, 2]}, "/?a=1&a=2", id="list"),
128+
pytest.param({"a": (1, 2)}, "/?a=1&a=2", id="tuple"),
129+
pytest.param({"a[]": [1, 2]}, "/?a%5B%5D=1&a%5B%5D=2", id="key with braces"),
130+
pytest.param({"&": [1, 2]}, "/?%26=1&%26=2", id="quote key"),
131+
pytest.param({"a": ["1", 2]}, "/?a=1&a=2", id="mixed types"),
132+
pytest.param({"&": ["=", 2]}, "/?%26=%3D&%26=2", id="quote key and value"),
133+
pytest.param({"a": 1, "b": [2, 3]}, "/?a=1&b=2&b=3", id="single then list"),
134+
pytest.param({"a": [1, 2], "b": 3}, "/?a=1&a=2&b=3", id="list then single"),
135+
pytest.param({"a": ["1&a=2", 3]}, "/?a=1%26a%3D2&a=3", id="ampersand then int"),
136+
pytest.param({"a": [1, "2&a=3"]}, "/?a=1&a=2%26a%3D3", id="int then ampersand"),
137+
],
138+
)
139+
def test_with_query_sequence(query, expected):
140+
url = URL("http://example.com")
141+
expected = "http://example.com{expected}".format_map(locals())
142+
assert str(url.with_query(query)) == expected
143+
144+
145+
@pytest.mark.parametrize(
146+
"query",
147+
[
148+
pytest.param({"a": [[1]]}, id="nested"),
149+
pytest.param([("a", [1, 2])], id="tuple list"),
150+
],
151+
)
152+
def test_with_query_sequence_invalid_use(query):
153+
url = URL("http://example.com")
154+
with pytest.raises(TypeError, match="Invalid variable type"):
155+
url.with_query(query)
156+
157+
120158
def test_with_query_non_str():
121159
url = URL("http://example.com")
122160
with pytest.raises(TypeError):
@@ -196,16 +234,27 @@ def test_with_query_memoryview():
196234
url.with_query(memoryview(b"123"))
197235

198236

199-
def test_with_query_params():
200-
url = URL("http://example.com/get")
201-
url2 = url.with_query([("key", "1;2;3")])
202-
assert str(url2) == "http://example.com/get?key=1%3B2%3B3"
203-
204-
205-
def test_with_query_params2():
237+
@pytest.mark.parametrize(
238+
("query", "expected"),
239+
[
240+
pytest.param([("key", "1;2;3")], "?key=1%3B2%3B3", id="tuple list semicolon"),
241+
pytest.param({"key": "1;2;3"}, "?key=1%3B2%3B3", id="mapping semicolon"),
242+
pytest.param([("key", "1&a=2")], "?key=1%26a%3D2", id="tuple list ampersand"),
243+
pytest.param({"key": "1&a=2"}, "?key=1%26a%3D2", id="mapping ampersand"),
244+
pytest.param([("&", "=")], "?%26=%3D", id="tuple list quote key"),
245+
pytest.param({"&": "="}, "?%26=%3D", id="mapping quote key"),
246+
pytest.param([("a[]", "3")], "?a%5B%5D=3", id="quote one key braces",),
247+
pytest.param(
248+
[("a[]", "3"), ("a[]", "4")],
249+
"?a%5B%5D=3&a%5B%5D=4",
250+
id="quote many key braces",
251+
),
252+
],
253+
)
254+
def test_with_query_params(query, expected):
206255
url = URL("http://example.com/get")
207-
url2 = url.with_query({"key": "1;2;3"})
208-
assert str(url2) == "http://example.com/get?key=1%3B2%3B3"
256+
url2 = url.with_query(query)
257+
assert str(url2) == ("http://example.com/get" + expected)
209258

210259

211260
def test_with_query_only():

yarl/__init__.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,15 @@ def with_path(self, path, *, encoded=False):
852852
path = "/" + path
853853
return URL(self._val._replace(path=path, query="", fragment=""), encoded=True)
854854

855+
@classmethod
856+
def _query_seq_pairs(cls, quoter, pairs):
857+
for key, val in pairs:
858+
if isinstance(val, (list, tuple)):
859+
for v in val:
860+
yield quoter(key) + "=" + quoter(cls._query_var(v))
861+
else:
862+
yield quoter(key) + "=" + quoter(cls._query_var(val))
863+
855864
@staticmethod
856865
def _query_var(v):
857866
if isinstance(v, str):
@@ -882,9 +891,7 @@ def _get_str_query(self, *args, **kwargs):
882891
query = ""
883892
elif isinstance(query, Mapping):
884893
quoter = self._QUERY_PART_QUOTER
885-
query = "&".join(
886-
quoter(k) + "=" + quoter(self._query_var(v)) for k, v in query.items()
887-
)
894+
query = "&".join(self._query_seq_pairs(quoter, query.items()))
888895
elif isinstance(query, str):
889896
query = self._QUERY_QUOTER(query)
890897
elif isinstance(query, (bytes, bytearray, memoryview)):
@@ -893,6 +900,10 @@ def _get_str_query(self, *args, **kwargs):
893900
)
894901
elif isinstance(query, Sequence):
895902
quoter = self._QUERY_PART_QUOTER
903+
# We don't expect sequence values if we're given a list of pairs
904+
# already; only mappings like builtin `dict` which can't have the
905+
# same key pointing to multiple values are allowed to use
906+
# `_query_seq_pairs`.
896907
query = "&".join(
897908
quoter(k) + "=" + quoter(self._query_var(v)) for k, v in query
898909
)

0 commit comments

Comments
 (0)