babelqueue 1.3.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.
- {babelqueue-1.3.0 → babelqueue-1.5.0}/.github/workflows/ci.yml +2 -2
- {babelqueue-1.3.0 → babelqueue-1.5.0}/.gitignore +3 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/CHANGELOG.md +40 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/PKG-INFO +5 -1
- {babelqueue-1.3.0 → babelqueue-1.5.0}/pyproject.toml +3 -1
- babelqueue-1.5.0/src/babelqueue/artemis_transport.py +227 -0
- babelqueue-1.5.0/src/babelqueue/kafka_transport.py +181 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/transport.py +10 -2
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/manifest.json +52 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_app.py +1 -1
- babelqueue-1.5.0/tests/test_artemis_conformance.py +50 -0
- babelqueue-1.5.0/tests/test_artemis_transport.py +205 -0
- babelqueue-1.5.0/tests/test_kafka_conformance.py +52 -0
- babelqueue-1.5.0/tests/test_kafka_transport.py +171 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/LICENSE +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/README.md +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/__init__.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_celery.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_codec.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_django.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.3.0 → babelqueue-1.5.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.3.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,46 @@ 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
|
+
|
|
33
|
+
## [1.4.0] - 2026-06-13
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **Apache Kafka transport** (`babelqueue[kafka]`, `confluent-kafka`) — `KafkaTransport`,
|
|
37
|
+
selected by the `kafka://` URL scheme (e.g. `kafka://host:9092`; or pass an injected
|
|
38
|
+
`producer` + `consumer_factory`). Implements [§6 of the broker-bindings
|
|
39
|
+
contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#apache-kafka): the record
|
|
40
|
+
**value** is the canonical envelope, projected onto native Kafka record headers (UTF-8 byte
|
|
41
|
+
strings) — `bq-job` = URN, `bq-trace-id`, `bq-message-id`, plus `bq-schema-version`/
|
|
42
|
+
`bq-source-lang`/`bq-attempts` — with the record timestamp mirroring `meta.created_at`.
|
|
43
|
+
Consume is **process-then-commit** (`pop` reserves via `poll` with `enable.auto.commit=false`,
|
|
44
|
+
`ack` commits the offset); the **`bq-attempts` header is the authoritative attempt counter**
|
|
45
|
+
(the body's `attempts` is the fallback for non-BabelQueue producers). The projection +
|
|
46
|
+
reconciliation + publish/pop/ack flow are unit-tested with no broker and no `confluent-kafka`
|
|
47
|
+
(the kafka import is lazy; the transport talks to injected producer/consumer fakes). The
|
|
48
|
+
envelope is unchanged (`schema_version: 1`); Apache Kafka is purely additive. Ships as a MINOR.
|
|
49
|
+
|
|
10
50
|
## [1.3.0] - 2026-06-13
|
|
11
51
|
|
|
12
52
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
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'
|
|
@@ -36,6 +38,8 @@ Requires-Dist: pytest>=7; extra == 'dev'
|
|
|
36
38
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
37
39
|
Provides-Extra: django
|
|
38
40
|
Requires-Dist: django>=4.2; extra == 'django'
|
|
41
|
+
Provides-Extra: kafka
|
|
42
|
+
Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
|
|
39
43
|
Provides-Extra: pulsar
|
|
40
44
|
Requires-Dist: pulsar-client>=3.4; extra == 'pulsar'
|
|
41
45
|
Provides-Extra: redis
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
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"
|
|
@@ -34,6 +34,8 @@ amqp = ["pika>=1.3"]
|
|
|
34
34
|
sqs = ["boto3>=1.26"]
|
|
35
35
|
azureservicebus = ["azure-servicebus>=7.11", "azure-identity>=1.15"]
|
|
36
36
|
pulsar = ["pulsar-client>=3.4"]
|
|
37
|
+
kafka = ["confluent-kafka>=2.3"]
|
|
38
|
+
artemis = ["python-qpid-proton>=0.39"]
|
|
37
39
|
celery = ["celery>=5"]
|
|
38
40
|
django = ["django>=4.2"]
|
|
39
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
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Apache Kafka transport. Requires the ``kafka`` extra:
|
|
2
|
+
|
|
3
|
+
pip install "babelqueue[kafka]"
|
|
4
|
+
|
|
5
|
+
Producing writes the canonical envelope as the record value and projects the contract
|
|
6
|
+
envelope fields onto native Kafka record headers (UTF-8 byte strings): ``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 — with the record timestamp mirroring ``meta.created_at``.
|
|
10
|
+
Consuming is process-then-commit: ``poll`` reserves a record (``enable.auto.commit=false``)
|
|
11
|
+
and ``ack`` commits the offset only after the handler returns (at-least-once). Kafka has no
|
|
12
|
+
native delivery count, so the ``bq-attempts`` header is the authoritative retry counter (the
|
|
13
|
+
body's ``attempts`` is the fallback for non-BabelQueue producers); the runtime owns retry by
|
|
14
|
+
republishing with attempts+1 and dead-letters to ``<queue>.dlq``.
|
|
15
|
+
|
|
16
|
+
This implements §6 of the broker-bindings contract. The envelope is unchanged
|
|
17
|
+
(``schema_version`` stays 1); Apache Kafka is purely additive.
|
|
18
|
+
|
|
19
|
+
URL form: ``kafka://host:9092[,host2:9092]``. The default consumer group is ``babelqueue``.
|
|
20
|
+
For a custom client, build the transport directly and pass it via ``BabelQueue(transport=...)``
|
|
21
|
+
or ``KafkaTransport(producer=..., consumer_factory=...)``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from .codec import EnvelopeCodec
|
|
29
|
+
from .transport import ReceivedMessage, Transport
|
|
30
|
+
|
|
31
|
+
Headers = List[Tuple[str, bytes]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _brokers_from_url(url: str) -> str:
|
|
35
|
+
rest = url.split("://", 1)[1] if "://" in url else url
|
|
36
|
+
return rest.split("/", 1)[0] or "localhost:9092"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KafkaTransport(Transport):
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
url: str = "kafka://localhost:9092",
|
|
43
|
+
*,
|
|
44
|
+
producer: Any = None,
|
|
45
|
+
consumer_factory: Optional[Callable[[str], Any]] = None,
|
|
46
|
+
group_id: str = "babelqueue",
|
|
47
|
+
**client_config: Any,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._brokers = _brokers_from_url(url or "kafka://localhost:9092")
|
|
50
|
+
self._group_id = group_id
|
|
51
|
+
self._client_config = client_config
|
|
52
|
+
self._producer = producer
|
|
53
|
+
self._consumer_factory = consumer_factory
|
|
54
|
+
self._consumers: Dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
# -- helpers ------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _producer_(self) -> Any:
|
|
59
|
+
if self._producer is None:
|
|
60
|
+
self._producer = self._build_producer() # pragma: no cover - needs Kafka / network
|
|
61
|
+
return self._producer
|
|
62
|
+
|
|
63
|
+
def _build_producer(self) -> Any: # pragma: no cover - needs Kafka / network
|
|
64
|
+
from confluent_kafka import Producer
|
|
65
|
+
|
|
66
|
+
return Producer({"bootstrap.servers": self._brokers, **self._client_config})
|
|
67
|
+
|
|
68
|
+
def _consumer(self, queue: str) -> Any:
|
|
69
|
+
consumer = self._consumers.get(queue)
|
|
70
|
+
if consumer is None:
|
|
71
|
+
if self._consumer_factory is not None:
|
|
72
|
+
consumer = self._consumer_factory(queue)
|
|
73
|
+
else:
|
|
74
|
+
consumer = self._build_consumer(queue) # pragma: no cover - needs Kafka / network
|
|
75
|
+
self._consumers[queue] = consumer
|
|
76
|
+
return consumer
|
|
77
|
+
|
|
78
|
+
def _build_consumer(self, queue: str) -> Any: # pragma: no cover - needs Kafka / network
|
|
79
|
+
from confluent_kafka import Consumer
|
|
80
|
+
|
|
81
|
+
consumer = Consumer(
|
|
82
|
+
{
|
|
83
|
+
"bootstrap.servers": self._brokers,
|
|
84
|
+
"group.id": self._group_id,
|
|
85
|
+
"enable.auto.commit": False,
|
|
86
|
+
"auto.offset.reset": "earliest",
|
|
87
|
+
**self._client_config,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
consumer.subscribe([queue])
|
|
91
|
+
return consumer
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _projection(body: str) -> Headers:
|
|
95
|
+
"""Native Kafka record headers (UTF-8 byte values) — a redundant, routable view of the
|
|
96
|
+
body: bq-job/bq-trace-id/bq-message-id + bq-schema-version/lang/attempts. §6.3."""
|
|
97
|
+
env = EnvelopeCodec.decode(body)
|
|
98
|
+
if not env:
|
|
99
|
+
return []
|
|
100
|
+
meta = env.get("meta") or {}
|
|
101
|
+
|
|
102
|
+
headers: Headers = []
|
|
103
|
+
|
|
104
|
+
def add(key: str, value: Any) -> None:
|
|
105
|
+
if value is not None and value != "":
|
|
106
|
+
headers.append((key, str(value).encode("utf-8")))
|
|
107
|
+
|
|
108
|
+
add("bq-job", env.get("job"))
|
|
109
|
+
add("bq-trace-id", env.get("trace_id"))
|
|
110
|
+
add("bq-message-id", meta.get("id"))
|
|
111
|
+
if meta.get("schema_version") is not None:
|
|
112
|
+
headers.append(("bq-schema-version", str(meta["schema_version"]).encode("utf-8")))
|
|
113
|
+
add("bq-source-lang", meta.get("lang"))
|
|
114
|
+
headers.append(("bq-attempts", str(int(env.get("attempts", 0) or 0)).encode("utf-8")))
|
|
115
|
+
return headers
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _reconcile(body: str, headers: Any) -> str:
|
|
119
|
+
"""Set attempts to the authoritative bq-attempts header (falling back to the body's own
|
|
120
|
+
attempts when the header is absent/unparseable — a non-BabelQueue producer). §6.5."""
|
|
121
|
+
env = EnvelopeCodec.decode(body)
|
|
122
|
+
if not env:
|
|
123
|
+
return body
|
|
124
|
+
attempts = int(env.get("attempts", 0) or 0)
|
|
125
|
+
for key, value in headers or []:
|
|
126
|
+
if key == "bq-attempts":
|
|
127
|
+
raw = value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else str(value)
|
|
128
|
+
try:
|
|
129
|
+
attempts = int(raw)
|
|
130
|
+
except (ValueError, TypeError):
|
|
131
|
+
pass
|
|
132
|
+
break
|
|
133
|
+
if attempts == int(env.get("attempts", 0) or 0):
|
|
134
|
+
return body
|
|
135
|
+
env["attempts"] = attempts
|
|
136
|
+
return EnvelopeCodec.encode(env)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _payload(message: Any) -> str:
|
|
140
|
+
value = message.value()
|
|
141
|
+
if isinstance(value, (bytes, bytearray)):
|
|
142
|
+
return value.decode("utf-8")
|
|
143
|
+
return str(value) if value is not None else ""
|
|
144
|
+
|
|
145
|
+
# -- Transport ----------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def publish(self, queue: str, body: str) -> None:
|
|
148
|
+
env = EnvelopeCodec.decode(body)
|
|
149
|
+
meta = env.get("meta") or {}
|
|
150
|
+
producer = self._producer_()
|
|
151
|
+
kwargs: Dict[str, Any] = {"value": body.encode("utf-8"), "headers": self._projection(body)}
|
|
152
|
+
created_at = meta.get("created_at")
|
|
153
|
+
if created_at:
|
|
154
|
+
kwargs["timestamp"] = int(created_at)
|
|
155
|
+
producer.produce(queue, **kwargs)
|
|
156
|
+
producer.poll(0)
|
|
157
|
+
|
|
158
|
+
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
159
|
+
wait = timeout if timeout and timeout > 0 else 1.0
|
|
160
|
+
message = self._consumer(queue).poll(wait)
|
|
161
|
+
if message is None or message.error() is not None:
|
|
162
|
+
return None
|
|
163
|
+
body = self._reconcile(self._payload(message), message.headers())
|
|
164
|
+
return ReceivedMessage(body=body, queue=queue, handle=message)
|
|
165
|
+
|
|
166
|
+
def ack(self, message: ReceivedMessage) -> None:
|
|
167
|
+
if message.handle is None:
|
|
168
|
+
return
|
|
169
|
+
self._consumer(message.queue).commit(message=message.handle, asynchronous=False)
|
|
170
|
+
|
|
171
|
+
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
172
|
+
try:
|
|
173
|
+
if self._producer is not None:
|
|
174
|
+
self._producer.flush()
|
|
175
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
176
|
+
pass
|
|
177
|
+
for consumer in self._consumers.values():
|
|
178
|
+
try:
|
|
179
|
+
consumer.close()
|
|
180
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
181
|
+
pass
|
|
@@ -91,9 +91,17 @@ def make_transport(broker_url: str) -> Transport:
|
|
|
91
91
|
from .pulsar_transport import PulsarTransport
|
|
92
92
|
|
|
93
93
|
return PulsarTransport(broker_url)
|
|
94
|
+
if scheme == "kafka":
|
|
95
|
+
from .kafka_transport import KafkaTransport
|
|
96
|
+
|
|
97
|
+
return KafkaTransport(broker_url)
|
|
98
|
+
if scheme in ("artemis", "artemis+ssl"):
|
|
99
|
+
from .artemis_transport import ArtemisTransport
|
|
100
|
+
|
|
101
|
+
return ArtemisTransport(broker_url)
|
|
94
102
|
|
|
95
103
|
raise BabelQueueError(
|
|
96
104
|
f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://', "
|
|
97
|
-
"'amqp://', 'sqs://', 'sb://' or '
|
|
98
|
-
"BabelQueue(transport=...)."
|
|
105
|
+
"'amqp://', 'sqs://', 'sb://', 'pulsar://', 'kafka://' or 'artemis://', or pass your "
|
|
106
|
+
"own Transport via BabelQueue(transport=...)."
|
|
99
107
|
)
|
|
@@ -148,5 +148,57 @@
|
|
|
148
148
|
{ "name": "native-equals-body", "body_attempts": 3, "redelivery_count": 3, "expected_attempts": 3 }
|
|
149
149
|
]
|
|
150
150
|
}
|
|
151
|
+
},
|
|
152
|
+
"kafka": {
|
|
153
|
+
"description": "Apache Kafka binding conformance (broker-bindings.md §6). Every SDK that ships a Kafka transport must satisfy these. The record value is the byte-identical envelope (the 'cases' above); these lock the bq- header projection + the bq-attempts-authoritative reconciliation the binding adds. Per-message values reuse fixtures/order-created.json so the expected projection is deterministic.",
|
|
154
|
+
"property_projection": {
|
|
155
|
+
"description": "On produce, the transport MUST project these native Kafka record headers from the envelope, all UTF-8 byte strings (Kafka headers are bytes, so integers are stringified): bq-job = job (the URN), bq-trace-id = trace_id, bq-message-id = meta.id, bq-schema-version = str(meta.schema_version), bq-source-lang = meta.lang, bq-attempts = str(attempts). The record value is the byte-identical envelope and the record timestamp mirrors meta.created_at (Unix ms). Applies to every Kafka-producing SDK.",
|
|
156
|
+
"envelope_file": "fixtures/order-created.json",
|
|
157
|
+
"headers": {
|
|
158
|
+
"bq-job": "urn:babel:orders:created",
|
|
159
|
+
"bq-trace-id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
|
|
160
|
+
"bq-message-id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
|
|
161
|
+
"bq-schema-version": "1",
|
|
162
|
+
"bq-source-lang": "php",
|
|
163
|
+
"bq-attempts": "0"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"attempts_reconciliation": {
|
|
167
|
+
"description": "On consume, attempts = the bq-attempts header when present (AUTHORITATIVE — Kafka has no native delivery count), else the body's own attempts (the fallback for a non-BabelQueue producer). This is NOT a max: the header overrides the body even when lower. A null header_attempts means the header is absent. The rule is identical across all five Kafka SDKs.",
|
|
168
|
+
"cases": [
|
|
169
|
+
{ "name": "header-matches-body", "body_attempts": 0, "header_attempts": 0, "expected_attempts": 0 },
|
|
170
|
+
{ "name": "header-present", "body_attempts": 0, "header_attempts": 2, "expected_attempts": 2 },
|
|
171
|
+
{ "name": "header-absent-falls-back", "body_attempts": 3, "header_attempts": null, "expected_attempts": 3 },
|
|
172
|
+
{ "name": "header-authoritative-overrides", "body_attempts": 5, "header_attempts": 1, "expected_attempts": 1 },
|
|
173
|
+
{ "name": "header-higher-than-body", "body_attempts": 1, "header_attempts": 4, "expected_attempts": 4 }
|
|
174
|
+
]
|
|
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
|
+
}
|
|
151
203
|
}
|
|
152
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()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Apache Kafka binding conformance: the vendored manifest's ``kafka`` block locks the §6
|
|
2
|
+
header projection (bq-* UTF-8 byte strings) and the ``attempts`` reconciliation (the
|
|
3
|
+
``bq-attempts`` header is authoritative when present, else the body — NOT a max). No
|
|
4
|
+
confluent-kafka, no broker — pure transport logic against golden values."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
from babelqueue import EnvelopeCodec
|
|
13
|
+
from babelqueue.kafka_transport import KafkaTransport
|
|
14
|
+
|
|
15
|
+
CONFORMANCE = os.path.join(os.path.dirname(__file__), "conformance")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KafkaConformanceTest(unittest.TestCase):
|
|
19
|
+
@classmethod
|
|
20
|
+
def setUpClass(cls) -> None:
|
|
21
|
+
with open(os.path.join(CONFORMANCE, "manifest.json"), encoding="utf-8") as fh:
|
|
22
|
+
cls.kafka = json.load(fh)["kafka"]
|
|
23
|
+
|
|
24
|
+
def test_property_projection(self) -> None:
|
|
25
|
+
projection = self.kafka["property_projection"]
|
|
26
|
+
with open(os.path.join(CONFORMANCE, projection["envelope_file"]), encoding="utf-8") as fh:
|
|
27
|
+
body = fh.read()
|
|
28
|
+
|
|
29
|
+
got = {key: value.decode("utf-8") for key, value in KafkaTransport._projection(body)}
|
|
30
|
+
self.assertEqual(got, projection["headers"])
|
|
31
|
+
|
|
32
|
+
def test_attempts_reconciliation(self) -> None:
|
|
33
|
+
for case in self.kafka["attempts_reconciliation"]["cases"]:
|
|
34
|
+
env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1})
|
|
35
|
+
env["attempts"] = case["body_attempts"]
|
|
36
|
+
body = EnvelopeCodec.encode(env)
|
|
37
|
+
|
|
38
|
+
headers = []
|
|
39
|
+
if case["header_attempts"] is not None:
|
|
40
|
+
headers = [("bq-attempts", str(case["header_attempts"]).encode("utf-8"))]
|
|
41
|
+
|
|
42
|
+
out = KafkaTransport._reconcile(body, headers)
|
|
43
|
+
|
|
44
|
+
self.assertEqual(
|
|
45
|
+
EnvelopeCodec.decode(out)["attempts"],
|
|
46
|
+
case["expected_attempts"],
|
|
47
|
+
case["name"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__": # pragma: no cover
|
|
52
|
+
unittest.main()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Apache Kafka transport tests.
|
|
2
|
+
|
|
3
|
+
The header projection and the ``attempts`` reconciliation (authoritative ``bq-attempts``
|
|
4
|
+
header, body fallback) — and the full publish / pop / ack flow — run with no broker and no
|
|
5
|
+
``confluent-kafka`` (the kafka import is lazy and the transport talks to injected
|
|
6
|
+
producer/consumer fakes). Only ``_build_producer``/``_build_consumer`` need the real package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
from babelqueue import EnvelopeCodec
|
|
14
|
+
from babelqueue.kafka_transport import KafkaTransport
|
|
15
|
+
from babelqueue.transport import ReceivedMessage, make_transport
|
|
16
|
+
|
|
17
|
+
URN = "urn:babel:orders:created"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def body(attempts: int = 0) -> str:
|
|
21
|
+
env = EnvelopeCodec.decode(
|
|
22
|
+
EnvelopeCodec.encode(EnvelopeCodec.make(URN, {"order_id": 1042}, queue="orders"))
|
|
23
|
+
)
|
|
24
|
+
env["attempts"] = attempts
|
|
25
|
+
return EnvelopeCodec.encode(env)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def header_map(headers):
|
|
29
|
+
return {k: (v.decode("utf-8") if isinstance(v, (bytes, bytearray)) else v) for k, v in headers}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeMessage:
|
|
33
|
+
def __init__(self, value, headers=None, err=None) -> None:
|
|
34
|
+
self._value = value
|
|
35
|
+
self._headers = headers or []
|
|
36
|
+
self._err = err
|
|
37
|
+
|
|
38
|
+
def value(self):
|
|
39
|
+
return self._value
|
|
40
|
+
|
|
41
|
+
def headers(self):
|
|
42
|
+
return self._headers
|
|
43
|
+
|
|
44
|
+
def error(self):
|
|
45
|
+
return self._err
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FakeProducer:
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self.produced = []
|
|
51
|
+
|
|
52
|
+
def produce(self, topic, value=None, headers=None, timestamp=None, **kw) -> None:
|
|
53
|
+
self.produced.append({"topic": topic, "value": value, "headers": headers, "timestamp": timestamp})
|
|
54
|
+
|
|
55
|
+
def poll(self, _t):
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
def flush(self, *a):
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FakeConsumer:
|
|
63
|
+
def __init__(self, messages=()) -> None:
|
|
64
|
+
self._messages = list(messages)
|
|
65
|
+
self.committed = []
|
|
66
|
+
|
|
67
|
+
def poll(self, timeout=None):
|
|
68
|
+
return self._messages.pop(0) if self._messages else None
|
|
69
|
+
|
|
70
|
+
def commit(self, message=None, asynchronous=True) -> None:
|
|
71
|
+
self.committed.append(message)
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def transport(producer=None, consumer=None) -> KafkaTransport:
|
|
78
|
+
producer = producer if producer is not None else FakeProducer()
|
|
79
|
+
consumer = consumer if consumer is not None else FakeConsumer()
|
|
80
|
+
return KafkaTransport(producer=producer, consumer_factory=lambda _q: consumer)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class KafkaProjectionTest(unittest.TestCase):
|
|
84
|
+
def test_projection_maps_contract_headers(self):
|
|
85
|
+
raw = body()
|
|
86
|
+
env = EnvelopeCodec.decode(raw)
|
|
87
|
+
headers = header_map(KafkaTransport._projection(raw))
|
|
88
|
+
|
|
89
|
+
self.assertEqual(headers["bq-job"], URN)
|
|
90
|
+
self.assertEqual(headers["bq-trace-id"], env["trace_id"])
|
|
91
|
+
self.assertEqual(headers["bq-message-id"], env["meta"]["id"])
|
|
92
|
+
self.assertEqual(headers["bq-schema-version"], str(env["meta"]["schema_version"]))
|
|
93
|
+
self.assertEqual(headers["bq-source-lang"], env["meta"]["lang"])
|
|
94
|
+
self.assertEqual(headers["bq-attempts"], "0")
|
|
95
|
+
|
|
96
|
+
def test_projection_values_are_bytes(self):
|
|
97
|
+
for _key, value in KafkaTransport._projection(body(3)):
|
|
98
|
+
self.assertIsInstance(value, bytes)
|
|
99
|
+
|
|
100
|
+
def test_projection_of_garbage_is_empty(self):
|
|
101
|
+
self.assertEqual(KafkaTransport._projection("not-json"), [])
|
|
102
|
+
|
|
103
|
+
def test_reconcile_header_is_authoritative(self):
|
|
104
|
+
out = KafkaTransport._reconcile(body(0), [("bq-attempts", b"2")])
|
|
105
|
+
self.assertEqual(EnvelopeCodec.decode(out)["attempts"], 2)
|
|
106
|
+
|
|
107
|
+
def test_reconcile_absent_header_falls_back_to_body(self):
|
|
108
|
+
raw = body(3)
|
|
109
|
+
self.assertEqual(KafkaTransport._reconcile(raw, []), raw)
|
|
110
|
+
|
|
111
|
+
def test_reconcile_garbage_header_falls_back(self):
|
|
112
|
+
raw = body(3)
|
|
113
|
+
self.assertEqual(KafkaTransport._reconcile(raw, [("bq-attempts", b"x")]), raw)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class KafkaPublishTest(unittest.TestCase):
|
|
117
|
+
def test_publish_writes_value_headers_and_timestamp(self):
|
|
118
|
+
raw = body()
|
|
119
|
+
env = EnvelopeCodec.decode(raw)
|
|
120
|
+
producer = FakeProducer()
|
|
121
|
+
transport(producer=producer).publish("orders", raw)
|
|
122
|
+
|
|
123
|
+
self.assertEqual(len(producer.produced), 1)
|
|
124
|
+
rec = producer.produced[0]
|
|
125
|
+
self.assertEqual(rec["topic"], "orders")
|
|
126
|
+
self.assertEqual(rec["value"], raw.encode("utf-8"))
|
|
127
|
+
self.assertEqual(rec["timestamp"], env["meta"]["created_at"])
|
|
128
|
+
self.assertEqual(header_map(rec["headers"])["bq-job"], URN)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class KafkaConsumeTest(unittest.TestCase):
|
|
132
|
+
def test_pop_reconciles_attempts_and_returns_handle(self):
|
|
133
|
+
message = FakeMessage(body(0).encode("utf-8"), headers=[("bq-attempts", b"2")])
|
|
134
|
+
consumer = FakeConsumer([message])
|
|
135
|
+
tr = transport(consumer=consumer)
|
|
136
|
+
|
|
137
|
+
msg = tr.pop("orders")
|
|
138
|
+
|
|
139
|
+
self.assertIsNotNone(msg)
|
|
140
|
+
self.assertIs(msg.handle, message)
|
|
141
|
+
self.assertEqual(EnvelopeCodec.decode(msg.body)["attempts"], 2)
|
|
142
|
+
|
|
143
|
+
def test_pop_empty_returns_none(self):
|
|
144
|
+
self.assertIsNone(transport(consumer=FakeConsumer([])).pop("orders"))
|
|
145
|
+
|
|
146
|
+
def test_pop_error_returns_none(self):
|
|
147
|
+
message = FakeMessage(body().encode("utf-8"), err="partition eof")
|
|
148
|
+
self.assertIsNone(transport(consumer=FakeConsumer([message])).pop("orders"))
|
|
149
|
+
|
|
150
|
+
def test_ack_commits_the_reserved_message(self):
|
|
151
|
+
message = FakeMessage(body().encode("utf-8"))
|
|
152
|
+
consumer = FakeConsumer([message])
|
|
153
|
+
tr = transport(consumer=consumer)
|
|
154
|
+
|
|
155
|
+
tr.ack(tr.pop("orders"))
|
|
156
|
+
|
|
157
|
+
self.assertEqual(consumer.committed, [message])
|
|
158
|
+
|
|
159
|
+
def test_ack_noop_without_handle(self):
|
|
160
|
+
transport().ack(ReceivedMessage(body="", queue="orders", handle=None)) # no error
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class KafkaSchemeTest(unittest.TestCase):
|
|
164
|
+
def test_make_transport_routes_kafka_scheme_lazily(self):
|
|
165
|
+
# Construction is lazy (no client built), so it succeeds with no confluent-kafka.
|
|
166
|
+
tr = make_transport("kafka://localhost:9092")
|
|
167
|
+
self.assertIsInstance(tr, KafkaTransport)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.3.0 → babelqueue-1.5.0}/src/babelqueue/django/management/commands/babelqueue_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.3.0 → babelqueue-1.5.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|