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.
- {taraqueue-0.4.0 → taraqueue-0.6.0}/CHANGES.rst +14 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/PKG-INFO +6 -2
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/taraqueue.rst +8 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/pyproject.toml +9 -1
- taraqueue-0.6.0/taraqueue/memory.py +58 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/redis.py +10 -5
- taraqueue-0.6.0/taraqueue/sqs.py +166 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/testing/compose.py +2 -6
- taraqueue-0.6.0/taraqueue/testing/queue.py +88 -0
- taraqueue-0.6.0/tests/test_queue.py +75 -0
- taraqueue-0.6.0/tests/test_sqs.py +82 -0
- taraqueue-0.6.0/uv.lock +2329 -0
- taraqueue-0.4.0/taraqueue/memory.py +0 -50
- taraqueue-0.4.0/taraqueue/testing/queue.py +0 -36
- taraqueue-0.4.0/tests/test_queue.py +0 -22
- taraqueue-0.4.0/uv.lock +0 -1041
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.editorconfig +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.gitattributes +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/CODEOWNERS +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/actions/setup-uv-env/action.yml +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/renovate.json +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/workflows/publish.yml +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/workflows/renovate.yaml +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.github/workflows/test.yml +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.gitignore +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.vscode/extensions.json +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.vscode/launch.json +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/.vscode/settings.json +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/CONTRIBUTING.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/LICENSE.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/Makefile +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/README.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/STYLE_GUIDE.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/compose.yml +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/changes.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/conf.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/contributing.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/index.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/license.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/modules.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/style_guide.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/docs/taraqueue.testing.rst +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/__init__.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/registry.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/testing/__init__.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/taraqueue/testing/services.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/tests/test_compose.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/tests/test_redis.py +0 -0
- {taraqueue-0.4.0 → taraqueue-0.6.0}/tests/test_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taraqueue
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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) ->
|
|
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
|
|
|
@@ -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
|
-
.
|
|
79
|
-
.
|
|
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"
|