firemq 0.1.0__py3-none-win_amd64.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.
- firemq/__init__.py +348 -0
- firemq/_ffi/__init__.py +1 -0
- firemq/_ffi/firemq_client_ffi.dll +0 -0
- firemq/_ffi/firemq_client_ffi.py +2255 -0
- firemq-0.1.0.dist-info/METADATA +63 -0
- firemq-0.1.0.dist-info/RECORD +8 -0
- firemq-0.1.0.dist-info/WHEEL +4 -0
- firemq-0.1.0.dist-info/sboms/firemq-client-ffi.cyclonedx.json +5246 -0
firemq/__init__.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""FireMQ client for Python, built on the Rust client.
|
|
2
|
+
|
|
3
|
+
>>> import firemq
|
|
4
|
+
>>> with firemq.connect("fire://guest:guest@localhost:5673") as client:
|
|
5
|
+
... client.declare_queue("jobs")
|
|
6
|
+
... client.publish("", "jobs", "hello", headers={"attempt": 1})
|
|
7
|
+
... with client.subscribe("jobs") as sub:
|
|
8
|
+
... for msg in sub:
|
|
9
|
+
... print(msg.text)
|
|
10
|
+
... msg.ack()
|
|
11
|
+
... break
|
|
12
|
+
|
|
13
|
+
Calls block until the broker answers; they release the GIL while waiting. Queues and
|
|
14
|
+
exchanges are durable and messages persistent by default, and ``publish`` waits for the
|
|
15
|
+
broker's confirm.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json as _json
|
|
21
|
+
from typing import Any, Dict, Iterator, Mapping, Optional, Union
|
|
22
|
+
|
|
23
|
+
from ._ffi import firemq_client_ffi as _ffi
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Client",
|
|
27
|
+
"ClosedError",
|
|
28
|
+
"FireMQError",
|
|
29
|
+
"Message",
|
|
30
|
+
"NackedError",
|
|
31
|
+
"OtherError",
|
|
32
|
+
"QueueInfo",
|
|
33
|
+
"ServerError",
|
|
34
|
+
"Subscription",
|
|
35
|
+
"UnroutableError",
|
|
36
|
+
"connect",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
#: Base class of every error. Each variant below is a subclass.
|
|
42
|
+
FireMQError = _ffi.FireMqError
|
|
43
|
+
#: The broker refused a request; ``code`` is an AMQP reply code such as 403, 404 or 406.
|
|
44
|
+
ServerError = _ffi.FireMqError.Server
|
|
45
|
+
#: A mandatory publish matched no queue.
|
|
46
|
+
UnroutableError = _ffi.FireMqError.Unroutable
|
|
47
|
+
#: The broker rejected a publish, for example because a queue was full.
|
|
48
|
+
NackedError = _ffi.FireMqError.Nacked
|
|
49
|
+
#: The connection or subscription is closed.
|
|
50
|
+
ClosedError = _ffi.FireMqError.Closed
|
|
51
|
+
#: Anything else: I/O failures, invalid URLs or JSON, protocol errors.
|
|
52
|
+
OtherError = _ffi.FireMqError.Other
|
|
53
|
+
|
|
54
|
+
#: What ``declare_queue`` returns: ``name``, ``messages`` and ``consumers``.
|
|
55
|
+
QueueInfo = _ffi.QueueInfo
|
|
56
|
+
|
|
57
|
+
DEFAULT_URL = "fire://guest:guest@localhost:5673"
|
|
58
|
+
|
|
59
|
+
# Property names as the protocol spells them, and as the Rust record spells them.
|
|
60
|
+
_PROPERTY_FIELDS = {
|
|
61
|
+
"content_type": "content_type",
|
|
62
|
+
"content_encoding": "content_encoding",
|
|
63
|
+
"correlation_id": "correlation_id",
|
|
64
|
+
"reply_to": "reply_to",
|
|
65
|
+
"expiration": "expiration",
|
|
66
|
+
"message_id": "message_id",
|
|
67
|
+
"timestamp": "timestamp",
|
|
68
|
+
"type": "message_type",
|
|
69
|
+
"user_id": "user_id",
|
|
70
|
+
"app_id": "app_id",
|
|
71
|
+
"priority": "priority",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Body = Union[str, bytes, bytearray, memoryview]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _json_or_none(value: Optional[Mapping[str, Any]]) -> Optional[str]:
|
|
78
|
+
return None if value is None else _json.dumps(dict(value))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _bytes(body: Body) -> bytes:
|
|
82
|
+
if isinstance(body, str):
|
|
83
|
+
return body.encode("utf-8")
|
|
84
|
+
return bytes(body)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def connect(url: str = DEFAULT_URL) -> "Client":
|
|
88
|
+
"""Connects with a URL such as ``fire://user:pass@host:5673/vhost`` (``fires://`` for TLS)."""
|
|
89
|
+
return Client(_ffi.Client.connect(url))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Client:
|
|
93
|
+
"""A connection to FireMQ. Safe to share between threads; use it as a context manager to
|
|
94
|
+
close it."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, inner: "_ffi.Client") -> None:
|
|
97
|
+
self._inner = inner
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def connect(cls, url: str = DEFAULT_URL) -> "Client":
|
|
101
|
+
return connect(url)
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> "Client":
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(self, *exc: object) -> None:
|
|
107
|
+
self.close()
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
"""Closes the connection. Unacknowledged messages return to their queues."""
|
|
111
|
+
self._inner.close()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def closed(self) -> bool:
|
|
115
|
+
return self._inner.is_closed()
|
|
116
|
+
|
|
117
|
+
def declare_queue(
|
|
118
|
+
self,
|
|
119
|
+
name: str = "",
|
|
120
|
+
*,
|
|
121
|
+
durable: bool = True,
|
|
122
|
+
exclusive: bool = False,
|
|
123
|
+
auto_delete: bool = False,
|
|
124
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
125
|
+
) -> QueueInfo:
|
|
126
|
+
"""Declares a queue. An empty name asks the broker to generate one. ``arguments`` are
|
|
127
|
+
RabbitMQ queue arguments such as ``{"x-max-length": 1000}``."""
|
|
128
|
+
return self._inner.declare_queue(
|
|
129
|
+
name, durable, exclusive, auto_delete, _json_or_none(arguments)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def delete_queue(self, name: str) -> int:
|
|
133
|
+
"""Deletes a queue and returns how many messages it held."""
|
|
134
|
+
return self._inner.delete_queue(name)
|
|
135
|
+
|
|
136
|
+
def purge_queue(self, name: str) -> int:
|
|
137
|
+
"""Removes every ready message and returns how many were removed."""
|
|
138
|
+
return self._inner.purge_queue(name)
|
|
139
|
+
|
|
140
|
+
def declare_exchange(
|
|
141
|
+
self,
|
|
142
|
+
name: str,
|
|
143
|
+
type: str = "direct",
|
|
144
|
+
*,
|
|
145
|
+
durable: bool = True,
|
|
146
|
+
auto_delete: bool = False,
|
|
147
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Declares an exchange of type ``direct``, ``fanout``, ``topic`` or ``headers``."""
|
|
150
|
+
self._inner.declare_exchange(name, type, durable, auto_delete, _json_or_none(arguments))
|
|
151
|
+
|
|
152
|
+
def delete_exchange(self, name: str, *, if_unused: bool = False) -> None:
|
|
153
|
+
self._inner.delete_exchange(name, if_unused)
|
|
154
|
+
|
|
155
|
+
def bind_queue(
|
|
156
|
+
self,
|
|
157
|
+
queue: str,
|
|
158
|
+
exchange: str,
|
|
159
|
+
routing_key: str = "",
|
|
160
|
+
*,
|
|
161
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Binds ``queue`` to ``exchange`` with a routing key or pattern."""
|
|
164
|
+
self._inner.bind_queue(queue, exchange, routing_key, _json_or_none(arguments))
|
|
165
|
+
|
|
166
|
+
def unbind_queue(self, queue: str, exchange: str, routing_key: str = "") -> None:
|
|
167
|
+
self._inner.unbind_queue(queue, exchange, routing_key)
|
|
168
|
+
|
|
169
|
+
def bind_exchange(self, destination: str, source: str, routing_key: str = "") -> None:
|
|
170
|
+
"""Routes messages published to ``source`` on to ``destination``."""
|
|
171
|
+
self._inner.bind_exchange(destination, source, routing_key)
|
|
172
|
+
|
|
173
|
+
def publish(
|
|
174
|
+
self,
|
|
175
|
+
exchange: str,
|
|
176
|
+
routing_key: str,
|
|
177
|
+
body: Body,
|
|
178
|
+
*,
|
|
179
|
+
headers: Optional[Mapping[str, Any]] = None,
|
|
180
|
+
persistent: bool = True,
|
|
181
|
+
mandatory: bool = False,
|
|
182
|
+
confirm: bool = True,
|
|
183
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
184
|
+
**property_kwargs: Any,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Publishes a message; ``str`` bodies are sent as UTF-8. Use exchange ``""`` to send
|
|
187
|
+
straight to the queue named by ``routing_key``.
|
|
188
|
+
|
|
189
|
+
Properties use the protocol's names and may be given as keywords or as a
|
|
190
|
+
``properties`` mapping: ``content_type``, ``content_encoding``, ``correlation_id``,
|
|
191
|
+
``reply_to``, ``expiration`` (milliseconds), ``message_id``, ``timestamp``, ``type``,
|
|
192
|
+
``user_id``, ``app_id`` and ``priority``. Header values may be any JSON value.
|
|
193
|
+
|
|
194
|
+
Raises :class:`UnroutableError` for a ``mandatory`` message no queue accepted and
|
|
195
|
+
:class:`NackedError` when the broker rejects it.
|
|
196
|
+
"""
|
|
197
|
+
props = dict(properties or {})
|
|
198
|
+
props.update(property_kwargs)
|
|
199
|
+
unknown = set(props) - set(_PROPERTY_FIELDS)
|
|
200
|
+
if unknown:
|
|
201
|
+
raise TypeError(f"unknown message properties: {', '.join(sorted(unknown))}")
|
|
202
|
+
record = _ffi.MessageProperties(
|
|
203
|
+
**{_PROPERTY_FIELDS[k]: v for k, v in props.items() if v is not None}
|
|
204
|
+
)
|
|
205
|
+
options = _ffi.PublishOptions(
|
|
206
|
+
persistent=persistent,
|
|
207
|
+
mandatory=mandatory,
|
|
208
|
+
confirm=confirm,
|
|
209
|
+
properties=record,
|
|
210
|
+
headers_json=_json_or_none(headers),
|
|
211
|
+
)
|
|
212
|
+
self._inner.publish(exchange, routing_key, _bytes(body), options)
|
|
213
|
+
|
|
214
|
+
def publish_json(self, exchange: str, routing_key: str, value: Any, **kwargs: Any) -> None:
|
|
215
|
+
"""Publishes ``value`` encoded as JSON with content type ``application/json``."""
|
|
216
|
+
kwargs.setdefault("content_type", "application/json")
|
|
217
|
+
self.publish(exchange, routing_key, _json.dumps(value), **kwargs)
|
|
218
|
+
|
|
219
|
+
def subscribe(self, queue: str, *, prefetch: int = 100, no_ack: bool = False) -> "Subscription":
|
|
220
|
+
"""Starts consuming. ``prefetch`` caps unacknowledged deliveries; with ``no_ack``
|
|
221
|
+
messages count as acknowledged when sent."""
|
|
222
|
+
return Subscription(self._inner.subscribe(queue, prefetch, no_ack))
|
|
223
|
+
|
|
224
|
+
def get(self, queue: str, *, no_ack: bool = False) -> Optional["Message"]:
|
|
225
|
+
"""Takes one message from a queue, or returns ``None`` when it is empty."""
|
|
226
|
+
d = self._inner.get(queue, no_ack)
|
|
227
|
+
return None if d is None else Message(d)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Subscription:
|
|
231
|
+
"""Messages from a queue. Iterate over it, or call :meth:`next`. Use it as a context
|
|
232
|
+
manager to cancel it."""
|
|
233
|
+
|
|
234
|
+
# How long each wait in the native library lasts, so Ctrl+C is handled promptly.
|
|
235
|
+
_POLL_MS = 500
|
|
236
|
+
|
|
237
|
+
def __init__(self, inner: "_ffi.Subscription") -> None:
|
|
238
|
+
self._inner = inner
|
|
239
|
+
|
|
240
|
+
def __enter__(self) -> "Subscription":
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
def __exit__(self, *exc: object) -> None:
|
|
244
|
+
self.cancel()
|
|
245
|
+
|
|
246
|
+
def __iter__(self) -> Iterator["Message"]:
|
|
247
|
+
"""Yields messages until the subscription ends."""
|
|
248
|
+
while True:
|
|
249
|
+
try:
|
|
250
|
+
msg = self.next(None)
|
|
251
|
+
except ClosedError:
|
|
252
|
+
return
|
|
253
|
+
if msg is not None:
|
|
254
|
+
yield msg
|
|
255
|
+
|
|
256
|
+
def next(self, timeout: Optional[float] = 1.0) -> Optional["Message"]:
|
|
257
|
+
"""Waits up to ``timeout`` seconds for a message (forever with ``None``) and returns
|
|
258
|
+
``None`` on timeout. Raises :class:`ClosedError` once the subscription has ended."""
|
|
259
|
+
if timeout is not None:
|
|
260
|
+
d = self._inner.next(max(0, int(timeout * 1000)))
|
|
261
|
+
return None if d is None else Message(d)
|
|
262
|
+
while True:
|
|
263
|
+
d = self._inner.next(self._POLL_MS)
|
|
264
|
+
if d is not None:
|
|
265
|
+
return Message(d)
|
|
266
|
+
|
|
267
|
+
def cancel(self) -> None:
|
|
268
|
+
"""Stops the subscription. Messages already received can still be acknowledged."""
|
|
269
|
+
self._inner.cancel()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Message:
|
|
273
|
+
"""A delivered message."""
|
|
274
|
+
|
|
275
|
+
__slots__ = ("_d", "_body", "_headers", "_properties")
|
|
276
|
+
|
|
277
|
+
def __init__(self, delivery: "_ffi.Delivery") -> None:
|
|
278
|
+
self._d = delivery
|
|
279
|
+
self._body: Optional[bytes] = None
|
|
280
|
+
self._headers: Optional[Dict[str, Any]] = None
|
|
281
|
+
self._properties: Optional[Dict[str, Any]] = None
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def body(self) -> bytes:
|
|
285
|
+
if self._body is None:
|
|
286
|
+
self._body = self._d.body()
|
|
287
|
+
return self._body
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def text(self) -> str:
|
|
291
|
+
"""The body decoded as UTF-8."""
|
|
292
|
+
return self.body.decode("utf-8")
|
|
293
|
+
|
|
294
|
+
def json(self) -> Any:
|
|
295
|
+
"""The body decoded as JSON."""
|
|
296
|
+
return _json.loads(self.body)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def exchange(self) -> str:
|
|
300
|
+
return self._d.exchange()
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def routing_key(self) -> str:
|
|
304
|
+
return self._d.routing_key()
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def redelivered(self) -> bool:
|
|
308
|
+
"""True when the message was delivered before and not acknowledged."""
|
|
309
|
+
return self._d.redelivered()
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def delivery_tag(self) -> int:
|
|
313
|
+
return self._d.delivery_tag()
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def headers(self) -> Dict[str, Any]:
|
|
317
|
+
"""Application headers. Binary values arrive base64-encoded."""
|
|
318
|
+
if self._headers is None:
|
|
319
|
+
self._headers = _json.loads(self._d.headers_json())
|
|
320
|
+
return self._headers
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def properties(self) -> Dict[str, Any]:
|
|
324
|
+
"""The properties that are set, keyed by their protocol names (``content_type``,
|
|
325
|
+
``type``, ``priority``...)."""
|
|
326
|
+
if self._properties is None:
|
|
327
|
+
p = self._d.properties()
|
|
328
|
+
self._properties = {
|
|
329
|
+
name: getattr(p, field)
|
|
330
|
+
for name, field in _PROPERTY_FIELDS.items()
|
|
331
|
+
if getattr(p, field) is not None
|
|
332
|
+
}
|
|
333
|
+
return self._properties
|
|
334
|
+
|
|
335
|
+
def ack(self) -> None:
|
|
336
|
+
"""Acknowledges the message. Does nothing when it is already settled."""
|
|
337
|
+
self._d.ack()
|
|
338
|
+
|
|
339
|
+
def nack(self, requeue: bool = True) -> None:
|
|
340
|
+
"""Rejects the message. With ``requeue`` it is delivered again; otherwise it is
|
|
341
|
+
dead-lettered when the queue has a dead-letter exchange, and dropped if not."""
|
|
342
|
+
self._d.nack(requeue)
|
|
343
|
+
|
|
344
|
+
def __repr__(self) -> str:
|
|
345
|
+
return (
|
|
346
|
+
f"Message(exchange={self.exchange!r}, routing_key={self.routing_key!r}, "
|
|
347
|
+
f"redelivered={self.redelivered}, body={len(self.body)} bytes)"
|
|
348
|
+
)
|
firemq/_ffi/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .firemq_client_ffi import * # NOQA
|
|
Binary file
|