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.
Files changed (51) hide show
  1. {babelqueue-1.1.0 → babelqueue-1.2.0}/.github/workflows/ci.yml +2 -2
  2. {babelqueue-1.1.0 → babelqueue-1.2.0}/CHANGELOG.md +18 -1
  3. {babelqueue-1.1.0 → babelqueue-1.2.0}/PKG-INFO +4 -1
  4. {babelqueue-1.1.0 → babelqueue-1.2.0}/pyproject.toml +2 -1
  5. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/__init__.py +1 -1
  6. babelqueue-1.2.0/src/babelqueue/asb_transport.py +173 -0
  7. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/transport.py +5 -1
  8. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/manifest.json +29 -0
  9. babelqueue-1.2.0/tests/test_asb_conformance.py +54 -0
  10. babelqueue-1.2.0/tests/test_asb_transport.py +169 -0
  11. {babelqueue-1.1.0 → babelqueue-1.2.0}/.github/FUNDING.yml +0 -0
  12. {babelqueue-1.1.0 → babelqueue-1.2.0}/.github/workflows/release.yml +0 -0
  13. {babelqueue-1.1.0 → babelqueue-1.2.0}/.gitignore +0 -0
  14. {babelqueue-1.1.0 → babelqueue-1.2.0}/LICENSE +0 -0
  15. {babelqueue-1.1.0 → babelqueue-1.2.0}/README.md +0 -0
  16. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/app.py +0 -0
  17. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/celery.py +0 -0
  18. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/codec.py +0 -0
  19. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/contracts.py +0 -0
  20. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/dead_letter.py +0 -0
  21. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/__init__.py +0 -0
  22. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/apps.py +0 -0
  23. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/__init__.py +0 -0
  24. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  25. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  26. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/exceptions.py +0 -0
  27. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/pika_transport.py +0 -0
  28. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/py.typed +0 -0
  29. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/redis_transport.py +0 -0
  30. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/routing.py +0 -0
  31. {babelqueue-1.1.0 → babelqueue-1.2.0}/src/babelqueue/sqs_transport.py +0 -0
  32. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  33. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  34. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  35. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/order-created.json +0 -0
  36. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  37. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  38. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  39. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/fixtures/dead-lettered.json +0 -0
  40. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/fixtures/order-created.json +0 -0
  41. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_app.py +0 -0
  42. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_celery.py +0 -0
  43. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_codec.py +0 -0
  44. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_conformance.py +0 -0
  45. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_dead_letter.py +0 -0
  46. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_django.py +0 -0
  47. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_overhead.py +0 -0
  48. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_pika_transport.py +0 -0
  49. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_redis_transport.py +0 -0
  50. {babelqueue-1.1.0 → babelqueue-1.2.0}/tests/test_sqs_conformance.py +0 -0
  51. {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.1.0...HEAD
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.1.0
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.1.0"
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"]
@@ -19,7 +19,7 @@ from .exceptions import BabelQueueError, UnknownUrnError
19
19
  from .routing import UnknownUrnStrategy
20
20
  from .transport import InMemoryTransport, ReceivedMessage, Transport
21
21
 
22
- __version__ = "1.1.0"
22
+ __version__ = "1.2.0"
23
23
 
24
24
  __all__ = [
25
25
  "BabelQueue",
@@ -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 'sqs://', or pass your own Transport via BabelQueue(transport=...)."
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