taraqueue 0.5.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 (47) hide show
  1. {taraqueue-0.5.0 → taraqueue-0.6.0}/CHANGES.rst +7 -0
  2. {taraqueue-0.5.0 → taraqueue-0.6.0}/PKG-INFO +6 -2
  3. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/taraqueue.rst +8 -0
  4. {taraqueue-0.5.0 → taraqueue-0.6.0}/pyproject.toml +9 -1
  5. taraqueue-0.6.0/taraqueue/sqs.py +166 -0
  6. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/testing/compose.py +2 -6
  7. taraqueue-0.6.0/taraqueue/testing/queue.py +88 -0
  8. taraqueue-0.6.0/tests/test_sqs.py +82 -0
  9. taraqueue-0.6.0/uv.lock +2329 -0
  10. taraqueue-0.5.0/taraqueue/testing/queue.py +0 -36
  11. taraqueue-0.5.0/uv.lock +0 -1051
  12. {taraqueue-0.5.0 → taraqueue-0.6.0}/.editorconfig +0 -0
  13. {taraqueue-0.5.0 → taraqueue-0.6.0}/.gitattributes +0 -0
  14. {taraqueue-0.5.0 → taraqueue-0.6.0}/.github/CODEOWNERS +0 -0
  15. {taraqueue-0.5.0 → taraqueue-0.6.0}/.github/actions/setup-uv-env/action.yml +0 -0
  16. {taraqueue-0.5.0 → taraqueue-0.6.0}/.github/renovate.json +0 -0
  17. {taraqueue-0.5.0 → taraqueue-0.6.0}/.github/workflows/publish.yml +0 -0
  18. {taraqueue-0.5.0 → taraqueue-0.6.0}/.github/workflows/renovate.yaml +0 -0
  19. {taraqueue-0.5.0 → taraqueue-0.6.0}/.github/workflows/test.yml +0 -0
  20. {taraqueue-0.5.0 → taraqueue-0.6.0}/.gitignore +0 -0
  21. {taraqueue-0.5.0 → taraqueue-0.6.0}/.vscode/extensions.json +0 -0
  22. {taraqueue-0.5.0 → taraqueue-0.6.0}/.vscode/launch.json +0 -0
  23. {taraqueue-0.5.0 → taraqueue-0.6.0}/.vscode/settings.json +0 -0
  24. {taraqueue-0.5.0 → taraqueue-0.6.0}/CONTRIBUTING.rst +0 -0
  25. {taraqueue-0.5.0 → taraqueue-0.6.0}/LICENSE.rst +0 -0
  26. {taraqueue-0.5.0 → taraqueue-0.6.0}/Makefile +0 -0
  27. {taraqueue-0.5.0 → taraqueue-0.6.0}/README.rst +0 -0
  28. {taraqueue-0.5.0 → taraqueue-0.6.0}/STYLE_GUIDE.rst +0 -0
  29. {taraqueue-0.5.0 → taraqueue-0.6.0}/compose.yml +0 -0
  30. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/changes.rst +0 -0
  31. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/conf.py +0 -0
  32. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/contributing.rst +0 -0
  33. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/index.rst +0 -0
  34. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/license.rst +0 -0
  35. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/modules.rst +0 -0
  36. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/style_guide.rst +0 -0
  37. {taraqueue-0.5.0 → taraqueue-0.6.0}/docs/taraqueue.testing.rst +0 -0
  38. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/__init__.py +0 -0
  39. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/memory.py +0 -0
  40. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/redis.py +0 -0
  41. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/registry.py +0 -0
  42. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/testing/__init__.py +0 -0
  43. {taraqueue-0.5.0 → taraqueue-0.6.0}/taraqueue/testing/services.py +0 -0
  44. {taraqueue-0.5.0 → taraqueue-0.6.0}/tests/test_compose.py +0 -0
  45. {taraqueue-0.5.0 → taraqueue-0.6.0}/tests/test_queue.py +0 -0
  46. {taraqueue-0.5.0 → taraqueue-0.6.0}/tests/test_redis.py +0 -0
  47. {taraqueue-0.5.0 → taraqueue-0.6.0}/tests/test_registry.py +0 -0
@@ -1,3 +1,10 @@
1
+ Version 0.6.0
2
+ -------------
3
+
4
+ Released 2026-05-22
5
+
6
+ - Add Amazon SQS queue.
7
+
1
8
  Version 0.5.0
2
9
  -------------
3
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taraqueue
3
- Version: 0.5.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,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,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"