babelqueue 1.12.0__tar.gz → 1.14.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.14.0/.github/dependabot.yml +11 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/.github/workflows/ci.yml +2 -1
- {babelqueue-1.12.0 → babelqueue-1.14.0}/CHANGELOG.md +80 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/PKG-INFO +85 -3
- {babelqueue-1.12.0 → babelqueue-1.14.0}/README.md +80 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/pyproject.toml +5 -2
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/__init__.py +11 -4
- babelqueue-1.14.0/src/babelqueue/app.py +502 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/artemis_transport.py +5 -5
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/asb_transport.py +2 -2
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/codec.py +93 -8
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/exceptions.py +12 -0
- babelqueue-1.14.0/src/babelqueue/gdpr.py +245 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/kafka_transport.py +3 -3
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/pulsar_transport.py +2 -2
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/schema.py +59 -1
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/sqs_transport.py +87 -11
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/transport.py +19 -0
- babelqueue-1.14.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- babelqueue-1.14.0/tests/conformance/fixtures/data-array-rejected.json +13 -0
- babelqueue-1.14.0/tests/conformance/fixtures/empty-data-roundtrip.json +13 -0
- babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-attempts.json +16 -0
- babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +16 -0
- babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-source.json +16 -0
- babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-meta-ts.json +16 -0
- babelqueue-1.14.0/tests/conformance/fixtures/forbidden-key-timestamp.json +16 -0
- babelqueue-1.14.0/tests/conformance/fixtures/unknown-lang.json +15 -0
- babelqueue-1.14.0/tests/conformance/fixtures/unknown-meta-roundtrip.json +20 -0
- babelqueue-1.14.0/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +16 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/manifest.json +256 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_artemis_transport.py +27 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_asb_transport.py +22 -0
- babelqueue-1.14.0/tests/test_behaviour_conformance.py +195 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_codec.py +27 -0
- babelqueue-1.14.0/tests/test_gdpr.py +328 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_kafka_transport.py +23 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_pulsar_transport.py +25 -0
- babelqueue-1.14.0/tests/test_shutdown.py +302 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_sqs_transport.py +245 -3
- babelqueue-1.12.0/src/babelqueue/app.py +0 -252
- {babelqueue-1.12.0 → babelqueue-1.14.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/.gitignore +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/LICENSE +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_app.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_celery.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_django.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_headers.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_otel.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_replay.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_schema.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.12.0 → babelqueue-1.14.0}/tests/test_sqs_conformance.py +0 -0
|
@@ -118,7 +118,8 @@ jobs:
|
|
|
118
118
|
- uses: actions/checkout@v5
|
|
119
119
|
- name: Verify vendored conformance matches the canonical suite
|
|
120
120
|
run: |
|
|
121
|
-
git clone --depth 1 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
|
|
121
|
+
git clone --depth 1 --branch v1.0.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
|
|
122
|
+
diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
|
|
122
123
|
diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
|
|
123
124
|
diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
|
|
124
125
|
diff -ru "$RUNNER_TEMP/conformance/schema" "tests/conformance/schema"
|
|
@@ -9,6 +9,86 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.14.0] - 2026-10-03
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Graceful shutdown** of `BabelQueue.consume()`: on SIGTERM/SIGINT the loop sets a stop flag,
|
|
16
|
+
takes no new message, lets the in-flight handler finish (or releases the message unchanged once
|
|
17
|
+
the new `shutdown_timeout` — default 30 s — expires), restores the previous signal handlers and
|
|
18
|
+
closes the transport. Handlers are installed only on the main thread (`handle_signals=False`
|
|
19
|
+
opts out); a second signal forces an immediate exit with the message released. A plain
|
|
20
|
+
`KeyboardInterrupt` keeps its previous behaviour. New `BabelQueue.stop()` ends the loop from any
|
|
21
|
+
thread after the in-flight message.
|
|
22
|
+
- `Redeliverer` protocol (`redeliver(message, body, delay)`) — a transport's native
|
|
23
|
+
release; the runtime uses it for retries, unknown-URN `RELEASE` and shutdown releases, and
|
|
24
|
+
falls back to publish + ack otherwise. New `retry_backoff` / `unknown_urn_release_delay`
|
|
25
|
+
settings feed its delay.
|
|
26
|
+
- Conformance runners for the `roundtrip`, `data_shape`, `forbidden_keys` and
|
|
27
|
+
`payload_schema_unicode` sections (none skipped).
|
|
28
|
+
- `.github/dependabot.yml` (pip + GitHub Actions, weekly).
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- **SQS release** (broker-bindings §3.5): retries, unknown-URN `RELEASE` and shutdown releases
|
|
32
|
+
**always** call `ChangeMessageVisibility` instead of re-sending, so the broker's
|
|
33
|
+
`ApproximateReceiveCount` carries the attempt count. There is no send + delete fallback. The
|
|
34
|
+
default delay for `retry_backoff` and `unknown_urn_release_delay` is **0 s** (immediate
|
|
35
|
+
redelivery); a delay outside 0..43200 s (including `inf`/`nan`) is clamped with a warning on the
|
|
36
|
+
`babelqueue.sqs` logger. A message without a receipt handle is not released (warning); one
|
|
37
|
+
without a receive count is released but its attempts cannot advance (warning).
|
|
38
|
+
**Poison-loop risk:** a message that always fails is redelivered immediately until
|
|
39
|
+
`max_attempts` — configure a native SQS `RedrivePolicy` (`maxReceiveCount` ≥ `max_attempts`) as
|
|
40
|
+
the backstop and a non-zero `retry_backoff` where needed. A shutdown release also counts as a
|
|
41
|
+
receive on SQS, so keep `shutdown_timeout` above the longest handler time.
|
|
42
|
+
- Delivery semantics are documented as **at-least-once**: a handler that finishes right as the
|
|
43
|
+
shutdown deadline fires may be released and run again.
|
|
44
|
+
- The SQS, Kafka, Pulsar, Azure Service Bus and Artemis transports' header/property projections
|
|
45
|
+
read the envelope without logging a false forbidden-key drop (the body is sent unchanged).
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
- The five forbidden envelope keys (`timestamp`, `meta.max_retries`, `meta.attempts`,
|
|
49
|
+
`meta.source`, `meta.ts` — message-envelope §10) are dropped on decode with a warning on the
|
|
50
|
+
`babelqueue.codec` logger, and never written on encode. **Transitional:** encode currently drops
|
|
51
|
+
them with a warning; a future MINOR (K-15 / R1-E0) will reject them instead. A raw-body release
|
|
52
|
+
republish now strips them too (a clean body is re-sent byte for byte); DLQ redrive still restores
|
|
53
|
+
the original bytes unchanged.
|
|
54
|
+
- `BabelQueue.stop()` called before `consume()` starts is now honoured instead of being cleared.
|
|
55
|
+
- A failed acknowledgement of an already-processed message (e.g. SQS `DeleteMessage` after a
|
|
56
|
+
successful handler, an unknown-URN `DELETE`, or a dead-letter publish) is no longer treated as a
|
|
57
|
+
handler failure: the message is **not** released or retried; the failure is logged at `ERROR`
|
|
58
|
+
on the new `babelqueue.app` logger and the broker redelivers it on its own (SQS: after the
|
|
59
|
+
visibility timeout). The consume loop keeps running.
|
|
60
|
+
|
|
61
|
+
## [1.13.0] - 2026-06-21
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
- **Runtime GDPR field encryption** (ADR-0030) — the optional `babelqueue.gdpr` module ports the Go
|
|
65
|
+
reference to Python: the SDK-enforcement half of governed PII. The babelqueue-registry only
|
|
66
|
+
**declares** and **audits** `x-gdpr-sensitive` fields (and can **mask** them for safe logging);
|
|
67
|
+
this module **enforces** them on the wire — a producer encrypts each marked leaf before publish, a
|
|
68
|
+
consumer decrypts it after decode. Strictly **opt-in**: a producer/consumer that never calls
|
|
69
|
+
`protect`/`unprotect` is unchanged. The **envelope stays frozen** (GR-1): only **values inside
|
|
70
|
+
`data`** change — a sensitive leaf's value becomes a ciphertext **string** — so `data` stays pure
|
|
71
|
+
JSON (an SDK without the key still carries the envelope), `meta.schema_version` stays `1`, and
|
|
72
|
+
`trace_id` is untouched (GR-4). The core stays **stdlib-only** (GR-7): Python's standard library
|
|
73
|
+
has **no AES-GCM**, so the module ships **no** concrete cipher and pulls **no** crypto dependency
|
|
74
|
+
— `Cipher` is a caller-provided `typing.Protocol` (`encrypt(bytes) -> str` / `decrypt(str) ->
|
|
75
|
+
bytes`) bound to a KMS / Vault / HSM / tokenisation service, or to a local AES-256-GCM via the
|
|
76
|
+
optional `cryptography` library (new `babelqueue[gdpr]` extra, documented in the README — still
|
|
77
|
+
**not** a core dependency). `protect(data, schema, cipher)` canonically JSON-encodes each marked
|
|
78
|
+
leaf and replaces it with the cipher's ciphertext string **in place**; `unprotect(...)` is the
|
|
79
|
+
exact inverse, restoring `data` **byte-for-byte**. The marked paths come from the **same per-URN
|
|
80
|
+
schema** the validator already loads — `babelqueue.schema.sensitive_paths` walks nested objects
|
|
81
|
+
(`profile.full_name`), array items (`addresses[].line`), container, and root marks, and the
|
|
82
|
+
`x-gdpr-sensitive` keyword is **validation-neutral** (annotating a schema is never breaking). An
|
|
83
|
+
absent marked field is skipped (not an error); a re-run `unprotect` on already-cleartext data is a
|
|
84
|
+
no-op (non-string leaves are left alone); a wrong key / tampered / non-ciphertext value raises the
|
|
85
|
+
new `babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
|
|
86
|
+
handle unreadable PII. Validate cleartext **before** `protect` / **after** `unprotect`, since a
|
|
87
|
+
schema constraining a sensitive field would reject the ciphertext string. Unit-tested without any
|
|
88
|
+
crypto dependency (a stdlib-only authenticated fake cipher) for round-trip exactness across
|
|
89
|
+
nested/array/container/root marks, skips, idempotent re-runs, frozen-envelope decode/validate, and
|
|
90
|
+
wrong-key failure. The envelope is unchanged (`schema_version: 1`); this is purely additive.
|
|
91
|
+
|
|
12
92
|
## [1.12.0] - 2026-06-21
|
|
13
93
|
|
|
14
94
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.14.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
|
|
@@ -35,9 +35,11 @@ Provides-Extra: dev
|
|
|
35
35
|
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
36
36
|
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
37
37
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
38
|
-
Requires-Dist: ruff
|
|
38
|
+
Requires-Dist: ruff<0.16,>=0.5; extra == 'dev'
|
|
39
39
|
Provides-Extra: django
|
|
40
40
|
Requires-Dist: django>=4.2; extra == 'django'
|
|
41
|
+
Provides-Extra: gdpr
|
|
42
|
+
Requires-Dist: cryptography>=42; extra == 'gdpr'
|
|
41
43
|
Provides-Extra: kafka
|
|
42
44
|
Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
|
|
43
45
|
Provides-Extra: otel
|
|
@@ -172,6 +174,27 @@ app.run() # consume forever (Ctrl-C to stop)
|
|
|
172
174
|
and `memory://` (in-process, great for tests/local). Bring your own by passing
|
|
173
175
|
`transport=...`.
|
|
174
176
|
|
|
177
|
+
- **Graceful shutdown:** SIGTERM/SIGINT finish the in-flight handler, or release
|
|
178
|
+
the message unchanged once `shutdown_timeout` expires. Delivery is
|
|
179
|
+
**at-least-once** — keep handlers idempotent (see the idempotency helper).
|
|
180
|
+
|
|
181
|
+
### SQS release and poison messages
|
|
182
|
+
|
|
183
|
+
On a handler failure, unknown-URN `release` or shutdown release, the SQS
|
|
184
|
+
transport **always** releases with `ChangeMessageVisibility` (broker-bindings
|
|
185
|
+
§3.5) — it never re-sends a copy. The broker's `ApproximateReceiveCount` is the
|
|
186
|
+
attempt counter, so a shutdown release also consumes an attempt. The default
|
|
187
|
+
delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
|
|
188
|
+
message is visible again immediately; delays outside 0..43200 s are clamped with
|
|
189
|
+
a warning.
|
|
190
|
+
|
|
191
|
+
**Poison-loop risk:** a message that always fails is redelivered at once until
|
|
192
|
+
`max_attempts` is reached. If the receive count is unavailable the SDK cannot
|
|
193
|
+
advance attempts at all. **Configure a native `RedrivePolicy`**
|
|
194
|
+
(`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
|
|
195
|
+
queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
|
|
196
|
+
handler's dependencies need time to recover.
|
|
197
|
+
|
|
175
198
|
### Sharing a Redis queue with Laravel
|
|
176
199
|
|
|
177
200
|
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
|
|
@@ -311,6 +334,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
|
|
|
311
334
|
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
312
335
|
correlation with no error.
|
|
313
336
|
|
|
337
|
+
## GDPR field encryption (optional)
|
|
338
|
+
|
|
339
|
+
The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
|
|
340
|
+
encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
|
|
341
|
+
**declares** and **audits** sensitivity (and can **mask** for safe logging); this module
|
|
342
|
+
**enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
|
|
343
|
+
decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
|
|
344
|
+
nothing changes.
|
|
345
|
+
|
|
346
|
+
The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
|
|
347
|
+
sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
|
|
348
|
+
the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
|
|
349
|
+
never touched. Which fields are sensitive lives in the **schema**, not the message.
|
|
350
|
+
|
|
351
|
+
Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
|
|
352
|
+
crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
|
|
353
|
+
tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
|
|
354
|
+
(`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
import base64, os
|
|
358
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
359
|
+
|
|
360
|
+
class AesGcmCipher:
|
|
361
|
+
def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
|
|
362
|
+
self._aead = AESGCM(key)
|
|
363
|
+
|
|
364
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
365
|
+
nonce = os.urandom(12)
|
|
366
|
+
return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
|
|
367
|
+
|
|
368
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
369
|
+
raw = base64.b64decode(ciphertext)
|
|
370
|
+
return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
|
|
374
|
+
schema that constrains a sensitive field would reject the ciphertext string:
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from babelqueue import EnvelopeCodec
|
|
378
|
+
from babelqueue.gdpr import protect, unprotect
|
|
379
|
+
|
|
380
|
+
# producer: encrypt marked leaves in place, then encode
|
|
381
|
+
protect(data, schema, cipher)
|
|
382
|
+
body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
|
|
383
|
+
|
|
384
|
+
# consumer: decode, then decrypt marked leaves in place before the handler reads data
|
|
385
|
+
envelope = EnvelopeCodec.decode(body)
|
|
386
|
+
unprotect(envelope["data"], schema, cipher)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
|
|
390
|
+
locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
|
|
391
|
+
root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
|
|
392
|
+
(non-string leaves are left alone); a wrong key / tampered ciphertext raises
|
|
393
|
+
`babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
|
|
394
|
+
process unreadable PII. The round-trip restores `data` **byte-for-byte**.
|
|
395
|
+
|
|
314
396
|
## What's here
|
|
315
397
|
|
|
316
398
|
The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
|
|
@@ -120,6 +120,27 @@ app.run() # consume forever (Ctrl-C to stop)
|
|
|
120
120
|
and `memory://` (in-process, great for tests/local). Bring your own by passing
|
|
121
121
|
`transport=...`.
|
|
122
122
|
|
|
123
|
+
- **Graceful shutdown:** SIGTERM/SIGINT finish the in-flight handler, or release
|
|
124
|
+
the message unchanged once `shutdown_timeout` expires. Delivery is
|
|
125
|
+
**at-least-once** — keep handlers idempotent (see the idempotency helper).
|
|
126
|
+
|
|
127
|
+
### SQS release and poison messages
|
|
128
|
+
|
|
129
|
+
On a handler failure, unknown-URN `release` or shutdown release, the SQS
|
|
130
|
+
transport **always** releases with `ChangeMessageVisibility` (broker-bindings
|
|
131
|
+
§3.5) — it never re-sends a copy. The broker's `ApproximateReceiveCount` is the
|
|
132
|
+
attempt counter, so a shutdown release also consumes an attempt. The default
|
|
133
|
+
delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
|
|
134
|
+
message is visible again immediately; delays outside 0..43200 s are clamped with
|
|
135
|
+
a warning.
|
|
136
|
+
|
|
137
|
+
**Poison-loop risk:** a message that always fails is redelivered at once until
|
|
138
|
+
`max_attempts` is reached. If the receive count is unavailable the SDK cannot
|
|
139
|
+
advance attempts at all. **Configure a native `RedrivePolicy`**
|
|
140
|
+
(`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
|
|
141
|
+
queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
|
|
142
|
+
handler's dependencies need time to recover.
|
|
143
|
+
|
|
123
144
|
### Sharing a Redis queue with Laravel
|
|
124
145
|
|
|
125
146
|
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
|
|
@@ -259,6 +280,65 @@ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttr
|
|
|
259
280
|
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
260
281
|
correlation with no error.
|
|
261
282
|
|
|
283
|
+
## GDPR field encryption (optional)
|
|
284
|
+
|
|
285
|
+
The optional `babelqueue.gdpr` module is the runtime, SDK-enforcement half of ADR-0030: it
|
|
286
|
+
encrypts the `data` fields a registry declared `x-gdpr-sensitive`. The babelqueue-registry only
|
|
287
|
+
**declares** and **audits** sensitivity (and can **mask** for safe logging); this module
|
|
288
|
+
**enforces** it on the wire — a producer encrypts each marked leaf before publish, a consumer
|
|
289
|
+
decrypts it after decode. It is strictly **opt-in**: if you never call `protect`/`unprotect`,
|
|
290
|
+
nothing changes.
|
|
291
|
+
|
|
292
|
+
The envelope stays **frozen** (`schema_version: 1`): only the **values inside `data`** change — a
|
|
293
|
+
sensitive leaf's value becomes a ciphertext **string**. `data` stays pure JSON, so an SDK without
|
|
294
|
+
the key still carries the envelope (it just can't read the protected fields), and `trace_id` is
|
|
295
|
+
never touched. Which fields are sensitive lives in the **schema**, not the message.
|
|
296
|
+
|
|
297
|
+
Because Python's standard library has **no AES-GCM**, the core ships **no** cipher and pulls **no**
|
|
298
|
+
crypto dependency (GR-7). `Cipher` is a caller-provided `Protocol` you bind to a KMS / Vault / HSM /
|
|
299
|
+
tokenisation service, or to a local AES-256-GCM via the optional `cryptography` library
|
|
300
|
+
(`pip install "babelqueue[gdpr]"` — still **not** a core dependency):
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
import base64, os
|
|
304
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
305
|
+
|
|
306
|
+
class AesGcmCipher:
|
|
307
|
+
def __init__(self, key: bytes) -> None: # 16/24/32-byte key — the CALLER's
|
|
308
|
+
self._aead = AESGCM(key)
|
|
309
|
+
|
|
310
|
+
def encrypt(self, plaintext: bytes) -> str:
|
|
311
|
+
nonce = os.urandom(12)
|
|
312
|
+
return base64.b64encode(nonce + self._aead.encrypt(nonce, plaintext, None)).decode("ascii")
|
|
313
|
+
|
|
314
|
+
def decrypt(self, ciphertext: str) -> bytes:
|
|
315
|
+
raw = base64.b64decode(ciphertext)
|
|
316
|
+
return self._aead.decrypt(raw[:12], raw[12:], None) # raises on wrong key / tamper
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Wire it around the codec — validate **cleartext** (before `protect`, after `unprotect`), since a
|
|
320
|
+
schema that constrains a sensitive field would reject the ciphertext string:
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from babelqueue import EnvelopeCodec
|
|
324
|
+
from babelqueue.gdpr import protect, unprotect
|
|
325
|
+
|
|
326
|
+
# producer: encrypt marked leaves in place, then encode
|
|
327
|
+
protect(data, schema, cipher)
|
|
328
|
+
body = EnvelopeCodec.encode(EnvelopeCodec.make("urn:babel:orders:created", data, queue="orders"))
|
|
329
|
+
|
|
330
|
+
# consumer: decode, then decrypt marked leaves in place before the handler reads data
|
|
331
|
+
envelope = EnvelopeCodec.decode(body)
|
|
332
|
+
unprotect(envelope["data"], schema, cipher)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`schema` is the same per-URN JSON Schema the validator uses; `babelqueue.schema.sensitive_paths`
|
|
336
|
+
locates every `x-gdpr-sensitive` leaf (nested objects, `addresses[].line` array items, container or
|
|
337
|
+
root marks). An absent marked field is skipped; a re-run `unprotect` on cleartext is a no-op
|
|
338
|
+
(non-string leaves are left alone); a wrong key / tampered ciphertext raises
|
|
339
|
+
`babelqueue.DecryptError` so the consumer fails the message (retry / dead-letter) rather than
|
|
340
|
+
process unreadable PII. The round-trip restores `data` **byte-for-byte**.
|
|
341
|
+
|
|
262
342
|
## What's here
|
|
263
343
|
|
|
264
344
|
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.
|
|
7
|
+
version = "1.14.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"
|
|
@@ -37,9 +37,12 @@ pulsar = ["pulsar-client>=3.4"]
|
|
|
37
37
|
kafka = ["confluent-kafka>=2.3"]
|
|
38
38
|
artemis = ["python-qpid-proton>=0.39"]
|
|
39
39
|
otel = ["opentelemetry-api>=1.20"]
|
|
40
|
+
# Only for the documented bring-your-own AES-256-GCM reference cipher (ADR-0030). The core never
|
|
41
|
+
# imports it; protect/unprotect take a caller-provided Cipher Protocol, so this is NOT a core dep.
|
|
42
|
+
gdpr = ["cryptography>=42"]
|
|
40
43
|
celery = ["celery>=5"]
|
|
41
44
|
django = ["django>=4.2"]
|
|
42
|
-
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
|
|
45
|
+
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5,<0.16"]
|
|
43
46
|
|
|
44
47
|
[project.urls]
|
|
45
48
|
Homepage = "https://babelqueue.com"
|
|
@@ -11,10 +11,11 @@ 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, headers, idempotency, outbox, redrive, replay
|
|
14
|
+
from . import dead_letter, gdpr, 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 .gdpr import Cipher, protect, unprotect
|
|
18
19
|
from .headers import headers_from_context
|
|
19
20
|
from .idempotency import IdempotencyStore, InMemoryStore
|
|
20
21
|
from .outbox import (
|
|
@@ -25,12 +26,12 @@ from .outbox import (
|
|
|
25
26
|
OutboxRelayResult,
|
|
26
27
|
OutboxStore,
|
|
27
28
|
)
|
|
28
|
-
from .exceptions import BabelQueueError, UnknownUrnError
|
|
29
|
+
from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
|
|
29
30
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
30
31
|
from .routing import UnknownUrnStrategy
|
|
31
|
-
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
|
|
32
|
+
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Redeliverer, Transport
|
|
32
33
|
|
|
33
|
-
__version__ = "1.
|
|
34
|
+
__version__ = "1.14.0"
|
|
34
35
|
|
|
35
36
|
__all__ = [
|
|
36
37
|
"BabelQueue",
|
|
@@ -44,8 +45,14 @@ __all__ = [
|
|
|
44
45
|
"InMemoryTransport",
|
|
45
46
|
"ReceivedMessage",
|
|
46
47
|
"HeaderPublisher",
|
|
48
|
+
"Redeliverer",
|
|
47
49
|
"BabelQueueError",
|
|
48
50
|
"UnknownUrnError",
|
|
51
|
+
"DecryptError",
|
|
52
|
+
"gdpr",
|
|
53
|
+
"Cipher",
|
|
54
|
+
"protect",
|
|
55
|
+
"unprotect",
|
|
49
56
|
"dead_letter",
|
|
50
57
|
"headers",
|
|
51
58
|
"idempotency",
|