Skip to content
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

77 multiple commands from a single message #82

Merged
merged 23 commits into from
Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
79319e3
#77: Move async iterable wrapper function to utils
MattPrit Aug 9, 2022
7887f41
#77: Add SplittingInterpreter classes and tests
MattPrit Aug 11, 2022
b295f36
Merge branch 'master' into 77_multiple_commands_from_a_single_message
MattPrit Aug 11, 2022
2dce8c9
#77:Remove SplittingInterpreter Sync/Async subclasses
MattPrit Aug 12, 2022
48a3c40
#77: Tidy up SplittingInterpreter __init__ docstrings
MattPrit Aug 12, 2022
f3cd583
#77: Improve test coverage of splitting_interpreter.py
MattPrit Aug 12, 2022
0edb090
#77: Ignore D107 rather than D105 on overloaded SplittingInterpreter …
MattPrit Aug 12, 2022
e6bce1d
#77: Allow SplittingInterpreter to split using a regex delimiter
MattPrit Aug 12, 2022
c2e3964
#77: Add multi response test and rename response collection method
MattPrit Aug 12, 2022
1328d5f
Merge branch 'master' into 77_multiple_commands_from_a_single_message
MattPrit Aug 16, 2022
280cc57
#77: Add response delimiter and handle the no sub-message case; updat…
MattPrit Aug 17, 2022
9906aa4
#77: Fix SplittingInterpreter.handle docstring
MattPrit Aug 17, 2022
d89b586
#77: Small refactor of SplittingInterpreter
MattPrit Aug 22, 2022
74e0227
#77: SplittingInterpreter: Change individual_messages list comp. to u…
MattPrit Aug 30, 2022
8b919dc
#77: Remove tests for empty string removal when splitting
MattPrit Aug 30, 2022
b1ae0e6
#77: SplittingInterpreter: remove check for empty messsage list
MattPrit Aug 31, 2022
aa5400a
Merge branch '88_joining_interpreter' into 77_multiple_commands_from_…
MattPrit Sep 5, 2022
978ad89
#77: Remove joining logic from SplittingInterpreter
MattPrit Sep 5, 2022
ee61b9b
Add tests for interpreter utils
MattPrit Sep 5, 2022
a60c8be
Ignore F821 undefined name 'anext' in interpreter utils tests
MattPrit Sep 5, 2022
125c6f4
Fix interpreter utils tests for Python<3.10
MattPrit Sep 5, 2022
a0384bc
Merge branch 'master' into 77_multiple_commands_from_a_single_message
MattPrit Sep 7, 2022
2a0ad1e
#77: Small refactor of SplittingInterpreter and utils
MattPrit Sep 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions tests/adapters/interpreters/wrappers/test_splitting_interpreter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from typing import AnyStr, AsyncIterable, List, Tuple

import pytest
from mock import ANY, AsyncMock, call, patch

from tickit.adapters.interpreters.utils import wrap_as_async_iterable
from tickit.adapters.interpreters.wrappers import SplittingInterpreter
from tickit.core.adapter import Adapter


async def dummy_response(msg: AnyStr) -> Tuple[AsyncIterable[AnyStr], bool]:
return wrap_as_async_iterable(msg), True


class DummySplittingInterpreter(SplittingInterpreter):
"""SplittingInterpreter with dummy implementation of abstract method."""

async def _handle_individual_messages(
self, adapter: Adapter, individual_messages: List[AnyStr]
) -> List[Tuple[AsyncIterable[AnyStr], bool]]:
return [await dummy_response(msg) for msg in individual_messages]


async def _test_sub_messages(
splitting_interpreter: DummySplittingInterpreter,
mock_handle_individual_messages: AsyncMock,
mock_get_response: AsyncMock,
test_message: AnyStr,
expected_sub_messages: List[AnyStr],
):
mock_get_response.return_value = await dummy_response(test_message)
await splitting_interpreter.handle(AsyncMock(), test_message)
mock_handle_individual_messages.assert_called_once_with(ANY, expected_sub_messages)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_message, delimiter, expected_sub_messages",
[
("test message", " ", ["test", "message"]),
(b"foo/bar", b"/", [b"foo", b"bar"]),
("single message", "/", ["single message"]),
],
)
@patch.object(
DummySplittingInterpreter, "_get_response_and_interrupt_from_individual_results"
)
@patch.object(DummySplittingInterpreter, "_handle_individual_messages")
async def test_handle_passes_on_correct_sub_messages(
mock_handle_individual_messages: AsyncMock,
mock_get_response: AsyncMock,
test_message: AnyStr,
delimiter: AnyStr,
expected_sub_messages: List[AnyStr],
):
splitting_interpreter = DummySplittingInterpreter(AsyncMock(), delimiter)

