Repository: encode/httpx

Issue:
uses deprecated ssl.PROTOCOL_TLS
```
_______________________________________________________________________ test_default_default_headers _______________________________________________________________________

    @pytest.mark.asyncio
    async def test_default_default_headers():
        config = Config(app=app, loop="asyncio", limit_max_requests=1)
        async with run_server(config):
>           async with httpx.AsyncClient() as client:

tests/test_default_headers.py:18: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
venv/lib/python3.10/site-packages/httpx/_client.py:1242: in __init__
    self._transport = self._init_transport(
venv/lib/python3.10/site-packages/httpx/_client.py:1285: in _init_transport
    ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
venv/lib/python3.10/site-packages/httpx/_config.py:49: in create_ssl_context
    return SSLConfig(
venv/lib/python3.10/site-packages/httpx/_config.py:73: in __init__
    self.ssl_context = self.load_ssl_context()
venv/lib/python3.10/site-packages/httpx/_config.py:85: in load_ssl_context
    return self.load_ssl_context_verify()
venv/lib/python3.10/site-packages/httpx/_config.py:122: in load_ssl_context_verify
    context = self._create_default_ssl_context()
venv/lib/python3.10/site-packages/httpx/_config.py:156: in _create_default_ssl_context
    context = SSL.SSLContext(ssl.PROTOCOL_TLS)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

cls = <class 'ssl.SSLContext'>, protocol = ssl.PROTOCOL_TLS, args = (), kwargs = {}

    def __new__(cls, protocol=None, *args, **kwargs):
        if protocol is None:
            warnings.warn(
                "ssl module: "
                "SSLContext() without protocol argument is deprecated.",
                category=DeprecationWarning,
                stacklevel=2
            )
            protocol = PROTOCOL_TLS
>       self = _SSLContext.__new__(cls, protocol)
E       DeprecationWarning: ssl module: PROTOCOL_TLS is deprecated

/usr/lib/python3.10/ssl.py:501: DeprecationWarning
```


Discussion:
Could we start this off with a bit more context?

