bourgade 0.0.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bourgade
|
|
3
|
+
Version: 0.0.0.dev0
|
|
4
|
+
Summary: A RabbitMQ-based event bus for Python.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Anatoly Frolov (anafro)
|
|
7
|
+
Author-email: anatolyfroloff@gmail.com
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: pika (>=1.3.2,<2.0.0)
|
|
14
|
+
Requires-Dist: reification (>=1.1.0,<2.0.0)
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Bourgade
|
|
18
|
+
|
|
19
|
+
**Bourgade** (fr. "village") is a simple event bus for RabbitMQ, that moves
|
|
20
|
+
focus from RabbitMQ abstractions to good ol' events.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
To install Bourgade, use:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install bourgade
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With Poetry, do:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
poetry add bourgade
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
**Bourgade** is licensed under MIT.
|
|
39
|
+
|
|
40
|
+
------------------
|
|
41
|
+
Copyright (c) 2026 Anatoly Frolov (anafro). All Rights Reserved.\
|
|
42
|
+
`contact@anafro.ru`
|
|
43
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Bourgade
|
|
2
|
+
|
|
3
|
+
**Bourgade** (fr. "village") is a simple event bus for RabbitMQ, that moves
|
|
4
|
+
focus from RabbitMQ abstractions to good ol' events.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
To install Bourgade, use:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install bourgade
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
With Poetry, do:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
poetry add bourgade
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## License
|
|
21
|
+
|
|
22
|
+
**Bourgade** is licensed under MIT.
|
|
23
|
+
|
|
24
|
+
------------------
|
|
25
|
+
Copyright (c) 2026 Anatoly Frolov (anafro). All Rights Reserved.\
|
|
26
|
+
`contact@anafro.ru`
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bourgade"
|
|
3
|
+
version = "0.0.0-dev"
|
|
4
|
+
description = "A RabbitMQ-based event bus for Python."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Anatoly Frolov (anafro)", email = "anatolyfroloff@gmail.com" },
|
|
7
|
+
]
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = ["pika (>=1.3.2,<2.0.0)", "reification (>=1.1.0,<2.0.0)"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
16
|
+
build-backend = "poetry.core.masonry.api"
|
|
17
|
+
|
|
18
|
+
[virtualenvs]
|
|
19
|
+
create = true
|
|
20
|
+
in-project = true
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import partial
|
|
4
|
+
import logging
|
|
5
|
+
from pika import BlockingConnection, ConnectionParameters, PlainCredentials
|
|
6
|
+
from pika.adapters.blocking_connection import BlockingChannel
|
|
7
|
+
from pika.exchange_type import ExchangeType
|
|
8
|
+
from pika.spec import Basic, BasicProperties
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from reification import Reified
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventHandler[E: Event = Event](ABC, Reified):
|
|
19
|
+
"""
|
|
20
|
+
A base of all classes handling events.
|
|
21
|
+
Specify event type in generic type (see Event class docs).
|
|
22
|
+
Write what your app should do on event in overriden `handle` method.
|
|
23
|
+
After creating a handler, add it to the event bus.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def get_event_type(cls) -> type[E]:
|
|
28
|
+
"""
|
|
29
|
+
Returns a type of the event this handler handles.
|
|
30
|
+
Used for automagical event creation keeping event hydration
|
|
31
|
+
outside Event class.
|
|
32
|
+
|
|
33
|
+
:returns: The exact event type.
|
|
34
|
+
"""
|
|
35
|
+
return cast(type[E], cls.targ)
|
|
36
|
+
|
|
37
|
+
def trigger(
|
|
38
|
+
self, event_bus: "EventBus", deliver: Basic.Deliver, message: bytes
|
|
39
|
+
) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Builds, and hydrates a new event object from RabbitMQ deliver,
|
|
42
|
+
and triggers `handle` within this handler.
|
|
43
|
+
|
|
44
|
+
:param EventBus event_bus: The event bus containing this handler
|
|
45
|
+
:param Basic.Deliver deliver: The RabbitMQ deliver object considered to be an event for the handler
|
|
46
|
+
:param bytes message: The RabbitMQ message bytes for event hydration
|
|
47
|
+
"""
|
|
48
|
+
event_class: type[E] = self.get_event_type()
|
|
49
|
+
event = event_class(event_bus, deliver=deliver, message=message)
|
|
50
|
+
event.hydrate()
|
|
51
|
+
self.handle(event=event)
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def handle(self, event: E) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Handles the event when triggered.
|
|
57
|
+
Override this method to define handling logic.
|
|
58
|
+
|
|
59
|
+
:param E event: The event to handle
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EventBus:
|
|
65
|
+
"""
|
|
66
|
+
Connects to RabbitMQ, declares its abstractions,
|
|
67
|
+
and manages RabbitMQ messages, passing them to handlers.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
event_handlers: dict[str, EventHandler["Event"]]
|
|
71
|
+
connection: BlockingConnection
|
|
72
|
+
channel: BlockingChannel
|
|
73
|
+
exchange_name: str
|
|
74
|
+
queue_name: str
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
host: str,
|
|
79
|
+
username: str,
|
|
80
|
+
password: str,
|
|
81
|
+
exchange_name: str,
|
|
82
|
+
queue_name: str,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Creates a new event bus.
|
|
86
|
+
|
|
87
|
+
:param str host: The RabbitMQ host
|
|
88
|
+
:param str username: The RabbitMQ username
|
|
89
|
+
:param str password: The RabbitMQ password
|
|
90
|
+
:param str exchange_name: The RabbitMQ exchange name containing events across the infrastructure
|
|
91
|
+
:param str host: The RabbitMQ queue name consuming events within the app
|
|
92
|
+
"""
|
|
93
|
+
connection_credentials: PlainCredentials = PlainCredentials(
|
|
94
|
+
username=username, password=password
|
|
95
|
+
)
|
|
96
|
+
connection_parameters: ConnectionParameters = ConnectionParameters(
|
|
97
|
+
host=host, port=5672, credentials=connection_credentials
|
|
98
|
+
)
|
|
99
|
+
self.exchange_name = exchange_name
|
|
100
|
+
self.queue_name = queue_name
|
|
101
|
+
self.connection = BlockingConnection(parameters=connection_parameters)
|
|
102
|
+
self.channel = self.connection.channel()
|
|
103
|
+
self.channel.basic_qos(prefetch_count=1)
|
|
104
|
+
self.channel.exchange_declare(
|
|
105
|
+
exchange=exchange_name,
|
|
106
|
+
exchange_type=ExchangeType.topic,
|
|
107
|
+
passive=False,
|
|
108
|
+
durable=True,
|
|
109
|
+
auto_delete=False,
|
|
110
|
+
)
|
|
111
|
+
self.channel.queue_declare(queue=queue_name, auto_delete=True)
|
|
112
|
+
self.event_handlers = {}
|
|
113
|
+
|
|
114
|
+
def register_handler[E: Event](self, event_handler: EventHandler[E]) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Registers a new handler, so when bus starts listening to messages,
|
|
117
|
+
it could be recognized by the bus, and got triggered by events.
|
|
118
|
+
|
|
119
|
+
:param EventHandler[E] event_handler: The event handler to register
|
|
120
|
+
"""
|
|
121
|
+
event_type: type[Event] = cast(type[Event], event_handler.targ)
|
|
122
|
+
self.event_handlers[event_type.get_event_name()] = cast(
|
|
123
|
+
EventHandler[Event], event_handler
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def start_listening(self) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Starts listening for RabbitMQ messages.
|
|
129
|
+
This method is blocking.
|
|
130
|
+
"""
|
|
131
|
+
self.channel.basic_consume(
|
|
132
|
+
queue=self.queue_name,
|
|
133
|
+
on_message_callback=partial(self.__consume, event_bus=self),
|
|
134
|
+
)
|
|
135
|
+
logger.info("Bourgade is listening for events...")
|
|
136
|
+
self.channel.start_consuming()
|
|
137
|
+
|
|
138
|
+
def dispatch(self, event: "Event") -> None:
|
|
139
|
+
"""
|
|
140
|
+
Dispatches an event to RabbitMQ exchange.
|
|
141
|
+
|
|
142
|
+
:param Event event: The event to dispatch
|
|
143
|
+
"""
|
|
144
|
+
self.channel.basic_publish(
|
|
145
|
+
exchange=self.exchange_name,
|
|
146
|
+
routing_key=event.get_event_name(),
|
|
147
|
+
body=event.serialize(),
|
|
148
|
+
mandatory=False,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def __consume(
|
|
153
|
+
event_bus: "EventBus",
|
|
154
|
+
event_handlers: dict[str, EventHandler["Event"]],
|
|
155
|
+
channel: BlockingChannel,
|
|
156
|
+
deliver: Basic.Deliver,
|
|
157
|
+
_: BasicProperties,
|
|
158
|
+
message: bytes,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Consumes a RabbitMQ message,
|
|
162
|
+
finds a handler for an event this message represends,
|
|
163
|
+
and triggers the handler to handle the event.
|
|
164
|
+
Used in RabbitMQ `basic_consume` method, and never outside.
|
|
165
|
+
|
|
166
|
+
:param EventBus event_bus: The event bus of the handler
|
|
167
|
+
:param dict[str, EventHandler["Event"]] event_handlers: The dictionary of handlers (`routing_key`: `handler`)
|
|
168
|
+
:param BlockingChannel channel: The RabbitMQ channel
|
|
169
|
+
:param Basic.Deliver deliver: The RabbitMQ deliver
|
|
170
|
+
:param BasicProperties _: The RabbitMQ properties, unused here
|
|
171
|
+
:param bytes message: The message body
|
|
172
|
+
"""
|
|
173
|
+
delivery_tag: int = cast(int, deliver.delivery_tag)
|
|
174
|
+
routing_key: str = cast(str, deliver.routing_key)
|
|
175
|
+
|
|
176
|
+
if routing_key not in event_handlers:
|
|
177
|
+
raise ValueError(f"There is no event handler for '{routing_key}'.")
|
|
178
|
+
|
|
179
|
+
event_handler: EventHandler[Event] = event_handlers[routing_key]
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
event_handler.trigger(event_bus=event_bus, deliver=deliver, message=message)
|
|
183
|
+
channel.basic_ack(delivery_tag=delivery_tag)
|
|
184
|
+
except Exception:
|
|
185
|
+
channel.basic_nack(delivery_tag=delivery_tag)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class Event(ABC):
|
|
190
|
+
"""
|
|
191
|
+
A base for all events.
|
|
192
|
+
Create a new class, override all abstract methods,
|
|
193
|
+
and see the abstract methods docs.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
event_bus: EventBus
|
|
197
|
+
deliver: Basic.Deliver
|
|
198
|
+
message: bytes
|
|
199
|
+
|
|
200
|
+
@abstractmethod
|
|
201
|
+
def hydrate(self) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Fills the event with data received from RabbitMQ.
|
|
204
|
+
Depending on the format you chose for the event,
|
|
205
|
+
use `string()`, `json()` methods, and map data from the message
|
|
206
|
+
into the class fields you defined.
|
|
207
|
+
|
|
208
|
+
E.g. you have an event `user.created` with user info, for example id, and name.
|
|
209
|
+
If you decided to use JSON as an event format, call `body = self.json()`,
|
|
210
|
+
and pass `self.id = int(body.id)`, `self.name = str(body.name)`.
|
|
211
|
+
So in handlers, you could access fields like `event.id`, or `event.name`.
|
|
212
|
+
"""
|
|
213
|
+
...
|
|
214
|
+
|
|
215
|
+
@abstractmethod
|
|
216
|
+
def serialize(self) -> bytes:
|
|
217
|
+
"""
|
|
218
|
+
Serializes the field back bytes.
|
|
219
|
+
This method is the opposide of `hydrate`.
|
|
220
|
+
|
|
221
|
+
:returns bytes: The serialized event as RabbitMQ message bytes
|
|
222
|
+
"""
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
@abstractmethod
|
|
227
|
+
def get_event_name() -> str:
|
|
228
|
+
"""
|
|
229
|
+
Returns an event name.
|
|
230
|
+
The event name is used as routing keys in RabbitMQ.
|
|
231
|
+
If your class is named `UserCreatedEvent`, return "user.created" from here.
|
|
232
|
+
|
|
233
|
+
:returns str: The event name
|
|
234
|
+
"""
|
|
235
|
+
...
|
|
236
|
+
|
|
237
|
+
def string(self) -> str:
|
|
238
|
+
"""
|
|
239
|
+
Decodes RabbitMQ message as a UTF-8 string.
|
|
240
|
+
|
|
241
|
+
:returns str: A message as a string
|
|
242
|
+
"""
|
|
243
|
+
return self.message.decode(encoding="utf-8")
|
|
244
|
+
|
|
245
|
+
def json(self) -> dict[str, Any]:
|
|
246
|
+
"""
|
|
247
|
+
Decodes RabbitMQ message as a JSON payload.
|
|
248
|
+
|
|
249
|
+
:returns dict[str, Any]: A message as a JSON payload
|
|
250
|
+
"""
|
|
251
|
+
return json.loads(self.string())
|