babelqueue 1.3.0__tar.gz → 1.4.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 (57) hide show
  1. {babelqueue-1.3.0 → babelqueue-1.4.0}/.gitignore +3 -0
  2. {babelqueue-1.3.0 → babelqueue-1.4.0}/CHANGELOG.md +17 -0
  3. {babelqueue-1.3.0 → babelqueue-1.4.0}/PKG-INFO +3 -1
  4. {babelqueue-1.3.0 → babelqueue-1.4.0}/pyproject.toml +2 -1
  5. babelqueue-1.4.0/src/babelqueue/kafka_transport.py +181 -0
  6. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/transport.py +6 -2
  7. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/manifest.json +25 -0
  8. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_app.py +1 -1
  9. babelqueue-1.4.0/tests/test_kafka_conformance.py +52 -0
  10. babelqueue-1.4.0/tests/test_kafka_transport.py +171 -0
  11. {babelqueue-1.3.0 → babelqueue-1.4.0}/.github/FUNDING.yml +0 -0
  12. {babelqueue-1.3.0 → babelqueue-1.4.0}/.github/workflows/ci.yml +0 -0
  13. {babelqueue-1.3.0 → babelqueue-1.4.0}/.github/workflows/release.yml +0 -0
  14. {babelqueue-1.3.0 → babelqueue-1.4.0}/LICENSE +0 -0
  15. {babelqueue-1.3.0 → babelqueue-1.4.0}/README.md +0 -0
  16. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/__init__.py +0 -0
  17. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/app.py +0 -0
  18. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/asb_transport.py +0 -0
  19. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/celery.py +0 -0
  20. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/codec.py +0 -0
  21. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/contracts.py +0 -0
  22. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/dead_letter.py +0 -0
  23. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/django/__init__.py +0 -0
  24. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/django/apps.py +0 -0
  25. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/django/management/__init__.py +0 -0
  26. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  27. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  28. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/exceptions.py +0 -0
  29. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/pika_transport.py +0 -0
  30. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/pulsar_transport.py +0 -0
  31. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/py.typed +0 -0
  32. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/redis_transport.py +0 -0
  33. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/routing.py +0 -0
  34. {babelqueue-1.3.0 → babelqueue-1.4.0}/src/babelqueue/sqs_transport.py +0 -0
  35. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  36. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  37. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  38. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/fixtures/order-created.json +0 -0
  39. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  40. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  41. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  42. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/fixtures/dead-lettered.json +0 -0
  43. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/fixtures/order-created.json +0 -0
  44. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_asb_conformance.py +0 -0
  45. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_asb_transport.py +0 -0
  46. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_celery.py +0 -0
  47. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_codec.py +0 -0
  48. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_conformance.py +0 -0
  49. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_dead_letter.py +0 -0
  50. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_django.py +0 -0
  51. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_overhead.py +0 -0
  52. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_pika_transport.py +0 -0
  53. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_pulsar_conformance.py +0 -0
  54. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_pulsar_transport.py +0 -0
  55. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_redis_transport.py +0 -0
  56. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_sqs_conformance.py +0 -0
  57. {babelqueue-1.3.0 → babelqueue-1.4.0}/tests/test_sqs_transport.py +0 -0
@@ -12,3 +12,6 @@ venv/
12
12
  .coverage
13
13
  coverage.xml
14
14
  htmlcov/
15
+
16
+ # CommitBrief local config and cache
17
+ .commitbrief/
@@ -7,6 +7,23 @@ 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.4.0] - 2026-06-13
11
+
12
+ ### Added
13
+ - **Apache Kafka transport** (`babelqueue[kafka]`, `confluent-kafka`) — `KafkaTransport`,
14
+ selected by the `kafka://` URL scheme (e.g. `kafka://host:9092`; or pass an injected
15
+ `producer` + `consumer_factory`). Implements [§6 of the broker-bindings
16
+ contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#apache-kafka): the record
17
+ **value** is the canonical envelope, projected onto native Kafka record headers (UTF-8 byte
18
+ strings) — `bq-job` = URN, `bq-trace-id`, `bq-message-id`, plus `bq-schema-version`/
19
+ `bq-source-lang`/`bq-attempts` — with the record timestamp mirroring `meta.created_at`.
20
+ Consume is **process-then-commit** (`pop` reserves via `poll` with `enable.auto.commit=false`,
21
+ `ack` commits the offset); the **`bq-attempts` header is the authoritative attempt counter**
22
+ (the body's `attempts` is the fallback for non-BabelQueue producers). The projection +
23
+ reconciliation + publish/pop/ack flow are unit-tested with no broker and no `confluent-kafka`
24
+ (the kafka import is lazy; the transport talks to injected producer/consumer fakes). The
25
+ envelope is unchanged (`schema_version: 1`); Apache Kafka is purely additive. Ships as a MINOR.
26
+
10
27
  ## [1.3.0] - 2026-06-13
11
28
 
12
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.3.0
3
+ Version: 1.4.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
@@ -36,6 +36,8 @@ Requires-Dist: pytest>=7; extra == 'dev'
36
36
  Requires-Dist: ruff>=0.5; extra == 'dev'
37
37
  Provides-Extra: django
38
38
  Requires-Dist: django>=4.2; extra == 'django'
39
+ Provides-Extra: kafka
40
+ Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
39
41
  Provides-Extra: pulsar
40
42
  Requires-Dist: pulsar-client>=3.4; extra == 'pulsar'
41
43
  Provides-Extra: redis
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.3.0"
7
+ version = "1.4.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,7 @@ 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"]
37
38
  celery = ["celery>=5"]
38
39
  django = ["django>=4.2"]
39
40
  dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
@@ -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,13 @@ 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)
94
98
 
95
99
  raise BabelQueueError(
96
100
  f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://', "
97
- "'amqp://', 'sqs://', 'sb://' or 'pulsar://', or pass your own Transport via "
98
- "BabelQueue(transport=...)."
101
+ "'amqp://', 'sqs://', 'sb://', 'pulsar://' or 'kafka://', or pass your own Transport "
102
+ "via BabelQueue(transport=...)."
99
103
  )
@@ -148,5 +148,30 @@
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
+ }
151
176
  }
152
177
  }
@@ -118,7 +118,7 @@ class AppTest(unittest.TestCase):
118
118
  from babelqueue import BabelQueueError
119
119
 
120
120
  with self.assertRaises(BabelQueueError):
121
- BabelQueue("kafka://localhost:9092")
121
+ BabelQueue("frobnicate://localhost:9092")
122
122
 
123
123
 
124
124
  if __name__ == "__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