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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [xyberos.plugins]
2
+ queues = xyberos_queues.plugin:plugin
@@ -0,0 +1,14 @@
1
+ xyberos>=1.0
2
+
3
+ [kafka]
4
+ kafka-python
5
+
6
+ [rabbitmq]
7
+ pika
8
+
9
+ [redis]
10
+ redis
11
+
12
+ [test]
13
+ pytest
14
+ fakeredis
@@ -0,0 +1 @@
1
+ xyberos_queues