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

One event class and Event Broker config extension #60

Merged
merged 4 commits into from Aug 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Expand Up @@ -264,3 +264,11 @@ Released 2022-08-01

- Adds missing exception Too Early - 425

### Version 0.5.12
Released 2022-08-05

- Use the same class representing Event (both sent/received)
- Extends Event Broker with additional, optional parameters
- Adds additional verbose test running to makefile
- Updates dev dependencies

4 changes: 4 additions & 0 deletions Makefile
Expand Up @@ -88,6 +88,10 @@ test-unit tu:
.PHONY: test
test: test-unit

.PHONY: test-unit-verbose tuv
test-unit-verbose tuv:
coverage run --include "lbz/*" -m pytest "tests" -vv
coverage report -m --skip-covered

###############################################################################
# Custom Scripts
Expand Down
47 changes: 10 additions & 37 deletions lbz/events/api.py
@@ -1,10 +1,10 @@
import json
from copy import deepcopy
from functools import wraps
from os import getenv
from typing import TYPE_CHECKING, Any, Callable, List

from lbz.aws_boto3 import client
from lbz.events.event import Event
from lbz.misc import Singleton, get_logger

if TYPE_CHECKING:
Expand All @@ -18,30 +18,13 @@
MAX_EVENTS_TO_SEND_AT_ONCE = 10


class BaseEvent:
type: str

def __init__(self, raw_data: dict) -> None:
self.raw_data = raw_data
self.data: str = self.serialize(raw_data)

def __eq__(self, other: object) -> bool:
if isinstance(other, BaseEvent):
return self.type == other.type and self.raw_data == other.raw_data
return False

@staticmethod
def serialize(raw_data: dict) -> str:
return json.dumps(raw_data, default=str)


class EventAPI(metaclass=Singleton):
def __init__(self) -> None:
self._source = getenv("AWS_LAMBDA_FUNCTION_NAME") or "lbz-event-api"
self._resources: List[str] = []
self._pending_events: List[BaseEvent] = []
self._sent_events: List[BaseEvent] = []
self._failed_events: List[BaseEvent] = []
self._pending_events: List[Event] = []
self._sent_events: List[Event] = []
self._failed_events: List[Event] = []
self._bus_name = getenv("EVENTS_BUS_NAME", f"{self._source}-event-bus")

def __repr__(self) -> str:
Expand All @@ -60,30 +43,20 @@ def set_bus_name(self, bus_name: str) -> None:
self._bus_name = bus_name

@property
def sent_events(self) -> List[BaseEvent]:
def sent_events(self) -> List[Event]:
return deepcopy(self._sent_events)

@property
def pending_events(self) -> List[BaseEvent]:
def pending_events(self) -> List[Event]:
return deepcopy(self._pending_events)

@property
def failed_events(self) -> List[BaseEvent]:
def failed_events(self) -> List[Event]:
return deepcopy(self._failed_events)

def register(self, new_event: BaseEvent) -> None:
def register(self, new_event: Event) -> None:
self._pending_events.append(new_event)

# TODO: Stop sharing protected lists outside the class, use the above properties instead
def get_all_pending_events(self) -> List[BaseEvent]:
return self._pending_events

def get_all_sent_events(self) -> List[BaseEvent]:
return self._sent_events

def get_all_failed_events(self) -> List[BaseEvent]:
return self._failed_events

def send(self) -> None:
self._sent_events = []
self._failed_events = []
Expand All @@ -108,9 +81,9 @@ def clear(self) -> None:
self._pending_events = []
self._failed_events = []

