Repository: encode/httpx

Issue:
Clearing authorization at the request level.
### Checklist

- [x] The bug is reproducible against the latest release and/or `master`.
- [x] There are no similar issues or pull requests to fix it yet.

### Describe the bug

Merging of request-level auth *unset* does not take priority over Client level value.

### To reproduce

Try to 'unset' the client set authorization values.

```python
with httpx.Client(auth=('tom', 'mot123')) as client:
    r = client.get('http://httpbin.org', auth=None)
    
assert 'Authorization' not in r.request.headers['Authorization']
```

### Expected behavior

The documentation for [Merging of configuration](https://www.python-httpx.org/advanced/#merging-of-configuration) states `For all other parameters, the request-level value takes priority.` and shows this example:

```python
with httpx.Client(auth=('tom', 'mot123')) as client:
    r = client.get('https://example.com', auth=('alice', 'ecila123'))
_, _, auth = r.request.headers['Authorization'].partition(' ')
```

I would expect that by setting `auth=None` in the request level that the individual request would be sent without authorization.

### Actual behavior

In reality, the authorization is not overridden. This is due to this line: https://github.com/encode/httpx/blob/master/httpx/_client.py#L258

Overriding the auth value via this method works:

```python
with httpx.Client(auth=('tom', 'mot123')) as client:
    r = client.get('http://httpbin.org', auth=Auth())
    
assert 'Authorization' not in r.request.headers['Authorization']
```

Personally, I found passing in `auth=None` to be more intuitive than `auth=Auth()` to indicate that no authorization should be used.


Discussion:
Right, so...

Presumably you'd get the same behaviour with `param`, `cookies`, `headers`, or `auth`...

All of these [default to `None` in the method signature](https://github.com/encode/httpx/blob/master/httpx/_client.py#L579-L582), and python *doesn't* give us any way of determining when a user has passed an explicit `auth=None` vs. nothing being passed.

```python
        params: QueryParamTypes = None,
        headers: HeaderTypes = None,
        cookies: CookieTypes = None,
        auth: AuthTypes = None,
```

We could change the method signature to use open-ended keyword arguments, `eg **kwargs`, but that's far less descriptive, and then needs a bunch of extra checking.

Alternately we could change them to some sentinel empty value, so eg `params = EMPTY`, but that's a big awkward.

I'd be interested to know if you get similar behaviour in, say, requests, or if they're working around this being a bit of an awkward pattern in a graceful way?

We may well just want to make sure we're documenting this well, since it's a usability gotcha, even if it's not buggy.


> Alternately we could change them to some sentinel empty value, so eg params = EMPTY, but that's a big awkward.

I was thinking on init we could set `self.auth` to the [`UNSET` type](https://github.com/encode/httpx/blob/master/httpx/_config.py#L40) if `auth=None`. Then at request time we can safely use `None` to signify "no auth"?
> Then at request time we can safely use None to signify "no auth"?

Only if we changed the `.request()` signature. Otherwise there's no way of determining the difference between the `auth=None` default, and an explicit `auth=None` passed by the user.
Looks like Requests behaves the same as we do for the specific case of `auth`, i.e. `request(auth=None)` is treated as "use the `auth` value on the session".

```python
>>> import requests
>>> s = requests.Session()
>>> s.auth = ("john", "admin")
>>> url = "https://httpbin.org/headers"
>>> r = s.get(url, auth=None)
>>> r.json()["headers"]["Authorization"]
'Basic am9objphZG1pbg=='
```

But honestly I'd consider it a usability bug too.

Eg we have `timeout=None` meaning "no timeout", so it'd be okay to assume that `auth=None` should mean "no auth".

Do we see an issue with considering using the same `UNSET` trick as we did for timeouts here (and maybe on other parameters such as `headers`, `cookies`, `params`)?
I was going to suggest `.build_request()`/`.send()` as a workaround, but as expected it won't work either — the "use client auth if request auth is `None`" logic is made inside `.send()`.
Can we use a decorator to evaluate signature data before set?

```
import functools

def auth_unset(func):
    @functools.wraps(func)
    def wrapper(*args,**kwargs):
        if 'auth' in kwargs and kwargs['auth'] is None:
        kwargs['auth'] = UNSET
    return func(*args,**kwargs)
return wrapper

@auth_unset
def get(
    self,
    url: URLTypes,
...
```

Resolving patch:
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -284,8 +284,10 @@ def _merge_queryparams(
             return merged_queryparams
         return params
 
-    def _build_auth(self, request: Request, auth: AuthTypes = None) -> Auth:
-        auth = self.auth if auth is None else auth
+    def _build_auth(
+        self, request: Request, auth: typing.Union[AuthTypes, UnsetType] = UNSET
+    ) -> Auth:
+        auth = self.auth if isinstance(auth, UnsetType) else auth
 
         if auth is not None:
             if isinstance(auth, tuple):
@@ -607,7 +609,7 @@ def request(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -646,7 +648,7 @@ def send(
         request: Request,
         *,
         stream: bool = False,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -792,7 +794,7 @@ def get(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -819,7 +821,7 @@ def options(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -846,7 +848,7 @@ def head(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = False,  # NOTE: Differs to usual default.
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -876,7 +878,7 @@ def post(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -909,7 +911,7 @@ def put(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -942,7 +944,7 @@ def patch(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -972,7 +974,7 @@ def delete(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1208,7 +1210,7 @@ async def request(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1248,7 +1250,7 @@ async def send(
         request: Request,
         *,
         stream: bool = False,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1396,7 +1398,7 @@ async def get(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1423,7 +1425,7 @@ async def options(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1450,7 +1452,7 @@ async def head(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = False,  # NOTE: Differs to usual default.
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1480,7 +1482,7 @@ async def post(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1513,7 +1515,7 @@ async def put(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1546,7 +1548,7 @@ async def patch(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
@@ -1576,7 +1578,7 @@ async def delete(
         params: QueryParamTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
-        auth: AuthTypes = None,
+        auth: typing.Union[AuthTypes, UnsetType] = UNSET,
         allow_redirects: bool = True,
         timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
     ) -> Response:
diff --git a/httpx/_types.py b/httpx/_types.py
--- a/httpx/_types.py
+++ b/httpx/_types.py
@@ -60,6 +60,7 @@
     Tuple[Union[str, bytes], Union[str, bytes]],
     Callable[["Request"], "Request"],
     "Auth",
+    None,
 ]
 
 RequestData = Union[dict, str, bytes, Iterator[bytes], AsyncIterator[bytes]]


Test patch:
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -279,6 +279,18 @@ async def test_trust_env_auth() -> None:
     }
 
 
+@pytest.mark.asyncio
+async def test_auth_disable_per_request() -> None:
+    url = "https://example.org/"
+    auth = ("tomchristie", "password123")
+
+    client = AsyncClient(transport=AsyncMockTransport(), auth=auth)
+    response = await client.get(url, auth=None)
+
+    assert response.status_code == 200
+    assert response.json() == {"auth": None}
+
+
 def test_auth_hidden_url() -> None:
     url = "http://example-username:example-password@example.org/"
     expected = "URL('http://example-username:[secure]@example.org/')"