await _test_sub_messages(
splitting_interpreter,
mock_handle_individual_messages,
mock_get_response,
test_message,
expected_sub_messages,
)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_message, expected_sub_messages",
[
(b"test message", [b"test", b"message"]),
(b"foo/bar", [b"foo/bar"]),
],
)
@patch.object(
DummySplittingInterpreter, "_get_response_and_interrupt_from_individual_results"
)
@patch.object(DummySplittingInterpreter, "_handle_individual_messages")
async def test_handle_passes_on_correct_sub_messages_with_default_delimiter(
mock_handle_individual_messages: AsyncMock,
mock_get_response: AsyncMock,
test_message: AnyStr,
expected_sub_messages: List[AnyStr],
):
splitting_interpreter = DummySplittingInterpreter(AsyncMock())

await _test_sub_messages(
splitting_interpreter,
mock_handle_individual_messages,
mock_get_response,
test_message,
expected_sub_messages,
)


@pytest.mark.asyncio
@pytest.mark.parametrize("sub_messages", [["one", "two", "three"], ["test", "message"]])
async def test_handle_individual_messages_makes_correct_handle_calls(sub_messages):
mock_interpreter = AsyncMock()
splitting_interpreter = SplittingInterpreter(mock_interpreter)
await splitting_interpreter._handle_individual_messages(AsyncMock(), sub_messages)
mock_handle_calls = mock_interpreter.handle.mock_calls
assert mock_handle_calls == [call(ANY, msg) for msg in sub_messages]


@pytest.mark.asyncio
@pytest.mark.parametrize(
[
"individual_messages",
"individual_interrupts",
"expected_combined_message",
"expected_combined_interrupt",
],
[
(["one", "two"], [False, False], "onetwo", False),
(["three", "four"], [False, True], "threefour", True),
(["five", "six"], [True, True], "fivesix", True),
],
)
@patch(
"tickit.adapters.interpreters.wrappers.splitting_interpreter.wrap_as_async_iterable"
)
async def test_individual_results_combined_correctly(
garryod marked this conversation as resolved.
Show resolved Hide resolved
mock_wrap_message: AsyncMock,
individual_messages: List[AnyStr],
individual_interrupts: List[bool],
expected_combined_message: AnyStr,
expected_combined_interrupt: bool,
):
splitting_interpreter = DummySplittingInterpreter(AsyncMock(), " ")
individual_results = [
(wrap_as_async_iterable(msg), interrupt)
for msg, interrupt in zip(individual_messages, individual_interrupts)
]
(
_,
combined_interrupt,
) = await splitting_interpreter._get_response_and_interrupt_from_individual_results(
individual_results
)
mock_wrap_message.assert_called_once_with(expected_combined_message)
assert combined_interrupt == expected_combined_interrupt
16 changes: 2 additions & 14 deletions tickit/adapters/interpreters/command/command_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from inspect import getmembers
from typing import AnyStr, AsyncIterable, Optional, Sequence, Tuple, get_type_hints

from tickit.adapters.interpreters.utils import wrap_as_async_iterable
from tickit.core.adapter import Adapter, Interpreter
from tickit.utils.compat.typing_compat import Protocol, runtime_checkable

Expand Down Expand Up @@ -39,19 +40,6 @@ class CommandInterpreter(Interpreter[AnyStr]):
called with the parsed arguments.
"""

@staticmethod
async def _wrap(reply: AnyStr) -> AsyncIterable[AnyStr]:
"""Wraps the reply in an asynchronous iterable.

Args:
response (AnyStr): A singular reply message.

Returns:
AsyncIterable[AnyStr]: An asynchronous iterable containing the reply
message.
"""
yield reply

@staticmethod
async def unknown_command() -> AsyncIterable[bytes]:
"""An asynchronous iterable of containing a single unknown command reply.
Expand Down Expand Up @@ -96,7 +84,7 @@ async def handle(
)
resp = await method(*args)
if not isinstance(resp, AsyncIterable):
resp = CommandInterpreter._wrap(resp)
resp = wrap_as_async_iterable(resp)
return resp, command.interrupt
resp = CommandInterpreter.unknown_command()
return resp, False
13 changes: 13 additions & 0 deletions tickit/adapters/interpreters/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import AnyStr, AsyncIterable


async def wrap_as_async_iterable(message: AnyStr) -> AsyncIterable[AnyStr]:
"""Wraps a message in an asynchronous iterable.

Args:
message (AnyStr): A singular message.

Returns:
AsyncIterable[AnyStr]: An asynchronous iterable containing the message.
"""
yield message
5 changes: 5 additions & 0 deletions tickit/adapters/interpreters/wrappers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from tickit.adapters.interpreters.wrappers.splitting_interpreter import (
SplittingInterpreter,
)

__all__ = ["SplittingInterpreter"]
110 changes: 110 additions & 0 deletions tickit/adapters/interpreters/wrappers/splitting_interpreter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import functools
from typing import AnyStr, AsyncIterable, List, Tuple, overload

from tickit.adapters.interpreters.utils import wrap_as_async_iterable
from tickit.core.adapter import Adapter, Interpreter