def _create_eb_entry(self, new_event: BaseEvent) -> PutEventsRequestEntryTypeDef:
def _create_eb_entry(self, new_event: Event) -> PutEventsRequestEntryTypeDef:
return {
"Detail": new_event.data,
"Detail": new_event.serialized_data,
"DetailType": new_event.type,
"EventBusName": self._bus_name,
"Resources": self._resources,
Expand Down
19 changes: 10 additions & 9 deletions lbz/events/broker.py
@@ -1,21 +1,22 @@
from dataclasses import dataclass
from typing import Callable, Dict, List

from lbz.events.event import Event
from lbz.misc import get_logger

logger = get_logger(__name__)


@dataclass()
class Event:
type: str
data: dict


class EventBroker:
def __init__(self, mapper: Dict[str, List[Callable]], raw_event: dict) -> None:
def __init__(
self,
mapper: Dict[str, List[Callable]],
raw_event: dict,
*,
type_key: str = "detail-type",
redlickigrzegorz marked this conversation as resolved.
Show resolved Hide resolved
data_key: str = "detail",
) -> None:
self.mapper = mapper
self.event = Event(type=raw_event["detail-type"], data=raw_event["detail"])
self.event = Event(raw_event[data_key], event_type=raw_event[type_key])

def handle(self) -> None:
self.pre_handle()
Expand Down
26 changes: 26 additions & 0 deletions lbz/events/event.py
@@ -0,0 +1,26 @@
import json
from typing import Optional


class Event:
type: str

def __init__(self, data: dict, *, event_type: Optional[str] = None) -> None:
self.data = data
self.type = event_type or self.type

def __eq__(self, other: object) -> bool:
if isinstance(other, Event):
return self.type == other.type and self.data == other.data
return False

def __repr__(self) -> str:
return f"Event(type='{self.type}', data={self.data})"
Copy link
Collaborator

Choose a reason for hiding this comment

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

In most events, self.data won't be small - I don't know if putting it into the representation makes sense 😉

Copy link
Owner Author

Choose a reason for hiding this comment

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

It was there out of the box in the original data class.

Copy link
Collaborator

Choose a reason for hiding this comment

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

But that does not mean it was good, dataclass puts there everything by default, I think.

If we need to present data, still, we can do this but I would like to keep representation clearer 😉


@staticmethod
def serialize(data: dict) -> str:
return json.dumps(data, default=str)

@property
def serialized_data(self) -> str:
return self.serialize(self.data)
8 changes: 5 additions & 3 deletions requirements-dev.txt
Expand Up @@ -12,7 +12,7 @@ black==22.6.0
# via -r requirements-dev.in
boto3-stubs[cognito-idp,dynamodb,events,lambda,s3,sns,sqs,ssm]==1.21.46
# via -r requirements-dev.in
botocore-stubs==1.27.42.post2
botocore-stubs==1.27.45
# via boto3-stubs
build==0.8.0
# via pip-tools
Expand All @@ -26,7 +26,7 @@ coverage[toml]==6.4.2
# pytest-cov
dill==0.3.5.1
# via pylint
flake8==5.0.0
flake8==5.0.4
# via -r requirements-dev.in
iniconfig==1.1.1
# via pytest
Expand Down Expand Up @@ -80,7 +80,7 @@ pluggy==1.0.0
# via pytest
py==1.11.0
# via pytest
pycodestyle==2.9.0
pycodestyle==2.9.1
# via flake8
pyflakes==2.5.0
# via flake8
Expand All @@ -104,6 +104,8 @@ tomli==2.0.1
# pytest
tomlkit==0.11.1
# via pylint
types-awscrt==0.13.14
# via botocore-stubs
typing-extensions==4.3.0
# via
# astroid
Expand Down
2 changes: 0 additions & 2 deletions setup.cfg
Expand Up @@ -24,8 +24,6 @@ use_parentheses = True
[mypy]
# https://mypy.readthedocs.io/en/latest/running_mypy.html#mapping-file-paths-to-modules
explicit_package_bases = True
mypy_path =
$MYPY_CONFIG_FILE_DIR,
namespace_packages = True

# https://mypy.readthedocs.io/en/latest/config_file.html
Expand Down
70 changes: 20 additions & 50 deletions tests/test_events/test_api.py
Expand Up @@ -6,44 +6,14 @@
from pytest import LogCaptureFixture

from lbz.aws_boto3 import Boto3Client
from lbz.events.api import BaseEvent, EventAPI, event_emitter
from lbz.events.api import EventAPI, event_emitter
from lbz.events.event import Event


class MyTestEvent(BaseEvent):
class MyTestEvent(Event):
type = "MY_TEST_EVENT"


class TestBaseEvent:
def test_base_event_creation_and_structure(self) -> None:
event = {"x": 1}
new_event = MyTestEvent(event)

assert new_event.type == "MY_TEST_EVENT"
assert new_event.raw_data == {"x": 1}
assert new_event.data == '{"x": 1}'

def test__eq__same(self) -> None:
new_event_1 = MyTestEvent({"x": 1})
new_event_2 = MyTestEvent({"x": 1})

assert new_event_1 == new_event_2

def test__eq__different_data(self) -> None:
new_event_1 = MyTestEvent({"x": 1})
new_event_2 = MyTestEvent({"x": 2})

assert new_event_1 != new_event_2

def test__eq__different_type_same_data(self) -> None:
class MySecondTestEvent(BaseEvent):
type = "MY_SECOND_TEST_EVENT"

new_event_1 = MyTestEvent({"x": 1})
new_event_2 = MySecondTestEvent({"x": 1})

assert new_event_1 != new_event_2


class TestEventAPI:
def setup_method(self) -> None:
# pylint: disable= attribute-defined-outside-init
Expand Down Expand Up @@ -109,15 +79,15 @@ def test__failed_events__disallows_changing_its_content_outside_api(self) -> Non
assert self.event_api.failed_events == []

def test_register_saves_event_in_right_place(self) -> None:
assert self.event_api.get_all_pending_events() == []
assert self.event_api.pending_events == []

event_1 = MyTestEvent({"x": 1})
event_2 = MyTestEvent({"x": 2})

self.event_api.register(event_1)
self.event_api.register(event_2)

assert self.event_api.get_all_pending_events() == [event_1, event_2]
assert self.event_api.pending_events == [event_1, event_2]

@patch.object(Boto3Client, "eventbridge")
def test_send(self, mock_send: MagicMock) -> None:
Expand All @@ -137,7 +107,7 @@ def test_send(self, mock_send: MagicMock) -> None:
}
]
)
assert self.event_api.get_all_sent_events() == [event]
assert self.event_api.sent_events == [event]

@patch.object(Boto3Client, "eventbridge")
def test__send__sends_events_in_chunks_respecting_limits(self, mock_send: MagicMock) -> None:
Expand Down Expand Up @@ -183,7 +153,7 @@ def test__send__always_tries_to_send_all_events_treating_each_chunk_individually

@patch.object(Boto3Client, "eventbridge")
def test_sent_fail_saves_events_in_right_place(self, mock_send: MagicMock) -> None:
assert self.event_api.get_all_failed_events() == []
assert self.event_api.failed_events == []

mock_send.put_events.side_effect = NotADirectoryError
event = MyTestEvent({"x": 1})
Expand All @@ -192,16 +162,16 @@ def test_sent_fail_saves_events_in_right_place(self, mock_send: MagicMock) -> No
with pytest.raises(RuntimeError):
self.event_api.send()

assert self.event_api.get_all_failed_events() == [event]
assert self.event_api.failed_events == [event]

@patch.object(Boto3Client, "eventbridge")
def test_send_no_events(self, mock_send: MagicMock) -> None:
self.event_api.send()

mock_send.put_events.assert_not_called()
assert self.event_api.get_all_failed_events() == []
assert self.event_api.get_all_sent_events() == []
assert self.event_api.get_all_pending_events() == []
assert self.event_api.failed_events == []
assert self.event_api.sent_events == []
assert self.event_api.pending_events == []

@patch.object(Boto3Client, "eventbridge")
def test_singleton_pattern_working_correctly_for_event_api(self, mock_send: MagicMock) -> None:
Expand Down Expand Up @@ -236,9 +206,9 @@ def test_second_send_clears_everything(self) -> None:
self.event_api.send()
self.event_api.send()

assert self.event_api.get_all_failed_events() == []
assert self.event_api.get_all_sent_events() == []
assert self.event_api.get_all_pending_events() == []
assert self.event_api.failed_events == []
assert self.event_api.sent_events == []
assert self.event_api.pending_events == []

@patch.object(Boto3Client, "eventbridge", MagicMock())
def test_clear(self) -> None:
Expand All @@ -249,9 +219,9 @@ def test_clear(self) -> None:

self.event_api.clear()

assert self.event_api.get_all_failed_events() == []
assert self.event_api.get_all_sent_events() == []
assert self.event_api.get_all_pending_events() == []
assert self.event_api.failed_events == []
assert self.event_api.sent_events == []
assert self.event_api.pending_events == []


@patch.object(Boto3Client, "eventbridge", MagicMock())
Expand All @@ -270,18 +240,18 @@ def decorated_function() -> None:
def test_sends_all_pending_events_when_decorated_function_finished_with_success(self) -> None:
@event_emitter
def decorated_function() -> None:
EventAPI().register(MyTestEvent(raw_data={"x": 1}))
EventAPI().register(MyTestEvent({"x": 1}))

decorated_function()

assert EventAPI().sent_events == [MyTestEvent(raw_data={"x": 1})]
assert EventAPI().sent_events == [MyTestEvent({"x": 1})]
assert not EventAPI().pending_events
assert not EventAPI().failed_events

def test_clears_queues_when_error_appeared_during_running_decorated_function(self) -> None:
@event_emitter
def decorated_function() -> None:
EventAPI().register(MyTestEvent(raw_data={"x": 1}))
EventAPI().register(MyTestEvent({"x": 1}))
raise RuntimeError

with pytest.raises(RuntimeError):
Expand Down