I can see[ in the 3.10 release notes, that `ssl.PROTOCOL_TLS` becomes deprecated](https://docs.python.org/3.10/library/ssl.html#ssl.PROTOCOL_TLS). Is there any further context explaining why, and what the preferred usage is instead, so that I (and anyone else) can understand this a bit more thoroughly?


@tiran 
Please create a ticket on BPO.
> Please create a ticket on BPO.

https://bugs.python.org/issue44362

Resolving patch:
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -897,32 +897,35 @@ def _send_handling_auth(
         history: typing.List[Response],
     ) -> Response:
         auth_flow = auth.sync_auth_flow(request)
-        request = next(auth_flow)
+        try:
+            request = next(auth_flow)
 
-        for hook in self._event_hooks["request"]:
-            hook(request)
+            for hook in self._event_hooks["request"]:
+                hook(request)
 
-        while True:
-            response = self._send_handling_redirects(
-                request,
-                timeout=timeout,
-                allow_redirects=allow_redirects,
-                history=history,
-            )
-            try:
+            while True:
+                response = self._send_handling_redirects(
+                    request,
+                    timeout=timeout,
+                    allow_redirects=allow_redirects,
+                    history=history,
+                )
                 try:
-                    next_request = auth_flow.send(response)
-                except StopIteration:
-                    return response
+                    try:
+                        next_request = auth_flow.send(response)
+                    except StopIteration:
+                        return response
 
-                response.history = list(history)
-                response.read()
-                request = next_request
-                history.append(response)
+                    response.history = list(history)
+                    response.read()
+                    request = next_request
+                    history.append(response)
 
-            except Exception as exc:
-                response.close()
-                raise exc
+                except Exception as exc:
+                    response.close()
+                    raise exc
+        finally:
+            auth_flow.close()
 
     def _send_handling_redirects(
         self,
@@ -1244,7 +1247,10 @@ def __exit__(
                 transport.__exit__(exc_type, exc_value, traceback)
 
     def __del__(self) -> None:
-        if self._state == ClientState.OPENED:
+        # We use 'getattr' here, to manage the case where '__del__()' is called
+        # on a partically initiallized instance that raised an exception during
+        # the call to '__init__()'.
+        if getattr(self, "_state", None) == ClientState.OPENED:  # noqa: B009
             self.close()
 
 
@@ -1588,32 +1594,35 @@ async def _send_handling_auth(
         history: typing.List[Response],
     ) -> Response:
         auth_flow = auth.async_auth_flow(request)
-        request = await auth_flow.__anext__()
+        try:
+            request = await auth_flow.__anext__()
 
-        for hook in self._event_hooks["request"]:
-            await hook(request)
+            for hook in self._event_hooks["request"]:
+                await hook(request)
 
-        while True:
-            response = await self._send_handling_redirects(
-                request,
-                timeout=timeout,
-                allow_redirects=allow_redirects,
-                history=history,
-            )
-            try:
+            while True:
+                response = await self._send_handling_redirects(
+                    request,
+                    timeout=timeout,
+                    allow_redirects=allow_redirects,
+                    history=history,
+                )
                 try:
-                    next_request = await auth_flow.asend(response)
-                except StopAsyncIteration:
-                    return response
+                    try:
+                        next_request = await auth_flow.asend(response)
+                    except StopAsyncIteration:
+                        return response
 
-                response.history = list(history)
-                await response.aread()
-                request = next_request
-                history.append(response)
+                    response.history = list(history)
+                    await response.aread()
+                    request = next_request
+                    history.append(response)
 
-            except Exception as exc:
-                await response.aclose()
-                raise exc
+                except Exception as exc:
+                    await response.aclose()
+                    raise exc
+        finally:
+            await auth_flow.aclose()
 
     async def _send_handling_redirects(
         self,
@@ -1942,7 +1951,10 @@ async def __aexit__(
                 await proxy.__aexit__(exc_type, exc_value, traceback)
 
     def __del__(self) -> None:
-        if self._state == ClientState.OPENED:
+        # We use 'getattr' here, to manage the case where '__del__()' is called
+        # on a partically initiallized instance that raised an exception during
+        # the call to '__init__()'.
+        if getattr(self, "_state", None) == ClientState.OPENED:  # noqa: B009
             # Unlike the sync case, we cannot silently close the client when
             # it is garbage collected, because `.aclose()` is an async operation,
             # but `__del__` is not.
diff --git a/httpx/_compat.py b/httpx/_compat.py
--- a/httpx/_compat.py
+++ b/httpx/_compat.py
@@ -1,6 +1,25 @@
+"""
+The _compat module is used for code which requires branching between different
+Python environments. It is excluded from the code coverage checks.
+"""
+import ssl
+import sys
+
 # `contextlib.asynccontextmanager` exists from Python 3.7 onwards.
 # For 3.6 we require the `async_generator` package for a backported version.
 try:
     from contextlib import asynccontextmanager  # type: ignore
-except ImportError:  # pragma: no cover
+except ImportError:
     from async_generator import asynccontextmanager  # type: ignore # noqa
+
+
+def set_minimum_tls_version_1_2(context: ssl.SSLContext) -> None:
+    if sys.version_info >= (3, 10):
+        context.minimum_version = ssl.TLSVersion.TLSv1_2
+    else:
+        # These become deprecated in favor of 'context.minimum_version'
+        # from Python 3.10 onwards.
+        context.options |= ssl.OP_NO_SSLv2
+        context.options |= ssl.OP_NO_SSLv3
+        context.options |= ssl.OP_NO_TLSv1
+        context.options |= ssl.OP_NO_TLSv1_1
diff --git a/httpx/_config.py b/httpx/_config.py
--- a/httpx/_config.py
+++ b/httpx/_config.py
@@ -6,6 +6,7 @@
 
 import certifi
 
+from ._compat import set_minimum_tls_version_1_2
 from ._models import URL, Headers
 from ._types import CertTypes, HeaderTypes, TimeoutTypes, URLTypes, VerifyTypes
 from ._utils import get_ca_bundle_from_env, get_logger
@@ -90,8 +91,8 @@ def load_ssl_context_no_verify(self) -> ssl.SSLContext:
         Return an SSL context for unverified connections.
         """
         context = self._create_default_ssl_context()
-        context.verify_mode = ssl.CERT_NONE
         context.check_hostname = False
+        context.verify_mode = ssl.CERT_NONE
         self._load_client_certs(context)
         return context
 
@@ -153,11 +154,8 @@ def _create_default_ssl_context(self) -> ssl.SSLContext:
         Creates the default SSLContext object that's used for both verified
         and unverified connections.
         """
-        context = ssl.SSLContext(ssl.PROTOCOL_TLS)
-        context.options |= ssl.OP_NO_SSLv2
-        context.options |= ssl.OP_NO_SSLv3
-        context.options |= ssl.OP_NO_TLSv1
-        context.options |= ssl.OP_NO_TLSv1_1
+        context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+        set_minimum_tls_version_1_2(context)
         context.options |= ssl.OP_NO_COMPRESSION
         context.set_ciphers(DEFAULT_CIPHERS)
 


Test patch:
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -107,34 +107,35 @@ def test_multipart_encode(tmp_path: typing.Any) -> None:
         "e": True,
         "f": "",
     }
-    files = {"file": ("name.txt", open(path, "rb"))}
-
-    with mock.patch("os.urandom", return_value=os.urandom(16)):
-        boundary = os.urandom(16).hex()
-
-        headers, stream = encode_request(data=data, files=files)
-        assert isinstance(stream, typing.Iterable)
-
-        content = (
-            '--{0}\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="b"\r\n\r\nC\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="c"\r\n\r\n11\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="c"\r\n\r\n22\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="c"\r\n\r\n33\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="d"\r\n\r\n\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="e"\r\n\r\ntrue\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="f"\r\n\r\n\r\n'
-            '--{0}\r\nContent-Disposition: form-data; name="file";'
-            ' filename="name.txt"\r\n'
-            "Content-Type: text/plain\r\n\r\n<file content>\r\n"
-            "--{0}--\r\n"
-            "".format(boundary).encode("ascii")
-        )
-        assert headers == {
-            "Content-Type": f"multipart/form-data; boundary={boundary}",
-            "Content-Length": str(len(content)),
-        }
-        assert content == b"".join(stream)
+    with open(path, "rb") as input_file:
+        files = {"file": ("name.txt", input_file)}
+
+        with mock.patch("os.urandom", return_value=os.urandom(16)):
+            boundary = os.urandom(16).hex()
+
+            headers, stream = encode_request(data=data, files=files)
+            assert isinstance(stream, typing.Iterable)
+
+            content = (
+                '--{0}\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="b"\r\n\r\nC\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="c"\r\n\r\n11\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="c"\r\n\r\n22\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="c"\r\n\r\n33\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="d"\r\n\r\n\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="e"\r\n\r\ntrue\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="f"\r\n\r\n\r\n'
+                '--{0}\r\nContent-Disposition: form-data; name="file";'
+                ' filename="name.txt"\r\n'
+                "Content-Type: text/plain\r\n\r\n<file content>\r\n"
+                "--{0}--\r\n"
+                "".format(boundary).encode("ascii")
+            )
+            assert headers == {
+                "Content-Type": f"multipart/form-data; boundary={boundary}",
+                "Content-Length": str(len(content)),
+            }
+            assert content == b"".join(stream)
 
 
 def test_multipart_encode_unicode_file_contents() -> None: