Repository: encode/httpx

Issue:
HTTPX claims Python 3.6+ support when asyncio backend TCPStream.start_tls explicitly does not support Python 3.6
As stated, HTTPX explicitly does not support Python 3.6 [here](https://github.com/encode/httpx/blob/master/httpx/concurrency/asyncio.py#L61) but declares Python 3.6 support [here](https://github.com/encode/httpx/blob/master/setup.py#L40). This has caused problems with users of a package of mine that is supposed to support Python 3.6.

Discussion:
tomchristie: Fantastic stuff!

I guess this PR should also update the [`test_start_tls_on_socket_stream`](https://github.com/encode/httpx/blob/1a32cf036a825f6eb35395af5388a3b23180a82e/tests/test_concurrency.py#L35) test case, right?
tomchristie: At a later date I may look at moving this out to a coverage-excluded `compat.py` module, so that we can tweak our tests to expect 100% coverage on any single run, without requiring collection across multiple Python versions. (Buit I'm very happy to treat that seperately)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cea654..34d47ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
+## 0.7.7 (November 15, 2019)
+
+### Fixed
+
+- Resolve an issue with cookies behavior on redirect requests. (Pull #529)
+
+### Added
+
+- Add request/response DEBUG logs. (Pull #502)
+- Use TRACE log level for low level info. (Pull #500)
+
 ## 0.7.6 (November 2, 2019)
 
 ### Removed
diff --git a/httpx/__version__.py b/httpx/__version__.py
index 106c345..5783d84 100644
--- a/httpx/__version__.py
+++ b/httpx/__version__.py
@@ -1,3 +1,3 @@
 __title__ = "httpx"
 __description__ = "A next generation HTTP client, for Python 3."
-__version__ = "0.7.6"
+__version__ = "0.7.7"
diff --git a/httpx/concurrency/asyncio.py b/httpx/concurrency/asyncio.py
index 010d821..019876e 100644
--- a/httpx/concurrency/asyncio.py
+++ b/httpx/concurrency/asyncio.py
@@ -58,16 +58,49 @@ class SocketStream(BaseSocketStream):
         self, hostname: str, ssl_context: ssl.SSLContext, timeout: TimeoutConfig
     ) -> "SocketStream":
         loop = asyncio.get_event_loop()
-        if not hasattr(loop, "start_tls"):  # pragma: no cover
-            raise NotImplementedError(
-                "asyncio.AbstractEventLoop.start_tls() is only available in Python 3.7+"
-            )
 
         stream_reader = asyncio.StreamReader()
         protocol = asyncio.StreamReaderProtocol(stream_reader)
         transport = self.stream_writer.transport
 
-        loop_start_tls = loop.start_tls  # type: ignore
+        if hasattr(loop, "start_tls"):
+            loop_start_tls = loop.start_tls  # type: ignore
+        else:
+
+            async def loop_start_tls(
+                transport: asyncio.BaseTransport,
+                protocol: asyncio.BaseProtocol,
+                sslcontext: ssl.SSLContext = None,
+                *,
+                server_side: bool = False,
+                server_hostname: str = None,
+                ssl_handshake_timeout: float = None,
+            ) -> asyncio.Transport:
+                """Python 3.6 asyncio doesn't have a start_tls() method on the loop
+                so we use this function in place of the loop's start_tls() method.
+                Adapted from this comment:
+                https://github.com/urllib3/urllib3/issues/1323#issuecomment-362494839
+                """
+                import asyncio.sslproto
+
+                waiter = loop.create_future()
+                ssl_protocol = asyncio.sslproto.SSLProtocol(
+                    loop,
+                    protocol,
+                    sslcontext,
+                    waiter,
+                    server_side=False,
+                    server_hostname=server_hostname,
+                    call_connection_made=False,
+                )
+
+                transport.set_protocol(ssl_protocol)
+                loop.call_soon(ssl_protocol.connection_made, transport)
+                loop.call_soon(transport.resume_reading)  # type: ignore
+
+                await waiter
+                return ssl_protocol._app_transport
+
         transport = await asyncio.wait_for(
             loop_start_tls(
                 transport=transport,
diff --git a/httpx/middleware/redirect.py b/httpx/middleware/redirect.py
index 6f5c02e..e093bf4 100644
--- a/httpx/middleware/redirect.py
+++ b/httpx/middleware/redirect.py
@@ -51,7 +51,6 @@ class RedirectMiddleware(BaseMiddleware):
         headers = self.redirect_headers(request, url, method)  # TODO: merge headers?
         content = self.redirect_content(request, method)
         cookies = Cookies(self.cookies)
-        cookies.update(request.cookies)
         return AsyncRequest(
             method=method, url=url, headers=headers, data=content, cookies=cookies
         )
@@ -115,6 +114,11 @@ class RedirectMiddleware(BaseMiddleware):
             # are only relevant to the request body.
             headers.pop("Content-Length", None)
             headers.pop("Transfer-Encoding", None)
+
+        # We should use the client cookie store to determine any cookie header,
+        # rather than whatever was on the original outgoing request.
+        headers.pop("Cookie", None)
+
         return headers
 
     def redirect_content(self, request: AsyncRequest, method: str) -> bytes:
diff --git a/noxfile.py b/noxfile.py
index ef524eb..43494b5 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -24,14 +24,7 @@ def lint(session):
 @nox.session
 def check(session):
     session.install(
-        "--upgrade",
-        "black",
-        "flake8",
-        "flake8-bugbear",
-        "flake8-comprehensions",
-        "flake8-pie",
-        "isort",
-        "mypy",
+        "--upgrade", "black", "flake8", "flake8-bugbear", "flake8-pie", "isort", "mypy"
     )
 
     session.run("black", "--check", "--diff", "--target-version=py36", *source_files)


Test patch:
diff --git a/test-requirements.txt b/test-requirements.txt
index 7aa572c..96437e0 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -6,7 +6,6 @@ brotlipy==0.7.*
 cryptography
 flake8
 flake8-bugbear
-flake8-comprehensions
 flake8-pie
 isort
 mypy
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
index d732ce4..b014b0f 100644
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -290,3 +290,71 @@ async def test_cross_subdomain_redirect(backend):
     url = "https://example.com/cross_subdomain"
     response = await client.get(url)
     assert response.url == URL("https://www.example.org/cross_subdomain")
+
+
+class MockCookieDispatch(AsyncDispatcher):
+    async def send(
+        self,
+        request: AsyncRequest,
+        verify: VerifyTypes = None,
+        cert: CertTypes = None,
+        timeout: TimeoutTypes = None,
+    ) -> AsyncResponse:
+        if request.url.path == "/":
+            if "cookie" in request.headers:
+                content = b"Logged in"
+            else:
+                content = b"Not logged in"
+            return AsyncResponse(codes.OK, content=content, request=request)
+
+        elif request.url.path == "/login":
+            status_code = codes.SEE_OTHER
+            headers = {
+                "location": "/",
+                "set-cookie": (
+                    "session=eyJ1c2VybmFtZSI6ICJ0b21; path=/; Max-Age=1209600; "
+                    "httponly; samesite=lax"
+                ),
+            }
+
+            return AsyncResponse(status_code, headers=headers, request=request)
+
+        elif request.url.path == "/logout":
+            status_code = codes.SEE_OTHER
+            headers = {
+                "location": "/",
+                "set-cookie": (
+                    "session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; "
+                    "httponly; samesite=lax"
+                ),
+            }
+            return AsyncResponse(status_code, headers=headers, request=request)
+
+
+async def test_redirect_cookie_behavior(backend):
+    client = AsyncClient(dispatch=MockCookieDispatch(), backend=backend)
+
+    # The client is not logged in.
+    response = await client.get("https://example.com/")
+    assert response.url == "https://example.com/"
+    assert response.text == "Not logged in"
+
+    # Login redirects to the homepage, setting a session cookie.
+    response = await client.post("https://example.com/login")
+    assert response.url == "https://example.com/"
+    assert response.text == "Logged in"
+
+    # The client is logged in.
+    response = await client.get("https://example.com/")
+    assert response.url == "https://example.com/"
+    assert response.text == "Logged in"
+
+    # Logout redirects to the homepage, expiring the session cookie.
+    response = await client.post("https://example.com/logout")
+    assert response.url == "https://example.com/"
+    assert response.text == "Not logged in"
+
+    # The client is not logged in.
+    response = await client.get("https://example.com/")
+    assert response.url == "https://example.com/"
+    assert response.text == "Not logged in"
diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
index 8bb933b..7477ea3 100644
--- a/tests/test_concurrency.py
+++ b/tests/test_concurrency.py
@@ -1,5 +1,3 @@
-import sys
-
 import pytest
 import trio
 
@@ -31,9 +29,6 @@ async def test_start_tls_on_socket_stream(https_server, backend, get_cipher):
     See that the concurrency backend can make a connection without TLS then
     start TLS on an existing connection.
     """
-    if isinstance(backend, AsyncioBackend) and sys.version_info < (3, 7):
-        pytest.xfail(reason="Requires Python 3.7+ for AbstractEventLoop.start_tls()")
-
     ctx = SSLConfig().load_ssl_context_no_verify(HTTPVersionConfig())
     timeout = TimeoutConfig(5)