babelqueue 1.4.0__tar.gz → 1.5.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 (60) hide show
  1. {babelqueue-1.4.0 → babelqueue-1.5.0}/.github/workflows/ci.yml +2 -2
  2. {babelqueue-1.4.0 → babelqueue-1.5.0}/CHANGELOG.md +23 -0
  3. {babelqueue-1.4.0 → babelqueue-1.5.0}/PKG-INFO +3 -1
  4. {babelqueue-1.4.0 → babelqueue-1.5.0}/pyproject.toml +2 -1
  5. babelqueue-1.5.0/src/babelqueue/artemis_transport.py +227 -0
  6. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/transport.py +6 -2
  7. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/manifest.json +27 -0
  8. babelqueue-1.5.0/tests/test_artemis_conformance.py +50 -0
  9. babelqueue-1.5.0/tests/test_artemis_transport.py +205 -0
  10. {babelqueue-1.4.0 → babelqueue-1.5.0}/.github/FUNDING.yml +0 -0
  11. {babelqueue-1.4.0 → babelqueue-1.5.0}/.github/workflows/release.yml +0 -0
  12. {babelqueue-1.4.0 → babelqueue-1.5.0}/.gitignore +0 -0
  13. {babelqueue-1.4.0 → babelqueue-1.5.0}/LICENSE +0 -0
  14. {babelqueue-1.4.0 → babelqueue-1.5.0}/README.md +0 -0
  15. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/__init__.py +0 -0
  16. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/app.py +0 -0
  17. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/asb_transport.py +0 -0
  18. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/celery.py +0 -0
  19. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/codec.py +0 -0
  20. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/contracts.py +0 -0
  21. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/dead_letter.py +0 -0
  22. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/django/__init__.py +0 -0
  23. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/django/apps.py +0 -0
  24. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/django/management/__init__.py +0 -0
  25. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  26. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  27. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/exceptions.py +0 -0
  28. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/kafka_transport.py +0 -0
  29. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/pika_transport.py +0 -0
  30. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/pulsar_transport.py +0 -0
  31. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/py.typed +0 -0
  32. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/redis_transport.py +0 -0
  33. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/routing.py +0 -0
  34. {babelqueue-1.4.0 → babelqueue-1.5.0}/src/babelqueue/sqs_transport.py +0 -0
  35. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  36. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  37. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  38. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/fixtures/order-created.json +0 -0
  39. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  40. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  41. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  42. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/fixtures/dead-lettered.json +0 -0
  43. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/fixtures/order-created.json +0 -0
  44. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_app.py +0 -0
  45. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_asb_conformance.py +0 -0
  46. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_asb_transport.py +0 -0
  47. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_celery.py +0 -0
  48. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_codec.py +0 -0
  49. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_conformance.py +0 -0
  50. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_dead_letter.py +0 -0
  51. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_django.py +0 -0
  52. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_kafka_conformance.py +0 -0
  53. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_kafka_transport.py +0 -0
  54. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_overhead.py +0 -0
  55. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_pika_transport.py +0 -0
  56. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_pulsar_conformance.py +0 -0
  57. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_pulsar_transport.py +0 -0
  58. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_redis_transport.py +0 -0
  59. {babelqueue-1.4.0 → babelqueue-1.5.0}/tests/test_sqs_conformance.py +0 -0
  60. {babelqueue-1.4.0 → babelqueue-1.5.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,29 @@ 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.5.0] - 2026-06-13
11
+
12
+ ### Added
13
+ - **Apache ActiveMQ Artemis transport** (`babelqueue[artemis]`, `python-qpid-proton`) —
14
+ `ArtemisTransport`, selected by the `artemis://` (or `artemis+ssl://`) URL scheme (e.g.
15
+ `artemis://localhost:5672`; or pass an injected `connection`). Artemis speaks **AMQP 1.0**
16
+ (not RabbitMQ's 0-9-1), so the transport uses the `python-qpid-proton` blocking client.
17
+ Implements [§7 of the broker-bindings
18
+ contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#apache-activemq-artemis): the
19
+ canonical envelope is the message body, projected onto the AMQP fields a JMS peer reads —
20
+ `correlation-id` = `trace_id` (JMSCorrelationID), `creation-time` = `meta.created_at`
21
+ (JMSTimestamp), the `x-opt-jms-type` annotation = URN (JMSType, the AMQP-JMS mapping), plus
22
+ the `bq-schema-version`/`bq-source-lang`/`bq-attempts`/`bq-app-id` application properties.
23
+ Consume reserves one message at a time (`receive` → process → `accept`); `attempts` is
24
+ reconciled to `max(body, delivery_count)` — the AMQP delivery-count header is 0-based, so it
25
+ maps directly with no −1 (the Java JMS binding reads the 1-based `JMSXDeliveryCount` and
26
+ subtracts 1, arriving at the same 0-based `attempts`), and the `max` never lowers a higher
27
+ body count carried by a republish-driven retry. The projection + reconciliation + pop/ack
28
+ flow are unit-tested with no broker and no `python-qpid-proton` (the proton import is lazy;
29
+ the transport talks to an injected connection fake); the publish flow that builds a real
30
+ proton `Message` is exercised wherever proton is installed. The envelope is unchanged
31
+ (`schema_version: 1`); Apache ActiveMQ Artemis is purely additive. Ships as a MINOR.
32
+
10
33
  ## [1.4.0] - 2026-06-13
11
34
 
12
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.4.0
3
+ Version: 1.5.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'
@@ -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.5.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
@@ -95,9 +95,13 @@ def make_transport(broker_url: str) -> Transport:
95
95
  from .kafka_transport import KafkaTransport
96
96
 
97
97
  return KafkaTransport(broker_url)
98
+ if scheme in ("artemis", "artemis+ssl"):
99
+ from .artemis_transport import ArtemisTransport
100
+
101
+ return ArtemisTransport(broker_url)
98
102
 
99
103
  raise BabelQueueError(
100
104
  f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://', "
101
- "'amqp://', 'sqs://', 'sb://', 'pulsar://' or 'kafka://', or pass your own Transport "
102
- "via BabelQueue(transport=...)."
105
+ "'amqp://', 'sqs://', 'sb://', 'pulsar://', 'kafka://' or 'artemis://', or pass your "
106
+ "own Transport via BabelQueue(transport=...)."
103
107
  )
@@ -173,5 +173,32 @@
173
173
  { "name": "header-higher-than-body", "body_attempts": 1, "header_attempts": 4, "expected_attempts": 4 }
174
174
  ]
175
175
  }
176
+ },
177
+ "artemis": {
178
+ "description": "Apache ActiveMQ Artemis binding conformance (broker-bindings.md §7). Every SDK that ships an Artemis transport must satisfy these. The message body stays byte-identical (the 'cases' above); these lock the AMQP-1.0 projection + reconciliation the binding adds. Per-message values reuse fixtures/order-created.json so the expected projection is deterministic.",
179
+ "property_projection": {
180
+ "description": "On produce, the transport MUST project the envelope onto the AMQP a JMS peer reads: the x-opt-jms-type message annotation = job (the URN, the AMQP-JMS mapping of JMSType), correlation-id = trace_id (JMSCorrelationID), creation-time = meta.created_at (JMSTimestamp, Unix ms), plus the bq_ application properties, all string-valued. The names use UNDERSCORES (bq_schema_version, not bq-schema-version): JMS property names must be valid Java identifiers (no hyphens), and the Java binding consumes/produces over JMS, so every Artemis SDK uses the JMS-legal underscore form for cross-protocol parity. bq_schema_version = str(meta.schema_version), bq_source_lang = meta.lang, bq_attempts = str(attempts), bq_app_id = 'babelqueue'. The body is the byte-identical envelope. Unlike Kafka/Pulsar, the URN / trace_id / message-id are NOT bq_ properties — they ride the JMS-native slots. Applies to every Artemis-producing SDK.",
181
+ "envelope_file": "fixtures/order-created.json",
182
+ "jms_type_annotation": "x-opt-jms-type",
183
+ "jms_type": "urn:babel:orders:created",
184
+ "correlation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
185
+ "properties": {
186
+ "bq_schema_version": "1",
187
+ "bq_source_lang": "php",
188
+ "bq_attempts": "0",
189
+ "bq_app_id": "babelqueue"
190
+ }
191
+ },
192
+ "attempts_reconciliation": {
193
+ "description": "On consume, attempts = max(body.attempts, delivery-count): the AMQP delivery-count header is 0-based (0 on first delivery) so it maps directly with NO -1, a runtime-incremented body count is never lowered, and delivery-count 0 leaves the body's own count untouched (the runtime/App retries by republishing with attempts+1, which resets the broker's delivery-count to 0). The Java JMS binding reads the 1-based JMSXDeliveryCount and subtracts 1, arriving at the same 0-based attempts. The rule is identical across the native-consumer SDKs (.NET/Java/Node) and the Transport+App SDKs (Python/Go).",
194
+ "cases": [
195
+ { "name": "first-delivery", "body_attempts": 0, "delivery_count": 0, "expected_attempts": 0 },
196
+ { "name": "third-delivery", "body_attempts": 0, "delivery_count": 2, "expected_attempts": 2 },
197
+ { "name": "native-exceeds-body", "body_attempts": 2, "delivery_count": 5, "expected_attempts": 5 },
198
+ { "name": "never-lower-runtime", "body_attempts": 5, "delivery_count": 1, "expected_attempts": 5 },
199
+ { "name": "first-delivery-keeps-body", "body_attempts": 4, "delivery_count": 0, "expected_attempts": 4 },
200
+ { "name": "native-equals-body", "body_attempts": 3, "delivery_count": 3, "expected_attempts": 3 }
201
+ ]
202
+ }
176
203
  }
177
204
  }
@@ -0,0 +1,50 @@
1
+ """Apache ActiveMQ Artemis binding conformance: the vendored manifest's ``artemis`` block locks
2
+ the §7 AMQP projection (the string-valued ``bq-`` application properties + the JMS-native
3
+ ``x-opt-jms-type`` / ``correlation-id`` mappings) and the ``attempts = max(body, delivery_count)``
4
+ reconciliation (the AMQP delivery-count is 0-based, so no −1). No qpid-proton, no broker — pure
5
+ transport logic against golden values."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import unittest
12
+
13
+ from babelqueue import EnvelopeCodec
14
+ from babelqueue.artemis_transport import ArtemisTransport
15
+
16
+ CONFORMANCE = os.path.join(os.path.dirname(__file__), "conformance")
17
+
18
+
19
+ class ArtemisConformanceTest(unittest.TestCase):
20
+ @classmethod
21
+ def setUpClass(cls) -> None:
22
+ with open(os.path.join(CONFORMANCE, "manifest.json"), encoding="utf-8") as fh:
23
+ cls.artemis = json.load(fh)["artemis"]
24
+
25
+ def test_property_projection(self) -> None:
26
+ projection = self.artemis["property_projection"]
27
+ with open(os.path.join(CONFORMANCE, projection["envelope_file"]), encoding="utf-8") as fh:
28
+ body = fh.read()
29
+
30
+ self.assertEqual(ArtemisTransport._projection(body), projection["properties"])
31
+ self.assertEqual(ArtemisTransport._jms_type(body), projection["jms_type"])
32
+ self.assertEqual(ArtemisTransport._correlation_id(body), projection["correlation_id"])
33
+
34
+ def test_attempts_reconciliation(self) -> None:
35
+ for case in self.artemis["attempts_reconciliation"]["cases"]:
36
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1})
37
+ env["attempts"] = case["body_attempts"]
38
+ body = EnvelopeCodec.encode(env)
39
+
40
+ out = ArtemisTransport._reconcile(body, case["delivery_count"])
41
+
42
+ self.assertEqual(
43
+ EnvelopeCodec.decode(out)["attempts"],
44
+ case["expected_attempts"],
45
+ case["name"],
46
+ )
47
+
48
+
49
+ if __name__ == "__main__": # pragma: no cover
50
+ unittest.main()
@@ -0,0 +1,205 @@
1
+ """Apache ActiveMQ Artemis (AMQP 1.0) transport tests.
2
+
3
+ The projection helpers, the ``attempts = max(body, delivery_count)`` reconciliation (no -1,
4
+ the AMQP counter is 0-based), and the pop/ack reservation flow run with no broker and no
5
+ ``python-qpid-proton`` (the transport's proton import is lazy). The publish flow, which builds a
6
+ real proton ``Message``, is skipped unless ``python-qpid-proton`` is installed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import unittest
12
+ from unittest import mock
13
+
14
+ from babelqueue import EnvelopeCodec
15
+ from babelqueue.artemis_transport import ArtemisTransport
16
+ from babelqueue.transport import ReceivedMessage, make_transport
17
+
18
+ try:
19
+ import proton # noqa: F401
20
+
21
+ HAVE_PROTON = True
22
+ except ImportError:
23
+ HAVE_PROTON = False
24
+
25
+ URN = "urn:babel:orders:created"
26
+
27
+
28
+ def body(attempts: int = 0, trace_id: str = "trace-1") -> str:
29
+ env = EnvelopeCodec.make(URN, {"order_id": 1042}, queue="orders", trace_id=trace_id)
30
+ env["attempts"] = attempts
31
+ return EnvelopeCodec.encode(env)
32
+
33
+
34
+ class FakeMessage:
35
+ """Duck-typed proton Message for the consume path (no proton needed)."""
36
+
37
+ def __init__(self, raw: str, delivery_count: int = 0) -> None:
38
+ self.body = raw
39
+ self.delivery_count = delivery_count
40
+
41
+
42
+ class Timeout(Exception):
43
+ """Stands in for proton.Timeout — matched by class name, like the real transport."""
44
+
45
+
46
+ class FakeSender:
47
+ def __init__(self) -> None:
48
+ self.sent: list = []
49
+
50
+ def send(self, message) -> None:
51
+ self.sent.append(message)
52
+
53
+
54
+ class FakeReceiver:
55
+ def __init__(self, messages=()) -> None:
56
+ self._messages = list(messages)
57
+ self.accepted = 0
58
+
59
+ def receive(self, timeout=None):
60
+ if not self._messages:
61
+ raise Timeout("no message")
62
+ return self._messages.pop(0)
63
+
64
+ def accept(self) -> None:
65
+ self.accepted += 1
66
+
67
+
68
+ class FakeConnection:
69
+ def __init__(self, sender=None, receiver=None) -> None:
70
+ self._sender = sender if sender is not None else FakeSender()
71
+ self._receiver = receiver if receiver is not None else FakeReceiver()
72
+ self.closed = False
73
+
74
+ def create_sender(self, address):
75
+ return self._sender
76
+
77
+ def create_receiver(self, address, credit=1):
78
+ return self._receiver
79
+
80
+ def close(self) -> None:
81
+ self.closed = True
82
+
83
+
84
+ class ArtemisProjectionTest(unittest.TestCase):
85
+ def test_application_properties_mirror_the_contract_fields(self) -> None:
86
+ props = ArtemisTransport._projection(body(attempts=2))
87
+ self.assertEqual("1", props["bq_schema_version"])
88
+ self.assertEqual("python", props["bq_source_lang"])
89
+ self.assertEqual("2", props["bq_attempts"])
90
+ self.assertEqual("babelqueue", props["bq_app_id"])
91
+ # The URN and trace_id ride JMS-native slots, not application properties.
92
+ self.assertNotIn("bq_job", props)
93
+ self.assertNotIn("bq_trace_id", props)
94
+
95
+ def test_jms_type_and_correlation_id_come_from_the_body(self) -> None:
96
+ self.assertEqual(URN, ArtemisTransport._jms_type(body()))
97
+ self.assertEqual("trace-1", ArtemisTransport._correlation_id(body(trace_id="trace-1")))
98
+
99
+ def test_creation_time_is_seconds_from_created_at_millis(self) -> None:
100
+ raw = body()
101
+ created_at = EnvelopeCodec.decode(raw)["meta"]["created_at"]
102
+ self.assertAlmostEqual(created_at / 1000.0, ArtemisTransport._creation_seconds(raw))
103
+
104
+ def test_projection_helpers_tolerate_garbage(self) -> None:
105
+ self.assertEqual({}, ArtemisTransport._projection("not-json"))
106
+ self.assertEqual("", ArtemisTransport._jms_type("not-json"))
107
+ self.assertEqual("", ArtemisTransport._correlation_id("not-json"))
108
+ self.assertIsNone(ArtemisTransport._creation_seconds("not-json"))
109
+
110
+
111
+ class ArtemisReconcileTest(unittest.TestCase):
112
+ def test_first_delivery_keeps_body_count(self) -> None:
113
+ self.assertEqual(0, EnvelopeCodec.decode(ArtemisTransport._reconcile(body(0), 0))["attempts"])
114
+
115
+ def test_delivery_count_raises_attempts_no_minus_one(self) -> None:
116
+ # AMQP delivery-count is 0-based: count 3 == 3 prior redeliveries == attempts 3.
117
+ out = ArtemisTransport._reconcile(body(0), 3)
118
+ self.assertEqual(3, EnvelopeCodec.decode(out)["attempts"])
119
+
120
+ def test_body_count_never_lowered_by_delivery_count(self) -> None:
121
+ out = ArtemisTransport._reconcile(body(5), 2)
122
+ self.assertEqual(5, EnvelopeCodec.decode(out)["attempts"])
123
+
124
+ def test_non_numeric_delivery_count_is_ignored(self) -> None:
125
+ raw = body(1)
126
+ self.assertEqual(raw, ArtemisTransport._reconcile(raw, None))
127
+
128
+
129
+ class ArtemisConsumeTest(unittest.TestCase):
130
+ def test_pop_reconciles_and_acks_via_the_receiver(self) -> None:
131
+ receiver = FakeReceiver([FakeMessage(body(0), delivery_count=2)])
132
+ transport = ArtemisTransport(connection=FakeConnection(receiver=receiver))
133
+
134
+ message = transport.pop("orders")
135
+ self.assertIsNotNone(message)
136
+ self.assertEqual(2, EnvelopeCodec.decode(message.body)["attempts"])
137
+
138
+ transport.ack(message)
139
+ self.assertEqual(1, receiver.accepted)
140
+
141
+ def test_pop_returns_none_on_timeout(self) -> None:
142
+ transport = ArtemisTransport(connection=FakeConnection(receiver=FakeReceiver([])))
143
+ self.assertIsNone(transport.pop("orders"))
144
+
145
+ def test_pop_propagates_non_timeout_errors(self) -> None:
146
+ class Broken:
147
+ def receive(self, timeout=None):
148
+ raise ConnectionError("link detached")
149
+
150
+ transport = ArtemisTransport(connection=FakeConnection(receiver=Broken()))
151
+ with self.assertRaises(ConnectionError):
152
+ transport.pop("orders")
153
+
154
+ def test_pop_decodes_bytes_payload(self) -> None:
155
+ receiver = FakeReceiver([FakeMessage(body(0).encode("utf-8"))])
156
+ transport = ArtemisTransport(connection=FakeConnection(receiver=receiver))
157
+ self.assertEqual(URN, EnvelopeCodec.decode(transport.pop("orders").body)["job"])
158
+
159
+ def test_ack_without_handle_is_a_no_op(self) -> None:
160
+ transport = ArtemisTransport(connection=FakeConnection())
161
+ transport.ack(ReceivedMessage(body=body(), queue="orders", handle=None)) # no raise
162
+
163
+ def test_close_closes_the_connection(self) -> None:
164
+ connection = FakeConnection()
165
+ transport = ArtemisTransport(connection=connection)
166
+ transport.close()
167
+ self.assertTrue(connection.closed)
168
+
169
+
170
+ class ArtemisUrlTest(unittest.TestCase):
171
+ def test_scheme_is_translated_to_proton(self) -> None:
172
+ self.assertEqual("amqp://h:5672", ArtemisTransport._to_amqp_url("artemis://h:5672"))
173
+ self.assertEqual("amqps://h:5671", ArtemisTransport._to_amqp_url("artemis+ssl://h:5671"))
174
+ self.assertEqual("amqp://h:5672", ArtemisTransport._to_amqp_url("amqp://h:5672"))
175
+
176
+ def test_factory_resolves_the_artemis_scheme(self) -> None:
177
+ # Patch the connection build so the scheme is exercised without opening a socket
178
+ # (whether or not python-qpid-proton is installed).
179
+ with mock.patch.object(ArtemisTransport, "_build_connection", return_value=FakeConnection()):
180
+ for url in ("artemis://localhost:5672", "artemis+ssl://localhost:5671"):
181
+ self.assertIsInstance(make_transport(url), ArtemisTransport)
182
+
183
+
184
+ @unittest.skipUnless(HAVE_PROTON, "python-qpid-proton not installed")
185
+ class ArtemisPublishTest(unittest.TestCase):
186
+ def test_publish_projects_the_envelope_onto_a_proton_message(self) -> None:
187
+ from proton import symbol
188
+
189
+ sender = FakeSender()
190
+ transport = ArtemisTransport(connection=FakeConnection(sender=sender))
191
+
192
+ transport.publish("orders", body(attempts=0, trace_id="trace-xyz"))
193
+
194
+ self.assertEqual(1, len(sender.sent))
195
+ message = sender.sent[0]
196
+ self.assertEqual("trace-xyz", message.correlation_id)
197
+ self.assertEqual("1", message.properties["bq_schema_version"])
198
+ self.assertEqual("babelqueue", message.properties["bq_app_id"])
199
+ self.assertEqual(URN, dict(message.annotations)[symbol("x-opt-jms-type")])
200
+ # The body is the byte-identical canonical envelope.
201
+ self.assertEqual(URN, EnvelopeCodec.decode(message.body)["job"])
202
+
203
+
204
+ if __name__ == "__main__":
205
+ unittest.main()
File without changes
File without changes
File without changes
File without changes