taraqueue 0.4.0__tar.gz → 0.6.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.
Files changed (49) hide show
  1. {taraqueue-0.4.0 → taraqueue-0.6.0}/CHANGES.rst +14 -0
  2. {taraqueue-0.4.0 → taraqueue-0.6.0}/PKG-INFO +6 -2
  3. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/taraqueue.rst +8 -0
  4. {taraqueue-0.4.0 → taraqueue-0.6.0}/pyproject.toml +9 -1
  5. taraqueue-0.6.0/taraqueue/memory.py +58 -0
  6. {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/redis.py +10 -5
  7. taraqueue-0.6.0/taraqueue/sqs.py +166 -0
  8. {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/testing/compose.py +2 -6
  9. taraqueue-0.6.0/taraqueue/testing/queue.py +88 -0
  10. taraqueue-0.6.0/tests/test_queue.py +75 -0
  11. taraqueue-0.6.0/tests/test_sqs.py +82 -0
  12. taraqueue-0.6.0/uv.lock +2329 -0
  13. taraqueue-0.4.0/taraqueue/memory.py +0 -50
  14. taraqueue-0.4.0/taraqueue/testing/queue.py +0 -36
  15. taraqueue-0.4.0/tests/test_queue.py +0 -22
  16. taraqueue-0.4.0/uv.lock +0 -1041
  17. {taraqueue-0.4.0 → taraqueue-0.6.0}/.editorconfig +0 -0
  18. {taraqueue-0.4.0 → taraqueue-0.6.0}/.gitattributes +0 -0
  19. {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/CODEOWNERS +0 -0
  20. {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/actions/setup-uv-env/action.yml +0 -0
  21. {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/renovate.json +0 -0
  22. {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/workflows/publish.yml +0 -0
  23. {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/workflows/renovate.yaml +0 -0
  24. {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/workflows/test.yml +0 -0
  25. {taraqueue-0.4.0 → taraqueue-0.6.0}/.gitignore +0 -0
  26. {taraqueue-0.4.0 → taraqueue-0.6.0}/.vscode/extensions.json +0 -0
  27. {taraqueue-0.4.0 → taraqueue-0.6.0}/.vscode/launch.json +0 -0
  28. {taraqueue-0.4.0 → taraqueue-0.6.0}/.vscode/settings.json +0 -0
  29. {taraqueue-0.4.0 → taraqueue-0.6.0}/CONTRIBUTING.rst +0 -0
  30. {taraqueue-0.4.0 → taraqueue-0.6.0}/LICENSE.rst +0 -0
  31. {taraqueue-0.4.0 → taraqueue-0.6.0}/Makefile +0 -0
  32. {taraqueue-0.4.0 → taraqueue-0.6.0}/README.rst +0 -0
  33. {taraqueue-0.4.0 → taraqueue-0.6.0}/STYLE_GUIDE.rst +0 -0
  34. {taraqueue-0.4.0 → taraqueue-0.6.0}/compose.yml +0 -0
  35. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/changes.rst +0 -0
  36. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/conf.py +0 -0
  37. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/contributing.rst +0 -0
  38. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/index.rst +0 -0
  39. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/license.rst +0 -0
  40. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/modules.rst +0 -0
  41. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/style_guide.rst +0 -0
  42. {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/taraqueue.testing.rst +0 -0
  43. {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/__init__.py +0 -0
  44. {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/registry.py +0 -0
  45. {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/testing/__init__.py +0 -0
  46. {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/testing/services.py +0 -0
  47. {taraqueue-0.4.0 → taraqueue-0.6.0}/tests/test_compose.py +0 -0
  48. {taraqueue-0.4.0 → taraqueue-0.6.0}/tests/test_redis.py +0 -0
  49. {taraqueue-0.4.0 → taraqueue-0.6.0}/tests/test_registry.py +0 -0
@@ -1,3 +1,17 @@
1
+ Version 0.6.0
2
+ -------------
3
+
4
+ Released 2026-05-22
5
+
6
+ - Add Amazon SQS queue.
7
+
8
+ Version 0.5.0
9
+ -------------
10
+
11
+ Released 2026-05-13
12
+
13
+ - Fix MemoryQueue to behave like RedisQueue.
14
+
1
15
  Version 0.4.0
2
16
  -------------
3
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taraqueue
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Python queue abstraction layer
5
5
  Project-URL: Repository, https://github.com/taradix/taraqueue
6
6
  Author-email: Marc Tardif <marc@taram.ca>
@@ -15,12 +15,16 @@ Requires-Dist: sphinx<10.0.0,>=9.0.0; extra == 'docs'
15
15
  Requires-Dist: sphinxcontrib-log-cabinet<2.0.0,>=1.0.1; extra == 'docs'
16
16
  Provides-Extra: redis
17
17
  Requires-Dist: redis>=7.4.0; extra == 'redis'
18
+ Provides-Extra: sqs
19
+ Requires-Dist: aiobotocore>=2.21.0; extra == 'sqs'
18
20
  Provides-Extra: test
21
+ Requires-Dist: aiobotocore>=2.21.0; extra == 'test'
19
22
  Requires-Dist: coverage<8.0.0,>=7.2.3; extra == 'test'
20
23
  Requires-Dist: more-itertools<11.1.0,>=11.0.1; extra == 'test'
24
+ Requires-Dist: moto[server,sns,sqs]>=5.1.0; extra == 'test'
21
25
  Requires-Dist: pytest-asyncio<2.0.0,>=1.0.0; extra == 'test'
22
26
  Requires-Dist: pytest-unique<1.0.0,>=0.1.8; extra == 'test'
23
- Requires-Dist: pytest-xdocker<1.0.0,>=0.2.8; extra == 'test'
27
+ Requires-Dist: pytest-xdocker<1.0.0,>=0.2.9; extra == 'test'
24
28
  Requires-Dist: pytest<10.0.0,>=9.0.0; extra == 'test'
25
29
  Requires-Dist: redis>=7.4.0; extra == 'test'
26
30
  Description-Content-Type: text/x-rst
@@ -28,6 +28,14 @@ taraqueue.redis module
28
28
  :show-inheritance:
29
29
  :undoc-members:
30
30
 
31
+ taraqueue.sqs module
32
+ --------------------
33
+
34
+ .. automodule:: taraqueue.sqs
35
+ :members:
36
+ :show-inheritance:
37
+ :undoc-members:
38
+
31
39
  taraqueue.registry module
32
40
  -------------------------
33
41
 
@@ -20,14 +20,20 @@ redis = [
20
20
  "redis>=7.4.0",
21
21
  ]
22
22
 
23
+ sqs = [
24
+ "aiobotocore>=2.21.0",
25
+ ]
26
+
23
27
  test = [
28
+ "aiobotocore>=2.21.0",
24
29
  "coverage>=7.2.3,<8.0.0",
30
+ "moto[sns,sqs,server]>=5.1.0",
25
31
  "more-itertools>=11.0.1,<11.1.0",
26
32
  "redis>=7.4.0",
27
33
  "pytest>=9.0.0,<10.0.0",
28
34
  "pytest-asyncio>=1.0.0,<2.0.0",
29
35
  "pytest-unique>=0.1.8,<1.0.0",
30
- "pytest-xdocker>=0.2.8,<1.0.0",
36
+ "pytest-xdocker>=0.2.9,<1.0.0",
31
37
  ]
32
38
 
33
39
  check = [
@@ -47,6 +53,7 @@ taraqueue-queue = "taraqueue.testing.queue"
47
53
  [project.entry-points."taraqueue"]
48
54
  memory = "taraqueue.memory:MemoryQueue"
49
55
  redis = "taraqueue.redis:RedisQueue"
56
+ sqs = "taraqueue.sqs:SQSQueue"
50
57
 
51
58
  [build-system]
52
59
  requires = ["hatchling", "hatch-vcs"]
@@ -110,6 +117,7 @@ ignore = [
110
117
 
111
118
  [tool.ruff.lint.per-file-ignores]
112
119
  "tests/*" = ["S101", "S106"]
120
+ "taraqueue/testing/*" = ["S106", "S603"]
113
121
 
114
122
  # Pytest options:
115
123
  # https://docs.pytest.org/en/6.2.x/reference.html#ini-options-ref
@@ -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) -> "RedisQueue":
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) -> "RedisQueue":
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) -> "RedisQueue":
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
 
@@ -0,0 +1,166 @@
1
+ """Amazon SQS queue implementation using SNS for pub/sub fan-out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from time import time
9
+ from typing import TYPE_CHECKING
10
+
11
+ from yarl import URL
12
+
13
+ from taraqueue import Queue, QueueEmpty
14
+
15
+ if TYPE_CHECKING:
16
+ from types import ModuleType
17
+
18
+
19
+ @dataclass
20
+ class SQSQueue(Queue):
21
+ """Queue backed by Amazon SNS (publish) and SQS (subscribe/receive)."""
22
+
23
+ session: object = field(repr=False)
24
+ region: str = "us-east-1"
25
+ endpoint_url: str | None = None
26
+ _subscriptions: dict[str, _Subscription] = field(default_factory=dict, repr=False)
27
+ _aiobotocore: ModuleType | None = field(default=None, repr=False)
28
+
29
+ @classmethod
30
+ def from_url(cls, url: URL | str) -> SQSQueue:
31
+ """Create an SQSQueue from a URL."""
32
+ import aiobotocore.session
33
+
34
+ url = URL(url)
35
+ region = url.path.lstrip("/") or "us-east-1"
36
+
37
+ endpoint_url = None
38
+ if url.host:
39
+ scheme = "https" if url.port == 443 else "http"
40
+ port = url.port or 4566
41
+ endpoint_url = f"{scheme}://{url.host}:{port}"
42
+
43
+ session = aiobotocore.session.get_session()
44
+ if url.user:
45
+ session.set_credentials(url.user, url.password or "")
46
+
47
+ return cls(
48
+ session=session,
49
+ region=region,
50
+ endpoint_url=endpoint_url,
51
+ )
52
+
53
+ def _create_client(self, service: str):
54
+ return self.session.create_client(
55
+ service,
56
+ region_name=self.region,
57
+ endpoint_url=self.endpoint_url,
58
+ )
59
+
60
+ async def subscribe(self, topic: str) -> None:
61
+ """See `Queue.subscribe`."""
62
+ if topic in self._subscriptions:
63
+ return
64
+
65
+ async with self._create_client("sns") as sns:
66
+ resp = await sns.create_topic(Name=topic)
67
+ topic_arn = resp["TopicArn"]
68
+
69
+ queue_name = f"taraqueue-{topic}-{uuid.uuid4().hex[:8]}"
70
+ async with self._create_client("sqs") as sqs:
71
+ resp = await sqs.create_queue(QueueName=queue_name)
72
+ queue_url = resp["QueueUrl"]
73
+ attrs = await sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])
74
+ queue_arn = attrs["Attributes"]["QueueArn"]
75
+
76
+ # Allow SNS to send messages to this SQS queue.
77
+ policy = json.dumps({
78
+ "Version": "2012-10-17",
79
+ "Statement": [{
80
+ "Effect": "Allow",
81
+ "Principal": "*",
82
+ "Action": "sqs:SendMessage",
83
+ "Resource": queue_arn,
84
+ "Condition": {"ArnEquals": {"aws:SourceArn": topic_arn}},
85
+ }],
86
+ })
87
+ await sqs.set_queue_attributes(QueueUrl=queue_url, Attributes={"Policy": policy})
88
+
89
+ async with self._create_client("sns") as sns:
90
+ resp = await sns.subscribe(
91
+ TopicArn=topic_arn,
92
+ Protocol="sqs",
93
+ Endpoint=queue_arn,
94
+ )
95
+ subscription_arn = resp["SubscriptionArn"]
96
+ # Request raw message delivery so we don't have to unwrap the SNS envelope.
97
+ await sns.set_subscription_attributes(
98
+ SubscriptionArn=subscription_arn,
99
+ AttributeName="RawMessageDelivery",
100
+ AttributeValue="true",
101
+ )
102
+
103
+ self._subscriptions[topic] = _Subscription(
104
+ topic_arn=topic_arn,
105
+ queue_url=queue_url,
106
+ queue_name=queue_name,
107
+ subscription_arn=subscription_arn,
108
+ )
109
+
110
+ async def unsubscribe(self, topic: str) -> None:
111
+ """See `Queue.unsubscribe`."""
112
+ sub = self._subscriptions.pop(topic, None)
113
+ if sub is None:
114
+ return
115
+
116
+ async with self._create_client("sns") as sns:
117
+ await sns.unsubscribe(SubscriptionArn=sub.subscription_arn)
118
+
119
+ async with self._create_client("sqs") as sqs:
120
+ await sqs.delete_queue(QueueUrl=sub.queue_url)
121
+
122
+ async def receive(self, timeout=None) -> str:
123
+ """See `Queue.receive`."""
124
+ if not self._subscriptions:
125
+ raise QueueEmpty("Queue is empty")
126
+
127
+ wait_time = int(timeout) if timeout and int(timeout) > 0 else 0
128
+ stop_time = time() + (float(timeout) if timeout else 0)
129
+
130
+ while True:
131
+ for sub in list(self._subscriptions.values()):
132
+ async with self._create_client("sqs") as sqs:
133
+ resp = await sqs.receive_message(
134
+ QueueUrl=sub.queue_url,
135
+ MaxNumberOfMessages=1,
136
+ WaitTimeSeconds=min(wait_time, 20),
137
+ )
138
+ messages = resp.get("Messages", [])
139
+ if messages:
140
+ msg = messages[0]
141
+ async with self._create_client("sqs") as sqs:
142
+ await sqs.delete_message(
143
+ QueueUrl=sub.queue_url,
144
+ ReceiptHandle=msg["ReceiptHandle"],
145
+ )
146
+ return msg["Body"]
147
+
148
+ if time() >= stop_time:
149
+ raise QueueEmpty("Queue is empty")
150
+
151
+ async def publish(self, topic: str, message: str) -> None:
152
+ """See `Queue.publish`."""
153
+ async with self._create_client("sns") as sns:
154
+ resp = await sns.create_topic(Name=topic)
155
+ topic_arn = resp["TopicArn"]
156
+ await sns.publish(TopicArn=topic_arn, Message=message)
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class _Subscription:
161
+ """Internal state for a single topic subscription."""
162
+
163
+ topic_arn: str
164
+ queue_url: str
165
+ queue_name: str
166
+ subscription_arn: str
@@ -66,7 +66,6 @@ class ComposeServer(ProcessServer):
66
66
 
67
67
  def prepare_func(self, controldir):
68
68
  """Prepare the function to run the compose service."""
69
- full_name = self.full_name(controldir.basename)
70
69
  compose = xdocker.compose().with_project_name(self.project)
71
70
  if env_file := self.env_file:
72
71
  compose = compose.with_env_file(env_file)
@@ -75,11 +74,8 @@ class ComposeServer(ProcessServer):
75
74
 
76
75
  command = (
77
76
  compose
78
- .run(controldir.basename)
79
- .with_name(full_name)
80
- .with_build()
81
- .with_remove()
82
- .with_optionals("--use-aliases")
77
+ .up(controldir.basename)
78
+ .with_optionals("--no-deps")
83
79
  )
84
80
 
85
81
  return ProcessData(self.pattern, command, timeout=self.timeout)
@@ -0,0 +1,88 @@
1
+ """Queue fixtures."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import time
6
+
7
+ import pytest
8
+ from yarl import URL
9
+
10
+ from taraqueue import Queue
11
+
12
+
13
+ @pytest.fixture
14
+ def memory_queue():
15
+ """Memory queue fixture."""
16
+ url = URL.build(scheme="memory")
17
+ return Queue.from_url(url)
18
+
19
+
20
+ @pytest.fixture
21
+ def redis_queue(redis_service, taraqueue_env_vars):
22
+ """Redis queue fixture."""
23
+ url = URL.build(
24
+ scheme="redis",
25
+ host=redis_service.ip,
26
+ port=6379,
27
+ password=taraqueue_env_vars["REDIS_PASSWORD"],
28
+ )
29
+ return Queue.from_url(url)
30
+
31
+
32
+ @pytest.fixture
33
+ def sqs_queue(moto_server):
34
+ """SQS queue fixture backed by moto server."""
35
+ url = URL.build(
36
+ scheme="sqs",
37
+ host="127.0.0.1",
38
+ port=moto_server,
39
+ user="testing",
40
+ password="testing",
41
+ )
42
+ return Queue.from_url(url)
43
+
44
+
45
+ @pytest.fixture(scope="session")
46
+ def moto_server():
47
+ """Start a moto standalone server for AWS service mocking."""
48
+ import socket
49
+
50
+ # Find a free port
51
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
52
+ s.bind(("127.0.0.1", 0))
53
+ port = s.getsockname()[1]
54
+
55
+ proc = subprocess.Popen(
56
+ [sys.executable, "-m", "moto.server", "-p", str(port)],
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.PIPE,
59
+ )
60
+
61
+ # Wait for the server to be ready
62
+ for _ in range(50):
63
+ try:
64
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
65
+ s.connect(("127.0.0.1", port))
66
+ break
67
+ except OSError:
68
+ time.sleep(0.1)
69
+ else:
70
+ proc.terminate()
71
+ raise RuntimeError("moto server failed to start")
72
+
73
+ yield port
74
+
75
+ proc.terminate()
76
+ proc.wait()
77
+
78
+
79
+ @pytest.fixture(
80
+ params=[
81
+ "memory_queue",
82
+ "redis_queue",
83
+ "sqs_queue",
84
+ ],
85
+ )
86
+ def queue(request):
87
+ """Queue fixture."""
88
+ return request.getfixturevalue(request.param)
@@ -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()
@@ -0,0 +1,82 @@
1
+ """Integration tests for the SQS queue."""
2
+
3
+ import pytest
4
+
5
+ from taraqueue import Queue, QueueEmpty
6
+
7
+
8
+ async def test_sqs_sns_topic_created_on_subscribe(sqs_queue, unique):
9
+ """Subscribing should create the SNS topic and SQS queue."""
10
+ topic = unique("text")
11
+ await sqs_queue.subscribe(topic)
12
+ try:
13
+ assert topic in sqs_queue._subscriptions
14
+ sub = sqs_queue._subscriptions[topic]
15
+ assert sub.topic_arn
16
+ assert sub.queue_url
17
+ assert sub.subscription_arn
18
+ finally:
19
+ await sqs_queue.unsubscribe(topic)
20
+
21
+
22
+ async def test_sqs_unsubscribe_cleans_up(sqs_queue, unique):
23
+ """Unsubscribing should remove the internal subscription state."""
24
+ topic = unique("text")
25
+ await sqs_queue.subscribe(topic)
26
+ await sqs_queue.unsubscribe(topic)
27
+ assert topic not in sqs_queue._subscriptions
28
+
29
+
30
+ async def test_sqs_subscribe_idempotent(sqs_queue, unique):
31
+ """Subscribing twice to the same topic should not create a duplicate."""
32
+ topic = unique("text")
33
+ await sqs_queue.subscribe(topic)
34
+ try:
35
+ sub1 = sqs_queue._subscriptions[topic]
36
+ await sqs_queue.subscribe(topic)
37
+ sub2 = sqs_queue._subscriptions[topic]
38
+ assert sub1 is sub2
39
+ finally:
40
+ await sqs_queue.unsubscribe(topic)
41
+
42
+
43
+ async def test_sqs_receive_empty_no_subscriptions(sqs_queue):
44
+ """Receiving with no subscriptions should raise QueueEmpty."""
45
+ with pytest.raises(QueueEmpty):
46
+ await sqs_queue.receive()
47
+
48
+
49
+ async def test_sqs_fan_out(moto_server, unique):
50
+ """Two SQS subscribers should both receive the same published message."""
51
+ sub1 = Queue.from_url(f"sqs://test:test@127.0.0.1:{moto_server}")
52
+ sub2 = Queue.from_url(f"sqs://test:test@127.0.0.1:{moto_server}")
53
+ publisher = Queue.from_url(f"sqs://test:test@127.0.0.1:{moto_server}")
54
+
55
+ topic = unique("text")
56
+ async with sub1.connect(topic), sub2.connect(topic):
57
+ await publisher.publish(topic, "fanout-msg")
58
+ msg1 = await sub1.receive(timeout=5)
59
+ msg2 = await sub2.receive(timeout=5)
60
+
61
+ assert msg1 == "fanout-msg"
62
+ assert msg2 == "fanout-msg"
63
+
64
+
65
+ async def test_sqs_late_subscriber_misses_message(moto_server, unique):
66
+ """A subscriber that connects after publish should not receive that message."""
67
+ publisher = Queue.from_url(f"sqs://test:test@127.0.0.1:{moto_server}")
68
+ late_sub = Queue.from_url(f"sqs://test:test@127.0.0.1:{moto_server}")
69
+
70
+ topic = unique("text")
71
+ await publisher.publish(topic, "early")
72
+
73
+ async with late_sub.connect(topic):
74
+ with pytest.raises(QueueEmpty):
75
+ await late_sub.receive()
76
+
77
+
78
+ async def test_sqs_from_url_with_credentials():
79
+ """from_url should parse credentials and endpoint from the URL."""
80
+ q = Queue.from_url("sqs://mykey:mysecret@localhost:4566/eu-west-1")
81
+ assert q.region == "eu-west-1"
82
+ assert q.endpoint_url == "http://localhost:4566"