babelqueue 1.1.0__tar.gz → 1.3.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.3.0}/.github/workflows/ci.yml +2 -2
- {babelqueue-1.1.0 → babelqueue-1.3.0}/CHANGELOG.md +36 -1
- {babelqueue-1.1.0 → babelqueue-1.3.0}/PKG-INFO +6 -1
- {babelqueue-1.1.0 → babelqueue-1.3.0}/pyproject.toml +3 -1
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/__init__.py +1 -1
- babelqueue-1.3.0/src/babelqueue/asb_transport.py +173 -0
- babelqueue-1.3.0/src/babelqueue/pulsar_transport.py +202 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/transport.py +10 -1
- babelqueue-1.3.0/tests/conformance/manifest.json +152 -0
- babelqueue-1.3.0/tests/test_asb_conformance.py +54 -0
- babelqueue-1.3.0/tests/test_asb_transport.py +169 -0
- babelqueue-1.3.0/tests/test_pulsar_conformance.py +48 -0
- babelqueue-1.3.0/tests/test_pulsar_transport.py +215 -0
- babelqueue-1.1.0/tests/conformance/manifest.json +0 -97
- {babelqueue-1.1.0 → babelqueue-1.3.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/.gitignore +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/LICENSE +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/README.md +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_app.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_celery.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_codec.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_django.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.1.0 → babelqueue-1.3.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,40 @@ 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.3.0] - 2026-06-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Apache Pulsar transport** (`babelqueue[pulsar]`, `pulsar-client`) — `PulsarTransport`,
|
|
14
|
+
selected by the `pulsar://` (or `pulsar+ssl://`) URL scheme (e.g.
|
|
15
|
+
`pulsar://localhost:6650`; or pass a built `client`). Implements [§5 of the broker-bindings
|
|
16
|
+
contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#apache-pulsar): the canonical
|
|
17
|
+
envelope is the message payload, projected onto native Pulsar message properties
|
|
18
|
+
(string→string) — `bq-job` = URN, `bq-trace-id` = `trace_id`, `bq-message-id` = `meta.id`,
|
|
19
|
+
plus `bq-schema-version`/`bq-source-lang`/`bq-attempts`; receive → `acknowledge`; `attempts`
|
|
20
|
+
reconciled to `max(bq-attempts, redelivery_count)` (Pulsar's redelivery count is 0-based, so
|
|
21
|
+
it maps directly with no −1, and the `max` never lowers a higher body count carried by a
|
|
22
|
+
republish-driven retry). Default `Shared` subscription named `babelqueue`; a client can be
|
|
23
|
+
injected for tests/DI. The projection + reconciliation + publish/pop/ack flow are unit-tested
|
|
24
|
+
with no broker and no `pulsar-client` (the pulsar import is lazy and publishing sends raw
|
|
25
|
+
bytes). The envelope is unchanged (`schema_version: 1`); Apache Pulsar is purely additive.
|
|
26
|
+
Ships as a MINOR.
|
|
27
|
+
|
|
28
|
+
## [1.2.0] - 2026-06-13
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **Azure Service Bus transport** (`babelqueue[azureservicebus]`, `azure-servicebus`) —
|
|
32
|
+
`AsbTransport`, selected by the `sb://` URL scheme (e.g.
|
|
33
|
+
`sb://<namespace>.servicebus.windows.net`, Azure AD via `DefaultAzureCredential`; or pass
|
|
34
|
+
`connection_string=...` / a built `client`). Implements [§4 of the broker-bindings
|
|
35
|
+
contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#azure-service-bus): the
|
|
36
|
+
canonical envelope is the message body, projected onto native Service Bus fields
|
|
37
|
+
(`Subject` = URN, `CorrelationId` = `trace_id`, `MessageId` = `meta.id`, plus the `bq-`
|
|
38
|
+
application properties); PeekLock reserve → `complete_message` ack; `attempts` reconciled
|
|
39
|
+
to the broker-authoritative `DeliveryCount − 1`. A client can be injected for tests/DI. The
|
|
40
|
+
projection + reconciliation are unit-tested with no broker and no `azure-servicebus` (the
|
|
41
|
+
azure import is lazy); the publish flow is covered with a fake client. The envelope is
|
|
42
|
+
unchanged (`schema_version: 1`); Azure Service Bus is purely additive. Ships as a MINOR.
|
|
43
|
+
|
|
10
44
|
## [1.1.0] - 2026-06-12
|
|
11
45
|
|
|
12
46
|
### Added
|
|
@@ -99,7 +133,8 @@ reference at [babelqueue.com](https://babelqueue.com).
|
|
|
99
133
|
- Pre-1.0: the public API may change before the `1.0.0` tag.
|
|
100
134
|
- The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
|
|
101
135
|
|
|
102
|
-
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.
|
|
136
|
+
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.2.0...HEAD
|
|
137
|
+
[1.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.1.0...v1.2.0
|
|
103
138
|
[1.1.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.0.0...v1.1.0
|
|
104
139
|
[1.0.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.5.0...v1.0.0
|
|
105
140
|
[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.3.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
|
|
@@ -33,6 +36,8 @@ Requires-Dist: pytest>=7; extra == 'dev'
|
|
|
33
36
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
34
37
|
Provides-Extra: django
|
|
35
38
|
Requires-Dist: django>=4.2; extra == 'django'
|
|
39
|
+
Provides-Extra: pulsar
|
|
40
|
+
Requires-Dist: pulsar-client>=3.4; extra == 'pulsar'
|
|
36
41
|
Provides-Extra: redis
|
|
37
42
|
Requires-Dist: redis>=4; extra == 'redis'
|
|
38
43
|
Provides-Extra: sqs
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.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,8 @@ 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"]
|
|
36
|
+
pulsar = ["pulsar-client>=3.4"]
|
|
35
37
|
celery = ["celery>=5"]
|
|
36
38
|
django = ["django>=4.2"]
|
|
37
39
|
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
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Apache Pulsar transport. Requires the ``pulsar`` extra:
|
|
2
|
+
|
|
3
|
+
pip install "babelqueue[pulsar]"
|
|
4
|
+
|
|
5
|
+
Producing sends the canonical envelope as the message payload and projects the contract
|
|
6
|
+
envelope fields onto native Pulsar message properties (string->string): ``bq-job`` = URN,
|
|
7
|
+
``bq-trace-id`` = trace_id, ``bq-message-id`` = meta.id, plus ``bq-schema-version`` /
|
|
8
|
+
``bq-source-lang`` / ``bq-attempts`` — so a Java/.NET/... peer can route on ``bq-job``
|
|
9
|
+
without parsing the body. Consuming receives one message at a time
|
|
10
|
+
(``receive`` -> process -> ``acknowledge``); the authoritative attempt count is the
|
|
11
|
+
envelope's ``bq-attempts`` (the body), cross-checked against the broker's native
|
|
12
|
+
``redelivery_count`` (0-based) and reconciled onto the envelope as
|
|
13
|
+
``attempts = max(body, redelivery_count)`` — no -1, because Pulsar's redelivery count is
|
|
14
|
+
0-based (0 on first delivery). A message left un-acked is redelivered (at-least-once).
|
|
15
|
+
|
|
16
|
+
This implements §5 of the broker-bindings contract. The envelope is unchanged
|
|
17
|
+
(``schema_version`` stays 1); Apache Pulsar is purely additive.
|
|
18
|
+
|
|
19
|
+
URL form: ``pulsar://host:6650`` (or ``pulsar+ssl://``). The default subscription name is
|
|
20
|
+
``babelqueue`` over a ``Shared`` subscription. For a custom client/subscription, build the
|
|
21
|
+
transport directly and pass it via ``BabelQueue(transport=...)`` or
|
|
22
|
+
``PulsarTransport(client=...)``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any, Dict, Optional
|
|
28
|
+
|
|
29
|
+
from .codec import EnvelopeCodec
|
|
30
|
+
from .transport import ReceivedMessage, Transport
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PulsarTransport(Transport):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
url: str = "pulsar://localhost:6650",
|
|
37
|
+
*,
|
|
38
|
+
client: Any = None,
|
|
39
|
+
subscription: str = "babelqueue",
|
|
40
|
+
consumer_type: Any = None,
|
|
41
|
+
topic_prefix: str = "",
|
|
42
|
+
receive_timeout_millis: int = 1000,
|
|
43
|
+
**subscribe_options: Any,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._url = url or "pulsar://localhost:6650"
|
|
46
|
+
self._subscription = subscription
|
|
47
|
+
self._consumer_type = consumer_type
|
|
48
|
+
self._topic_prefix = topic_prefix
|
|
49
|
+
self._receive_timeout_millis = receive_timeout_millis
|
|
50
|
+
self._subscribe_options = subscribe_options
|
|
51
|
+
self._producers: Dict[str, Any] = {}
|
|
52
|
+
self._consumers: Dict[str, Any] = {}
|
|
53
|
+
|
|
54
|
+
if client is not None:
|
|
55
|
+
self._client = client
|
|
56
|
+
return
|
|
57
|
+
self._client = self._build_client() # pragma: no cover - needs Pulsar / network
|
|
58
|
+
|
|
59
|
+
def _build_client(self) -> Any: # pragma: no cover - needs Pulsar / network
|
|
60
|
+
try:
|
|
61
|
+
import pulsar
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
raise ImportError(
|
|
64
|
+
"PulsarTransport requires the 'pulsar-client' package. Install with "
|
|
65
|
+
'pip install "babelqueue[pulsar]".'
|
|
66
|
+
) from exc
|
|
67
|
+
return pulsar.Client(self._url)
|
|
68
|
+
|
|
69
|
+
# -- helpers ------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _topic(self, queue: str) -> str:
|
|
72
|
+
return f"{self._topic_prefix}{queue}" if self._topic_prefix else queue
|
|
73
|
+
|
|
74
|
+
def _producer(self, queue: str) -> Any:
|
|
75
|
+
producer = self._producers.get(queue)
|
|
76
|
+
if producer is None:
|
|
77
|
+
producer = self._client.create_producer(self._topic(queue))
|
|
78
|
+
self._producers[queue] = producer
|
|
79
|
+
return producer
|
|
80
|
+
|
|
81
|
+
def _consumer(self, queue: str) -> Any:
|
|
82
|
+
consumer = self._consumers.get(queue)
|
|
83
|
+
if consumer is None:
|
|
84
|
+
consumer = self._client.subscribe(
|
|
85
|
+
self._topic(queue), self._subscription, **self._subscribe_kwargs()
|
|
86
|
+
)
|
|
87
|
+
self._consumers[queue] = consumer
|
|
88
|
+
return consumer
|
|
89
|
+
|
|
90
|
+
def _subscribe_kwargs(self) -> Dict[str, Any]:
|
|
91
|
+
kwargs = dict(self._subscribe_options)
|
|
92
|
+
consumer_type = self._consumer_type
|
|
93
|
+
if consumer_type is None:
|
|
94
|
+
consumer_type = self._default_shared_type()
|
|
95
|
+
if consumer_type is not None:
|
|
96
|
+
kwargs.setdefault("consumer_type", consumer_type)
|
|
97
|
+
return kwargs
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _default_shared_type() -> Any:
|
|
101
|
+
try:
|
|
102
|
+
import pulsar
|
|
103
|
+
except ImportError: # pragma: no cover - exercised only with pulsar absent
|
|
104
|
+
return None
|
|
105
|
+
return pulsar.ConsumerType.Shared # pragma: no cover - needs Pulsar
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _projection(body: str) -> Dict[str, str]:
|
|
109
|
+
"""Native Pulsar message properties (string->string) — a redundant, routable view of
|
|
110
|
+
the body: bq-job/bq-trace-id/bq-message-id + bq-schema-version/lang/attempts. §5.2."""
|
|
111
|
+
env = EnvelopeCodec.decode(body)
|
|
112
|
+
if not env:
|
|
113
|
+
return {}
|
|
114
|
+
meta = env.get("meta") or {}
|
|
115
|
+
|
|
116
|
+
props: Dict[str, str] = {}
|
|
117
|
+
if env.get("job"):
|
|
118
|
+
props["bq-job"] = str(env["job"])
|
|
119
|
+
if env.get("trace_id"):
|
|
120
|
+
props["bq-trace-id"] = str(env["trace_id"])
|
|
121
|
+
if meta.get("id"):
|
|
122
|
+
props["bq-message-id"] = str(meta["id"])
|
|
123
|
+
if meta.get("schema_version") is not None:
|
|
124
|
+
props["bq-schema-version"] = str(meta["schema_version"])
|
|
125
|
+
if meta.get("lang"):
|
|
126
|
+
props["bq-source-lang"] = str(meta["lang"])
|
|
127
|
+
props["bq-attempts"] = str(int(env.get("attempts", 0) or 0))
|
|
128
|
+
return props
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _reconcile(body: str, redelivery_count: Any) -> str:
|
|
132
|
+
"""Set attempts to max(current, redelivery_count). Pulsar's redelivery count is
|
|
133
|
+
0-based (0 on first delivery), so it maps directly to attempts with no -1. The
|
|
134
|
+
runtime retries by republishing with attempts+1 in the body (redelivery count back to
|
|
135
|
+
0), so a republished message must not have its higher body count lowered."""
|
|
136
|
+
try:
|
|
137
|
+
rc = int(redelivery_count)
|
|
138
|
+
except (ValueError, TypeError):
|
|
139
|
+
return body
|
|
140
|
+
if rc <= 0:
|
|
141
|
+
return body
|
|
142
|
+
env = EnvelopeCodec.decode(body)
|
|
143
|
+
if not env or rc <= int(env.get("attempts", 0) or 0):
|
|
144
|
+
return body
|
|
145
|
+
env["attempts"] = rc
|
|
146
|
+
return EnvelopeCodec.encode(env)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _redelivery_count(message: Any) -> int:
|
|
150
|
+
getter = getattr(message, "redelivery_count", None)
|
|
151
|
+
value = getter() if callable(getter) else getter
|
|
152
|
+
if value is None:
|
|
153
|
+
return 0
|
|
154
|
+
try:
|
|
155
|
+
return int(value)
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _payload(message: Any) -> str:
|
|
161
|
+
getter = getattr(message, "data", None)
|
|
162
|
+
data = getter() if callable(getter) else getter
|
|
163
|
+
if isinstance(data, bytes):
|
|
164
|
+
return data.decode("utf-8")
|
|
165
|
+
return str(data) if data is not None else ""
|
|
166
|
+
|
|
167
|
+
# -- Transport ----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def publish(self, queue: str, body: str) -> None:
|
|
170
|
+
self._producer(queue).send(body.encode("utf-8"), properties=self._projection(body))
|
|
171
|
+
|
|
172
|
+
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
173
|
+
wait = self._receive_timeout_millis
|
|
174
|
+
if timeout and timeout > 0:
|
|
175
|
+
wait = int(timeout * 1000)
|
|
176
|
+
consumer = self._consumer(queue)
|
|
177
|
+
try:
|
|
178
|
+
message = consumer.receive(timeout_millis=wait)
|
|
179
|
+
except Exception as exc: # noqa: BLE001 - Pulsar raises pulsar.Timeout on no message
|
|
180
|
+
if type(exc).__name__ == "Timeout":
|
|
181
|
+
return None
|
|
182
|
+
raise
|
|
183
|
+
if message is None: # pragma: no cover - defensive (real client raises instead)
|
|
184
|
+
return None
|
|
185
|
+
body = self._reconcile(self._payload(message), self._redelivery_count(message))
|
|
186
|
+
return ReceivedMessage(body=body, queue=queue, handle=message)
|
|
187
|
+
|
|
188
|
+
def ack(self, message: ReceivedMessage) -> None:
|
|
189
|
+
if message.handle is None:
|
|
190
|
+
return
|
|
191
|
+
self._consumer(message.queue).acknowledge(message.handle)
|
|
192
|
+
|
|
193
|
+
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
194
|
+
for resource in (*self._producers.values(), *self._consumers.values()):
|
|
195
|
+
try:
|
|
196
|
+
resource.close()
|
|
197
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
self._client.close()
|
|
201
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
202
|
+
pass
|
|
@@ -83,8 +83,17 @@ 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)
|
|
90
|
+
if scheme in ("pulsar", "pulsar+ssl"):
|
|
91
|
+
from .pulsar_transport import PulsarTransport
|
|
92
|
+
|
|
93
|
+
return PulsarTransport(broker_url)
|
|
86
94
|
|
|
87
95
|
raise BabelQueueError(
|
|
88
96
|
f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://', "
|
|
89
|
-
"'amqp://' or '
|
|
97
|
+
"'amqp://', 'sqs://', 'sb://' or 'pulsar://', or pass your own Transport via "
|
|
98
|
+
"BabelQueue(transport=...)."
|
|
90
99
|
)
|