Repository: encode/httpx

Issue:
Change QueryParams(key=None) to not emit a parameter
This one I know is [`requests` behaviour](https://github.com/psf/requests/blob/9a2e5df000691ad28613524ca7f80aa28a94a8d5/requests/models.py#L101):

Take a dict:
```py
>>> from httpx import QueryParams
>>> params = {"thing_one": 1, "thing_two": [1,2,3], "thing_three": None}
>>> QueryParams(params)
QueryParams('thing_one=1&thing_two=%5B1%2C+2%2C+3%5D&thing_three=None')
```
Expected:
```py
QueryParams('thing_one=1&thing_two=%5B1%2C+2%2C+3%5D')
```
Adding `if v is not None` [here](https://github.com/encode/httpx/blob/master/httpx/models.py#L263-L264) will do it.


Discussion:
So there's an issue on the requests tracker where there's no interface for setting query parameters without a value. Cuz you can do `{"q": ""}` -> `q=` but there's no way to get just `q` followed by the next parameter. Requests can't make the change for None to be the interface for that behavior because it'd be a breaking change. Maybe we should look through that thread first before inheriting that interface.
Found the issue: https://github.com/psf/requests/issues/2651
I think `None` rendering as a valueless param is the best way suggested in that thread. 
Rats, I was relying on that behavior as well to remove a param that breaks the API I'm calling if it's provided but not in the right format, but I haven't tried it as a valueless param. I'll have to see if it breaks as a valueless param `?key=` on Monday.
I think the desired behavior somewhat depends on how strict we want to be about types here, and if we're allowing type coercing of *other* non-str types.

I think either of the two following options are reasonable choices:

* Strictly type check, and *only* allow `str` types. Anything else raises a type error.
* Allow primitive types only - `str`, `int`, `float`, `Decimal`, `bool`, `None`, with `None` -> `""`, `True` -> `"true"`, `False` -> `"false"`, and perhaps also enforcing `isfinite()` on floats. Date/Time/DateTime -> ISO formatted could also be okay at a pinch.

I guess of those two options, the second probably plays more to Python's strengths? We could allow the same subset of types in urlencoded/multipart data too.
I'm using Pydantic to roundtrip my params and standardize on values with custom validators/parsers: https://pydantic-docs.helpmanual.io/#json-serialisation so I'm sure some inspiration could be taken from that.

I would not be opposed to the latter, especially since query params can imitate form data, they should also allow lists, and explode them since QueryParams is already a multi-dict. This however could be left up to the user.
Since I'm no longer on mobile and can type symbols easily I'm proposing:

- `{"q": "1"}` -> `?q=1`
- `{"q": ""}` -> `?q=`
- `{"q": None}` -> `?q`

There are also instances where the order matters for the parameters, so maybe we need to allow `typing.Iterable[typing.Tuple[typing.AnyStr, typing.AnyStr]]` in addition to dict?

Resolving patch:
diff --git a/httpx/models.py b/httpx/models.py
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -33,14 +33,17 @@
     is_known_encoding,
     normalize_header_key,
     normalize_header_value,
+    str_query_param
 )
 
+PrimitiveData = typing.Union[str, int, float, bool, type(None)]
+
 URLTypes = typing.Union["URL", str]
 
 QueryParamTypes = typing.Union[
     "QueryParams",
-    typing.Mapping[str, str],
-    typing.List[typing.Tuple[typing.Any, typing.Any]],
+    typing.Mapping[str, PrimitiveData],
+    typing.List[typing.Tuple[str, PrimitiveData]],
     str,
 ]
 
@@ -259,8 +262,8 @@ def __init__(self, *args: QueryParamTypes, **kwargs: typing.Any) -> None:
         else:
             items = value.items()  # type: ignore
 
-        self._list = [(str(k), str(v)) for k, v in items]
-        self._dict = {str(k): str(v) for k, v in items}
+        self._list = [(str(k), str_query_param(v)) for k, v in items]
+        self._dict = {str(k): str_query_param(v) for k, v in items}
 
     def getlist(self, key: typing.Any) -> typing.List[str]:
         return [item_value for item_key, item_value in self._list if item_key == key]
diff --git a/httpx/utils.py b/httpx/utils.py
--- a/httpx/utils.py
+++ b/httpx/utils.py
@@ -20,6 +20,21 @@ def normalize_header_value(value: typing.AnyStr, encoding: str = None) -> bytes:
     return value.encode(encoding or "ascii")
 
 
+def str_query_param(value: typing.Union[str, int, float, bool, type(None)]) -> str:
+    """
+    Coerce a primitive data type into a string value for query params.
+
+    Note that we prefer JSON-style 'true'/'false' for boolean values here.
+    """
+    if value is True:
+        return "true"
+    elif value is False:
+        return "false"
+    elif value is None:
+        return ""
+    return str(value)
+
+
 def is_known_encoding(encoding: str) -> bool:
     """
     Return `True` if `encoding` is a known codec.


Test patch:
diff --git a/tests/models/test_queryparams.py b/tests/models/test_queryparams.py
--- a/tests/models/test_queryparams.py
+++ b/tests/models/test_queryparams.py
@@ -31,3 +31,23 @@ def test_queryparams():
 
     q = QueryParams([("a", "123"), ("a", "456")])
     assert QueryParams(q) == q
+
+
+def test_queryparam_types():
+    q = QueryParams({"a": True})
+    assert str(q) == "a=true"
+
+    q = QueryParams({"a": False})
+    assert str(q) == "a=false"
+
+    q = QueryParams({"a": ""})
+    assert str(q) == "a="
+
+    q = QueryParams({"a": None})
+    assert str(q) == "a="
+
+    q = QueryParams({"a": 1.23})
+    assert str(q) == "a=1.23"
+
+    q = QueryParams({"a": 123})
+    assert str(q) == "a=123"