-
Notifications
You must be signed in to change notification settings - Fork 15
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
Convert the evaluation from Redis pubsub to Redis Streams #677
Changes from all commits
95609c2
c8ea07a
7653041
4b57229
903bf73
fb76c69
6a5c447
12cc42f
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 |
---|---|---|
|
@@ -2,6 +2,7 @@ | |
Defines a websocket consumer that does nothing other than pass messages directly to and from another websocket | ||
to another service | ||
""" | ||
import json | ||
import typing | ||
import pathlib | ||
import asyncio | ||
|
@@ -21,15 +22,38 @@ | |
LOGGER = logging.ConfiguredLogger() | ||
|
||
|
||
ASGIView = typing.Callable[ | ||
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. Why not use a protocol instead? class ASGIView(typing.Protocol):
def __call__(
self,
scope: typing.Mapping,
receive: typing.Callable[[bool | None, float | None], typing.Mapping],
send: typing.Callable[[typing.Mapping], None],
) -> typing.Coroutine[typing.Any, typing.Any, None]: ... 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. What's the benefit of using a protocol in this instance? The purpose is to describe a hint for an expected signature for type hinting, not a promise for an object structure. Would changing its name to |
||
[ | ||
typing.Mapping, | ||
typing.Callable[[typing.Optional[bool], typing.Optional[float]], typing.Mapping], | ||
typing.Callable[[typing.Mapping], None] | ||
], typing.Coroutine[typing.Any, typing.Any, None] | ||
] | ||
""" | ||
The signature for a function that yields an ASGI View | ||
|
||
Args: | ||
scope: HTTP Scope information | ||
receive: A function to retrieve data from a queue | ||
send: A function to send data through the socket | ||
""" | ||
|
||
|
||
class ForwardingSocket(SocketConsumer): | ||
""" | ||
A WebSocket Consumer that simply passes messages to and from another connection | ||
""" | ||
@classmethod | ||
def asgi_from_configuration( | ||
cls, | ||
configuration: ForwardingConfiguration | ||
) -> typing.Coroutine[typing.Any, typing.Any, None]: | ||
def asgi_from_configuration(cls, configuration: ForwardingConfiguration) -> ASGIView: | ||
""" | ||
Create an asgi view with parameters provided by a ForwardingConfiguration | ||
|
||
Args: | ||
configuration: A configuration dictating where to forward socket messages | ||
|
||
Returns: | ||
A view function that requests may route to | ||
""" | ||
interface = cls.as_asgi( | ||
target_host_name=configuration.name, | ||
target_host_url=configuration.url, | ||
|
@@ -101,10 +125,16 @@ def target_host_port(self) -> typing.Optional[typing.Union[str, int]]: | |
|
||
@property | ||
def uses_ssl(self) -> bool: | ||
""" | ||
Whether the websocket connection is using SSL | ||
""" | ||
return self.__use_ssl | ||
|
||
@property | ||
def certificate_path(self) -> typing.Optional[str]: | ||
""" | ||
The path to an SSL certificate to use if SSL is to be employed | ||
""" | ||
return self._certificate_path | ||
|
||
@property | ||
|
@@ -139,31 +169,50 @@ def target_connection_url(self) -> str: | |
|
||
@property | ||
def ssl_context(self) -> typing.Optional[ssl.SSLContext]: | ||
if not self.__use_ssl: | ||
return None | ||
|
||
if not self._ssl_context: | ||
""" | ||
Get the SSL context to use if SSL is to be employed when connecting to a remote websocket | ||
""" | ||
if self.__use_ssl and not self._ssl_context: | ||
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | ||
if not self._certificate_path: | ||
raise ValueError( | ||
f"An SSL certificate is required to connect to {self.__target_host_name} as configured, " | ||
f"but none was given." | ||
) | ||
elif not os.path.exists(self._certificate_path): | ||
|
||
if not os.path.exists(self._certificate_path): | ||
raise ValueError( | ||
f"The SSL Certificate needed to connect to {self.__target_host_name} was not " | ||
f"found at {self._certificate_path}" | ||
) | ||
elif os.path.isfile(self._certificate_path): | ||
|
||
if os.path.isfile(self._certificate_path): | ||
self._ssl_context.load_verify_locations(cafile=self._certificate_path) | ||
else: | ||
self._ssl_context.load_verify_locations(capath=self._certificate_path) | ||
|
||
return self._ssl_context | ||
|
||
async def _connect_to_target(self): | ||
""" | ||
Connect to a remote websocket | ||
""" | ||
self.__connection = await connect_to_socket(uri=self.target_connection_url, ssl=self.ssl_context) | ||
|
||
try: | ||
# The connection was created, but throw an error if it can't be connected through | ||
await self.__connection.ping() | ||
except BaseException as ping_exception: | ||
message = "Connection to remote server could not be established" | ||
error_message = { | ||
"event": "error", | ||
"data": { | ||
"message": message | ||
} | ||
} | ||
await self.send(json.dumps(error_message)) | ||
raise Exception(message) from ping_exception | ||
|
||
if self.__listen_task is None: | ||
self.__listen_task = asyncio.create_task(self.listen(), name=f"ListenTo{self.__target_host_name}") | ||
|
||
|
@@ -174,10 +223,22 @@ async def connect(self): | |
await super().accept() | ||
await self._connect_to_target() | ||
|
||
async def get_connection(self) -> WebSocketClientProtocol: | ||
""" | ||
Get a connection to the remote websocket | ||
|
||
Returns: | ||
A socket connection that facilitates sending and receiving messages | ||
""" | ||
if self.__connection is None or self.__connection.closed: | ||
await self._connect_to_target() | ||
return self.__connection | ||
|
||
async def disconnect(self, code): | ||
""" | ||
Handler for when a client disconnects | ||
""" | ||
LOGGER.debug(f"Received signal for {self} to disconnect with code {code}") | ||
# Attempt to cancel the task. This is mostly a safety measure. Cancelling here is preferred but a | ||
# failure isn't the end of the world | ||
if self.__listen_task is not None and not self.__listen_task.done(): | ||
|
@@ -233,14 +294,13 @@ async def listen(self): | |
""" | ||
Listen for messages from the target connection and send them through the caller connection | ||
""" | ||
if self.__connection is None: | ||
await self._connect_to_target() | ||
|
||
async for message in self.__connection: | ||
if isinstance(message, bytes): | ||
await self.send(bytes_data=message) | ||
else: | ||
await self.send(message) | ||
while True: | ||
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. Since this in unbounded, we should add the return type hint of 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. Not a bad idea. Might also be handy to detail how this is being managed and why to make it clear that this is a dependent operation that will be interrupted and won't necessarily create an infinite blocking loop. |
||
connection = await self.get_connection() | ||
async for message in connection: | ||
if isinstance(message, bytes): | ||
await self.send(bytes_data=message) | ||
else: | ||
await self.send(message) | ||
|
||
async def receive(self, text_data: str = None, bytes_data: bytes = None, **kwargs): | ||
""" | ||
|
@@ -253,10 +313,11 @@ async def receive(self, text_data: str = None, bytes_data: bytes = None, **kwarg | |
bytes_data: Bytes data sent over the socket | ||
**kwargs: | ||
""" | ||
connection = await self.get_connection() | ||
if bytes_data and not text_data: | ||
await self.__connection.send(bytes_data) | ||
await connection.send(bytes_data) | ||
elif text_data: | ||
await self.__connection.send(text_data) | ||
await connection.send(text_data) | ||
else: | ||
LOGGER.warn("A message was received from the client but not text or bytes data was received.") | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
__version__ = '0.19.0' | ||
__version__ = '0.20.0' |
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.
Was this accidentally committed?
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.
No, it's a fix for annoying behavior I encountered while working. You can submit an evaluation, but the button becomes permanently disabled. I removed the behavior. It will allow you to rapid fire identical evaluations (not ideal), but if something fails and you only need to change a part of the config, you no longer need to refresh the page.