class SplittingInterpreter(Interpreter[AnyStr]):
"""An wrapper for an interpreter that splits a single message into multiple.

An interpreter wrapper class that takes a message, splits it according to a given
delimiter, and passes on the resulting sub-messages individually on to the
wrapped interpreter. The individual responses to the sub-messages are combined into
a single response.
"""

@overload
def __init__(self, interpreter: Interpreter[AnyStr]) -> None: # noqa: D107
pass

@overload
def __init__(
self, interpreter: Interpreter[AnyStr], delimiter: AnyStr
) -> None: # noqa: D107
pass

def __init__(self, interpreter: Interpreter[AnyStr], delimiter=b" ") -> None:
"""A decorator for an interpreter that splits a message into multiple sub-messages.

Args:
interpreter (Interpreter): The interpreter messages are passed on to.
delimiter (AnyStr): The delimiter by which the message is split up.

"""
super().__init__()
self.interpreter: Interpreter[AnyStr] = interpreter
self.delimiter: AnyStr = delimiter

async def _handle_individual_messages(
self, adapter: Adapter, individual_messages: List[AnyStr]
) -> List[Tuple[AsyncIterable[AnyStr], bool]]:
results = [
await self.interpreter.handle(adapter, message)
for message in individual_messages
]
return results

@staticmethod
async def _get_response_and_interrupt_from_individual_results(
Copy link
Member

@garryod garryod Aug 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_collect_responses might be a nice shortform alternative

results: List[Tuple[AsyncIterable[AnyStr], bool]]
) -> Tuple[AsyncIterable[AnyStr], bool]:
"""Combines results from handling multiple messages.

Takes the responses from when the wrapped interpreter handles multiple messages
and returns an appropriate composite repsonse and interrrupt. The composite
response is the concatentation of each of the individual responses, the
composite interrupt is a logical inclusive 'or' of all of the individual
responses.

Args:
results (List[Tuple[AsyncIterable[AnyStr], bool]]): a list of returned
values from the wrapped class' handle() method.

Returns:
Tuple[AsyncIterable[AnyStr], bool]:
A tuple of the asynchronous iterable of reply messages and a flag
indicating whether an interrupt should be raised by the adapter.
"""
individual_responses, individual_interrupts = zip(*results)

response_list = [
response
for response_gen in individual_responses
async for response in response_gen
]
response = functools.reduce(lambda a, b: a + b, response_list)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be nice to use something along the lines of response_deliminator.join(response_list) here, as it is more readable and allows us to define a response deliminator

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did consider this. Whilst it's conceivable that a response where the individual messages are separated in some specific format could be required, we can't really think of one; so perhaps just add this feature if/when it's needed? We also would have to ensure that both delimiters are passed in as the same type - how much complication could this add?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it would add any complication if the line were literally

response = "".join(response_list)

which, I agree with @garryod, is more readable. You could also have

response = sum(response_list)

I generally use functools.reduce when there aren't alternatives like this.

Copy link
Collaborator Author

@MattPrit MattPrit Aug 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think response = "".join(response_list) may introduce typing issues since responses are of type AnyStr and we would need b"".join(...) sometimes?

Also, I don't think sum works with str or bytes?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sum is literally functools.reduce(lambda a, b: a + b, collection), but I think that may be less readable in this case.
I think you're right about join too, so maybe leave as is

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Afaik sum cannot be used for string types, even with start="some_str". However, the fact that reduce(lambda a, b: a + b, response_list) is allowed by mypy is actually erroneous for response_list: list[Union[str, bytes]] as there is no implementation of __add__(self, other) for self: str & other: bytes or vice-versa, as such, we would need to type the input as Union[list[str], list[bytes]] or use a TypeVar to achieve the same (preferable), at which point ensuring both deliminators and the messages are the same type is fairly trivial.

Copy link
Member

@garryod garryod Aug 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@callumforrester not quite sure why it doesn't like strings, but here we are

>>> sum(["a", "b", "c"])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
>>> sum(["a", "b", "c"], start="")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sum() can't sum strings [use ''.join(seq) instead]

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad...

resp = wrap_as_async_iterable(response)

interrrupt = any(individual_interrupts)

return resp, interrrupt

async def handle(
self, adapter: Adapter, message: AnyStr
) -> Tuple[AsyncIterable[AnyStr], bool]:
"""Splits a message and passes the resulting sub-messages on to an interpreter.

Splits a given message and passes the resulting sub-messages on to an
interpreter. The responses to the individual sub-messages are then combined
into a single response and returned.

Args:
adapter (Adapter): The adapter in which the function should be executed
message: (AnyStr): The message to be split up and handled.

Returns:
Tuple[AsyncIterable[Union[str, bytes]], bool]:
A tuple of the asynchronous iterable of reply messages and a flag
indicating whether an interrupt should be raised by the adapter.
"""
individual_messages = message.split(self.delimiter)

results = await self._handle_individual_messages(adapter, individual_messages)

(
resp,
interrupt,
) = await self._get_response_and_interrupt_from_individual_results(results)

return resp, interrupt