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())