babelqueue 1.4.0__tar.gz → 1.6.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.4.0 → babelqueue-1.6.0}/.github/workflows/ci.yml +2 -2
- {babelqueue-1.4.0 → babelqueue-1.6.0}/CHANGELOG.md +51 -1
- {babelqueue-1.4.0 → babelqueue-1.6.0}/PKG-INFO +32 -4
- {babelqueue-1.4.0 → babelqueue-1.6.0}/README.md +29 -3
- {babelqueue-1.4.0 → babelqueue-1.6.0}/pyproject.toml +2 -1
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/__init__.py +1 -1
- babelqueue-1.6.0/src/babelqueue/artemis_transport.py +227 -0
- babelqueue-1.6.0/src/babelqueue/redis_transport.py +254 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/transport.py +6 -2
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/manifest.json +53 -0
- babelqueue-1.6.0/tests/test_artemis_conformance.py +50 -0
- babelqueue-1.6.0/tests/test_artemis_transport.py +205 -0
- babelqueue-1.6.0/tests/test_redis_transport.py +396 -0
- babelqueue-1.4.0/src/babelqueue/redis_transport.py +0 -53
- babelqueue-1.4.0/tests/test_redis_transport.py +0 -78
- {babelqueue-1.4.0 → babelqueue-1.6.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/.gitignore +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/LICENSE +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_app.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_celery.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_codec.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_django.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.4.0 → babelqueue-1.6.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,azureservicebus]"
|
|
47
|
+
pip install -e ".[dev,celery,django,redis,amqp,sqs,azureservicebus,artemis]"
|
|
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,azureservicebus,celery,django,dev]"
|
|
92
|
+
pip install -e ".[redis,amqp,sqs,azureservicebus,artemis,celery,django,dev]"
|
|
93
93
|
|
|
94
94
|
- name: Wait for ElasticMQ
|
|
95
95
|
run: |
|
|
@@ -7,6 +7,55 @@ 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
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [1.6.0] - 2026-06-14
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Redis/Laravel reservation parity** — the Redis transport can now consume a
|
|
16
|
+
**shared** Laravel BabelQueue Redis queue using Laravel's reserved-set / reliable-queue
|
|
17
|
+
semantics, not just a Python-owned queue. Enable it with the `laravel=1` URL flag
|
|
18
|
+
(`redis://host:6379/0?laravel=1`) or `RedisTransport(..., laravel_compat=True)`. In this
|
|
19
|
+
mode the key layout is Laravel's stock Redis queue ([§1 of the broker-bindings
|
|
20
|
+
contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)): a `queues:<name>`
|
|
21
|
+
ready list, a `queues:<name>:reserved` **sorted set** scored by a `retry_after` deadline
|
|
22
|
+
(default 60s), a `queues:<name>:delayed` set, and a `queues:<name>:notify` wake-up list.
|
|
23
|
+
Reserve, ack and release run the **byte-for-byte same Lua scripts** Laravel uses, so the
|
|
24
|
+
reserved member a Python worker writes is identical to a Laravel worker's — either side can
|
|
25
|
+
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share one
|
|
26
|
+
Redis queue without losing or double-processing messages. Before each pop, expired
|
|
27
|
+
reserved/delayed jobs migrate back to the ready list, so a crashed worker's in-flight job
|
|
28
|
+
is re-reserved exactly as Laravel does. Tunable via `?prefix=` / `?retry_after=` (or the
|
|
29
|
+
`key_prefix` / `retry_after` constructor args). The default Python-owned reliable-queue
|
|
30
|
+
mode (`BLMOVE` + `<queue>:processing` list) is unchanged and stays the default, so existing
|
|
31
|
+
callers are unaffected. The reservation logic is fully unit-tested with an injected
|
|
32
|
+
in-memory Redis double (no `redis` package, no broker); a live cross-runtime PHP↔Python
|
|
33
|
+
shared-queue round-trip is covered by the integration suite where a real Redis is present.
|
|
34
|
+
The envelope is unchanged (`schema_version: 1`); this is purely additive. Ships as a MINOR.
|
|
35
|
+
|
|
36
|
+
## [1.5.0] - 2026-06-13
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- **Apache ActiveMQ Artemis transport** (`babelqueue[artemis]`, `python-qpid-proton`) —
|
|
40
|
+
`ArtemisTransport`, selected by the `artemis://` (or `artemis+ssl://`) URL scheme (e.g.
|
|
41
|
+
`artemis://localhost:5672`; or pass an injected `connection`). Artemis speaks **AMQP 1.0**
|
|
42
|
+
(not RabbitMQ's 0-9-1), so the transport uses the `python-qpid-proton` blocking client.
|
|
43
|
+
Implements [§7 of the broker-bindings
|
|
44
|
+
contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#apache-activemq-artemis): the
|
|
45
|
+
canonical envelope is the message body, projected onto the AMQP fields a JMS peer reads —
|
|
46
|
+
`correlation-id` = `trace_id` (JMSCorrelationID), `creation-time` = `meta.created_at`
|
|
47
|
+
(JMSTimestamp), the `x-opt-jms-type` annotation = URN (JMSType, the AMQP-JMS mapping), plus
|
|
48
|
+
the `bq-schema-version`/`bq-source-lang`/`bq-attempts`/`bq-app-id` application properties.
|
|
49
|
+
Consume reserves one message at a time (`receive` → process → `accept`); `attempts` is
|
|
50
|
+
reconciled to `max(body, delivery_count)` — the AMQP delivery-count header is 0-based, so it
|
|
51
|
+
maps directly with no −1 (the Java JMS binding reads the 1-based `JMSXDeliveryCount` and
|
|
52
|
+
subtracts 1, arriving at the same 0-based `attempts`), and the `max` never lowers a higher
|
|
53
|
+
body count carried by a republish-driven retry. The projection + reconciliation + pop/ack
|
|
54
|
+
flow are unit-tested with no broker and no `python-qpid-proton` (the proton import is lazy;
|
|
55
|
+
the transport talks to an injected connection fake); the publish flow that builds a real
|
|
56
|
+
proton `Message` is exercised wherever proton is installed. The envelope is unchanged
|
|
57
|
+
(`schema_version: 1`); Apache ActiveMQ Artemis is purely additive. Ships as a MINOR.
|
|
58
|
+
|
|
10
59
|
## [1.4.0] - 2026-06-13
|
|
11
60
|
|
|
12
61
|
### Added
|
|
@@ -150,7 +199,8 @@ reference at [babelqueue.com](https://babelqueue.com).
|
|
|
150
199
|
- Pre-1.0: the public API may change before the `1.0.0` tag.
|
|
151
200
|
- The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
|
|
152
201
|
|
|
153
|
-
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.
|
|
202
|
+
[Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.6.0...HEAD
|
|
203
|
+
[1.6.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.5.0...v1.6.0
|
|
154
204
|
[1.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.1.0...v1.2.0
|
|
155
205
|
[1.1.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.0.0...v1.1.0
|
|
156
206
|
[1.0.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.5.0...v1.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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,8 @@ 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: artemis
|
|
28
|
+
Requires-Dist: python-qpid-proton>=0.39; extra == 'artemis'
|
|
27
29
|
Provides-Extra: azureservicebus
|
|
28
30
|
Requires-Dist: azure-identity>=1.15; extra == 'azureservicebus'
|
|
29
31
|
Requires-Dist: azure-servicebus>=7.11; extra == 'azureservicebus'
|
|
@@ -162,9 +164,35 @@ app.run() # consume forever (Ctrl-C to stop)
|
|
|
162
164
|
- **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
|
|
163
165
|
envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
|
|
164
166
|
messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
|
|
165
|
-
- **Transports:** `redis://` (reliable-queue pattern
|
|
166
|
-
|
|
167
|
-
|
|
167
|
+
- **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
|
|
168
|
+
Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
|
|
169
|
+
see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
|
|
170
|
+
and `memory://` (in-process, great for tests/local). Bring your own by passing
|
|
171
|
+
`transport=...`.
|
|
172
|
+
|
|
173
|
+
### Sharing a Redis queue with Laravel
|
|
174
|
+
|
|
175
|
+
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
|
|
176
|
+
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). To consume a
|
|
177
|
+
**shared** Laravel BabelQueue Redis queue instead, enable Laravel-compatible mode:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
|
|
181
|
+
# or: RedisTransport("redis://localhost:6379/0", laravel_compat=True)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
This replicates Laravel's stock Redis reservation exactly ([§1 of the
|
|
185
|
+
broker-bindings contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)):
|
|
186
|
+
the ready list is `queues:<name>`, reservations move into the `queues:<name>:reserved`
|
|
187
|
+
**sorted set** scored by a `retry_after` deadline (default 60s), with a
|
|
188
|
+
`queues:<name>:delayed` set and a `queues:<name>:notify` wake-up list. Reserve, ack
|
|
189
|
+
and release run the **byte-for-byte same Lua scripts** Laravel uses, so the reserved
|
|
190
|
+
member a Python worker writes is identical to a Laravel worker's — either side can
|
|
191
|
+
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
|
|
192
|
+
one queue without losing or double-processing messages. Before each pop, expired
|
|
193
|
+
reserved/delayed jobs migrate back to the ready list, so a crashed worker's
|
|
194
|
+
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=60`
|
|
195
|
+
(or the `key_prefix` / `retry_after` constructor args).
|
|
168
196
|
|
|
169
197
|
## Framework adapters — Celery & Django
|
|
170
198
|
|
|
@@ -114,9 +114,35 @@ app.run() # consume forever (Ctrl-C to stop)
|
|
|
114
114
|
- **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
|
|
115
115
|
envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
|
|
116
116
|
messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
|
|
117
|
-
- **Transports:** `redis://` (reliable-queue pattern
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
- **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
|
|
118
|
+
Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
|
|
119
|
+
see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
|
|
120
|
+
and `memory://` (in-process, great for tests/local). Bring your own by passing
|
|
121
|
+
`transport=...`.
|
|
122
|
+
|
|
123
|
+
### Sharing a Redis queue with Laravel
|
|
124
|
+
|
|
125
|
+
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
|
|
126
|
+
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). To consume a
|
|
127
|
+
**shared** Laravel BabelQueue Redis queue instead, enable Laravel-compatible mode:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
|
|
131
|
+
# or: RedisTransport("redis://localhost:6379/0", laravel_compat=True)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This replicates Laravel's stock Redis reservation exactly ([§1 of the
|
|
135
|
+
broker-bindings contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)):
|
|
136
|
+
the ready list is `queues:<name>`, reservations move into the `queues:<name>:reserved`
|
|
137
|
+
**sorted set** scored by a `retry_after` deadline (default 60s), with a
|
|
138
|
+
`queues:<name>:delayed` set and a `queues:<name>:notify` wake-up list. Reserve, ack
|
|
139
|
+
and release run the **byte-for-byte same Lua scripts** Laravel uses, so the reserved
|
|
140
|
+
member a Python worker writes is identical to a Laravel worker's — either side can
|
|
141
|
+
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
|
|
142
|
+
one queue without losing or double-processing messages. Before each pop, expired
|
|
143
|
+
reserved/delayed jobs migrate back to the ready list, so a crashed worker's
|
|
144
|
+
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=60`
|
|
145
|
+
(or the `key_prefix` / `retry_after` constructor args).
|
|
120
146
|
|
|
121
147
|
## Framework adapters — Celery & Django
|
|
122
148
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.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"
|
|
@@ -35,6 +35,7 @@ sqs = ["boto3>=1.26"]
|
|
|
35
35
|
azureservicebus = ["azure-servicebus>=7.11", "azure-identity>=1.15"]
|
|
36
36
|
pulsar = ["pulsar-client>=3.4"]
|
|
37
37
|
kafka = ["confluent-kafka>=2.3"]
|
|
38
|
+
artemis = ["python-qpid-proton>=0.39"]
|
|
38
39
|
celery = ["celery>=5"]
|
|
39
40
|
django = ["django>=4.2"]
|
|
40
41
|
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Apache ActiveMQ Artemis transport over AMQP 1.0. Requires the ``artemis`` extra:
|
|
2
|
+
|
|
3
|
+
pip install "babelqueue[artemis]"
|
|
4
|
+
|
|
5
|
+
Artemis speaks AMQP 1.0 (not the 0-9-1 of RabbitMQ), so this transport uses the
|
|
6
|
+
``python-qpid-proton`` blocking client rather than ``pika``. Producing sends the canonical
|
|
7
|
+
envelope as the message body and projects the contract envelope fields onto the AMQP
|
|
8
|
+
properties a JMS peer reads: ``correlation-id`` = trace_id (JMSCorrelationID), ``creation-time``
|
|
9
|
+
= meta.created_at (JMSTimestamp), the ``x-opt-jms-type`` message annotation = URN (JMSType, the
|
|
10
|
+
AMQP-JMS mapping), plus the ``bq_`` application properties (``bq_schema_version`` /
|
|
11
|
+
``bq_source_lang`` / ``bq_attempts`` / ``bq_app_id``; underscores, since JMS property names must
|
|
12
|
+
be valid Java identifiers) — so a Java (JMS) or .NET/Node/... peer
|
|
13
|
+
routes and correlates without parsing the body. The URN in the body's ``job`` field stays
|
|
14
|
+
authoritative.
|
|
15
|
+
|
|
16
|
+
Consuming reserves one message at a time (``receive`` -> process -> ``accept``); the
|
|
17
|
+
authoritative attempt count is the envelope's ``attempts`` (the body), reconciled against the
|
|
18
|
+
broker's native AMQP ``delivery-count`` as ``attempts = max(body, delivery_count)`` — no -1,
|
|
19
|
+
because the AMQP header counter is 0-based (0 on first delivery), counting prior failed
|
|
20
|
+
deliveries. (The Java JMS binding reads ``JMSXDeliveryCount`` which is 1-based and subtracts 1,
|
|
21
|
+
arriving at the same 0-based ``attempts``.) A message left un-accepted is redelivered
|
|
22
|
+
(at-least-once).
|
|
23
|
+
|
|
24
|
+
This implements §7 of the broker-bindings contract. The envelope is unchanged
|
|
25
|
+
(``schema_version`` stays 1); Apache ActiveMQ Artemis is purely additive.
|
|
26
|
+
|
|
27
|
+
URL form: ``artemis://host:5672`` (or ``artemis+ssl://`` for TLS) — translated to the
|
|
28
|
+
``amqp://`` / ``amqps://`` proton speaks. For credentials or a custom connection, build the
|
|
29
|
+
transport directly and pass it via ``BabelQueue(transport=...)`` or
|
|
30
|
+
``ArtemisTransport(connection=...)``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Any, Dict, Optional
|
|
36
|
+
|
|
37
|
+
from .codec import EnvelopeCodec
|
|
38
|
+
from .transport import ReceivedMessage, Transport
|
|
39
|
+
|
|
40
|
+
JMS_TYPE_ANNOTATION = "x-opt-jms-type"
|
|
41
|
+
APP_ID = "babelqueue"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ArtemisTransport(Transport):
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
url: str = "artemis://localhost:5672",
|
|
48
|
+
*,
|
|
49
|
+
connection: Any = None,
|
|
50
|
+
credit: int = 1,
|
|
51
|
+
receive_timeout_millis: int = 1000,
|
|
52
|
+
**connect_options: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._url = url or "artemis://localhost:5672"
|
|
55
|
+
self._credit = credit
|
|
56
|
+
self._receive_timeout_millis = receive_timeout_millis
|
|
57
|
+
self._connect_options = connect_options
|
|
58
|
+
self._senders: Dict[str, Any] = {}
|
|
59
|
+
self._receivers: Dict[str, Any] = {}
|
|
60
|
+
|
|
61
|
+
if connection is not None:
|
|
62
|
+
self._connection = connection
|
|
63
|
+
return
|
|
64
|
+
self._connection = self._build_connection() # pragma: no cover - needs Artemis / network
|
|
65
|
+
|
|
66
|
+
def _build_connection(self) -> Any: # pragma: no cover - needs Artemis / network
|
|
67
|
+
try:
|
|
68
|
+
from proton.utils import BlockingConnection
|
|
69
|
+
except ImportError as exc:
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"ArtemisTransport requires the 'python-qpid-proton' package. Install with "
|
|
72
|
+
'pip install "babelqueue[artemis]".'
|
|
73
|
+
) from exc
|
|
74
|
+
return BlockingConnection(self._to_amqp_url(self._url), **self._connect_options)
|
|
75
|
+
|
|
76
|
+
# -- helpers ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _to_amqp_url(url: str) -> str:
|
|
80
|
+
"""``artemis://`` -> ``amqp://`` and ``artemis+ssl://`` -> ``amqps://`` (proton's schemes)."""
|
|
81
|
+
if url.startswith("artemis+ssl://"):
|
|
82
|
+
return "amqps://" + url[len("artemis+ssl://"):]
|
|
83
|
+
if url.startswith("artemis://"):
|
|
84
|
+
return "amqp://" + url[len("artemis://"):]
|
|
85
|
+
return url
|
|
86
|
+
|
|
87
|
+
def _sender(self, queue: str) -> Any:
|
|
88
|
+
sender = self._senders.get(queue)
|
|
89
|
+
if sender is None:
|
|
90
|
+
sender = self._connection.create_sender(queue)
|
|
91
|
+
self._senders[queue] = sender
|
|
92
|
+
return sender
|
|
93
|
+
|
|
94
|
+
def _receiver(self, queue: str) -> Any:
|
|
95
|
+
receiver = self._receivers.get(queue)
|
|
96
|
+
if receiver is None:
|
|
97
|
+
receiver = self._connection.create_receiver(queue, credit=self._credit)
|
|
98
|
+
self._receivers[queue] = receiver
|
|
99
|
+
return receiver
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _projection(body: str) -> Dict[str, str]:
|
|
103
|
+
"""AMQP application properties (string->string) — a redundant, routable view of the
|
|
104
|
+
body: bq_schema_version/bq_source_lang/bq_attempts/bq_app_id. §7.2 (the URN is carried
|
|
105
|
+
by the x-opt-jms-type annotation, trace_id by correlation-id). The names use UNDERSCORES,
|
|
106
|
+
not hyphens: a JMS property name must be a valid Java identifier, and every Artemis SDK
|
|
107
|
+
uses the same JMS-legal form for cross-protocol parity."""
|
|
108
|
+
env = EnvelopeCodec.decode(body)
|
|
109
|
+
if not env:
|
|
110
|
+
return {}
|
|
111
|
+
meta = env.get("meta") or {}
|
|
112
|
+
|
|
113
|
+
props: Dict[str, str] = {}
|
|
114
|
+
if meta.get("schema_version") is not None:
|
|
115
|
+
props["bq_schema_version"] = str(meta["schema_version"])
|
|
116
|
+
if meta.get("lang"):
|
|
117
|
+
props["bq_source_lang"] = str(meta["lang"])
|
|
118
|
+
props["bq_attempts"] = str(int(env.get("attempts", 0) or 0))
|
|
119
|
+
props["bq_app_id"] = APP_ID
|
|
120
|
+
return props
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _jms_type(body: str) -> str:
|
|
124
|
+
env = EnvelopeCodec.decode(body)
|
|
125
|
+
return str(env["job"]) if env and env.get("job") else ""
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _correlation_id(body: str) -> str:
|
|
129
|
+
env = EnvelopeCodec.decode(body)
|
|
130
|
+
return str(env["trace_id"]) if env and env.get("trace_id") else ""
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _creation_seconds(body: str) -> Optional[float]:
|
|
134
|
+
"""proton's creation_time is float seconds; the contract's created_at is epoch ms."""
|
|
135
|
+
env = EnvelopeCodec.decode(body)
|
|
136
|
+
meta = (env or {}).get("meta") or {}
|
|
137
|
+
created_at = meta.get("created_at")
|
|
138
|
+
if created_at is None:
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return int(created_at) / 1000.0
|
|
142
|
+
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def _message(self, body: str) -> Any:
|
|
146
|
+
"""Build the proton Message projecting the §7 JMS-readable metadata."""
|
|
147
|
+
from proton import Message, symbol
|
|
148
|
+
|
|
149
|
+
message: Any = Message(body=body)
|
|
150
|
+
props = self._projection(body)
|
|
151
|
+
if props:
|
|
152
|
+
message.properties = props
|
|
153
|
+
correlation_id = self._correlation_id(body)
|
|
154
|
+
if correlation_id:
|
|
155
|
+
message.correlation_id = correlation_id
|
|
156
|
+
creation = self._creation_seconds(body)
|
|
157
|
+
if creation is not None:
|
|
158
|
+
message.creation_time = creation
|
|
159
|
+
jms_type = self._jms_type(body)
|
|
160
|
+
if jms_type:
|
|
161
|
+
message.annotations = {symbol(JMS_TYPE_ANNOTATION): jms_type}
|
|
162
|
+
return message
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _reconcile(body: str, delivery_count: Any) -> str:
|
|
166
|
+
"""Set attempts to max(current, delivery_count). The AMQP delivery-count header is
|
|
167
|
+
0-based (0 on first delivery), so it maps directly to attempts with no -1. The runtime
|
|
168
|
+
retries by republishing with attempts+1 in the body (delivery-count back to 0), so a
|
|
169
|
+
republished message must not have its higher body count lowered."""
|
|
170
|
+
try:
|
|
171
|
+
dc = int(delivery_count)
|
|
172
|
+
except (ValueError, TypeError):
|
|
173
|
+
return body
|
|
174
|
+
if dc <= 0:
|
|
175
|
+
return body
|
|
176
|
+
env = EnvelopeCodec.decode(body)
|
|
177
|
+
if not env or dc <= int(env.get("attempts", 0) or 0):
|
|
178
|
+
return body
|
|
179
|
+
env["attempts"] = dc
|
|
180
|
+
return EnvelopeCodec.encode(env)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _delivery_count(message: Any) -> int:
|
|
184
|
+
value = getattr(message, "delivery_count", 0)
|
|
185
|
+
try:
|
|
186
|
+
return int(value)
|
|
187
|
+
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _payload(message: Any) -> str:
|
|
192
|
+
body = getattr(message, "body", None)
|
|
193
|
+
if isinstance(body, bytes):
|
|
194
|
+
return body.decode("utf-8")
|
|
195
|
+
return str(body) if body is not None else ""
|
|
196
|
+
|
|
197
|
+
# -- Transport ----------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def publish(self, queue: str, body: str) -> None:
|
|
200
|
+
self._sender(queue).send(self._message(body))
|
|
201
|
+
|
|
202
|
+
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
203
|
+
wait = self._receive_timeout_millis / 1000.0
|
|
204
|
+
if timeout and timeout > 0:
|
|
205
|
+
wait = timeout
|
|
206
|
+
receiver = self._receiver(queue)
|
|
207
|
+
try:
|
|
208
|
+
message = receiver.receive(timeout=wait)
|
|
209
|
+
except Exception as exc: # noqa: BLE001 - proton raises proton.Timeout on no message
|
|
210
|
+
if type(exc).__name__ == "Timeout":
|
|
211
|
+
return None
|
|
212
|
+
raise
|
|
213
|
+
if message is None: # pragma: no cover - defensive (real client raises instead)
|
|
214
|
+
return None
|
|
215
|
+
body = self._reconcile(self._payload(message), self._delivery_count(message))
|
|
216
|
+
return ReceivedMessage(body=body, queue=queue, handle=receiver)
|
|
217
|
+
|
|
218
|
+
def ack(self, message: ReceivedMessage) -> None:
|
|
219
|
+
if message.handle is None:
|
|
220
|
+
return
|
|
221
|
+
message.handle.accept()
|
|
222
|
+
|
|
223
|
+
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
224
|
+
try:
|
|
225
|
+
self._connection.close()
|
|
226
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
227
|
+
pass
|