babelqueue 1.10.0__tar.gz → 1.12.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 (78) hide show
  1. {babelqueue-1.10.0 → babelqueue-1.12.0}/CHANGELOG.md +50 -0
  2. {babelqueue-1.10.0 → babelqueue-1.12.0}/PKG-INFO +78 -1
  3. {babelqueue-1.10.0 → babelqueue-1.12.0}/README.md +77 -0
  4. {babelqueue-1.10.0 → babelqueue-1.12.0}/pyproject.toml +1 -1
  5. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/__init__.py +20 -2
  6. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/app.py +43 -3
  7. babelqueue-1.12.0/src/babelqueue/headers.py +70 -0
  8. babelqueue-1.12.0/src/babelqueue/otel.py +265 -0
  9. babelqueue-1.12.0/src/babelqueue/outbox.py +360 -0
  10. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/pika_transport.py +46 -10
  11. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/redis_transport.py +103 -2
  12. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/sqs_transport.py +60 -2
  13. babelqueue-1.12.0/tests/test_headers.py +112 -0
  14. babelqueue-1.12.0/tests/test_otel.py +263 -0
  15. babelqueue-1.12.0/tests/test_outbox.py +263 -0
  16. babelqueue-1.12.0/tests/test_pika_transport.py +195 -0
  17. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_redis_transport.py +110 -2
  18. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_sqs_transport.py +69 -1
  19. babelqueue-1.10.0/src/babelqueue/otel.py +0 -176
  20. babelqueue-1.10.0/tests/test_otel.py +0 -131
  21. babelqueue-1.10.0/tests/test_pika_transport.py +0 -98
  22. {babelqueue-1.10.0 → babelqueue-1.12.0}/.github/FUNDING.yml +0 -0
  23. {babelqueue-1.10.0 → babelqueue-1.12.0}/.github/workflows/ci.yml +0 -0
  24. {babelqueue-1.10.0 → babelqueue-1.12.0}/.github/workflows/release.yml +0 -0
  25. {babelqueue-1.10.0 → babelqueue-1.12.0}/.gitignore +0 -0
  26. {babelqueue-1.10.0 → babelqueue-1.12.0}/LICENSE +0 -0
  27. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/artemis_transport.py +0 -0
  28. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/asb_transport.py +0 -0
  29. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/celery.py +0 -0
  30. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/codec.py +0 -0
  31. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/contracts.py +0 -0
  32. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/dead_letter.py +0 -0
  33. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/__init__.py +0 -0
  34. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/apps.py +0 -0
  35. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/management/__init__.py +0 -0
  36. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  37. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  38. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/exceptions.py +0 -0
  39. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/idempotency.py +0 -0
  40. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/kafka_transport.py +0 -0
  41. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/pulsar_transport.py +0 -0
  42. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/py.typed +0 -0
  43. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/redrive.py +0 -0
  44. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/replay.py +0 -0
  45. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/routing.py +0 -0
  46. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/schema.py +0 -0
  47. {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/transport.py +0 -0
  48. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  49. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  50. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  51. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/order-created.json +0 -0
  52. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  53. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  54. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/manifest.json +0 -0
  55. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  56. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/fixtures/dead-lettered.json +0 -0
  57. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/fixtures/order-created.json +0 -0
  58. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_app.py +0 -0
  59. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_artemis_conformance.py +0 -0
  60. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_artemis_transport.py +0 -0
  61. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_asb_conformance.py +0 -0
  62. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_asb_transport.py +0 -0
  63. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_celery.py +0 -0
  64. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_codec.py +0 -0
  65. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_conformance.py +0 -0
  66. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_dead_letter.py +0 -0
  67. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_django.py +0 -0
  68. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_idempotency.py +0 -0
  69. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_kafka_conformance.py +0 -0
  70. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_kafka_transport.py +0 -0
  71. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_overhead.py +0 -0
  72. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_pulsar_conformance.py +0 -0
  73. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_pulsar_transport.py +0 -0
  74. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_redrive.py +0 -0
  75. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_replay.py +0 -0
  76. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_schema.py +0 -0
  77. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_schema_conformance.py +0 -0
  78. {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_sqs_conformance.py +0 -0
@@ -9,6 +9,56 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.12.0] - 2026-06-21
13
+
14
+ ### Added
15
+ - **Transactional outbox helper** (ADR-0029) — the optional `babelqueue.outbox` module ports the
16
+ PHP `BabelQueue\Outbox` helper to Python, removing the producer **dual write**: the message is
17
+ persisted **into your database, in the same transaction** as the business data (so it commits or
18
+ rolls back atomically with it), and a separate **relay** publishes the durable rows afterwards.
19
+ No distributed transaction; exactly-once *handoff* into the broker, then at-least-once on the wire
20
+ (the consumer dedupes on `meta.id` via the idempotency helper, the consumer-side mirror, ADR-0022).
21
+ The core stays **stdlib-only** (GR-7): `OutboxStore` is an abstract `typing.Protocol` the caller
22
+ binds to their own DB — the module ships only the in-memory `InMemoryOutboxStore` reference and
23
+ pulls in **no** DB driver. `Outbox.write(envelope)` encodes via the frozen `EnvelopeCodec` and
24
+ delegates to `OutboxStore.save` **inside the transaction the caller already opened** — it does not
25
+ begin/commit anything (the caller owns the transaction boundary). `OutboxRelay.flush()` publishes
26
+ one batch through the existing publish-only `Transport`, marking each row published **only after**
27
+ the transport accepts it, or failed (caught → `mark_failed`, row left pending, with a bounded
28
+ linear backoff via an injectable sleeper) so one poison row never blocks the batch;
29
+ `OutboxRelay.drain()` loops while a pass makes progress, with a safety ceiling. The relay
30
+ publishes the **stored bytes verbatim** — it never decodes, rebuilds or re-encodes the envelope —
31
+ so `trace_id` is preserved end-to-end and the body is byte-compatible across SDKs (GR-1/GR-4/GR-5).
32
+ Unit-tested without a broker (write stores the encoded envelope byte-identical; relay publishes via
33
+ a fake `Transport` + marks published; a raising publish → `mark_failed`, row still pending, batch
34
+ continues; `drain` loops to empty and stops on no-progress; backoff grows linearly and caps via the
35
+ injected sleeper). The envelope is unchanged (`schema_version: 1`); this is purely additive.
36
+
37
+ ## [1.11.0] - 2026-06-21
38
+
39
+ ### Added
40
+ - **W3C `traceparent` span-context propagation** (OpenTelemetry v0.2, ADR-0028) — the optional
41
+ `babelqueue.otel` module now carries true cross-hop **span** parent-child linkage, not just
42
+ shared-`trace_id` correlation. On publish, `otel.publish` injects the active span context as a
43
+ W3C `traceparent` **transport header** (and still stamps `trace_id` for the v0.1 fallback); on
44
+ consume, `otel.wrap_handler` extracts it and starts the CONSUMER span as a true **child** of the
45
+ producer span. With no `traceparent` present it falls back to the v0.1 `trace_id`-derived parent,
46
+ so it is a strict, backward-compatible upgrade — no regression. The header rides **out of band**
47
+ via a new dependency-free core seam — `BabelQueue.publish_with_headers(urn, data, headers, …)`
48
+ (produce side) and `babelqueue.headers_from_context()` (consume side, surfaced by the runtime) —
49
+ so the wire envelope stays **frozen** (`schema_version: 1`, GR-1) and the core stays
50
+ zero-dependency (OTel remains the optional `[otel]` extra, GR-7). `traceparent` is carried on the
51
+ **in-memory** (reference), **Redis** (a transport-owned `__bq_frame` JSON frame with bare-value
52
+ back-compat, so cross-version queues interoperate; degrades to a bare publish in Laravel-compat
53
+ mode), **RabbitMQ** (native AMQP header table, beside the contract `x-*` headers) and **SQS**
54
+ (native `MessageAttributes`, beside the contract `bq-*` attributes) transports; where a transport
55
+ can't carry headers, propagation degrades cleanly to v0.1 `trace_id` correlation with no error.
56
+ A plain `publish` is byte-identical to before. Unit-tested without a broker (frame round-trip +
57
+ bare back-compat, header merge/extract per transport, and an in-memory producer→consumer
58
+ parent-child end-to-end with the OTel SDK's `InMemorySpanExporter`); broker-gated integration
59
+ tests assert a published `traceparent` arrives on the consumed message's headers beside the
60
+ unchanged body. The envelope is unchanged; this is purely additive. Ships as a MINOR.
61
+
12
62
  ## [1.6.0] - 2026-06-14
13
63
 
14
64
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.10.0
3
+ Version: 1.12.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
@@ -234,6 +234,83 @@ def on_created(data, meta): ...
234
234
  python manage.py babelqueue_worker --queue orders # run the consumer
235
235
  ```
236
236
 
237
+ ## Transactional outbox (optional)
238
+
239
+ The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
240
+ business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
241
+ message is persisted **into your database, in the same transaction** as the business data — so it
242
+ commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
243
+ afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
244
+ on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
245
+
246
+ The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
247
+ (the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
248
+ unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
249
+ rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
250
+
251
+ ```python
252
+ from babelqueue import BabelQueue, EnvelopeCodec
253
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
254
+
255
+ store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
256
+ outbox = Outbox(store)
257
+
258
+ # write side — YOU own the transaction boundary (this is the whole point):
259
+ with db.transaction(): # your own open transaction
260
+ db.insert_order(order) # the business write
261
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
262
+ outbox.write(envelope) # same connection, same tx — both, or neither
263
+
264
+ # read/publish side — run on a short interval, after the business tx commits:
265
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
266
+ relay = OutboxRelay(app.transport, store)
267
+ relay.drain() # publish all pending rows; flush() does one batch
268
+ ```
269
+
270
+ `Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
271
+ A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
272
+ marks a row published only **after** the transport accepts it; a publish that raises is recorded via
273
+ `mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
274
+ poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
275
+ ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
276
+ is the reference for tests and single-process demos (no real transaction).
277
+
278
+ ## OpenTelemetry tracing (optional)
279
+
280
+ `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
281
+ imports OpenTelemetry, so it stays zero-dependency. It emits a PRODUCER span per publish and a
282
+ CONSUMER span per handled message, correlated across every hop and SDK, at two layered levels:
283
+
284
+ - **`trace_id` correlation** (v0.1): the envelope's `trace_id` maps 1:1 to an OTel trace id, so
285
+ every hop that shares a `trace_id` shares one trace — with **zero** wire/transport change.
286
+ - **W3C `traceparent` span linkage** (v0.2): the producer also injects its active span context as
287
+ a `traceparent` **transport header** (beside the frozen envelope, never in it), so the consumer
288
+ starts its span as a true **child** of the producer span — real cross-hop parent-child linkage.
289
+ With no `traceparent` present it falls back to the v0.1 `trace_id` behaviour, so enabling it is a
290
+ strict, backward-compatible upgrade.
291
+
292
+ ```python
293
+ from opentelemetry import trace
294
+ from babelqueue import BabelQueue, otel
295
+
296
+ tracer = trace.get_tracer("orders")
297
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
298
+
299
+ # consumer: wrap_handler starts a CONSUMER span (child of the producer span when a
300
+ # traceparent rode along; else in the trace_id-derived trace)
301
+ app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
302
+
303
+ # producer: otel.publish starts a PRODUCER span and carries traceparent + trace_id
304
+ otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
305
+ ```
306
+
307
+ The `traceparent` rides the out-of-band transport-header seam (`publish_with_headers` /
308
+ `headers_from_context`) — the same seam the replay-bypass marker uses — so the envelope stays
309
+ frozen (`schema_version: 1`). It is carried on the in-memory, Redis (a transport-owned JSON frame,
310
+ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttributes`)
311
+ transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
312
+ correlation with no error.
313
+
237
314
  ## What's here
238
315
 
239
316
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -182,6 +182,83 @@ def on_created(data, meta): ...
182
182
  python manage.py babelqueue_worker --queue orders # run the consumer
183
183
  ```
184
184
 
185
+ ## Transactional outbox (optional)
186
+
187
+ The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
188
+ business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
189
+ message is persisted **into your database, in the same transaction** as the business data — so it
190
+ commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
191
+ afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
192
+ on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
193
+
194
+ The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
195
+ (the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
196
+ unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
197
+ rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
198
+
199
+ ```python
200
+ from babelqueue import BabelQueue, EnvelopeCodec
201
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
202
+
203
+ store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
204
+ outbox = Outbox(store)
205
+
206
+ # write side — YOU own the transaction boundary (this is the whole point):
207
+ with db.transaction(): # your own open transaction
208
+ db.insert_order(order) # the business write
209
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
210
+ outbox.write(envelope) # same connection, same tx — both, or neither
211
+
212
+ # read/publish side — run on a short interval, after the business tx commits:
213
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
214
+ relay = OutboxRelay(app.transport, store)
215
+ relay.drain() # publish all pending rows; flush() does one batch
216
+ ```
217
+
218
+ `Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
219
+ A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
220
+ marks a row published only **after** the transport accepts it; a publish that raises is recorded via
221
+ `mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
222
+ poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
223
+ ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
224
+ is the reference for tests and single-process demos (no real transaction).
225
+
226
+ ## OpenTelemetry tracing (optional)
227
+
228
+ `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
229
+ imports OpenTelemetry, so it stays zero-dependency. It emits a PRODUCER span per publish and a
230
+ CONSUMER span per handled message, correlated across every hop and SDK, at two layered levels:
231
+
232
+ - **`trace_id` correlation** (v0.1): the envelope's `trace_id` maps 1:1 to an OTel trace id, so
233
+ every hop that shares a `trace_id` shares one trace — with **zero** wire/transport change.
234
+ - **W3C `traceparent` span linkage** (v0.2): the producer also injects its active span context as
235
+ a `traceparent` **transport header** (beside the frozen envelope, never in it), so the consumer
236
+ starts its span as a true **child** of the producer span — real cross-hop parent-child linkage.
237
+ With no `traceparent` present it falls back to the v0.1 `trace_id` behaviour, so enabling it is a
238
+ strict, backward-compatible upgrade.
239
+
240
+ ```python
241
+ from opentelemetry import trace
242
+ from babelqueue import BabelQueue, otel
243
+
244
+ tracer = trace.get_tracer("orders")
245
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
246
+
247
+ # consumer: wrap_handler starts a CONSUMER span (child of the producer span when a
248
+ # traceparent rode along; else in the trace_id-derived trace)
249
+ app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
250
+
251
+ # producer: otel.publish starts a PRODUCER span and carries traceparent + trace_id
252
+ otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
253
+ ```
254
+
255
+ The `traceparent` rides the out-of-band transport-header seam (`publish_with_headers` /
256
+ `headers_from_context`) — the same seam the replay-bypass marker uses — so the envelope stays
257
+ frozen (`schema_version: 1`). It is carried on the in-memory, Redis (a transport-owned JSON frame,
258
+ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttributes`)
259
+ transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
260
+ correlation with no error.
261
+
185
262
  ## What's here
186
263
 
187
264
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.10.0"
7
+ version = "1.12.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"
@@ -11,17 +11,26 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from . import dead_letter, idempotency, redrive, replay
14
+ from . import dead_letter, headers, idempotency, outbox, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
+ from .headers import headers_from_context
18
19
  from .idempotency import IdempotencyStore, InMemoryStore
20
+ from .outbox import (
21
+ InMemoryOutboxStore,
22
+ Outbox,
23
+ OutboxRecord,
24
+ OutboxRelay,
25
+ OutboxRelayResult,
26
+ OutboxStore,
27
+ )
19
28
  from .exceptions import BabelQueueError, UnknownUrnError
20
29
  from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
21
30
  from .routing import UnknownUrnStrategy
22
31
  from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
23
32
 
24
- __version__ = "1.6.0"
33
+ __version__ = "1.12.0"
25
34
 
26
35
  __all__ = [
27
36
  "BabelQueue",
@@ -38,11 +47,20 @@ __all__ = [
38
47
  "BabelQueueError",
39
48
  "UnknownUrnError",
40
49
  "dead_letter",
50
+ "headers",
41
51
  "idempotency",
52
+ "outbox",
53
+ "Outbox",
54
+ "OutboxStore",
55
+ "OutboxRecord",
56
+ "OutboxRelay",
57
+ "OutboxRelayResult",
58
+ "InMemoryOutboxStore",
42
59
  "redrive",
43
60
  "replay",
44
61
  "is_replay",
45
62
  "bypass_external_effects",
63
+ "headers_from_context",
46
64
  "HEADER_REPLAY_BYPASS",
47
65
  "IdempotencyStore",
48
66
  "InMemoryStore",
@@ -25,9 +25,10 @@ from typing import Any, Callable, Dict, Mapping, Optional
25
25
  from . import dead_letter
26
26
  from .codec import EnvelopeCodec
27
27
  from .exceptions import UnknownUrnError
28
+ from .headers import _headers_scope
28
29
  from .replay import HEADER_REPLAY_BYPASS, _replay_scope
29
30
  from .routing import UnknownUrnStrategy
30
- from .transport import ReceivedMessage, Transport, make_transport
31
+ from .transport import HeaderPublisher, ReceivedMessage, Transport, make_transport
31
32
 
32
33
  Handler = Callable[..., None]
33
34
 
@@ -70,6 +71,37 @@ class BabelQueue:
70
71
  self.transport.publish(target, EnvelopeCodec.encode(envelope))
71
72
  return envelope["meta"]["id"]
72
73
 
74
+ def publish_with_headers(
75
+ self,
76
+ urn: str,
77
+ data: Mapping[str, Any],
78
+ headers: Mapping[str, str],
79
+ *,
80
+ queue: Optional[str] = None,
81
+ trace_id: Optional[str] = None,
82
+ ) -> str:
83
+ """Publish a message together with out-of-band transport ``headers``; returns its id.
84
+
85
+ The headers ride **beside** the frozen envelope (GR-1) on the transport's per-message
86
+ metadata channel — e.g. a W3C ``traceparent`` for cross-hop span linkage (ADR-0028) —
87
+ never inside it. It is the produce-side counterpart of the headers the runtime surfaces
88
+ to a handler via :func:`~babelqueue.headers.headers_from_context`.
89
+
90
+ When the transport implements :class:`~babelqueue.transport.HeaderPublisher` and
91
+ ``headers`` is non-empty, the headers are propagated; otherwise it transparently falls
92
+ back to a plain :meth:`publish` (the headers are dropped — no error, no regression),
93
+ exactly as :func:`~babelqueue.redrive.redrive` degrades. Passing empty headers is
94
+ equivalent to :meth:`publish`, so callers need not branch on transport capability.
95
+ """
96
+ target = queue or self.queue
97
+ envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
98
+ body = EnvelopeCodec.encode(envelope)
99
+ if headers and isinstance(self.transport, HeaderPublisher):
100
+ self.transport.publish_with_headers(target, body, dict(headers))
101
+ else:
102
+ self.transport.publish(target, body)
103
+ return envelope["meta"]["id"]
104
+
73
105
  # -- Register handlers --------------------------------------------------
74
106
 
75
107
  def handler(self, urn: str) -> Callable[[Handler], Handler]:
@@ -116,8 +148,16 @@ class BabelQueue:
116
148
  run = consume
117
149
 
118
150
  def dispatch(self, received: ReceivedMessage) -> None:
119
- """Route one reserved message to its handler and acknowledge it."""
120
- with _replay_scope(bool(received.headers.get(HEADER_REPLAY_BYPASS))):
151
+ """Route one reserved message to its handler and acknowledge it.
152
+
153
+ The delivered message's out-of-band transport headers are surfaced onto the context for
154
+ the span of this dispatch (:func:`~babelqueue.headers.headers_from_context`), so a handler
155
+ or an optional wrapper (e.g. the ``otel`` module reading a W3C ``traceparent``, ADR-0028)
156
+ can read metadata that travels beside the frozen envelope (GR-1).
157
+ """
158
+ with _headers_scope(received.headers), _replay_scope(
159
+ bool(received.headers.get(HEADER_REPLAY_BYPASS))
160
+ ):
121
161
  envelope = EnvelopeCodec.decode(received.body)
122
162
  urn = str(envelope.get("job") or envelope.get("urn") or "")
123
163
  handler = self._handlers.get(urn) if urn else None
@@ -0,0 +1,70 @@
1
+ """Consume-side out-of-band transport headers (the Python mirror of Go's ``headers.go``).
2
+
3
+ The runtime surfaces a delivered message's :attr:`~babelqueue.transport.ReceivedMessage.headers`
4
+ onto a :class:`contextvars.ContextVar` for the span of one dispatch, so a handler — or an
5
+ optional wrapper such as the ``otel`` module — can read per-message metadata that travels
6
+ **beside** the frozen envelope (GR-1), never in it. It is the consume-side counterpart of
7
+ :class:`~babelqueue.transport.HeaderPublisher`.
8
+
9
+ This is the same out-of-band seam the replay-bypass marker rides (ADR-0027); ADR-0028's W3C
10
+ ``traceparent`` (for cross-hop span parent-child linkage) is the second rider on it. The header
11
+ map is read-only — treat it as immutable. Adds only :mod:`contextvars` + a plain ``dict`` (no
12
+ dependency), exactly like :mod:`babelqueue.replay`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import contextvars
19
+ from typing import Dict, Iterator, Mapping, Optional
20
+
21
+ #: The delivered message's out-of-band transport headers, for the span of one dispatch.
22
+ #: Defaults to an empty mapping so :func:`headers_from_context` is always nil-safe.
23
+ _headers_var: contextvars.ContextVar[Mapping[str, str]] = contextvars.ContextVar(
24
+ "babelqueue_headers", default={}
25
+ )
26
+
27
+
28
+ def headers_from_context() -> Mapping[str, str]:
29
+ """Return the out-of-band transport headers that arrived with the message currently being
30
+ handled, or an empty mapping when none were carried (or the transport surfaces none).
31
+
32
+ The returned mapping is read-only — do not mutate it. It is the consume-side counterpart of
33
+ :class:`~babelqueue.transport.HeaderPublisher`: a handler or an optional wrapper (e.g. the
34
+ ``otel`` module's :func:`~babelqueue.otel.wrap_handler`) reads per-message metadata that
35
+ travels beside the frozen envelope, never in it (GR-1).
36
+ """
37
+ return _headers_var.get()
38
+
39
+
40
+ @contextlib.contextmanager
41
+ def _headers_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
42
+ """Internal: surface ``headers`` on the context for the span of one dispatch, then reset.
43
+
44
+ A nil/empty map is fine; reads stay nil-safe. The runtime calls this in
45
+ :meth:`~babelqueue.app.BabelQueue.dispatch` so wrappers can read the delivered headers.
46
+ """
47
+ scoped: Mapping[str, str] = headers or {}
48
+ token = _headers_var.set(scoped)
49
+ try:
50
+ yield
51
+ finally:
52
+ _headers_var.reset(token)
53
+
54
+
55
+ def merge_headers(*sources: Optional[Mapping[str, str]]) -> Dict[str, str]:
56
+ """Combine header maps into a single ``dict[str, str]``, dropping blank keys and blank values.
57
+
58
+ Later sources win a key collision. Returns a fresh dict (callers may mutate it freely). Used
59
+ to merge an injected ``traceparent`` onto a transport's contract headers without clobbering
60
+ them — the contract keys are passed *last* so they win (mirrors the Go merge-not-clobber).
61
+ """
62
+ out: Dict[str, str] = {}
63
+ for source in sources:
64
+ if not source:
65
+ continue
66
+ for key, value in source.items():
67
+ if not key or value is None or value == "":
68
+ continue
69
+ out[str(key)] = str(value)
70
+ return out