Repository: encode/httpx

Issue:
Reconsider UNSET?
Prompted by #1372 

Right now several of our public API functions or methods have a reference to an `UnsetType` instantiated as `UNSET` in their signature. For example:

https://github.com/encode/httpx/blob/589c6e0f2f4f3821095bfc247417aa98a5dad838/httpx/_client.py#L690-L705

Here's the full list of public API objects and methods that refer to `UnsetType`:

* `Client.request`
* `Client.stream`
* `Client.send`
* `Client.get`, `Client.post`, `Client.put`, `Client.patch`, `Client.delete`, `Client.options`, `Client.head`
* Equivalent methods on `AsyncClient`
* `Timeout(...)`

This style was introduced in preference to eg `auth=None`. The goal was to be able to distinguish between for example "`auth` was not passed" (indicating "use the default `auth` on the client"), and "`auth` was passed explicitly with `None`" (indicating that the user _really does not want auth to be used_).

But the problem is that now this `UNSET` instance and `UnsetType` are part of the public API: if people want to override methods on `Client` and keep the default behavior, then they need to import `UNSET` and default to it in the method.

```python
import httpx
from httpx._config import UNSET  # Oops

class MyClient(httpx.Client):
    def post(self, *args, auth=UNSET, **kwargs):
        ...  # Perhaps pre-process `auth`
```

It's fair to ask whether those use cases are valid, and if people can't always find a way to _not_ have to refer to `UNSET`.

But there's also a case to be made for us dropping that "sentinel value" idea entirely. For example, we could:

* Accept a `**kwargs` and pull `auth` from there.
  * Pros: no need for a special sentinel value.
  * Cons: we lose type hinting on `auth` and `timeout` (at least until typed `**kwargs` are a thing, for example: python/mypy#4441) and autocompletion.
* Use a builtin instead, for example `auth="__UNSET__"`.
  * Pros: builtin type (string) means it removes the "I need to import `UNSET`" problem.
  * Cons:
    * Still a sentinel value which users need to be aware of.
    * To type this properly we'd need `Literal["__UNSET__"]`, and `Literal` is Py3.8+ (or we need to add `typing_extensions` as a dependency).

I'm not sure what option is better, or if there are other options to consider, but I'm flagging this for 1.0 because this touches the public API.


Discussion:
I’m wondering if it is ok to use `typing.Undefined`
@lepture Where does `typing.Undefined` exist? https://github.com/python/mypy/issues/675 mentions it as an "old convention", and `import typing; typing.Undefined` is an `AttributeError`.

However, _this_ makes mypy happy enough…

```python
import builtins

class Client:
    def request(
        self,
        # ...
        auth: typing.Union[AuthTypes, builtins.ellipsis] = ...,
        allow_redirects: bool = True,
        timeout: typing.Union[TimeoutTypes, builtins.ellipsis] = ...,
    ) -> Response:
```

In otherwords, `...` (`Ellipsis`, aka `builtins.ellipsis`) could be treated as a shared `UNSET` sentinel value.

I've seen FastAPI do this in places. In general FastAPI uses it to mean "this API parameter is _required_", whereas here we're giving it a different meaning). But it still means means it's not totally unprecedented in large typing-heavy projects.

And for users who want to override things then it looks reasonable enough…

```python
import httpx

class MyClient(httpx.Client):
    def post(self, *args, auth=..., **kwargs):
        ...  # Perhaps pre-process `auth`
```
I've used `...` (ellipsis) as a shortcut for undefined a few times at $WORK before, but to be honest it seemed a bit of "magic" (reviewers were confused initially, it's not an usual pattern) and it was never part of any public API.

What's wrong with having the current sentinel `UNSET` as part of the public API? It has no issues with mypy, and I see the only usages would be for libs that want to subclass httpx so it shouldn't be that widespread.
Yeah, I've just closed #1385 because `builtins.ellipsis` is actually invalid at runtime (raises `AttributeError`), it's only available to type checkers, and so most likely for their private use only.
@florimondmanca 

We can specify 
```python
UnsetType = type(Ellipsis)
```
in `_types.py`

Then `mypy` would be able to accept Ellipsis as a sentinel value 
Some thoughts here...

I think we could perfectly well drop it on `Timeout(...)` in favour of `kwargs`.
That seems to just make sense there to me somehow.

The remaining cases are `auth` and `timeout` arguments on client methods, where we have it because we necessarily need a value that means "Use the client defaults" which is distinct from `None`, meaning "no auth" or "no timeout". Perhaps we could expose our sentinel as public API for those cases, but renaming it slightly to "USE_CLIENT_DEFAULT", which is much more clear?
Im happy to open a PR for this one

Resolving patch:
diff --git a/httpx/__init__.py b/httpx/__init__.py
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -1,7 +1,7 @@
 from .__version__ import __description__, __title__, __version__
 from ._api import delete, get, head, options, patch, post, put, request, stream
 from ._auth import Auth, BasicAuth, DigestAuth
