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 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
+ )
@@ -0,0 +1 @@
1
+ from .firemq_client_ffi import * # NOQA
Binary file