Skip to content

Commit 33af60f

Browse files
committed
Update version to 1.1.0, add SQS support, and enhance CI workflow
1 parent 998b86b commit 33af60f

9 files changed

Lines changed: 638 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,14 @@ jobs:
4444
- name: Install (dev + all adapters for type context)
4545
run: |
4646
python -m pip install --upgrade pip
47-
pip install -e ".[dev,celery,django,redis,amqp]"
47+
pip install -e ".[dev,celery,django,redis,amqp,sqs]"
4848
- name: Ruff
4949
run: ruff check src tests
5050
- name: Mypy
5151
run: mypy
5252

5353
integration:
54-
name: Redis integration
54+
name: Broker integration (Redis · RabbitMQ · SQS)
5555
runs-on: ubuntu-latest
5656
services:
5757
redis:
@@ -72,6 +72,12 @@ jobs:
7272
--health-interval 10s
7373
--health-timeout 5s
7474
--health-retries 15
75+
# Free, SQS-compatible broker for the SqsTransport round-trip. The native
76+
# image is shell-less, so readiness is polled by a TCP-probe step below.
77+
elasticmq:
78+
image: softwaremill/elasticmq-native:1.6.11
79+
ports:
80+
- 9324:9324
7581
steps:
7682
- uses: actions/checkout@v5
7783

@@ -83,12 +89,26 @@ jobs:
8389
- name: Install (all adapters — full coverage with brokers)
8490
run: |
8591
python -m pip install --upgrade pip
86-
pip install -e ".[redis,amqp,celery,django,dev]"
92+
pip install -e ".[redis,amqp,sqs,celery,django,dev]"
93+
94+
- name: Wait for ElasticMQ
95+
run: |
96+
for i in $(seq 1 30); do
97+
if (echo > /dev/tcp/localhost/9324) >/dev/null 2>&1; then
98+
echo "ElasticMQ port 9324 open"; exit 0
99+
fi
100+
sleep 2
101+
done
102+
echo "ElasticMQ did not open port 9324"; exit 1
87103
88104
- name: Run full suite with coverage gate (>=90%)
89105
env:
90106
BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
91107
BABELQUEUE_TEST_AMQP: amqp://guest:guest@localhost:5672/
108+
SQS_ENDPOINT: http://localhost:9324
109+
AWS_REGION: us-east-1
110+
AWS_ACCESS_KEY_ID: test
111+
AWS_SECRET_ACCESS_KEY: test
92112
run: pytest --cov=babelqueue --cov-report=term-missing --cov-fail-under=90
93113

94114
conformance:

CHANGELOG.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
The envelope wire format is versioned separately by `meta.schema_version`
88
(currently **1**) — see the contract at [babelqueue.com](https://babelqueue.com).
99

10-
## [Unreleased]
10+
## [1.1.0] - 2026-06-12
11+
12+
### Added
13+
- **Amazon SQS transport** (`babelqueue[sqs]`, `boto3`) — `SqsTransport`, selected by
14+
the `sqs://` URL scheme (e.g. `sqs://us-east-1?endpoint=http://localhost:4566` for
15+
LocalStack). Implements [§3 of the broker-bindings contract](https://babelqueue.com):
16+
the canonical envelope is the `MessageBody`, projected onto native `MessageAttributes`
17+
(`bq-job`/`bq-trace-id`/`bq-message-id`/`bq-schema-version`/`bq-source-lang`/`bq-created-at`);
18+
visibility-timeout reserve → `delete_message` ack; `attempts` reconciled to
19+
`ApproximateReceiveCount − 1` (never lowering a runtime-incremented count). Supports
20+
FIFO (`MessageGroupId`/`MessageDeduplicationId` = `meta.id`), content-based dedup,
21+
configurable wait/visibility, and a `queue_url_prefix` that skips `GetQueueUrl`. A
22+
client can be injected for tests/DI. 16 unit tests run against a fake client (no boto3,
23+
no broker); a LocalStack integration test round-trips the real `boto3` path. The
24+
envelope is unchanged (`schema_version: 1`); SQS is purely additive. Ships as a MINOR.
1125

1226
## [1.0.0] - 2026-06-07
1327

@@ -85,7 +99,8 @@ reference at [babelqueue.com](https://babelqueue.com).
8599
- Pre-1.0: the public API may change before the `1.0.0` tag.
86100
- The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
87101

88-
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.0.0...HEAD
102+
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.1.0...HEAD
103+
[1.1.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.0.0...v1.1.0
89104
[1.0.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.5.0...v1.0.0
90105
[0.5.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.4.0...v0.5.0
91106
[0.4.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.3.0...v0.4.0

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "babelqueue"
7-
version = "1.0.0"
7+
version = "1.1.0"
88
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
99
readme = "README.md"
1010
requires-python = ">=3.9"
@@ -31,6 +31,7 @@ dependencies = []
3131
# Optional runtime drivers + framework adapters — standard, zero-heavy-dep.
3232
redis = ["redis>=4"]
3333
amqp = ["pika>=1.3"]
34+
sqs = ["boto3>=1.26"]
3435
celery = ["celery>=5"]
3536
django = ["django>=4.2"]
3637
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]

src/babelqueue/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from .routing import UnknownUrnStrategy
2020
from .transport import InMemoryTransport, ReceivedMessage, Transport
2121

22-
__version__ = "1.0.0"
22+
__version__ = "1.1.0"
2323

2424
__all__ = [
2525
"BabelQueue",

src/babelqueue/sqs_transport.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Amazon SQS transport. Requires the ``sqs`` extra:
2+
3+
pip install "babelqueue[sqs]"
4+
5+
Producing sends the canonical envelope as the message body and projects the
6+
contract envelope fields onto native SQS ``MessageAttributes`` (``bq-job`` = URN,
7+
``bq-trace-id`` = trace_id, ``bq-message-id`` = meta.id, plus ``bq-schema-version`` /
8+
``bq-source-lang`` / ``bq-created-at``) — so a Go/PHP/... peer can route on ``bq-job``
9+
and correlate on ``bq-trace-id`` without parsing the body. Consuming uses the
10+
visibility-timeout reservation model (``receive_message`` -> process ->
11+
``delete_message``); the authoritative attempt count is the broker's
12+
``ApproximateReceiveCount``, reconciled onto the envelope as ``attempts = count - 1``.
13+
14+
This implements §3 of the broker-bindings contract. The envelope is unchanged
15+
(``schema_version`` stays 1); SQS is purely additive.
16+
17+
URL form: ``sqs://[region][?endpoint=...&prefix=...&fifo=1&group_id=...&wait_time=20]``
18+
(e.g. ``sqs://us-east-1?endpoint=http://localhost:4566`` for LocalStack). Credentials
19+
come from the standard AWS default provider chain. For richer setups, build the
20+
transport directly and pass it via ``BabelQueue(transport=...)``.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from typing import Any, Dict, Optional
26+
from urllib.parse import parse_qs, urlsplit
27+
28+
from .codec import EnvelopeCodec
29+
from .transport import ReceivedMessage, Transport
30+
31+
32+
class SqsTransport(Transport):
33+
def __init__(
34+
self,
35+
url: str = "sqs://",
36+
*,
37+
client: Any = None,
38+
region: Optional[str] = None,
39+
endpoint: Optional[str] = None,
40+
queue_url_prefix: Optional[str] = None,
41+
wait_time: Optional[int] = None,
42+
visibility_timeout: Optional[int] = None,
43+
fifo: bool = False,
44+
message_group_id: Optional[str] = None,
45+
content_dedup: bool = False,
46+
) -> None:
47+
parts = urlsplit(url) if url else urlsplit("sqs://")
48+
q = parse_qs(parts.query)
49+
50+
self._region = region or (parts.hostname or None)
51+
self._endpoint = endpoint or _q1(q, "endpoint")
52+
self._queue_url_prefix = queue_url_prefix or _q1(q, "prefix")
53+
self._wait_time = wait_time if wait_time is not None else _qint(q, "wait_time")
54+
self._visibility_timeout = (
55+
visibility_timeout if visibility_timeout is not None else _qint(q, "visibility_timeout")
56+
)
57+
self._fifo = fifo or _qbool(q, "fifo")
58+
self._message_group_id = message_group_id or _q1(q, "group_id")
59+
self._content_dedup = content_dedup or _qbool(q, "content_dedup")
60+
self._urls: Dict[str, str] = {}
61+
62+
if client is not None:
63+
self._sqs = client
64+
return
65+
try:
66+
import boto3
67+
except ImportError as exc: # pragma: no cover - import guard
68+
raise ImportError(
69+
"SqsTransport requires the 'boto3' package. Install with "
70+
'pip install "babelqueue[sqs]".'
71+
) from exc
72+
kwargs: Dict[str, Any] = {}
73+
if self._region:
74+
kwargs["region_name"] = self._region
75+
if self._endpoint:
76+
kwargs["endpoint_url"] = self._endpoint
77+
self._sqs = boto3.client("sqs", **kwargs) # pragma: no cover - needs AWS/LocalStack
78+
79+
# -- helpers ------------------------------------------------------------
80+
81+
def _resolve_url(self, name: str) -> str:
82+
cached = self._urls.get(name)
83+
if cached is not None:
84+
return cached
85+
if self._queue_url_prefix:
86+
url = self._queue_url_prefix.rstrip("/") + "/" + name
87+
else:
88+
url = self._sqs.get_queue_url(QueueName=name)["QueueUrl"]
89+
self._urls[name] = url
90+
return url
91+
92+
@staticmethod
93+
def _attributes(body: str) -> Dict[str, Dict[str, str]]:
94+
"""Project the envelope's contract fields onto SQS MessageAttributes — a
95+
redundant, routable view of the body (the body stays authoritative)."""
96+
try:
97+
env: Dict[str, Any] = EnvelopeCodec.decode(body)
98+
except (ValueError, TypeError): # pragma: no cover - decode is defensive
99+
return {}
100+
meta = env.get("meta") or {}
101+
102+
def s(v: Any) -> Dict[str, str]:
103+
return {"DataType": "String", "StringValue": str(v)}
104+
105+
def n(v: Any) -> Dict[str, str]:
106+
return {"DataType": "Number", "StringValue": str(v)}
107+
108+
attrs: Dict[str, Dict[str, str]] = {}
109+
if env.get("job"):
110+
attrs["bq-job"] = s(env["job"])
111+
if env.get("trace_id"):
112+
attrs["bq-trace-id"] = s(env["trace_id"])
113+
if meta.get("id"):
114+
attrs["bq-message-id"] = s(meta["id"])
115+
if meta.get("schema_version") is not None:
116+
attrs["bq-schema-version"] = n(meta["schema_version"])
117+
if meta.get("lang"):
118+
attrs["bq-source-lang"] = s(meta["lang"])
119+
if meta.get("created_at") is not None:
120+
attrs["bq-created-at"] = n(meta["created_at"])
121+
return attrs
122+
123+
@staticmethod
124+
def _reconcile(body: str, receive_count: Any) -> str:
125+
"""Set attempts to max(current, ApproximateReceiveCount - 1): a first delivery
126+
reads 0, a natively-redelivered message reflects its true count, and a
127+
runtime-incremented counter is never lowered."""
128+
try:
129+
rc = int(receive_count)
130+
except (ValueError, TypeError):
131+
return body
132+
if rc <= 1:
133+
return body
134+
env = EnvelopeCodec.decode(body)
135+
if not env:
136+
return body
137+
native = rc - 1
138+
if native <= int(env.get("attempts", 0)):
139+
return body
140+
env["attempts"] = native
141+
return EnvelopeCodec.encode(env)
142+
143+
# -- Transport ----------------------------------------------------------
144+
145+
def publish(self, queue: str, body: str) -> None:
146+
params: Dict[str, Any] = {"QueueUrl": self._resolve_url(queue), "MessageBody": body}
147+
attrs = self._attributes(body)
148+
if attrs:
149+
params["MessageAttributes"] = attrs
150+
if self._fifo:
151+
params["MessageGroupId"] = self._message_group_id or queue
152+
if not self._content_dedup:
153+
msg_id = (EnvelopeCodec.decode(body).get("meta") or {}).get("id")
154+
if msg_id:
155+
params["MessageDeduplicationId"] = msg_id
156+
self._sqs.send_message(**params)
157+
158+
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
159+
wait = int(timeout) if timeout and timeout > 0 else 0
160+
if wait > 20:
161+
wait = 20
162+
if self._wait_time is not None and self._wait_time < wait:
163+
wait = self._wait_time
164+
params: Dict[str, Any] = {
165+
"QueueUrl": self._resolve_url(queue),
166+
"MaxNumberOfMessages": 1,
167+
"WaitTimeSeconds": wait,
168+
"MessageAttributeNames": ["All"],
169+
"AttributeNames": ["ApproximateReceiveCount"],
170+
}
171+
if self._visibility_timeout is not None:
172+
params["VisibilityTimeout"] = self._visibility_timeout
173+
resp = self._sqs.receive_message(**params)
174+
messages = resp.get("Messages") or []
175+
if not messages:
176+
return None
177+
msg = messages[0]
178+
body = msg.get("Body", "")
179+
receive_count = (msg.get("Attributes") or {}).get("ApproximateReceiveCount")
180+
if receive_count is not None:
181+
body = self._reconcile(body, receive_count)
182+
return ReceivedMessage(body=body, queue=queue, handle=msg.get("ReceiptHandle"))
183+
184+
def ack(self, message: ReceivedMessage) -> None:
185+
if not message.handle:
186+
return
187+
self._sqs.delete_message(
188+
QueueUrl=self._resolve_url(message.queue), ReceiptHandle=message.handle
189+
)
190+
191+
192+
def _q1(q: Dict[str, list], key: str) -> Optional[str]:
193+
values = q.get(key)
194+
return values[0] if values else None
195+
196+
197+
def _qint(q: Dict[str, list], key: str) -> Optional[int]:
198+
v = _q1(q, key)
199+
if v is None:
200+
return None
201+
try:
202+
return int(v)
203+
except ValueError: # pragma: no cover - defensive
204+
return None
205+
206+
207+
def _qbool(q: Dict[str, list], key: str) -> bool:
208+
v = _q1(q, key)
209+
return v is not None and v.lower() in ("1", "true", "yes", "on")

src/babelqueue/transport.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ def make_transport(broker_url: str) -> Transport:
7979
from .pika_transport import PikaTransport
8080

8181
return PikaTransport(broker_url)
82+
if scheme == "sqs":
83+
from .sqs_transport import SqsTransport
84+
85+
return SqsTransport(broker_url)
8286

8387
raise BabelQueueError(
84-
f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://' or "
85-
"'amqp://', or pass your own Transport via BabelQueue(transport=...)."
88+
f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://', "
89+
"'amqp://' or 'sqs://', or pass your own Transport via BabelQueue(transport=...)."
8690
)

tests/conformance/manifest.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,31 @@
6767
"valid": false,
6868
"reason": "no 'job' or 'urn' — the message has no identity"
6969
}
70-
]
70+
],
71+
"sqs": {
72+
"description": "Amazon SQS binding conformance (broker-bindings.md §3). Every SDK that ships an SQS transport must satisfy these. The envelope body stays byte-identical (the 'cases' above); these lock the native projection + reconciliation the binding adds. Per-message values reuse fixtures/order-created.json so the expected attributes are deterministic.",
73+
"attribute_projection": {
74+
"description": "On produce, the transport MUST project these native MessageAttributes from the envelope (a redundant, routable view of the body; ids/strings are DataType String, counters are DataType Number). Applies to every SQS-producing SDK.",
75+
"envelope_file": "fixtures/order-created.json",
76+
"message_attributes": {
77+
"bq-job": { "DataType": "String", "StringValue": "urn:babel:orders:created" },
78+
"bq-trace-id": { "DataType": "String", "StringValue": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b" },
79+
"bq-message-id": { "DataType": "String", "StringValue": "f1e2d3c4-b5a6-4789-90ab-cdef01234567" },
80+
"bq-schema-version": { "DataType": "Number", "StringValue": "1" },
81+
"bq-source-lang": { "DataType": "String", "StringValue": "php" },
82+
"bq-created-at": { "DataType": "Number", "StringValue": "1749132727000" }
83+
}
84+
},
85+
"attempts_reconciliation": {
86+
"description": "On consume, attempts = max(body.attempts, ApproximateReceiveCount - 1): a first delivery reads 0, an absent/garbage count is ignored, a runtime-incremented count is never lowered. Applies to SDKs that reconcile the envelope body on consume (the framework-less/runtime transports). A drop-in driver that surfaces the broker's native delivery count instead (e.g. Laravel's SqsJob.attempts() = ApproximateReceiveCount) is exempt — it documents that divergence.",
87+
"cases": [
88+
{ "name": "first-delivery", "body_attempts": 0, "approximate_receive_count": "1", "expected_attempts": 0 },
89+
{ "name": "third-delivery", "body_attempts": 0, "approximate_receive_count": "3", "expected_attempts": 2 },
90+
{ "name": "native-exceeds-body", "body_attempts": 2, "approximate_receive_count": "5", "expected_attempts": 4 },
91+
{ "name": "never-lower-runtime", "body_attempts": 5, "approximate_receive_count": "1", "expected_attempts": 5 },
92+
{ "name": "garbage-count-ignored", "body_attempts": 4, "approximate_receive_count": "not-a-number", "expected_attempts": 4 },
93+
{ "name": "absent-count", "body_attempts": 3, "approximate_receive_count": null, "expected_attempts": 3 }
94+
]
95+
}
96+
}
7197
}

0 commit comments

Comments
 (0)