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