messagex-client 0.1.0__py3-none-any.whl

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.
messagex/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Official Python (asyncio) client for the MessageX message broker."""
2
+
3
+ from .client import MessageX
4
+ from .errors import (
5
+ MessageXBrokerError,
6
+ MessageXConnectionError,
7
+ MessageXError,
8
+ MessageXTimeoutError,
9
+ )
10
+ from .types import (
11
+ ConsumerSubscription,
12
+ DeliveredMessage,
13
+ ExchangeType,
14
+ MessageHandler,
15
+ PublishResult,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "MessageX",
22
+ "MessageXError",
23
+ "MessageXConnectionError",
24
+ "MessageXTimeoutError",
25
+ "MessageXBrokerError",
26
+ "DeliveredMessage",
27
+ "ConsumerSubscription",
28
+ "PublishResult",
29
+ "ExchangeType",
30
+ "MessageHandler",
31
+ "__version__",
32
+ ]
messagex/client.py ADDED
@@ -0,0 +1,531 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import uuid
7
+ from typing import Any, Dict, Mapping, Optional, Set
8
+
9
+ from .errors import (
10
+ MessageXBrokerError,
11
+ MessageXConnectionError,
12
+ MessageXTimeoutError,
13
+ )
14
+ from .events import EventEmitter, logger
15
+ from .frame import FrameDecoder, encode_frame
16
+ from .types import (
17
+ BindingInfo,
18
+ ConsumerSubscription,
19
+ DeliveredMessage,
20
+ ExchangeInfo,
21
+ ExchangeType,
22
+ MessageHandler,
23
+ PublishResult,
24
+ QueueInfo,
25
+ )
26
+
27
+ PROTOCOL_VERSION = 1
28
+
29
+
30
+ def _compact(values: Mapping[str, Any]) -> Dict[str, Any]:
31
+ """Drop ``None`` values so the broker applies its own defaults."""
32
+ return {k: v for k, v in values.items() if v is not None}
33
+
34
+
35
+ class _Consumer:
36
+ __slots__ = ("queue_name", "on_message")
37
+
38
+ def __init__(self, queue_name: str, on_message: MessageHandler) -> None:
39
+ self.queue_name = queue_name
40
+ self.on_message = on_message
41
+
42
+
43
+ class _QueueApi:
44
+ def __init__(self, client: "MessageX") -> None:
45
+ self._client = client
46
+
47
+ async def declare(
48
+ self,
49
+ name: str,
50
+ *,
51
+ durable: Optional[bool] = None,
52
+ exclusive: Optional[bool] = None,
53
+ auto_delete: Optional[bool] = None,
54
+ max_delivery_count: Optional[int] = None,
55
+ dead_letter_queue: Optional[str] = None,
56
+ ) -> Dict[str, QueueInfo]:
57
+ """Declare a queue. Returns the broker payload: ``{"queue": {...}}``."""
58
+ return await self._client._request(
59
+ "QUEUE_DECLARE",
60
+ _compact(
61
+ {
62
+ "name": name,
63
+ "durable": durable,
64
+ "exclusive": exclusive,
65
+ "autoDelete": auto_delete,
66
+ "maxDeliveryCount": max_delivery_count,
67
+ "deadLetterQueue": dead_letter_queue,
68
+ }
69
+ ),
70
+ ) # type: ignore[return-value]
71
+
72
+ async def delete(self, queue_name: str) -> Dict[str, Any]:
73
+ return await self._client._request("QUEUE_DELETE", {"queueName": queue_name})
74
+
75
+
76
+ class _ExchangeApi:
77
+ def __init__(self, client: "MessageX") -> None:
78
+ self._client = client
79
+
80
+ async def declare(
81
+ self, name: str, type: ExchangeType, *, durable: Optional[bool] = None
82
+ ) -> Dict[str, ExchangeInfo]:
83
+ """Declare an exchange. Returns the broker payload: ``{"exchange": {...}}``."""
84
+ return await self._client._request(
85
+ "EXCHANGE_DECLARE", _compact({"name": name, "type": type, "durable": durable})
86
+ ) # type: ignore[return-value]
87
+
88
+ async def delete(self, exchange_name: str) -> Dict[str, Any]:
89
+ return await self._client._request("EXCHANGE_DELETE", {"exchangeName": exchange_name})
90
+
91
+ async def bind(
92
+ self,
93
+ exchange_name: str,
94
+ queue_name: str,
95
+ routing_key: str = "",
96
+ arguments: Optional[Mapping[str, Any]] = None,
97
+ ) -> Dict[str, BindingInfo]:
98
+ """Bind a queue to an exchange. Returns ``{"binding": {...}}``."""
99
+ return await self._client._request(
100
+ "BIND",
101
+ {
102
+ "exchangeName": exchange_name,
103
+ "queueName": queue_name,
104
+ "routingKey": routing_key,
105
+ "arguments": dict(arguments or {}),
106
+ },
107
+ ) # type: ignore[return-value]
108
+
109
+ async def unbind(
110
+ self,
111
+ exchange_name: str,
112
+ queue_name: str,
113
+ routing_key: str = "",
114
+ arguments: Optional[Mapping[str, Any]] = None,
115
+ ) -> Dict[str, Any]:
116
+ return await self._client._request(
117
+ "UNBIND",
118
+ {
119
+ "exchangeName": exchange_name,
120
+ "queueName": queue_name,
121
+ "routingKey": routing_key,
122
+ "arguments": dict(arguments or {}),
123
+ },
124
+ )
125
+
126
+
127
+ class MessageX(EventEmitter):
128
+ """Asyncio client for the MessageX broker.
129
+
130
+ Events (``client.on(name, listener)``): ``connect``, ``disconnect``, ``close``,
131
+ ``message``, ``event``, ``warning``, ``consumer_error`` and ``error``.
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ host: str,
137
+ username: str,
138
+ password: str,
139
+ port: int = 5678,
140
+ *,
141
+ client_name: str = "messagex-python-client",
142
+ connect_timeout: float = 10.0,
143
+ request_timeout: float = 15.0,
144
+ max_frame_size: int = 1024 * 1024,
145
+ ) -> None:
146
+ super().__init__()
147
+ if not host or not username or not password:
148
+ raise TypeError("host, username and password are required")
149
+
150
+ self.host = host
151
+ self.port = port
152
+ self._username = username
153
+ self._password = password
154
+ self.client_name = client_name
155
+ self.connect_timeout = connect_timeout
156
+ self.request_timeout = request_timeout
157
+ self.max_frame_size = max_frame_size
158
+
159
+ self._reader: Optional[asyncio.StreamReader] = None
160
+ self._writer: Optional[asyncio.StreamWriter] = None
161
+ self._reader_task: Optional["asyncio.Task[None]"] = None
162
+ self._connect_task: Optional["asyncio.Task[None]"] = None
163
+ self._decoder = FrameDecoder(max_frame_size)
164
+ self._pending: Dict[str, "asyncio.Future[Dict[str, Any]]"] = {}
165
+ self._consumers: Dict[str, _Consumer] = {}
166
+ self._pending_consumers_by_queue: Dict[str, _Consumer] = {}
167
+ self._callback_tasks: Set["asyncio.Task[None]"] = set()
168
+ self._closing = False
169
+ self._connected = False
170
+ self._authenticated = False
171
+
172
+ self.queue = _QueueApi(self)
173
+ self.exchange = _ExchangeApi(self)
174
+
175
+ # ------------------------------------------------------------------ state
176
+
177
+ @property
178
+ def is_connected(self) -> bool:
179
+ return self._connected and self._writer is not None and not self._writer.is_closing()
180
+
181
+ @property
182
+ def is_authenticated(self) -> bool:
183
+ return self._authenticated
184
+
185
+ async def __aenter__(self) -> "MessageX":
186
+ await self.connect()
187
+ return self
188
+
189
+ async def __aexit__(self, *exc_info: Any) -> None:
190
+ await self.close()
191
+
192
+ # ------------------------------------------------------------- connection
193
+
194
+ async def connect(self) -> None:
195
+ """Open the TCP connection and perform the CONNECT/AUTH handshake."""
196
+ if self.is_connected and self._authenticated:
197
+ return
198
+
199
+ task = self._connect_task
200
+ if task is None:
201
+ task = asyncio.ensure_future(self._establish_connection())
202
+ self._connect_task = task
203
+
204
+ def _clear(done: "asyncio.Task[None]") -> None:
205
+ if self._connect_task is done:
206
+ self._connect_task = None
207
+ if not done.cancelled():
208
+ done.exception() # mark retrieved
209
+
210
+ task.add_done_callback(_clear)
211
+
212
+ await asyncio.shield(task)
213
+
214
+ async def _establish_connection(self) -> None:
215
+ if self.is_connected:
216
+ raise MessageXConnectionError("Socket is connected but authentication is incomplete")
217
+
218
+ self._closing = False
219
+ self._decoder.reset()
220
+
221
+ try:
222
+ reader, writer = await asyncio.wait_for(
223
+ asyncio.open_connection(self.host, self.port), self.connect_timeout
224
+ )
225
+ except asyncio.TimeoutError:
226
+ raise MessageXTimeoutError(
227
+ f"Timed out connecting to {self.host}:{self.port}"
228
+ ) from None
229
+ except OSError as exc:
230
+ raise MessageXConnectionError(
231
+ f"Could not connect to MessageX at {self.host}:{self.port}: {exc}"
232
+ ) from exc
233
+
234
+ self._reader, self._writer = reader, writer
235
+ self._connected = True
236
+ self._reader_task = asyncio.ensure_future(self._read_loop(reader, writer))
237
+
238
+ try:
239
+ await self._request("CONNECT", {"clientName": self.client_name})
240
+ await self._request("AUTH", {"username": self._username, "password": self._password})
241
+ self._authenticated = True
242
+ self.emit("connect")
243
+ except BaseException:
244
+ self._destroy_connection()
245
+ raise
246
+
247
+ async def close(self) -> None:
248
+ """Close the socket, clear consumers and reject pending requests."""
249
+ self._closing = True
250
+ self._authenticated = False
251
+ self._connected = False
252
+ self._consumers.clear()
253
+ self._pending_consumers_by_queue.clear()
254
+
255
+ writer = self._writer
256
+ task = self._reader_task
257
+ if writer is not None:
258
+ writer.close()
259
+ try:
260
+ await asyncio.wait_for(writer.wait_closed(), 1.0)
261
+ except (Exception, asyncio.TimeoutError):
262
+ pass
263
+
264
+ if task is not None and task is not asyncio.current_task() and not task.done():
265
+ task.cancel()
266
+ await asyncio.wait([task])
267
+
268
+ self._reject_pending(MessageXConnectionError("MessageX client was closed"))
269
+ self._writer = None
270
+ self._reader = None
271
+ self._reader_task = None
272
+ self.emit("close")
273
+
274
+ # ------------------------------------------------------------- operations
275
+
276
+ async def publish(
277
+ self,
278
+ exchange_name: str,
279
+ message: Any,
280
+ routing_key: str = "",
281
+ headers: Optional[Mapping[str, Any]] = None,
282
+ ) -> PublishResult:
283
+ """Publish a JSON-serializable ``message`` to an exchange."""
284
+ payload = await self._request(
285
+ "PUBLISH",
286
+ {
287
+ "exchangeName": exchange_name,
288
+ "routingKey": routing_key,
289
+ "message": message,
290
+ "headers": dict(headers or {}),
291
+ },
292
+ )
293
+ return PublishResult(
294
+ exchange_name=str(payload.get("exchangeName", exchange_name)),
295
+ routed_queues=int(payload.get("routedQueues", 0)),
296
+ )
297
+
298
+ async def consume(self, queue_name: str, on_message: MessageHandler) -> ConsumerSubscription:
299
+ """Start consuming ``queue_name``. ``on_message`` may be sync or async."""
300
+ if not callable(on_message):
301
+ raise TypeError("consume requires an on_message callback")
302
+
303
+ consumer = _Consumer(queue_name, on_message)
304
+
305
+ # The broker may emit MESSAGE_DELIVERED before replying to CONSUME, so the
306
+ # callback is registered by queue name first to avoid losing that delivery.
307
+ self._pending_consumers_by_queue[queue_name] = consumer
308
+ try:
309
+ payload = await self._request("CONSUME", {"queueName": queue_name})
310
+ finally:
311
+ self._pending_consumers_by_queue.pop(queue_name, None)
312
+
313
+ consumer_tag = str(payload.get("consumerTag") or "")
314
+ if not consumer_tag:
315
+ raise MessageXConnectionError("Broker did not return a consumerTag")
316
+
317
+ self._consumers[consumer_tag] = consumer
318
+ cancelled = False
319
+
320
+ async def cancel() -> None:
321
+ nonlocal cancelled
322
+ if cancelled:
323
+ return
324
+ await self._request("CANCEL", {"consumerTag": consumer_tag})
325
+ cancelled = True
326
+ self._consumers.pop(consumer_tag, None)
327
+
328
+ return ConsumerSubscription(consumer_tag, queue_name, cancel)
329
+
330
+ # --------------------------------------------------------------- protocol
331
+
332
+ async def _request(self, command: str, payload: Dict[str, Any]) -> Dict[str, Any]:
333
+ writer = self._writer
334
+ if writer is None or writer.is_closing() or not self._connected:
335
+ raise MessageXConnectionError("Not connected to MessageX. Call connect() first.")
336
+ if command not in ("CONNECT", "AUTH") and not self._authenticated:
337
+ raise MessageXConnectionError(
338
+ "MessageX client is not authenticated. Await connect() before sending commands."
339
+ )
340
+
341
+ request_id = str(uuid.uuid4())
342
+ frame = encode_frame(
343
+ {
344
+ "version": PROTOCOL_VERSION,
345
+ "type": "COMMAND",
346
+ "command": command,
347
+ "requestId": request_id,
348
+ "payload": payload,
349
+ },
350
+ self.max_frame_size,
351
+ )
352
+
353
+ future: "asyncio.Future[Dict[str, Any]]" = asyncio.get_running_loop().create_future()
354
+ self._pending[request_id] = future
355
+ try:
356
+ try:
357
+ writer.write(frame)
358
+ await writer.drain()
359
+ except OSError as exc:
360
+ raise MessageXConnectionError(f"Failed to send {command}: {exc}") from exc
361
+
362
+ try:
363
+ return await asyncio.wait_for(future, self.request_timeout)
364
+ except asyncio.TimeoutError:
365
+ raise MessageXTimeoutError(
366
+ f"MessageX command {command} timed out after {self.request_timeout}s"
367
+ ) from None
368
+ finally:
369
+ self._pending.pop(request_id, None)
370
+
371
+ async def _read_loop(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
372
+ try:
373
+ while True:
374
+ chunk = await reader.read(65536)
375
+ if not chunk:
376
+ break
377
+ try:
378
+ self._handle_data(chunk)
379
+ except Exception as exc:
380
+ self._emit_error(exc)
381
+ self._destroy_connection()
382
+ return
383
+ except asyncio.CancelledError:
384
+ raise
385
+ except OSError as exc:
386
+ if not self._closing:
387
+ self._emit_error(MessageXConnectionError(str(exc)))
388
+ finally:
389
+ self._handle_close(writer)
390
+
391
+ def _handle_data(self, chunk: bytes) -> None:
392
+ for frame in self._decoder.push(chunk):
393
+ parsed = json.loads(frame.decode("utf-8"))
394
+ if parsed.get("version") != PROTOCOL_VERSION:
395
+ raise ValueError(f"Unsupported broker protocol version: {parsed.get('version')}")
396
+ kind = parsed.get("type")
397
+ if kind == "RESPONSE":
398
+ self._handle_response(parsed)
399
+ elif kind == "EVENT":
400
+ self._handle_event(parsed)
401
+
402
+ def _handle_response(self, response: Dict[str, Any]) -> None:
403
+ request_id = response.get("requestId")
404
+ future = self._pending.pop(request_id, None)
405
+ if future is None:
406
+ self._emit_warning(
407
+ MessageXConnectionError(f"Received response for unknown requestId {request_id}")
408
+ )
409
+ return
410
+ if future.done():
411
+ return
412
+ if not response.get("success"):
413
+ future.set_exception(
414
+ MessageXBrokerError(
415
+ response.get("error")
416
+ or {"code": "ERR_UNKNOWN", "message": "Broker command failed"}
417
+ )
418
+ )
419
+ return
420
+ future.set_result(response.get("payload") or {})
421
+
422
+ def _handle_event(self, event: Dict[str, Any]) -> None:
423
+ if event.get("event") != "MESSAGE_DELIVERED":
424
+ self.emit("event", event)
425
+ return
426
+
427
+ data = event.get("payload") or {}
428
+ consumer_tag = str(data.get("consumerTag") or "")
429
+ consumer = self._consumers.get(consumer_tag) or self._pending_consumers_by_queue.get(
430
+ str(data.get("queueName") or "")
431
+ )
432
+ if consumer is None:
433
+ self._emit_warning(
434
+ MessageXConnectionError(f"Message delivered for unknown consumer {consumer_tag}")
435
+ )
436
+ return
437
+
438
+ delivery_tag = str(data.get("deliveryTag") or "")
439
+
440
+ async def ack() -> None:
441
+ await self._request("ACK", {"deliveryTag": delivery_tag})
442
+
443
+ async def nack(requeue: bool) -> None:
444
+ await self._request("NACK", {"deliveryTag": delivery_tag, "requeue": requeue})
445
+
446
+ delivered = DeliveredMessage(
447
+ consumer_tag=consumer_tag,
448
+ queue_name=str(data.get("queueName") or consumer.queue_name),
449
+ message_id=str(data.get("messageId") or ""),
450
+ delivery_tag=delivery_tag,
451
+ body=data.get("body"),
452
+ headers=dict(data.get("headers") or {}),
453
+ routing_key=str(data.get("routingKey") or ""),
454
+ delivery_count=int(data.get("deliveryCount") or 0),
455
+ created_at=str(data.get("createdAt") or ""),
456
+ _ack=ack,
457
+ _nack=nack,
458
+ )
459
+
460
+ self._dispatch(consumer, delivered)
461
+ self.emit("message", delivered)
462
+
463
+ def _dispatch(self, consumer: _Consumer, delivered: DeliveredMessage) -> None:
464
+ try:
465
+ result = consumer.on_message(delivered)
466
+ except Exception as exc:
467
+ self.emit("consumer_error", exc, delivered)
468
+ return
469
+
470
+ if inspect.isawaitable(result):
471
+ task = asyncio.ensure_future(self._await_callback(result, delivered))
472
+ self._callback_tasks.add(task)
473
+ task.add_done_callback(self._callback_tasks.discard)
474
+
475
+ async def _await_callback(self, awaitable: Any, delivered: DeliveredMessage) -> None:
476
+ try:
477
+ await awaitable
478
+ except Exception as exc:
479
+ self.emit("consumer_error", exc, delivered)
480
+
481
+ # ---------------------------------------------------------------- cleanup
482
+
483
+ def _handle_close(self, writer: asyncio.StreamWriter) -> None:
484
+ if self._writer is not writer:
485
+ return # a newer connection owns the state now
486
+ was_connected = self._connected
487
+ self._connected = False
488
+ self._authenticated = False
489
+ if not writer.is_closing():
490
+ writer.close()
491
+ self._writer = None
492
+ self._reader = None
493
+ self._decoder.reset()
494
+ self._consumers.clear()
495
+ self._pending_consumers_by_queue.clear()
496
+ self._reject_pending(MessageXConnectionError("Connection to MessageX was closed"))
497
+ if not self._closing and was_connected:
498
+ self.emit("disconnect")
499
+
500
+ def _destroy_connection(self) -> None:
501
+ self._connected = False
502
+ self._authenticated = False
503
+ self._consumers.clear()
504
+ self._pending_consumers_by_queue.clear()
505
+ self._reject_pending(MessageXConnectionError("MessageX connection was terminated"))
506
+ writer, task = self._writer, self._reader_task
507
+ self._writer = None
508
+ self._reader = None
509
+ if writer is not None:
510
+ writer.close()
511
+ if task is not None and task is not asyncio.current_task() and not task.done():
512
+ task.cancel()
513
+ self._reader_task = None
514
+
515
+ def _reject_pending(self, error: Exception) -> None:
516
+ for request_id, future in list(self._pending.items()):
517
+ if not future.done():
518
+ future.set_exception(error)
519
+ self._pending.pop(request_id, None)
520
+
521
+ def _emit_error(self, error: Exception) -> None:
522
+ if self.listener_count("error") > 0:
523
+ self.emit("error", error)
524
+ else:
525
+ self._emit_warning(error)
526
+
527
+ def _emit_warning(self, error: Exception) -> None:
528
+ if self.listener_count("warning") > 0:
529
+ self.emit("warning", error)
530
+ else:
531
+ logger.warning("%s", error)
messagex/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+
6
+ class MessageXError(Exception):
7
+ """Base class for all SDK errors."""
8
+
9
+
10
+ class MessageXConnectionError(MessageXError):
11
+ """Socket and connection lifecycle errors."""
12
+
13
+
14
+ class MessageXTimeoutError(MessageXError):
15
+ """A connection attempt or broker command timed out."""
16
+
17
+
18
+ class MessageXBrokerError(MessageXError):
19
+ """The broker rejected a command. See :attr:`code`."""
20
+
21
+ def __init__(self, error: Mapping[str, Any]) -> None:
22
+ message = str(error.get("message", "Broker command failed"))
23
+ super().__init__(message)
24
+ self.code: str = str(error.get("code", "ERR_UNKNOWN"))
25
+ self.message: str = message
messagex/events.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ from typing import Any, Callable, Dict, List
7
+
8
+ logger = logging.getLogger("messagex")
9
+
10
+
11
+ class EventEmitter:
12
+ """Tiny Node-style emitter. Listeners may be sync functions or coroutine functions."""
13
+
14
+ def __init__(self) -> None:
15
+ self._listeners: Dict[str, List[Callable[..., Any]]] = {}
16
+ self._background: set = set()
17
+
18
+ def on(self, event: str, listener: Callable[..., Any]) -> "EventEmitter":
19
+ self._listeners.setdefault(event, []).append(listener)
20
+ return self
21
+
22
+ def once(self, event: str, listener: Callable[..., Any]) -> "EventEmitter":
23
+ def wrapper(*args: Any) -> Any:
24
+ self.off(event, wrapper)
25
+ return listener(*args)
26
+
27
+ wrapper.__wrapped__ = listener # type: ignore[attr-defined]
28
+ return self.on(event, wrapper)
29
+
30
+ def off(self, event: str, listener: Callable[..., Any]) -> "EventEmitter":
31
+ items = self._listeners.get(event, [])
32
+ for item in list(items):
33
+ if item is listener or getattr(item, "__wrapped__", None) is listener:
34
+ items.remove(item)
35
+ break
36
+ return self
37
+
38
+ def listener_count(self, event: str) -> int:
39
+ return len(self._listeners.get(event, []))
40
+
41
+ def emit(self, event: str, *args: Any) -> bool:
42
+ listeners = list(self._listeners.get(event, []))
43
+ for listener in listeners:
44
+ try:
45
+ result = listener(*args)
46
+ if inspect.isawaitable(result):
47
+ task = asyncio.ensure_future(self._run(event, result))
48
+ self._background.add(task)
49
+ task.add_done_callback(self._background.discard)
50
+ except Exception:
51
+ logger.exception("Unhandled exception in %r listener", event)
52
+ return bool(listeners)
53
+
54
+ async def _run(self, event: str, awaitable: Any) -> None:
55
+ try:
56
+ await awaitable
57
+ except Exception:
58
+ logger.exception("Unhandled exception in %r listener", event)