babelqueue 1.1.0__tar.gz → 1.2.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.
- {babelqueue-1.1.0 → babelqueue-1.2.0}/.github/workflows/ci.yml +2 -2
- {babelqueue-1.1.0 → babelqueue-1.2.0}/CHANGELOG.md +18 -1
- {babelqueue-1.1.0 → babelqueue-1.2.0}/PKG-INFO +4 -1
- {babelqueue-1.1.0 → babelqueue-1.2.0}/pyproject.toml +2 -1
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/__init__.py +1 -1
- babelqueue-1.2.0/src/babelqueue/asb_transport.py +173 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/transport.py +5 -1
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/manifest.json +29 -0
- babelqueue-1.2.0/tests/test_asb_conformance.py +54 -0
- babelqueue-1.2.0/tests/test_asb_transport.py +169 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/.gitignore +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/LICENSE +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/README.md +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_app.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_celery.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_codec.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_django.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_sqs_transport.py +0 -0
|
@@ -44,7 +44,7 @@ jobs:
|
|
|
44
44
|
- name: Install (dev + all adapters for type context)
|
|
45
45
|
run: |
|
|
46
46
|
python -m pip install --upgrade pip
|
|
47
|
-
pip install -e ".[dev,celery,django,redis,amqp,sqs]"
|
|
47
|
+
pip install -e ".[dev,celery,django,redis,amqp,sqs,azureservicebus]"
|
|
48
48
|
- name: Ruff
|
|
49
49
|
run: ruff check src tests
|
|
50
50
|
- name: Mypy
|
|
@@ -89,7 +89,7 @@ jobs:
|
|
|
89
89
|
- name: Install (all adapters — full coverage with brokers)
|
|
90
90
|
run: |
|
|
91
91
|
python -m pip install --upgrade pip
|
|
92
|
-
pip install -e ".[redis,amqp,sqs,celery,django,dev]"
|
|
92
|
+
pip install -e ".[redis,amqp,sqs,azureservicebus,celery,django,dev]"
|
|
93
93
|
|
|
94
94
|
- name: Wait for ElasticMQ
|
|
95
95
|
run: |
|
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
The envelope wire format is versioned separately by `meta.schema_version`
|
|
8
8
|
(currently **1**) — see the contract at [babelqueue.com](https://babelqueue.com).
|
|
9
9
|
|
|
10
|
+
## [1.2.0] - 2026-06-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Azure Service Bus transport** (`babelqueue[azureservicebus]`, `azure-servicebus`) —
|
|
14
|
+
`AsbTransport`, selected by the `sb://` URL scheme (e.g.
|
|
15
|
+
`sb://<namespace>.servicebus.windows.net`, Azure AD via `DefaultAzureCredential`; or pass
|
|
16
|
+
`connection_string=...` / a built `client`). Implements [§4 of the broker-bindings
|
|
17
|
+
contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#azure-service-bus): the
|
|
18
|
+
canonical envelope is the message body, projected onto native Service Bus fields
|
|
19
|
+
(`Subject` = URN, `CorrelationId` = `trace_id`, `MessageId` = `meta.id`, plus the `bq-`
|
|
20
|
+
application properties); PeekLock reserve → `complete_message` ack; `attempts` reconciled
|
|
21
|
+
to the broker-authoritative `DeliveryCount − 1`. A client can be injected for tests/DI. The
|
|
22
|
+
projection + reconciliation are unit-tested with no broker and no `azure-servicebus` (the
|
|
23
|
+
azure import is lazy); the publish flow is covered with a fake client. The envelope is
|
|
24
|
+
unchanged (`schema_version: 1`); Azure Service Bus is purely additive. Ships as a MINOR.
|
|
25
|
+
|
|
10
26
|
## [1.1.0] - 2026-06-12
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -99,7 +115,8 @@ reference at [babelqueue.com](https://babelqueue.com).
|
|
|
99
115
|
- Pre-1.0: the public API may change before the `1.0.0` tag.
|
|
100
116
|
- The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
|
|
101
117
|
|
|
102
|
-
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.
|
|
118
|
+
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.2.0...HEAD
|
|
119
|
+
[1.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.1.0...v1.2.0
|
|
103
120
|
[1.1.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.0.0...v1.1.0
|
|
104
121
|
[1.0.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.5.0...v1.0.0
|
|
105
122
|
[0.5.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.4.0...v0.5.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
|
|
5
5
|
Project-URL: Homepage, https://babelqueue.com
|
|
6
6
|
Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
|
|
@@ -24,6 +24,9 @@ Classifier: Typing :: Typed
|
|
|
24
24
|
Requires-Python: >=3.9
|
|
25
25
|
Provides-Extra: amqp
|
|
26
26
|
Requires-Dist: pika>=1.3; extra == 'amqp'
|
|
27
|
+
Provides-Extra: azureservicebus
|
|
28
|
+
Requires-Dist: azure-identity>=1.15; extra == 'azureservicebus'
|
|
29
|
+
Requires-Dist: azure-servicebus>=7.11; extra == 'azureservicebus'
|
|
27
30
|
Provides-Extra: celery
|
|
28
31
|
Requires-Dist: celery>=5; extra == 'celery'
|
|
29
32
|
Provides-Extra: dev
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -32,6 +32,7 @@ dependencies = []
|
|
|
32
32
|
redis = ["redis>=4"]
|
|
33
33
|
amqp = ["pika>=1.3"]
|
|
34
34
|
sqs = ["boto3>=1.26"]
|
|
35
|
+
azureservicebus = ["azure-servicebus>=7.11", "azure-identity>=1.15"]
|
|
35
36
|
celery = ["celery>=5"]
|
|
36
37
|
django = ["django>=4.2"]
|
|
37
38
|
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Azure Service Bus transport. Requires the ``azureservicebus`` extra:
|
|
2
|
+
|
|
3
|
+
pip install "babelqueue[azureservicebus]"
|
|
4
|
+
|
|
5
|
+
Producing sends the canonical envelope as the message body and projects the contract
|
|
6
|
+
envelope fields onto native Service Bus fields — ``Subject`` = URN, ``CorrelationId`` =
|
|
7
|
+
trace_id, ``MessageId`` = meta.id, plus the ``bq-`` application properties
|
|
8
|
+
(``bq-schema-version`` / ``bq-source-lang`` / ``bq-created-at``) — so a .NET/Java/... peer
|
|
9
|
+
can route on ``Subject`` and correlate on ``CorrelationId`` without parsing the body.
|
|
10
|
+
Consuming uses the PeekLock reservation model (``receive_messages`` -> process ->
|
|
11
|
+
``complete_message``); the authoritative attempt count is the broker's native
|
|
12
|
+
``DeliveryCount`` (1-based), reconciled onto the envelope as ``attempts = DeliveryCount - 1``.
|
|
13
|
+
A message left un-acked has its lock expire and is redelivered (at-least-once).
|
|
14
|
+
|
|
15
|
+
This implements §4 of the broker-bindings contract. The envelope is unchanged
|
|
16
|
+
(``schema_version`` stays 1); Azure Service Bus is purely additive.
|
|
17
|
+
|
|
18
|
+
URL form: ``sb://<namespace>.servicebus.windows.net`` (Azure AD via
|
|
19
|
+
``DefaultAzureCredential``). For connection-string auth or a custom client, build the
|
|
20
|
+
transport directly and pass it via ``BabelQueue(transport=...)`` or
|
|
21
|
+
``AsbTransport(connection_string=...)``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
27
|
+
from urllib.parse import urlsplit
|
|
28
|
+
|
|
29
|
+
from .codec import EnvelopeCodec
|
|
30
|
+
from .transport import ReceivedMessage, Transport
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsbTransport(Transport):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
url: str = "sb://",
|
|
37
|
+
*,
|
|
38
|
+
client: Any = None,
|
|
39
|
+
connection_string: Optional[str] = None,
|
|
40
|
+
credential: Any = None,
|
|
41
|
+
max_wait_time: Optional[float] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
parts = urlsplit(url) if url else urlsplit("sb://")
|
|
44
|
+
self._namespace = parts.hostname or None
|
|
45
|
+
self._connection_string = connection_string
|
|
46
|
+
self._credential = credential
|
|
47
|
+
self._max_wait_time = max_wait_time
|
|
48
|
+
self._senders: Dict[str, Any] = {}
|
|
49
|
+
self._receivers: Dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
if client is not None:
|
|
52
|
+
self._client = client
|
|
53
|
+
return
|
|
54
|
+
self._client = self._build_client() # pragma: no cover - needs Azure / network
|
|
55
|
+
|
|
56
|
+
def _build_client(self) -> Any: # pragma: no cover - needs Azure / network
|
|
57
|
+
try:
|
|
58
|
+
from azure.servicebus import ServiceBusClient
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"AsbTransport requires the 'azure-servicebus' package. Install with "
|
|
62
|
+
'pip install "babelqueue[azureservicebus]".'
|
|
63
|
+
) from exc
|
|
64
|
+
import os
|
|
65
|
+
|
|
66
|
+
cs = self._connection_string or os.environ.get("AZURE_SERVICEBUS_CONNECTION_STRING")
|
|
67
|
+
if cs:
|
|
68
|
+
return ServiceBusClient.from_connection_string(cs)
|
|
69
|
+
if self._namespace:
|
|
70
|
+
credential = self._credential
|
|
71
|
+
if credential is None:
|
|
72
|
+
from azure.identity import DefaultAzureCredential
|
|
73
|
+
|
|
74
|
+
credential = DefaultAzureCredential()
|
|
75
|
+
return ServiceBusClient(self._namespace, credential)
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"AsbTransport needs a connection string, a namespace + credential, or an injected client."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# -- helpers ------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _sender(self, queue: str) -> Any:
|
|
83
|
+
sender = self._senders.get(queue)
|
|
84
|
+
if sender is None:
|
|
85
|
+
sender = self._client.get_queue_sender(queue)
|
|
86
|
+
self._senders[queue] = sender
|
|
87
|
+
return sender
|
|
88
|
+
|
|
89
|
+
def _receiver(self, queue: str) -> Any:
|
|
90
|
+
receiver = self._receivers.get(queue)
|
|
91
|
+
if receiver is None:
|
|
92
|
+
receiver = self._client.get_queue_receiver(queue)
|
|
93
|
+
self._receivers[queue] = receiver
|
|
94
|
+
return receiver
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _projection(body: str) -> Dict[str, Any]:
|
|
98
|
+
"""Native ServiceBusMessage kwargs — Subject/CorrelationId/MessageId + the bq-
|
|
99
|
+
application properties (a redundant, routable view of the body). §4.2–§4.3."""
|
|
100
|
+
try:
|
|
101
|
+
env: Dict[str, Any] = EnvelopeCodec.decode(body)
|
|
102
|
+
except (ValueError, TypeError): # pragma: no cover - defensive
|
|
103
|
+
return {"content_type": "application/json"}
|
|
104
|
+
meta = env.get("meta") or {}
|
|
105
|
+
|
|
106
|
+
props: Dict[str, Any] = {}
|
|
107
|
+
if meta.get("schema_version") is not None:
|
|
108
|
+
props["bq-schema-version"] = meta["schema_version"]
|
|
109
|
+
if meta.get("lang"):
|
|
110
|
+
props["bq-source-lang"] = meta["lang"]
|
|
111
|
+
if meta.get("created_at") is not None:
|
|
112
|
+
props["bq-created-at"] = meta["created_at"]
|
|
113
|
+
|
|
114
|
+
kwargs: Dict[str, Any] = {"content_type": "application/json"}
|
|
115
|
+
if env.get("job"):
|
|
116
|
+
kwargs["subject"] = env["job"]
|
|
117
|
+
if env.get("trace_id"):
|
|
118
|
+
kwargs["correlation_id"] = env["trace_id"]
|
|
119
|
+
if meta.get("id"):
|
|
120
|
+
kwargs["message_id"] = meta["id"]
|
|
121
|
+
if props:
|
|
122
|
+
kwargs["application_properties"] = props
|
|
123
|
+
return kwargs
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _reconcile(body: str, delivery_count: Any) -> str:
|
|
127
|
+
"""Set attempts to max(current, DeliveryCount - 1) — DeliveryCount (1-based) is the
|
|
128
|
+
broker's native redelivery floor, but the runtime retries by republishing with
|
|
129
|
+
attempts+1 in the body, so a republished message (DeliveryCount back to 1) must not
|
|
130
|
+
have its higher body count lowered. First delivery (DeliveryCount 1) reads 0."""
|
|
131
|
+
try:
|
|
132
|
+
dc = int(delivery_count)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
return body
|
|
135
|
+
if dc <= 1:
|
|
136
|
+
return body
|
|
137
|
+
native = dc - 1
|
|
138
|
+
env = EnvelopeCodec.decode(body)
|
|
139
|
+
if not env or native <= int(env.get("attempts", 0)):
|
|
140
|
+
return body
|
|
141
|
+
env["attempts"] = native
|
|
142
|
+
return EnvelopeCodec.encode(env)
|
|
143
|
+
|
|
144
|
+
# -- Transport ----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def publish(self, queue: str, body: str) -> None:
|
|
147
|
+
from azure.servicebus import ServiceBusMessage
|
|
148
|
+
|
|
149
|
+
message = ServiceBusMessage(body, **self._projection(body))
|
|
150
|
+
self._sender(queue).send_messages(message)
|
|
151
|
+
|
|
152
|
+
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
153
|
+
wait = self._max_wait_time
|
|
154
|
+
if wait is None and timeout and timeout > 0:
|
|
155
|
+
wait = timeout
|
|
156
|
+
messages = self._receiver(queue).receive_messages(max_message_count=1, max_wait_time=wait)
|
|
157
|
+
if not messages:
|
|
158
|
+
return None
|
|
159
|
+
message = messages[0]
|
|
160
|
+
body = self._reconcile(str(message), getattr(message, "delivery_count", None))
|
|
161
|
+
return ReceivedMessage(body=body, queue=queue, handle=message)
|
|
162
|
+
|
|
163
|
+
def ack(self, message: ReceivedMessage) -> None:
|
|
164
|
+
if message.handle is None:
|
|
165
|
+
return
|
|
166
|
+
self._receiver(message.queue).complete_message(message.handle)
|
|
167
|
+
|
|
168
|
+
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
169
|
+
for resource in (*self._senders.values(), *self._receivers.values(), self._client):
|
|
170
|
+
try:
|
|
171
|
+
resource.close()
|
|
172
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
173
|
+
pass
|
|
@@ -83,8 +83,12 @@ def make_transport(broker_url: str) -> Transport:
|
|
|
83
83
|
from .sqs_transport import SqsTransport
|
|
84
84
|
|
|
85
85
|
return SqsTransport(broker_url)
|
|
86
|
+
if scheme in ("sb", "servicebus", "azureservicebus"):
|
|
87
|
+
from .asb_transport import AsbTransport
|
|
88
|
+
|
|
89
|
+
return AsbTransport(broker_url)
|
|
86
90
|
|
|
87
91
|
raise BabelQueueError(
|
|
88
92
|
f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://', "
|
|
89
|
-
"'amqp://' or '
|
|
93
|
+
"'amqp://', 'sqs://' or 'sb://', or pass your own Transport via BabelQueue(transport=...)."
|
|
90
94
|
)
|
|
@@ -93,5 +93,34 @@
|
|
|
93
93
|
{ "name": "absent-count", "body_attempts": 3, "approximate_receive_count": null, "expected_attempts": 3 }
|
|
94
94
|
]
|
|
95
95
|
}
|
|
96
|
+
},
|
|
97
|
+
"asb": {
|
|
98
|
+
"description": "Azure Service Bus binding conformance (broker-bindings.md §4). Every SDK that ships an ASB 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 projection is deterministic.",
|
|
99
|
+
"property_projection": {
|
|
100
|
+
"description": "On produce, the transport MUST project these native Service Bus message fields from the envelope: Subject = job (the URN), CorrelationId = trace_id, MessageId = meta.id, ContentType = application/json, plus the bq- ApplicationProperties as native AMQP-typed values (numbers stay numbers, not strings). Applies to every ASB-producing SDK.",
|
|
101
|
+
"envelope_file": "fixtures/order-created.json",
|
|
102
|
+
"message": {
|
|
103
|
+
"subject": "urn:babel:orders:created",
|
|
104
|
+
"correlation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
|
|
105
|
+
"message_id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
|
|
106
|
+
"content_type": "application/json"
|
|
107
|
+
},
|
|
108
|
+
"application_properties": {
|
|
109
|
+
"bq-schema-version": 1,
|
|
110
|
+
"bq-source-lang": "php",
|
|
111
|
+
"bq-created-at": 1749132727000
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"attempts_reconciliation": {
|
|
115
|
+
"description": "On consume, attempts = max(body.attempts, DeliveryCount - 1): a first delivery (DeliveryCount 1) reads 0, a runtime-incremented body count is never lowered, and DeliveryCount <= 1 leaves the body's own count untouched (the runtime retries by republishing with attempts+1). DeliveryCount is the native 1-based ASB redelivery counter; the rule is identical across the native-consumer SDKs (.NET/Java/Node) and the Transport+App SDKs (Python/Go).",
|
|
116
|
+
"cases": [
|
|
117
|
+
{ "name": "first-delivery", "body_attempts": 0, "delivery_count": 1, "expected_attempts": 0 },
|
|
118
|
+
{ "name": "third-delivery", "body_attempts": 0, "delivery_count": 3, "expected_attempts": 2 },
|
|
119
|
+
{ "name": "native-exceeds-body", "body_attempts": 2, "delivery_count": 5, "expected_attempts": 4 },
|
|
120
|
+
{ "name": "never-lower-runtime", "body_attempts": 5, "delivery_count": 2, "expected_attempts": 5 },
|
|
121
|
+
{ "name": "first-delivery-keeps-body", "body_attempts": 4, "delivery_count": 1, "expected_attempts": 4 },
|
|
122
|
+
{ "name": "zero-count-keeps-body", "body_attempts": 3, "delivery_count": 0, "expected_attempts": 3 }
|
|
123
|
+
]
|
|
124
|
+
}
|
|
96
125
|
}
|
|
97
126
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Azure Service Bus binding conformance: the vendored manifest's ``asb`` block locks the
|
|
2
|
+
§4 native projection (Subject/CorrelationId/MessageId + bq- application properties) and the
|
|
3
|
+
``attempts = max(body, DeliveryCount - 1)`` reconciliation. No azure-servicebus, no broker —
|
|
4
|
+
pure transport logic against golden values."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
from babelqueue import EnvelopeCodec
|
|
13
|
+
from babelqueue.asb_transport import AsbTransport
|
|
14
|
+
|
|
15
|
+
CONFORMANCE = os.path.join(os.path.dirname(__file__), "conformance")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsbConformanceTest(unittest.TestCase):
|
|
19
|
+
@classmethod
|
|
20
|
+
def setUpClass(cls) -> None:
|
|
21
|
+
with open(os.path.join(CONFORMANCE, "manifest.json"), encoding="utf-8") as fh:
|
|
22
|
+
cls.asb = json.load(fh)["asb"]
|
|
23
|
+
|
|
24
|
+
def test_property_projection(self) -> None:
|
|
25
|
+
projection = self.asb["property_projection"]
|
|
26
|
+
with open(os.path.join(CONFORMANCE, projection["envelope_file"]), encoding="utf-8") as fh:
|
|
27
|
+
body = fh.read()
|
|
28
|
+
|
|
29
|
+
got = AsbTransport._projection(body)
|
|
30
|
+
message = projection["message"]
|
|
31
|
+
|
|
32
|
+
self.assertEqual(got.get("subject"), message["subject"])
|
|
33
|
+
self.assertEqual(got.get("correlation_id"), message["correlation_id"])
|
|
34
|
+
self.assertEqual(got.get("message_id"), message["message_id"])
|
|
35
|
+
self.assertEqual(got.get("content_type"), message["content_type"])
|
|
36
|
+
self.assertEqual(got.get("application_properties"), projection["application_properties"])
|
|
37
|
+
|
|
38
|
+
def test_attempts_reconciliation(self) -> None:
|
|
39
|
+
for case in self.asb["attempts_reconciliation"]["cases"]:
|
|
40
|
+
env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1})
|
|
41
|
+
env["attempts"] = case["body_attempts"]
|
|
42
|
+
body = EnvelopeCodec.encode(env)
|
|
43
|
+
|
|
44
|
+
out = AsbTransport._reconcile(body, case["delivery_count"])
|
|
45
|
+
|
|
46
|
+
self.assertEqual(
|
|
47
|
+
EnvelopeCodec.decode(out)["attempts"],
|
|
48
|
+
case["expected_attempts"],
|
|
49
|
+
case["name"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__": # pragma: no cover
|
|
54
|
+
unittest.main()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Azure Service Bus transport tests.
|
|
2
|
+
|
|
3
|
+
The native projection and the ``attempts = DeliveryCount - 1`` reconciliation run with no
|
|
4
|
+
broker and no ``azure-servicebus`` (the transport's azure import is lazy). The publish flow,
|
|
5
|
+
which builds a real ``ServiceBusMessage``, is skipped unless ``azure-servicebus`` is installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
from babelqueue import EnvelopeCodec
|
|
13
|
+
from babelqueue.asb_transport import AsbTransport
|
|
14
|
+
from babelqueue.transport import ReceivedMessage, make_transport
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import azure.servicebus # noqa: F401
|
|
18
|
+
|
|
19
|
+
HAVE_ASB = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
HAVE_ASB = False
|
|
22
|
+
|
|
23
|
+
URN = "urn:babel:orders:created"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def body(attempts: int = 0) -> str:
|
|
27
|
+
env = EnvelopeCodec.decode(
|
|
28
|
+
EnvelopeCodec.encode(EnvelopeCodec.make(URN, {"order_id": 1042}, queue="orders"))
|
|
29
|
+
)
|
|
30
|
+
env["attempts"] = attempts
|
|
31
|
+
return EnvelopeCodec.encode(env)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FakeReceived:
|
|
35
|
+
"""Duck-typed ServiceBusReceivedMessage (the real one has no public constructor)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, raw: str, delivery_count: int = 1) -> None:
|
|
38
|
+
self._raw = raw
|
|
39
|
+
self.delivery_count = delivery_count
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return self._raw
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FakeSender:
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.sent: list = []
|
|
48
|
+
|
|
49
|
+
def send_messages(self, message) -> None:
|
|
50
|
+
self.sent.append(message)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FakeReceiver:
|
|
54
|
+
def __init__(self, messages=()) -> None:
|
|
55
|
+
self._messages = list(messages)
|
|
56
|
+
self.completed: list = []
|
|
57
|
+
|
|
58
|
+
def receive_messages(self, max_message_count: int = 1, max_wait_time=None):
|
|
59
|
+
batch = self._messages[:max_message_count]
|
|
60
|
+
del self._messages[:max_message_count]
|
|
61
|
+
return batch
|
|
62
|
+
|
|
63
|
+
def complete_message(self, message) -> None:
|
|
64
|
+
self.completed.append(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FakeClient:
|
|
68
|
+
def __init__(self, sender=None, receiver=None) -> None:
|
|
69
|
+
self.sender = sender if sender is not None else FakeSender()
|
|
70
|
+
self.receiver = receiver if receiver is not None else FakeReceiver()
|
|
71
|
+
|
|
72
|
+
def get_queue_sender(self, queue: str):
|
|
73
|
+
return self.sender
|
|
74
|
+
|
|
75
|
+
def get_queue_receiver(self, queue: str):
|
|
76
|
+
return self.receiver
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsbProjectionTest(unittest.TestCase):
|
|
80
|
+
def test_projection_maps_native_fields_and_properties(self):
|
|
81
|
+
raw = body()
|
|
82
|
+
env = EnvelopeCodec.decode(raw)
|
|
83
|
+
proj = AsbTransport._projection(raw)
|
|
84
|
+
|
|
85
|
+
self.assertEqual(proj["subject"], URN)
|
|
86
|
+
self.assertEqual(proj["correlation_id"], env["trace_id"])
|
|
87
|
+
self.assertEqual(proj["message_id"], env["meta"]["id"])
|
|
88
|
+
self.assertEqual(proj["content_type"], "application/json")
|
|
89
|
+
props = proj["application_properties"]
|
|
90
|
+
self.assertEqual(props["bq-schema-version"], env["meta"]["schema_version"])
|
|
91
|
+
self.assertEqual(props["bq-source-lang"], env["meta"]["lang"])
|
|
92
|
+
self.assertEqual(props["bq-created-at"], env["meta"]["created_at"])
|
|
93
|
+
|
|
94
|
+
def test_reconcile_attempts_is_delivery_count_minus_one(self):
|
|
95
|
+
out = AsbTransport._reconcile(body(0), 3)
|
|
96
|
+
self.assertEqual(EnvelopeCodec.decode(out)["attempts"], 2)
|
|
97
|
+
|
|
98
|
+
def test_reconcile_first_delivery_is_zero(self):
|
|
99
|
+
out = AsbTransport._reconcile(body(0), 1)
|
|
100
|
+
self.assertEqual(EnvelopeCodec.decode(out)["attempts"], 0)
|
|
101
|
+
|
|
102
|
+
def test_reconcile_ignores_garbage_count(self):
|
|
103
|
+
raw = body(5)
|
|
104
|
+
self.assertEqual(AsbTransport._reconcile(raw, "not-a-number"), raw)
|
|
105
|
+
|
|
106
|
+
def test_reconcile_never_lowers_runtime_count(self):
|
|
107
|
+
# The runtime retries by republishing with attempts+1 (DeliveryCount back to 1),
|
|
108
|
+
# so a higher body count must win over a lower native one.
|
|
109
|
+
raw = body(5)
|
|
110
|
+
self.assertEqual(int(EnvelopeCodec.decode(AsbTransport._reconcile(raw, 2))["attempts"]), 5)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AsbConsumeTest(unittest.TestCase):
|
|
114
|
+
def test_pop_reconciles_attempts_and_returns_handle(self):
|
|
115
|
+
received = FakeReceived(body(0), delivery_count=3)
|
|
116
|
+
tr = AsbTransport(client=FakeClient(receiver=FakeReceiver([received])))
|
|
117
|
+
|
|
118
|
+
msg = tr.pop("orders")
|
|
119
|
+
|
|
120
|
+
self.assertIsNotNone(msg)
|
|
121
|
+
self.assertEqual(msg.queue, "orders")
|
|
122
|
+
self.assertIs(msg.handle, received)
|
|
123
|
+
self.assertEqual(EnvelopeCodec.decode(msg.body)["attempts"], 2)
|
|
124
|
+
|
|
125
|
+
def test_pop_empty_returns_none(self):
|
|
126
|
+
tr = AsbTransport(client=FakeClient(receiver=FakeReceiver([])))
|
|
127
|
+
self.assertIsNone(tr.pop("orders"))
|
|
128
|
+
|
|
129
|
+
def test_ack_completes_the_reserved_message(self):
|
|
130
|
+
received = FakeReceived(body(), delivery_count=1)
|
|
131
|
+
receiver = FakeReceiver([received])
|
|
132
|
+
tr = AsbTransport(client=FakeClient(receiver=receiver))
|
|
133
|
+
|
|
134
|
+
tr.ack(tr.pop("orders"))
|
|
135
|
+
|
|
136
|
+
self.assertEqual(receiver.completed, [received])
|
|
137
|
+
|
|
138
|
+
def test_ack_noop_without_handle(self):
|
|
139
|
+
tr = AsbTransport(client=FakeClient())
|
|
140
|
+
tr.ack(ReceivedMessage(body="", queue="orders", handle=None)) # no error
|
|
141
|
+
|
|
142
|
+
def test_make_transport_routes_sb_scheme(self):
|
|
143
|
+
if HAVE_ASB:
|
|
144
|
+
self.skipTest("azure installed — building a real client would need credentials")
|
|
145
|
+
with self.assertRaises(ImportError):
|
|
146
|
+
make_transport("sb://ns.servicebus.windows.net")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@unittest.skipUnless(HAVE_ASB, "azure-servicebus not installed")
|
|
150
|
+
class AsbPublishTest(unittest.TestCase):
|
|
151
|
+
def test_publish_builds_message_with_native_projection(self):
|
|
152
|
+
raw = body()
|
|
153
|
+
env = EnvelopeCodec.decode(raw)
|
|
154
|
+
sender = FakeSender()
|
|
155
|
+
tr = AsbTransport(client=FakeClient(sender=sender))
|
|
156
|
+
|
|
157
|
+
tr.publish("orders", raw)
|
|
158
|
+
|
|
159
|
+
self.assertEqual(len(sender.sent), 1)
|
|
160
|
+
sent = sender.sent[0]
|
|
161
|
+
self.assertEqual(sent.subject, URN)
|
|
162
|
+
self.assertEqual(sent.correlation_id, env["trace_id"])
|
|
163
|
+
self.assertEqual(sent.message_id, env["meta"]["id"])
|
|
164
|
+
self.assertEqual(sent.content_type, "application/json")
|
|
165
|
+
self.assertEqual(EnvelopeCodec.urn(EnvelopeCodec.decode(str(sent))), URN)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/commands/babelqueue_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|