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.
Files changed (62) hide show
  1. {babelqueue-1.4.0 → babelqueue-1.6.0}/.github/workflows/ci.yml +2 -2
  2. {babelqueue-1.4.0 → babelqueue-1.6.0}/CHANGELOG.md +51 -1
  3. {babelqueue-1.4.0 → babelqueue-1.6.0}/PKG-INFO +32 -4
  4. {babelqueue-1.4.0 → babelqueue-1.6.0}/README.md +29 -3
  5. {babelqueue-1.4.0 → babelqueue-1.6.0}/pyproject.toml +2 -1
  6. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/__init__.py +1 -1
  7. babelqueue-1.6.0/src/babelqueue/artemis_transport.py +227 -0
  8. babelqueue-1.6.0/src/babelqueue/redis_transport.py +254 -0
  9. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/transport.py +6 -2
  10. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/manifest.json +53 -0
  11. babelqueue-1.6.0/tests/test_artemis_conformance.py +50 -0
  12. babelqueue-1.6.0/tests/test_artemis_transport.py +205 -0
  13. babelqueue-1.6.0/tests/test_redis_transport.py +396 -0
  14. babelqueue-1.4.0/src/babelqueue/redis_transport.py +0 -53
  15. babelqueue-1.4.0/tests/test_redis_transport.py +0 -78
  16. {babelqueue-1.4.0 → babelqueue-1.6.0}/.github/FUNDING.yml +0 -0
  17. {babelqueue-1.4.0 → babelqueue-1.6.0}/.github/workflows/release.yml +0 -0
  18. {babelqueue-1.4.0 → babelqueue-1.6.0}/.gitignore +0 -0
  19. {babelqueue-1.4.0 → babelqueue-1.6.0}/LICENSE +0 -0
  20. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/app.py +0 -0
  21. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/asb_transport.py +0 -0
  22. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/celery.py +0 -0
  23. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/codec.py +0 -0
  24. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/contracts.py +0 -0
  25. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/dead_letter.py +0 -0
  26. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/__init__.py +0 -0
  27. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/apps.py +0 -0
  28. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/management/__init__.py +0 -0
  29. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  30. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  31. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/exceptions.py +0 -0
  32. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/kafka_transport.py +0 -0
  33. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/pika_transport.py +0 -0
  34. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/pulsar_transport.py +0 -0
  35. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/py.typed +0 -0
  36. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/routing.py +0 -0
  37. {babelqueue-1.4.0 → babelqueue-1.6.0}/src/babelqueue/sqs_transport.py +0 -0
  38. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  39. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  40. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  41. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/order-created.json +0 -0
  42. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  43. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  44. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  45. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/fixtures/dead-lettered.json +0 -0
  46. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/fixtures/order-created.json +0 -0
  47. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_app.py +0 -0
  48. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_asb_conformance.py +0 -0
  49. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_asb_transport.py +0 -0
  50. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_celery.py +0 -0
  51. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_codec.py +0 -0
  52. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_conformance.py +0 -0
  53. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_dead_letter.py +0 -0
  54. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_django.py +0 -0
  55. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_kafka_conformance.py +0 -0
  56. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_kafka_transport.py +0 -0
  57. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_overhead.py +0 -0
  58. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_pika_transport.py +0 -0
  59. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_pulsar_conformance.py +0 -0
  60. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_pulsar_transport.py +0 -0
  61. {babelqueue-1.4.0 → babelqueue-1.6.0}/tests/test_sqs_conformance.py +0 -0
  62. {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.2.0...HEAD
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.4.0
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), `amqp://` (RabbitMQ via
166
- `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
167
- tests/local). Bring your own by passing `transport=...`.
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), `amqp://` (RabbitMQ via
118
- `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
119
- tests/local). Bring your own by passing `transport=...`.
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.4.0"
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"]
@@ -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.2.0"
22
+ __version__ = "1.6.0"
23
23
 
24
24
  __all__ = [
25
25
  "BabelQueue",
@@ -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