-from ._client import AsyncClient, Client
+from ._client import USE_CLIENT_DEFAULT, AsyncClient, Client
 from ._config import Limits, Proxy, Timeout, create_ssl_context
 from ._content import ByteStream
 from ._exceptions import (
@@ -111,6 +111,7 @@
     "TransportError",
     "UnsupportedProtocol",
     "URL",
+    "USE_CLIENT_DEFAULT",
     "WriteError",
     "WriteTimeout",
     "WSGITransport",
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -12,11 +12,9 @@
     DEFAULT_LIMITS,
     DEFAULT_MAX_REDIRECTS,
     DEFAULT_TIMEOUT_CONFIG,
-    UNSET,
     Limits,
     Proxy,
     Timeout,
-    UnsetType,
 )
 from ._decoders import SUPPORTED_DECODERS
 from ._exceptions import (
@@ -65,6 +63,31 @@
 U = typing.TypeVar("U", bound="AsyncClient")
 
 
+class UseClientDefault:
+    """
+    For some parameters such as `auth=...` and `timeout=...` we need to be able
+    to indicate the default "unset" state, in a way that is distinctly different
+    to using `None`.
+
+    The default "unset" state indicates that whatever default is set on the
+    client should be used. This is different to setting `None`, which
+    explicitly disables the parameter, possibly overriding a client default.
+
+    For example we use `timeout=USE_CLIENT_DEFAULT` in the `request()` signature.
+    Omitting the `timeout` parameter will send a request using whatever default
+    timeout has been configured on the client. Including `timeout=None` will
+    ensure no timeout is used.
+
+    Note that user code shouldn't need to use the `USE_CLIENT_DEFAULT` constant,
+    but it is used internally when a parameter is not included.
+    """
+
+    pass  # pragma: nocover
+
+
+USE_CLIENT_DEFAULT = UseClientDefault()
+
+
 logger = get_logger(__name__)
 
 USER_AGENT = f"python-httpx/{__version__}"
@@ -402,9 +425,13 @@ def _build_auth(self, auth: AuthTypes) -> typing.Optional[Auth]:
             raise TypeError(f'Invalid "auth" argument: {auth!r}')
 
     def _build_request_auth(
-        self, request: Request, auth: typing.Union[AuthTypes, UnsetType] = UNSET
+        self,
+        request: Request,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Auth:
-        auth = self._auth if isinstance(auth, UnsetType) else self._build_auth(auth)
+        auth = (
+            self._auth if isinstance(auth, UseClientDefault) else self._build_auth(auth)
+        )
 
         if auth is not None:
             return auth
@@ -705,9 +732,9 @@ def request(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Build and send a request.
@@ -761,9 +788,9 @@ def stream(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> typing.Iterator[Response]:
         """
         Alternative to `httpx.request()` that streams the response body
@@ -803,9 +830,9 @@ def send(
         request: Request,
         *,
         stream: bool = False,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a request.
@@ -824,7 +851,9 @@ def send(
             raise RuntimeError("Cannot send a request, as the client has been closed.")
 
         self._state = ClientState.OPENED
-        timeout = self.timeout if isinstance(timeout, UnsetType) else Timeout(timeout)
+        timeout = (
+            self.timeout if isinstance(timeout, UseClientDefault) else Timeout(timeout)
+        )
 
         auth = self._build_request_auth(request, auth)
 
@@ -963,9 +992,9 @@ def get(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `GET` request.
@@ -990,9 +1019,9 @@ def options(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send an `OPTIONS` request.
@@ -1017,9 +1046,9 @@ def head(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `HEAD` request.
@@ -1048,9 +1077,9 @@ def post(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `POST` request.
@@ -1083,9 +1112,9 @@ def put(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `PUT` request.
@@ -1118,9 +1147,9 @@ def patch(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `PATCH` request.
@@ -1149,9 +1178,9 @@ def delete(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `DELETE` request.
@@ -1391,9 +1420,9 @@ async def request(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Build and send a request.
@@ -1440,9 +1469,9 @@ async def stream(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> typing.AsyncIterator[Response]:
         """
         Alternative to `httpx.request()` that streams the response body
@@ -1482,9 +1511,9 @@ async def send(
         request: Request,
         *,
         stream: bool = False,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a request.
@@ -1503,7 +1532,9 @@ async def send(
             raise RuntimeError("Cannot send a request, as the client has been closed.")
 
         self._state = ClientState.OPENED
-        timeout = self.timeout if isinstance(timeout, UnsetType) else Timeout(timeout)
+        timeout = (
+            self.timeout if isinstance(timeout, UseClientDefault) else Timeout(timeout)
+        )
 
         auth = self._build_request_auth(request, auth)
 
@@ -1649,9 +1680,9 @@ async def get(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `GET` request.
@@ -1676,9 +1707,9 @@ async def options(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send an `OPTIONS` request.
@@ -1703,9 +1734,9 @@ async def head(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `HEAD` request.
@@ -1734,9 +1765,9 @@ async def post(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `POST` request.
@@ -1769,9 +1800,9 @@ async def put(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `PUT` request.
@@ -1804,9 +1835,9 @@ async def patch(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `PATCH` request.
@@ -1835,9 +1866,9 @@ async def delete(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
+        auth: typing.Union[AuthTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
         allow_redirects: bool = True,
-        timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
+        timeout: typing.Union[TimeoutTypes, UseClientDefault] = USE_CLIENT_DEFAULT,
     ) -> Response:
         """
         Send a `DELETE` request.


Test patch: