Skip to content

Commit

Permalink
Merge pull request #443 from Lonami/quote_list
Browse files Browse the repository at this point in the history
  • Loading branch information
webknjaz authored May 11, 2020
2 parents d0a30d7 + 434c3cb commit c92e380
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 12 deletions.
5 changes: 5 additions & 0 deletions CHANGES/443.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Allow use of sequences such as :class:`list` and :class:`tuple` in the values
of a mapping such as :class:`dict` to represent that a key has many values:

url = URL("http://example.com")
assert url.with_query({"a": [1, 2]}) == URL("http://example.com/?a=1&a=2")
10 changes: 10 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,9 @@ section generates a new *URL* instance.

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

If a mapping such as :class:`dict` is used, the values may also be
:class:`list` or :class:`tuple` to represent a key has many values.

Please see :ref:`yarl-bools-support` for the reason why :class:`bool` is not
supported out-of-the-box.

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

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

If a mapping such as :class:`dict` is used, the values may also be
:class:`list` or :class:`tuple` to represent a key has many values.

Please see :ref:`yarl-bools-support` for the reason why :class:`bool` is not
supported out-of-the-box.

Expand All @@ -600,6 +608,8 @@ section generates a new *URL* instance.
URL('http://example.com/path?a=b&c=d')
>>> URL('http://example.com/path?a=b').update_query({'c': 'd'})
URL('http://example.com/path?a=b&c=d')
>>> URL('http://example.com/path?a=b').update_query({'c': [1, 2]})
URL('http://example.com/path?a=b&c=1&c=2')
>>> URL('http://example.com/path?a=b').update_query({'кл': 'зн'})
URL('http://example.com/path?a=b&%D0%BA%D0%BB=%D0%B7%D0%BD')
>>> URL('http://example.com/path?a=b&b=1').update_query(b='2')
Expand Down
67 changes: 58 additions & 9 deletions tests/test_update_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,44 @@ def test_with_query_list_int():
assert str(url.with_query([("a", 1)])) == "http://example.com/?a=1"


@pytest.mark.parametrize(
("query", "expected"),
[
pytest.param({"a": []}, "", id="empty list"),
pytest.param({"a": ()}, "", id="empty tuple"),
pytest.param({"a": [1]}, "/?a=1", id="single list"),
pytest.param({"a": (1,)}, "/?a=1", id="single tuple"),
pytest.param({"a": [1, 2]}, "/?a=1&a=2", id="list"),
pytest.param({"a": (1, 2)}, "/?a=1&a=2", id="tuple"),
pytest.param({"a[]": [1, 2]}, "/?a%5B%5D=1&a%5B%5D=2", id="key with braces"),
pytest.param({"&": [1, 2]}, "/?%26=1&%26=2", id="quote key"),
pytest.param({"a": ["1", 2]}, "/?a=1&a=2", id="mixed types"),
pytest.param({"&": ["=", 2]}, "/?%26=%3D&%26=2", id="quote key and value"),
pytest.param({"a": 1, "b": [2, 3]}, "/?a=1&b=2&b=3", id="single then list"),
pytest.param({"a": [1, 2], "b": 3}, "/?a=1&a=2&b=3", id="list then single"),
pytest.param({"a": ["1&a=2", 3]}, "/?a=1%26a%3D2&a=3", id="ampersand then int"),
pytest.param({"a": [1, "2&a=3"]}, "/?a=1&a=2%26a%3D3", id="int then ampersand"),
],
)
def test_with_query_sequence(query, expected):
url = URL("http://example.com")
expected = "http://example.com{expected}".format_map(locals())
assert str(url.with_query(query)) == expected


@pytest.mark.parametrize(
"query",
[
pytest.param({"a": [[1]]}, id="nested"),
pytest.param([("a", [1, 2])], id="tuple list"),
],
)
def test_with_query_sequence_invalid_use(query):
url = URL("http://example.com")
with pytest.raises(TypeError, match="Invalid variable type"):
url.with_query(query)


def test_with_query_non_str():
url = URL("http://example.com")
with pytest.raises(TypeError):
Expand Down Expand Up @@ -196,16 +234,27 @@ def test_with_query_memoryview():
url.with_query(memoryview(b"123"))


def test_with_query_params():
url = URL("http://example.com/get")
url2 = url.with_query([("key", "1;2;3")])
assert str(url2) == "http://example.com/get?key=1%3B2%3B3"


def test_with_query_params2():
@pytest.mark.parametrize(
("query", "expected"),
[
pytest.param([("key", "1;2;3")], "?key=1%3B2%3B3", id="tuple list semicolon"),
pytest.param({"key": "1;2;3"}, "?key=1%3B2%3B3", id="mapping semicolon"),
pytest.param([("key", "1&a=2")], "?key=1%26a%3D2", id="tuple list ampersand"),
pytest.param({"key": "1&a=2"}, "?key=1%26a%3D2", id="mapping ampersand"),
pytest.param([("&", "=")], "?%26=%3D", id="tuple list quote key"),
pytest.param({"&": "="}, "?%26=%3D", id="mapping quote key"),
pytest.param([("a[]", "3")], "?a%5B%5D=3", id="quote one key braces",),
pytest.param(
[("a[]", "3"), ("a[]", "4")],
"?a%5B%5D=3&a%5B%5D=4",
id="quote many key braces",
),
],
)
def test_with_query_params(query, expected):
url = URL("http://example.com/get")
url2 = url.with_query({"key": "1;2;3"})
assert str(url2) == "http://example.com/get?key=1%3B2%3B3"
url2 = url.with_query(query)
assert str(url2) == ("http://example.com/get" + expected)


def test_with_query_only():
Expand Down
17 changes: 14 additions & 3 deletions yarl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,15 @@ def with_path(self, path, *, encoded=False):
path = "/" + path
return URL(self._val._replace(path=path, query="", fragment=""), encoded=True)

@classmethod
def _query_seq_pairs(cls, quoter, pairs):
for key, val in pairs:
if isinstance(val, (list, tuple)):
for v in val:
yield quoter(key) + "=" + quoter(cls._query_var(v))
else:
yield quoter(key) + "=" + quoter(cls._query_var(val))

@staticmethod
def _query_var(v):
if isinstance(v, str):
Expand Down Expand Up @@ -882,9 +891,7 @@ def _get_str_query(self, *args, **kwargs):
query = ""
elif isinstance(query, Mapping):
quoter = self._QUERY_PART_QUOTER
query = "&".join(
quoter(k) + "=" + quoter(self._query_var(v)) for k, v in query.items()
)
query = "&".join(self._query_seq_pairs(quoter, query.items()))
elif isinstance(query, str):
query = self._QUERY_QUOTER(query)
elif isinstance(query, (bytes, bytearray, memoryview)):
Expand All @@ -893,6 +900,10 @@ def _get_str_query(self, *args, **kwargs):
)
elif isinstance(query, Sequence):
quoter = self._QUERY_PART_QUOTER
# We don't expect sequence values if we're given a list of pairs
# already; only mappings like builtin `dict` which can't have the
# same key pointing to multiple values are allowed to use
# `_query_seq_pairs`.
query = "&".join(
quoter(k) + "=" + quoter(self._query_var(v)) for k, v in query
)
Expand Down

0 comments on commit c92e380

Please sign in to comment.