-
-
Notifications
You must be signed in to change notification settings - Fork 856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use Python 3.8 asyncio.Stream where possible #369
Changes from all commits
a4413cb
1d2e525
d629af9
b00de57
1844118
bf0a325
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from .backend import AsyncioBackend, BackgroundManager, PoolSemaphore, TCPStream | ||
|
||
__all__ = ["AsyncioBackend", "BackgroundManager", "PoolSemaphore", "TCPStream"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
import asyncio | ||
import ssl | ||
import sys | ||
import typing | ||
|
||
if sys.version_info >= (3, 8): | ||
from typing import Protocol | ||
else: | ||
from typing_extensions import Protocol | ||
|
||
|
||
class Stream(Protocol): # pragma: no cover | ||
"""Protocol defining just the methods we use from asyncio.Stream.""" | ||
|
||
def at_eof(self) -> bool: | ||
... | ||
|
||
def close(self) -> typing.Awaitable[None]: | ||
... | ||
|
||
async def drain(self) -> None: | ||
... | ||
|
||
def get_extra_info(self, name: str, default: typing.Any = None) -> typing.Any: | ||
... | ||
|
||
async def read(self, n: int = -1) -> bytes: | ||
... | ||
|
||
async def start_tls( | ||
self, | ||
sslContext: ssl.SSLContext, | ||
*, | ||
server_hostname: typing.Optional[str] = None, | ||
ssl_handshake_timeout: typing.Optional[float] = None, | ||
) -> None: | ||
... | ||
|
||
def write(self, data: bytes) -> typing.Awaitable[None]: | ||
... | ||
|
||
|
||
async def connect_compat(*args: typing.Any, **kwargs: typing.Any) -> Stream: | ||
if sys.version_info >= (3, 8): | ||
return await asyncio.connect(*args, **kwargs) | ||
else: | ||
reader, writer = await asyncio.open_connection(*args, **kwargs) | ||
return StreamCompat(reader, writer) | ||
|
||
|
||
class StreamCompat: | ||
""" | ||
Thin wrapper around asyncio.StreamReader/StreamWriter to make them look and | ||
behave similarly to an asyncio.Stream. | ||
""" | ||
|
||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): | ||
self.reader = reader | ||
self.writer = writer | ||
|
||
def at_eof(self) -> bool: | ||
return self.reader.at_eof() | ||
|
||
def close(self) -> typing.Awaitable[None]: | ||
self.writer.close() | ||
return _OptionalAwait(self.wait_closed) | ||
|
||
async def drain(self) -> None: | ||
await self.writer.drain() | ||
|
||
def get_extra_info(self, name: str, default: typing.Any = None) -> typing.Any: | ||
return self.writer.get_extra_info(name, default) | ||
|
||
async def read(self, n: int = -1) -> bytes: | ||
return await self.reader.read(n) | ||
|
||
async def start_tls( | ||
self, | ||
sslContext: ssl.SSLContext, | ||
*, | ||
server_hostname: typing.Optional[str] = None, | ||
ssl_handshake_timeout: typing.Optional[float] = None, | ||
) -> None: | ||
if not sys.version_info >= (3, 7): # pragma: no cover | ||
raise NotImplementedError( | ||
"asyncio.AbstractEventLoop.start_tls() is only available in Python 3.7+" | ||
) | ||
else: | ||
# This code is in an else branch to appease mypy on Python < 3.7 | ||
|
||
reader = asyncio.StreamReader() | ||
protocol = asyncio.StreamReaderProtocol(reader) | ||
transport = self.writer.transport | ||
|
||
loop = asyncio.get_event_loop() | ||
loop_start_tls = loop.start_tls # type: ignore | ||
tls_transport = await loop_start_tls( | ||
transport=transport, | ||
protocol=protocol, | ||
sslcontext=sslContext, | ||
server_hostname=server_hostname, | ||
ssl_handshake_timeout=ssl_handshake_timeout, | ||
) | ||
|
||
reader.set_transport(tls_transport) | ||
self.reader = reader | ||
self.writer = asyncio.StreamWriter( | ||
transport=tls_transport, protocol=protocol, reader=reader, loop=loop | ||
) | ||
|
||
def write(self, data: bytes) -> typing.Awaitable[None]: | ||
self.writer.write(data) | ||
return _OptionalAwait(self.drain) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apparently drain() was deprecated in 3.8 too, and it is now recommended to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was hoping this could tie into #341 quite nicely maybe? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, actually it does. :) |
||
|
||
async def wait_closed(self) -> None: | ||
if sys.version_info >= (3, 7): | ||
await self.writer.wait_closed() | ||
# else not much we can do to wait for the connection to close | ||
|
||
|
||
# This code is copied from cPython 3.8 but with type annotations added: | ||
# https://github.com/python/cpython/blob/v3.8.0b4/Lib/asyncio/streams.py#L1262-L1273 | ||
_T = typing.TypeVar("_T") | ||
|
||
|
||
class _OptionalAwait(typing.Generic[_T]): | ||
# The class doesn't create a coroutine | ||
# if not awaited | ||
# It prevents "coroutine is never awaited" message | ||
|
||
__slots___ = ("_method",) | ||
|
||
def __init__(self, method: typing.Callable[[], typing.Awaitable[_T]]): | ||
self._method = method | ||
|
||
def __await__(self) -> typing.Generator[typing.Any, None, _T]: | ||
return self._method().__await__() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the
await
, thetest_start_tls_on_socket_stream
test fails transiently with the error mentioned in this thread in about 1 out of 5-30 test runs (thanks,pytest-repeat
) for me on Python 3.7.2, 3.7.4, and 3.8.0b4.There is a possible fix (a custom exception handler set on the event loop) in the thread, but it's fairly complicated and I'm not sure how to integrate it nicely. This is no worse than we had so I think it can be fixed in a separate PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow, that thread doesn't look good. 😨
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it OK to not await this
.close()
call, though (since we did await it before)? Will it close itself in the background in pure asyncio magic?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@florimondmanca
afaics, we don't/never have await-ed on the
close
/wait_closed
?We should probably
await
the close, though. I've looked at this a bit more and read the aiohttp thread a bit more thoroughly and...await
-ing onStreamWriter.wait_closed()
(under the hood, Python 3.8 (for now) callsStream.wait_closed()
when you awaitStream.close()
)wait_closed
so this doesn't apply.The best I've come up with is something like:
Thoughts?
I'm not sure exactly what state the socket is in when that error is raised 😕. Once we try to close and get that error there doesn't seem to be anything we can do without just getting that error again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, all these details are very helpful. :)
Since the 3.8 docs it’s only possible to await on .close() (which I find kind of weird, having in mind all those « coroutine was never awaited » exceptions that usually causes), I’m okay with keeping it as it is currently. As you said, we actually never waited for the socket to close before (and if we did, we’d have probably only encountered this issue earlier?).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One (not very good) reason to include this
try
/except
is that without it I don't think this change has 100% test coverage sincewait_closed
is never called.