diff --git a/CHANGES/443.feature b/CHANGES/443.feature new file mode 100644 index 000000000..11a10f7d3 --- /dev/null +++ b/CHANGES/443.feature @@ -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") diff --git a/docs/api.rst b/docs/api.rst index e5db8ffc2..959c15a4d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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. @@ -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) @@ -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. @@ -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') diff --git a/tests/test_update_query.py b/tests/test_update_query.py index 36c1de379..f8224954a 100644 --- a/tests/test_update_query.py +++ b/tests/test_update_query.py @@ -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): @@ -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(): diff --git a/yarl/__init__.py b/yarl/__init__.py index 3ec3fadbf..2af172608 100644 --- a/yarl/__init__.py +++ b/yarl/__init__.py @@ -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): @@ -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)): @@ -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 )