taraqueue 0.3.0__tar.gz → 0.5.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.
- {taraqueue-0.3.0 → taraqueue-0.5.0}/CHANGES.rst +14 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/PKG-INFO +1 -1
- {taraqueue-0.3.0 → taraqueue-0.5.0}/compose.yml +1 -1
- taraqueue-0.5.0/taraqueue/memory.py +58 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/redis.py +10 -5
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/testing/queue.py +2 -2
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/testing/services.py +15 -26
- taraqueue-0.5.0/tests/test_queue.py +75 -0
- taraqueue-0.5.0/tests/test_redis.py +6 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/uv.lock +172 -162
- taraqueue-0.3.0/taraqueue/memory.py +0 -50
- taraqueue-0.3.0/tests/test_queue.py +0 -22
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.editorconfig +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.gitattributes +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.github/CODEOWNERS +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.github/actions/setup-uv-env/action.yml +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.github/renovate.json +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.github/workflows/publish.yml +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.github/workflows/renovate.yaml +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.github/workflows/test.yml +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.gitignore +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.vscode/extensions.json +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.vscode/launch.json +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/.vscode/settings.json +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/CONTRIBUTING.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/LICENSE.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/Makefile +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/README.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/STYLE_GUIDE.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/changes.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/conf.py +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/contributing.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/index.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/license.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/modules.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/style_guide.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/taraqueue.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/docs/taraqueue.testing.rst +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/pyproject.toml +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/__init__.py +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/registry.py +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/testing/__init__.py +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/taraqueue/testing/compose.py +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/tests/test_compose.py +0 -0
- {taraqueue-0.3.0 → taraqueue-0.5.0}/tests/test_registry.py +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Memory queue implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from yarl import URL
|
|
10
|
+
|
|
11
|
+
from taraqueue import Queue, QueueEmpty
|
|
12
|
+
|
|
13
|
+
_channels: dict[str, list[asyncio.Queue[str]]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class MemoryQueue(Queue):
|
|
18
|
+
"""In-process queue with fan-out semantics."""
|
|
19
|
+
|
|
20
|
+
queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_url(cls, url: URL) -> MemoryQueue:
|
|
24
|
+
return cls()
|
|
25
|
+
|
|
26
|
+
async def subscribe(self, topic: str) -> None:
|
|
27
|
+
"""See `Queue.subscribe`."""
|
|
28
|
+
subscribers = _channels.setdefault(topic, [])
|
|
29
|
+
if self.queue in subscribers:
|
|
30
|
+
return
|
|
31
|
+
subscribers.append(self.queue)
|
|
32
|
+
|
|
33
|
+
async def unsubscribe(self, topic: str) -> None:
|
|
34
|
+
"""See `Queue.unsubscribe`."""
|
|
35
|
+
subscribers = _channels.get(topic)
|
|
36
|
+
if subscribers is None:
|
|
37
|
+
return
|
|
38
|
+
with suppress(ValueError):
|
|
39
|
+
subscribers.remove(self.queue)
|
|
40
|
+
if not subscribers:
|
|
41
|
+
_channels.pop(topic, None)
|
|
42
|
+
|
|
43
|
+
async def receive(self, timeout=None) -> str:
|
|
44
|
+
"""See `Queue.receive`."""
|
|
45
|
+
if not timeout:
|
|
46
|
+
try:
|
|
47
|
+
return self.queue.get_nowait()
|
|
48
|
+
except asyncio.QueueEmpty as e:
|
|
49
|
+
raise QueueEmpty("Queue is empty") from e
|
|
50
|
+
try:
|
|
51
|
+
return await asyncio.wait_for(self.queue.get(), timeout=float(timeout))
|
|
52
|
+
except TimeoutError as e:
|
|
53
|
+
raise QueueEmpty("Timed out waiting for message") from e
|
|
54
|
+
|
|
55
|
+
async def publish(self, topic: str, message: str) -> None:
|
|
56
|
+
"""See `Queue.publish`."""
|
|
57
|
+
for q in list(_channels.get(topic, [])):
|
|
58
|
+
await q.put(message)
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
"""Redis queue implementation."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import os
|
|
4
6
|
from dataclasses import dataclass, field
|
|
5
7
|
from time import time
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
6
9
|
|
|
7
|
-
from redis.asyncio import Redis
|
|
8
|
-
from redis.asyncio.client import PubSub
|
|
9
10
|
from yarl import URL
|
|
10
11
|
|
|
11
12
|
from taraqueue import Queue, QueueEmpty
|
|
12
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from redis.asyncio import Redis
|
|
16
|
+
from redis.asyncio.client import PubSub
|
|
17
|
+
|
|
13
18
|
|
|
14
19
|
@dataclass
|
|
15
20
|
class RedisQueue(Queue):
|
|
@@ -18,14 +23,14 @@ class RedisQueue(Queue):
|
|
|
18
23
|
pubsub: PubSub = field()
|
|
19
24
|
|
|
20
25
|
@classmethod
|
|
21
|
-
def from_env(cls, env=os.environ) ->
|
|
26
|
+
def from_env(cls, env=os.environ) -> RedisQueue:
|
|
22
27
|
host = env.get("REDIS_SLAVEOF_IP", "") or env.get("IPV4_NETWORK", "172.22.1") + ".249"
|
|
23
28
|
port = int(env.get("REDIS_SLAVEOF_PORT", "") or "6379")
|
|
24
29
|
password = env.get("REDIS_PASSWORD")
|
|
25
30
|
return cls.from_host(host, port, password=password)
|
|
26
31
|
|
|
27
32
|
@classmethod
|
|
28
|
-
def from_host(cls, host: str, port: int = 6379, password: str | None = None) ->
|
|
33
|
+
def from_host(cls, host: str, port: int = 6379, password: str | None = None) -> RedisQueue:
|
|
29
34
|
from redis.asyncio import StrictRedis
|
|
30
35
|
|
|
31
36
|
client = StrictRedis(
|
|
@@ -39,7 +44,7 @@ class RedisQueue(Queue):
|
|
|
39
44
|
return cls(client, pubsub)
|
|
40
45
|
|
|
41
46
|
@classmethod
|
|
42
|
-
def from_url(cls, url: URL | str) ->
|
|
47
|
+
def from_url(cls, url: URL | str) -> RedisQueue:
|
|
43
48
|
url = URL(url)
|
|
44
49
|
return cls.from_host(url.host, url.port, password=url.password)
|
|
45
50
|
|
|
@@ -14,13 +14,13 @@ def memory_queue():
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
@pytest.fixture
|
|
17
|
-
def redis_queue(redis_service,
|
|
17
|
+
def redis_queue(redis_service, taraqueue_env_vars):
|
|
18
18
|
"""Redis queue fixture."""
|
|
19
19
|
url = URL.build(
|
|
20
20
|
scheme="redis",
|
|
21
21
|
host=redis_service.ip,
|
|
22
22
|
port=6379,
|
|
23
|
-
password=
|
|
23
|
+
password=taraqueue_env_vars["REDIS_PASSWORD"],
|
|
24
24
|
)
|
|
25
25
|
return Queue.from_url(url)
|
|
26
26
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Service fixtures."""
|
|
2
2
|
|
|
3
|
-
from functools import partial
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
6
5
|
import pytest
|
|
@@ -9,35 +8,31 @@ from taraqueue.testing.compose import ComposeServer
|
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
@pytest.fixture(scope="session")
|
|
12
|
-
def
|
|
13
|
-
return "test"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@pytest.fixture(scope="session")
|
|
17
|
-
def env_vars(project):
|
|
11
|
+
def taraqueue_env_vars():
|
|
18
12
|
"""Environment variables for the services."""
|
|
19
13
|
return {
|
|
20
|
-
"COMPOSE_PROJECT_NAME":
|
|
14
|
+
"COMPOSE_PROJECT_NAME": "test",
|
|
21
15
|
"REDIS_PASSWORD": "test",
|
|
22
16
|
}
|
|
23
17
|
|
|
24
18
|
|
|
25
19
|
@pytest.fixture(scope="session")
|
|
26
|
-
def
|
|
27
|
-
"""Environment file containing `
|
|
20
|
+
def taraqueue_env_file(taraqueue_env_vars, request):
|
|
21
|
+
"""Environment file containing `taraqueue_env_vars`.
|
|
28
22
|
|
|
29
23
|
Cached for troubleshooting purposes.
|
|
30
24
|
"""
|
|
31
25
|
env_file = request.config.cache.makedir("compose") / "env"
|
|
32
26
|
with env_file.open("w") as f:
|
|
33
|
-
for k, v in
|
|
27
|
+
for k, v in taraqueue_env_vars.items():
|
|
34
28
|
f.write(f"{k}={v}\n")
|
|
35
29
|
|
|
36
30
|
return env_file
|
|
37
31
|
|
|
38
32
|
|
|
39
33
|
@pytest.fixture(scope="session")
|
|
40
|
-
def
|
|
34
|
+
def taraqueue_compose_files(request):
|
|
35
|
+
"""Use the compose files from the project - not this library."""
|
|
41
36
|
directory = Path(request.config.rootdir)
|
|
42
37
|
filenames = ["docker-compose.yml", "compose.yaml", "compose.yml"]
|
|
43
38
|
while True:
|
|
@@ -55,26 +50,20 @@ def compose_files(request):
|
|
|
55
50
|
|
|
56
51
|
|
|
57
52
|
@pytest.fixture(scope="session")
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
env_file=
|
|
63
|
-
compose_files=
|
|
53
|
+
def redis_service(process, taraqueue_env_file, taraqueue_compose_files):
|
|
54
|
+
"""Redis service fixture."""
|
|
55
|
+
server = ComposeServer(
|
|
56
|
+
pattern="Ready to accept connections tcp",
|
|
57
|
+
env_file=taraqueue_env_file,
|
|
58
|
+
compose_files=taraqueue_compose_files,
|
|
64
59
|
process=process,
|
|
65
60
|
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@pytest.fixture(scope="session")
|
|
69
|
-
def redis_service(compose_server):
|
|
70
|
-
"""Redis service fixture."""
|
|
71
|
-
server = compose_server("Ready to accept connections tcp")
|
|
72
61
|
with server.run("redis") as service:
|
|
73
62
|
yield service
|
|
74
63
|
|
|
75
64
|
|
|
76
65
|
@pytest.fixture(scope="session")
|
|
77
|
-
def redis_client(redis_service,
|
|
66
|
+
def redis_client(redis_service, taraqueue_env_vars):
|
|
78
67
|
"""Redis client to the service fixture."""
|
|
79
68
|
from redis import StrictRedis
|
|
80
69
|
|
|
@@ -83,5 +72,5 @@ def redis_client(redis_service, env_vars):
|
|
|
83
72
|
port=6379,
|
|
84
73
|
decode_responses=True,
|
|
85
74
|
db=0,
|
|
86
|
-
password=
|
|
75
|
+
password=taraqueue_env_vars["REDIS_PASSWORD"],
|
|
87
76
|
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Integration tests for the queue module."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from taraqueue import Queue, QueueEmpty
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def test_queue_send_receive(queue, unique):
|
|
10
|
+
"""Sending a message to a queue should be the next received message."""
|
|
11
|
+
topic = unique("text")
|
|
12
|
+
async with queue.connect(topic) as session:
|
|
13
|
+
await session.publish(topic, "test")
|
|
14
|
+
result = await session.receive(5)
|
|
15
|
+
assert result == "test"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def test_queue_receive_empty(queue, unique):
|
|
19
|
+
"""Receiving from an empty queue should raise a QueueEmpty error."""
|
|
20
|
+
topic = unique("text")
|
|
21
|
+
async with queue.connect(topic) as session:
|
|
22
|
+
with pytest.raises(QueueEmpty):
|
|
23
|
+
await session.receive()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_queue_two_subscribers_both_receive(unique):
|
|
27
|
+
"""Every active subscriber should independently receive each published message.
|
|
28
|
+
|
|
29
|
+
This verifies fan-out semantics: two separate Queue instances subscribed to
|
|
30
|
+
the same channel both receive the message, rather than one consuming it and
|
|
31
|
+
the other seeing nothing.
|
|
32
|
+
"""
|
|
33
|
+
topic = unique("text")
|
|
34
|
+
sub1 = Queue.from_url("memory://")
|
|
35
|
+
sub2 = Queue.from_url("memory://")
|
|
36
|
+
publisher = Queue.from_url("memory://")
|
|
37
|
+
|
|
38
|
+
async with sub1.connect(topic), sub2.connect(topic):
|
|
39
|
+
await publisher.publish(topic, "hello")
|
|
40
|
+
msg1 = await sub1.receive(timeout=5)
|
|
41
|
+
msg2 = await sub2.receive(timeout=5)
|
|
42
|
+
|
|
43
|
+
assert msg1 == "hello"
|
|
44
|
+
assert msg2 == "hello"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_queue_subscribe_two_topics_receives_from_both(unique):
|
|
48
|
+
"""A single subscriber connected to two topics should receive from either."""
|
|
49
|
+
topic_a = unique("text")
|
|
50
|
+
topic_b = unique("text")
|
|
51
|
+
sub = Queue.from_url("memory://")
|
|
52
|
+
publisher = Queue.from_url("memory://")
|
|
53
|
+
|
|
54
|
+
async with sub.connect(topic_a), sub.connect(topic_b):
|
|
55
|
+
await publisher.publish(topic_a, "from-a")
|
|
56
|
+
await publisher.publish(topic_b, "from-b")
|
|
57
|
+
msg1 = await sub.receive(timeout=5)
|
|
58
|
+
msg2 = await sub.receive(timeout=5)
|
|
59
|
+
|
|
60
|
+
assert {msg1, msg2} == {"from-a", "from-b"}
|
|
61
|
+
|
|
62
|
+
"""A subscriber that connects after a publish should not receive that message.
|
|
63
|
+
|
|
64
|
+
This matches Redis pub/sub behaviour: messages are only delivered to
|
|
65
|
+
subscribers that are active at the moment of publishing.
|
|
66
|
+
"""
|
|
67
|
+
topic = unique("text")
|
|
68
|
+
publisher = Queue.from_url("memory://")
|
|
69
|
+
late_sub = Queue.from_url("memory://")
|
|
70
|
+
|
|
71
|
+
await publisher.publish(topic, "early")
|
|
72
|
+
|
|
73
|
+
async with late_sub.connect(topic):
|
|
74
|
+
with pytest.raises(QueueEmpty):
|
|
75
|
+
await late_sub.receive()
|