xyberos-queues 0.1.0__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.
- xyberos_queues-0.1.0/PKG-INFO +66 -0
- xyberos_queues-0.1.0/README.md +47 -0
- xyberos_queues-0.1.0/pyproject.toml +29 -0
- xyberos_queues-0.1.0/setup.cfg +4 -0
- xyberos_queues-0.1.0/tests/test_adapters.py +83 -0
- xyberos_queues-0.1.0/tests/test_plugin.py +33 -0
- xyberos_queues-0.1.0/xyberos_queues/__init__.py +7 -0
- xyberos_queues-0.1.0/xyberos_queues/adapters.py +172 -0
- xyberos_queues-0.1.0/xyberos_queues/contract.py +21 -0
- xyberos_queues-0.1.0/xyberos_queues/plugin.py +85 -0
- xyberos_queues-0.1.0/xyberos_queues.egg-info/PKG-INFO +66 -0
- xyberos_queues-0.1.0/xyberos_queues.egg-info/SOURCES.txt +14 -0
- xyberos_queues-0.1.0/xyberos_queues.egg-info/dependency_links.txt +1 -0
- xyberos_queues-0.1.0/xyberos_queues.egg-info/entry_points.txt +2 -0
- xyberos_queues-0.1.0/xyberos_queues.egg-info/requires.txt +14 -0
- xyberos_queues-0.1.0/xyberos_queues.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-queues
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Message queue plugin (RFC-0019, M9): Redis Streams, RabbitMQ, Kafka behind one MessageQueue contract
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,queue,redis,rabbitmq,kafka
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: redis
|
|
11
|
+
Requires-Dist: redis; extra == "redis"
|
|
12
|
+
Provides-Extra: rabbitmq
|
|
13
|
+
Requires-Dist: pika; extra == "rabbitmq"
|
|
14
|
+
Provides-Extra: kafka
|
|
15
|
+
Requires-Dist: kafka-python; extra == "kafka"
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Requires-Dist: fakeredis; extra == "test"
|
|
19
|
+
|
|
20
|
+
# xyberos-queues
|
|
21
|
+
|
|
22
|
+
**Message queue plugin — RFC-0019, M9.** Redis Streams, RabbitMQ, and Kafka
|
|
23
|
+
behind one `MessageQueue` contract (`publish` / `poll`).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ./queues
|
|
29
|
+
pip install xyberos-queues[redis] # optional drivers
|
|
30
|
+
pip install xyberos-queues[rabbitmq]
|
|
31
|
+
pip install xyberos-queues[kafka]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from xyberos import create_app
|
|
38
|
+
from xyberos_queues import QueuesPlugin
|
|
39
|
+
|
|
40
|
+
app = create_app()
|
|
41
|
+
app.load_plugin(QueuesPlugin(provider="redis")) # or QUEUE_PROVIDER
|
|
42
|
+
|
|
43
|
+
app.tools.execute("queue_publish", None, topic="jobs", message="do work")
|
|
44
|
+
app.tools.execute("queue_poll", None, topic="jobs") # -> "do work"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Provider | Config |
|
|
48
|
+
| -------- | ------ |
|
|
49
|
+
| `redis` | Redis Streams (URL via `REDIS_URL`) |
|
|
50
|
+
| `rabbitmq` | host/port (default `localhost:5672`) |
|
|
51
|
+
| `kafka` | `bootstrap_servers` (default `localhost:9092`) |
|
|
52
|
+
|
|
53
|
+
`poll` drains one message (oldest first) per call; returns `None` when empty.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pytest fakeredis
|
|
59
|
+
pytest tests/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
fakeredis + fake clients — no live brokers.
|
|
63
|
+
|
|
64
|
+
## Ship location
|
|
65
|
+
|
|
66
|
+
Plugin (`xyberos.plugins` entry point) — infrastructure queues (M9).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# xyberos-queues
|
|
2
|
+
|
|
3
|
+
**Message queue plugin — RFC-0019, M9.** Redis Streams, RabbitMQ, and Kafka
|
|
4
|
+
behind one `MessageQueue` contract (`publish` / `poll`).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install -e ./queues
|
|
10
|
+
pip install xyberos-queues[redis] # optional drivers
|
|
11
|
+
pip install xyberos-queues[rabbitmq]
|
|
12
|
+
pip install xyberos-queues[kafka]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from xyberos import create_app
|
|
19
|
+
from xyberos_queues import QueuesPlugin
|
|
20
|
+
|
|
21
|
+
app = create_app()
|
|
22
|
+
app.load_plugin(QueuesPlugin(provider="redis")) # or QUEUE_PROVIDER
|
|
23
|
+
|
|
24
|
+
app.tools.execute("queue_publish", None, topic="jobs", message="do work")
|
|
25
|
+
app.tools.execute("queue_poll", None, topic="jobs") # -> "do work"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Provider | Config |
|
|
29
|
+
| -------- | ------ |
|
|
30
|
+
| `redis` | Redis Streams (URL via `REDIS_URL`) |
|
|
31
|
+
| `rabbitmq` | host/port (default `localhost:5672`) |
|
|
32
|
+
| `kafka` | `bootstrap_servers` (default `localhost:9092`) |
|
|
33
|
+
|
|
34
|
+
`poll` drains one message (oldest first) per call; returns `None` when empty.
|
|
35
|
+
|
|
36
|
+
## Tests
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install pytest fakeredis
|
|
40
|
+
pytest tests/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
fakeredis + fake clients — no live brokers.
|
|
44
|
+
|
|
45
|
+
## Ship location
|
|
46
|
+
|
|
47
|
+
Plugin (`xyberos.plugins` entry point) — infrastructure queues (M9).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xyberos-queues"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Message queue plugin (RFC-0019, M9): Redis Streams, RabbitMQ, Kafka behind one MessageQueue contract"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
dependencies = ["xyberos>=1.0"]
|
|
13
|
+
keywords = ["xyberos", "plugin", "queue", "redis", "rabbitmq", "kafka"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
redis = ["redis"]
|
|
17
|
+
rabbitmq = ["pika"]
|
|
18
|
+
kafka = ["kafka-python"]
|
|
19
|
+
test = ["pytest", "fakeredis"]
|
|
20
|
+
|
|
21
|
+
[project.entry-points."xyberos.plugins"]
|
|
22
|
+
queues = "xyberos_queues.plugin:plugin"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["xyberos_queues"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Tests for the queue adapters (fakeredis / fake clients, no servers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xyberos_queues import KafkaQueue, RabbitMqQueue, RedisStreamsQueue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture()
|
|
11
|
+
def redis_client():
|
|
12
|
+
return pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_redis_streams_roundtrip(redis_client):
|
|
16
|
+
queue = RedisStreamsQueue(client=redis_client)
|
|
17
|
+
queue.publish("events", "hello")
|
|
18
|
+
assert queue.poll("events") == "hello"
|
|
19
|
+
assert queue.poll("events", timeout=0.01) is None # drained
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _FakePikaChannel:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.messages: list[str] = []
|
|
25
|
+
|
|
26
|
+
def queue_declare(self, **kwargs):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
def basic_publish(self, **kwargs):
|
|
30
|
+
self.messages.append(kwargs["body"])
|
|
31
|
+
|
|
32
|
+
def basic_get(self, queue, auto_ack):
|
|
33
|
+
if self.messages:
|
|
34
|
+
return ("method", None, self.messages.pop(0))
|
|
35
|
+
return (None, None, None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_rabbitmq_roundtrip():
|
|
39
|
+
channel = _FakePikaChannel()
|
|
40
|
+
queue = RabbitMqQueue(channel=channel)
|
|
41
|
+
queue.publish("q", "hello")
|
|
42
|
+
assert queue.poll("q") == "hello"
|
|
43
|
+
assert queue.poll("q") is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _FakeKafkaProducer:
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.sent: list[bytes] = []
|
|
49
|
+
|
|
50
|
+
def send(self, topic, value):
|
|
51
|
+
self.sent.append(value)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def flush(self):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def close(self):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _FakeKafkaConsumer:
|
|
62
|
+
def __init__(self, messages: list[bytes]):
|
|
63
|
+
self._messages = messages
|
|
64
|
+
self.closed = False
|
|
65
|
+
|
|
66
|
+
def poll(self, **kwargs):
|
|
67
|
+
if self._messages:
|
|
68
|
+
return {"tp": [type("M", (), {"value": self._messages.pop(0)})()]}
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
def close(self):
|
|
72
|
+
self.closed = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_kafka_roundtrip():
|
|
76
|
+
producer = _FakeKafkaProducer()
|
|
77
|
+
consumer = _FakeKafkaConsumer([b"hello"])
|
|
78
|
+
queue = KafkaQueue(producer=producer, consumer=consumer)
|
|
79
|
+
queue.publish("topic", "world")
|
|
80
|
+
assert producer.sent == [b"world"]
|
|
81
|
+
assert queue.poll("topic") == "hello"
|
|
82
|
+
queue.close()
|
|
83
|
+
assert consumer.closed
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tests for loading the queues plugin into a Xyberos app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xyberos import create_app
|
|
7
|
+
|
|
8
|
+
from xyberos_queues import QueuesPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def redis_client():
|
|
13
|
+
return pytest.importorskip("fakeredis").FakeStrictRedis()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_plugin_registers_and_executes(redis_client):
|
|
17
|
+
app = create_app()
|
|
18
|
+
app.load_plugin(QueuesPlugin(provider="redis", client=redis_client))
|
|
19
|
+
assert "queue_publish" in app.tools.names
|
|
20
|
+
assert "queue_poll" in app.tools.names
|
|
21
|
+
|
|
22
|
+
app.tools.execute("queue_publish", None, topic="jobs", message="do work")
|
|
23
|
+
assert app.tools.execute("queue_poll", None, topic="jobs") == "do work"
|
|
24
|
+
|
|
25
|
+
app.unload_plugin("queues")
|
|
26
|
+
assert "queue_publish" not in app.tools.names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_unknown_provider_raises():
|
|
30
|
+
import pytest as _pytest
|
|
31
|
+
|
|
32
|
+
with _pytest.raises(ValueError, match="unknown queue provider"):
|
|
33
|
+
QueuesPlugin(provider="bogus").message_queue()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Message queue plugin (RFC-0019, M9)."""
|
|
2
|
+
|
|
3
|
+
from .adapters import KafkaQueue, RabbitMqQueue, RedisStreamsQueue
|
|
4
|
+
from .contract import MessageQueue
|
|
5
|
+
from .plugin import QueuesPlugin
|
|
6
|
+
|
|
7
|
+
__all__ = ["KafkaQueue", "MessageQueue", "QueuesPlugin", "RabbitMqQueue", "RedisStreamsQueue"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Queue adapters: Redis Streams, RabbitMQ, Kafka (lazy drivers, injectable)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from xyberos.exceptions.provider import ProviderError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require(package: str, extra: str) -> Any:
|
|
13
|
+
try:
|
|
14
|
+
return importlib.import_module(package)
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ProviderError(
|
|
17
|
+
f"the '{package}' package is required for {extra}; install with "
|
|
18
|
+
f"'pip install xyberos-queues[{extra}]'"
|
|
19
|
+
) from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RedisStreamsQueue:
|
|
23
|
+
"""Redis Streams (lazy ``redis``; injectable client for tests)."""
|
|
24
|
+
|
|
25
|
+
name = "redis"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
url: str | None = None,
|
|
31
|
+
client: Any | None = None,
|
|
32
|
+
stream_prefix: str = "xyberos:queue",
|
|
33
|
+
) -> None:
|
|
34
|
+
self._url = url
|
|
35
|
+
self._client = client
|
|
36
|
+
self._prefix = stream_prefix
|
|
37
|
+
|
|
38
|
+
def publish(self, topic: str, message: str) -> None:
|
|
39
|
+
self._get_client().xadd(self._stream(topic), {"message": message})
|
|
40
|
+
|
|
41
|
+
def poll(self, topic: str, timeout: float = 0.1) -> str | None:
|
|
42
|
+
# XRANGE (oldest first) + XDEL drains the stream like a queue and works
|
|
43
|
+
# on both real Redis and fakeredis (no XREAD-">" quirks).
|
|
44
|
+
client = self._get_client()
|
|
45
|
+
stream = self._stream(topic)
|
|
46
|
+
entries = client.xrange(stream, count=1)
|
|
47
|
+
if not entries:
|
|
48
|
+
return None
|
|
49
|
+
entry_id, fields = entries[0]
|
|
50
|
+
client.xdel(stream, entry_id)
|
|
51
|
+
value = fields.get(b"message", fields.get("message"))
|
|
52
|
+
if isinstance(value, bytes):
|
|
53
|
+
return value.decode("utf-8")
|
|
54
|
+
return str(value)
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def _stream(self, topic: str) -> str:
|
|
60
|
+
return f"{self._prefix}:{topic}"
|
|
61
|
+
|
|
62
|
+
def _get_client(self) -> Any:
|
|
63
|
+
if self._client is not None:
|
|
64
|
+
return self._client
|
|
65
|
+
redis = _require("redis", "redis")
|
|
66
|
+
self._client = redis.from_url(self._url) if self._url else redis.Redis()
|
|
67
|
+
return self._client
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RabbitMqQueue:
|
|
71
|
+
"""RabbitMQ via lazy ``pika`` (injectable channel for tests)."""
|
|
72
|
+
|
|
73
|
+
name = "rabbitmq"
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
queue: str = "xyberos",
|
|
78
|
+
*,
|
|
79
|
+
host: str = "localhost",
|
|
80
|
+
port: int = 5672,
|
|
81
|
+
channel: Any | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._queue = queue
|
|
84
|
+
self._host = host
|
|
85
|
+
self._port = port
|
|
86
|
+
self._channel = channel
|
|
87
|
+
|
|
88
|
+
def _get_channel(self) -> Any:
|
|
89
|
+
if self._channel is not None:
|
|
90
|
+
return self._channel
|
|
91
|
+
pika = _require("pika", "rabbitmq")
|
|
92
|
+
connection = pika.BlockingConnection(
|
|
93
|
+
pika.ConnectionParameters(host=self._host, port=self._port)
|
|
94
|
+
)
|
|
95
|
+
channel = connection.channel()
|
|
96
|
+
channel.queue_declare(queue=self._queue)
|
|
97
|
+
self._channel = channel
|
|
98
|
+
return channel
|
|
99
|
+
|
|
100
|
+
def publish(self, topic: str, message: str) -> None:
|
|
101
|
+
self._get_channel().basic_publish(exchange="", routing_key=self._queue, body=message)
|
|
102
|
+
|
|
103
|
+
def poll(self, topic: str, timeout: float = 0.1) -> str | None:
|
|
104
|
+
method, _properties, body = self._get_channel().basic_get(self._queue, auto_ack=True)
|
|
105
|
+
if method is None or body is None:
|
|
106
|
+
return None
|
|
107
|
+
return body.decode("utf-8") if isinstance(body, bytes) else str(body)
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class KafkaQueue:
|
|
114
|
+
"""Apache Kafka via lazy ``kafka-python`` (injectable producer/consumer)."""
|
|
115
|
+
|
|
116
|
+
name = "kafka"
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
bootstrap_servers: str = "localhost:9092",
|
|
121
|
+
*,
|
|
122
|
+
producer: Any | None = None,
|
|
123
|
+
consumer: Any | None = None,
|
|
124
|
+
group_id: str = "xyberos",
|
|
125
|
+
) -> None:
|
|
126
|
+
self._bootstrap_servers = bootstrap_servers
|
|
127
|
+
self._producer = producer
|
|
128
|
+
self._consumer = consumer
|
|
129
|
+
self._group_id = group_id
|
|
130
|
+
|
|
131
|
+
def publish(self, topic: str, message: str) -> None:
|
|
132
|
+
producer = self._get_producer()
|
|
133
|
+
producer.send(topic, value=message.encode("utf-8"))
|
|
134
|
+
producer.flush()
|
|
135
|
+
|
|
136
|
+
def poll(self, topic: str, timeout: float = 0.1) -> str | None:
|
|
137
|
+
consumer = self._get_consumer(topic)
|
|
138
|
+
records = consumer.poll(timeout_ms=int(timeout * 1000), max_records=1)
|
|
139
|
+
for _tp, messages in records.items():
|
|
140
|
+
for message in messages:
|
|
141
|
+
return message.value.decode("utf-8")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
if self._consumer is not None:
|
|
146
|
+
try:
|
|
147
|
+
self._consumer.close()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
self._consumer = None
|
|
151
|
+
if self._producer is not None:
|
|
152
|
+
try:
|
|
153
|
+
self._producer.close()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
self._producer = None
|
|
157
|
+
|
|
158
|
+
def _get_producer(self) -> Any:
|
|
159
|
+
if self._producer is not None:
|
|
160
|
+
return self._producer
|
|
161
|
+
kafka = _require("kafka", "kafka")
|
|
162
|
+
self._producer = kafka.KafkaProducer(bootstrap_servers=self._bootstrap_servers)
|
|
163
|
+
return self._producer
|
|
164
|
+
|
|
165
|
+
def _get_consumer(self, topic: str) -> Any:
|
|
166
|
+
if self._consumer is not None:
|
|
167
|
+
return self._consumer
|
|
168
|
+
kafka = _require("kafka", "kafka")
|
|
169
|
+
self._consumer = kafka.KafkaConsumer(
|
|
170
|
+
topic, bootstrap_servers=self._bootstrap_servers, group_id=self._group_id
|
|
171
|
+
)
|
|
172
|
+
return self._consumer
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The MessageQueue contract (RFC-0019, M9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class MessageQueue(Protocol):
|
|
10
|
+
"""Anything that publishes and polls messages on named topics."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
def publish(self, topic: str, message: str) -> None:
|
|
15
|
+
"""Publish ``message`` to ``topic``."""
|
|
16
|
+
|
|
17
|
+
def poll(self, topic: str, timeout: float = 0.1) -> str | None:
|
|
18
|
+
"""Return one message from ``topic`` (or ``None`` if empty)."""
|
|
19
|
+
|
|
20
|
+
def close(self) -> None:
|
|
21
|
+
"""Release the connection."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Queues plugin entry point (RFC-0019, M9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from xyberos.contracts import Plugin, Tool
|
|
9
|
+
from xyberos.tools import FunctionTool
|
|
10
|
+
|
|
11
|
+
from .adapters import KafkaQueue, RabbitMqQueue, RedisStreamsQueue
|
|
12
|
+
from .contract import MessageQueue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pop_tool(registry: Any, name: str) -> None:
|
|
16
|
+
unregister = getattr(registry, "unregister", None)
|
|
17
|
+
if callable(unregister):
|
|
18
|
+
unregister(name)
|
|
19
|
+
return
|
|
20
|
+
store = getattr(registry, "_tools", None)
|
|
21
|
+
if isinstance(store, dict):
|
|
22
|
+
cast(dict[str, Any], store).pop(name, None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QueuesPlugin(Plugin):
|
|
26
|
+
"""Registers ``queue_publish`` / ``queue_poll`` for a configured provider."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
provider: str | None = None,
|
|
31
|
+
*,
|
|
32
|
+
env_prefix: str = "QUEUE",
|
|
33
|
+
client: Any | None = None,
|
|
34
|
+
channel: Any | None = None,
|
|
35
|
+
producer: Any | None = None,
|
|
36
|
+
consumer: Any | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._provider = (provider or os.getenv(f"{env_prefix}_PROVIDER") or "redis").lower()
|
|
39
|
+
self._client = client
|
|
40
|
+
self._channel = channel
|
|
41
|
+
self._producer = producer
|
|
42
|
+
self._consumer = consumer
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
return "queues"
|
|
47
|
+
|
|
48
|
+
def message_queue(self) -> MessageQueue:
|
|
49
|
+
name = self._provider
|
|
50
|
+
if name == "redis":
|
|
51
|
+
return RedisStreamsQueue(client=self._client)
|
|
52
|
+
if name == "rabbitmq":
|
|
53
|
+
return RabbitMqQueue(channel=self._channel)
|
|
54
|
+
if name == "kafka":
|
|
55
|
+
return KafkaQueue(producer=self._producer, consumer=self._consumer)
|
|
56
|
+
raise ValueError(f"unknown queue provider '{name}' (redis | rabbitmq | kafka)")
|
|
57
|
+
|
|
58
|
+
def tools(self) -> list[Tool]:
|
|
59
|
+
queue = self.message_queue()
|
|
60
|
+
|
|
61
|
+
def _publish(topic: str, message: str) -> dict[str, Any]:
|
|
62
|
+
queue.publish(topic, message)
|
|
63
|
+
return {"topic": topic, "published": True}
|
|
64
|
+
|
|
65
|
+
def _poll(topic: str, timeout: float = 0.1) -> str | None:
|
|
66
|
+
return queue.poll(topic, timeout)
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
FunctionTool("queue_publish", _publish, description=f"Publish a message via {self._provider}."),
|
|
70
|
+
FunctionTool("queue_poll", _poll, description=f"Poll one message from a {self._provider} topic."),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def register(self, kernel: object) -> None:
|
|
74
|
+
registry = kernel.resolve("tools")
|
|
75
|
+
for tool in self.tools():
|
|
76
|
+
registry.register(tool)
|
|
77
|
+
|
|
78
|
+
def unregister(self, kernel: object) -> None:
|
|
79
|
+
registry = kernel.resolve("tools")
|
|
80
|
+
for tool in self.tools():
|
|
81
|
+
_pop_tool(registry, tool.name)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: Auto-discovered by ``app.load_entry_points()``.
|
|
85
|
+
plugin = QueuesPlugin()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-queues
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Message queue plugin (RFC-0019, M9): Redis Streams, RabbitMQ, Kafka behind one MessageQueue contract
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,queue,redis,rabbitmq,kafka
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: redis
|
|
11
|
+
Requires-Dist: redis; extra == "redis"
|
|
12
|
+
Provides-Extra: rabbitmq
|
|
13
|
+
Requires-Dist: pika; extra == "rabbitmq"
|
|
14
|
+
Provides-Extra: kafka
|
|
15
|
+
Requires-Dist: kafka-python; extra == "kafka"
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Requires-Dist: fakeredis; extra == "test"
|
|
19
|
+
|
|
20
|
+
# xyberos-queues
|
|
21
|
+
|
|
22
|
+
**Message queue plugin — RFC-0019, M9.** Redis Streams, RabbitMQ, and Kafka
|
|
23
|
+
behind one `MessageQueue` contract (`publish` / `poll`).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ./queues
|
|
29
|
+
pip install xyberos-queues[redis] # optional drivers
|
|
30
|
+
pip install xyberos-queues[rabbitmq]
|
|
31
|
+
pip install xyberos-queues[kafka]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from xyberos import create_app
|
|
38
|
+
from xyberos_queues import QueuesPlugin
|
|
39
|
+
|
|
40
|
+
app = create_app()
|
|
41
|
+
app.load_plugin(QueuesPlugin(provider="redis")) # or QUEUE_PROVIDER
|
|
42
|
+
|
|
43
|
+
app.tools.execute("queue_publish", None, topic="jobs", message="do work")
|
|
44
|
+
app.tools.execute("queue_poll", None, topic="jobs") # -> "do work"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Provider | Config |
|
|
48
|
+
| -------- | ------ |
|
|
49
|
+
| `redis` | Redis Streams (URL via `REDIS_URL`) |
|
|
50
|
+
| `rabbitmq` | host/port (default `localhost:5672`) |
|
|
51
|
+
| `kafka` | `bootstrap_servers` (default `localhost:9092`) |
|
|
52
|
+
|
|
53
|
+
`poll` drains one message (oldest first) per call; returns `None` when empty.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pytest fakeredis
|
|
59
|
+
pytest tests/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
fakeredis + fake clients — no live brokers.
|
|
63
|
+
|
|
64
|
+
## Ship location
|
|
65
|
+
|
|
66
|
+
Plugin (`xyberos.plugins` entry point) — infrastructure queues (M9).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_adapters.py
|
|
4
|
+
tests/test_plugin.py
|
|
5
|
+
xyberos_queues/__init__.py
|
|
6
|
+
xyberos_queues/adapters.py
|
|
7
|
+
xyberos_queues/contract.py
|
|
8
|
+
xyberos_queues/plugin.py
|
|
9
|
+
xyberos_queues.egg-info/PKG-INFO
|
|
10
|
+
xyberos_queues.egg-info/SOURCES.txt
|
|
11
|
+
xyberos_queues.egg-info/dependency_links.txt
|
|
12
|
+
xyberos_queues.egg-info/entry_points.txt
|
|
13
|
+
xyberos_queues.egg-info/requires.txt
|
|
14
|
+
xyberos_queues.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_queues
|