babelqueue 1.19.0__tar.gz → 1.20.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 (109) hide show
  1. {babelqueue-1.19.0 → babelqueue-1.20.0}/.github/workflows/ci.yml +1 -1
  2. {babelqueue-1.19.0 → babelqueue-1.20.0}/CHANGELOG.md +35 -0
  3. {babelqueue-1.19.0 → babelqueue-1.20.0}/PKG-INFO +24 -1
  4. {babelqueue-1.19.0 → babelqueue-1.20.0}/README.md +23 -0
  5. {babelqueue-1.19.0 → babelqueue-1.20.0}/pyproject.toml +1 -1
  6. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/__init__.py +1 -1
  7. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/_envelope_shape.py +3 -0
  8. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/app.py +9 -5
  9. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/codec.py +107 -32
  10. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/exceptions.py +15 -0
  11. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/pulsar_transport.py +10 -5
  12. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/sqs_transport.py +5 -2
  13. babelqueue-1.20.0/tests/conformance/CONFORMANCE_VERSION +1 -0
  14. babelqueue-1.20.0/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +16 -0
  15. babelqueue-1.20.0/tests/conformance/fixtures/dead-letter-roundtrip.json +23 -0
  16. babelqueue-1.20.0/tests/conformance/fixtures/forbidden-key-job-and-urn.json +16 -0
  17. babelqueue-1.20.0/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +16 -0
  18. babelqueue-1.20.0/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +27 -0
  19. babelqueue-1.20.0/tests/conformance/fixtures/urn-only-roundtrip.json +15 -0
  20. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/manifest.json +290 -2
  21. babelqueue-1.20.0/tests/test_behaviour_conformance.py +513 -0
  22. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_codec.py +5 -7
  23. babelqueue-1.20.0/tests/test_reencode_regression.py +186 -0
  24. babelqueue-1.19.0/tests/conformance/CONFORMANCE_VERSION +0 -1
  25. babelqueue-1.19.0/tests/test_behaviour_conformance.py +0 -195
  26. {babelqueue-1.19.0 → babelqueue-1.20.0}/.github/FUNDING.yml +0 -0
  27. {babelqueue-1.19.0 → babelqueue-1.20.0}/.github/dependabot.yml +0 -0
  28. {babelqueue-1.19.0 → babelqueue-1.20.0}/.github/workflows/release.yml +0 -0
  29. {babelqueue-1.19.0 → babelqueue-1.20.0}/.gitignore +0 -0
  30. {babelqueue-1.19.0 → babelqueue-1.20.0}/LICENSE +0 -0
  31. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/artemis_transport.py +0 -0
  32. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/asb_transport.py +0 -0
  33. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/celery.py +0 -0
  34. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/contracts.py +0 -0
  35. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/dead_letter.py +0 -0
  36. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/django/__init__.py +0 -0
  37. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/django/apps.py +0 -0
  38. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/django/management/__init__.py +0 -0
  39. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  40. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  41. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/gdpr.py +0 -0
  42. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/headers.py +0 -0
  43. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/idempotency.py +0 -0
  44. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/kafka_transport.py +0 -0
  45. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/otel.py +0 -0
  46. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/outbox.py +0 -0
  47. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/pika_transport.py +0 -0
  48. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/py.typed +0 -0
  49. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/redis_transport.py +0 -0
  50. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/redrive.py +0 -0
  51. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/replay.py +0 -0
  52. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/routing.py +0 -0
  53. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/schema.py +0 -0
  54. {babelqueue-1.19.0 → babelqueue-1.20.0}/src/babelqueue/transport.py +0 -0
  55. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  56. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  57. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
  58. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  59. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  60. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  61. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  62. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  63. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  64. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  65. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  66. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
  67. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/order-created.json +0 -0
  68. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  69. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  70. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  71. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  72. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  73. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
  74. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  75. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
  76. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  77. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/fixtures/dead-lettered.json +0 -0
  78. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/fixtures/order-created.json +0 -0
  79. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/poison-trigger-set.json +0 -0
  80. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_app.py +0 -0
  81. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_artemis_conformance.py +0 -0
  82. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_artemis_transport.py +0 -0
  83. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_asb_conformance.py +0 -0
  84. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_asb_transport.py +0 -0
  85. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_celery.py +0 -0
  86. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_conformance.py +0 -0
  87. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_dead_letter.py +0 -0
  88. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_django.py +0 -0
  89. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_gdpr.py +0 -0
  90. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_headers.py +0 -0
  91. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_idempotency.py +0 -0
  92. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_kafka_conformance.py +0 -0
  93. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_kafka_integration.py +0 -0
  94. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_kafka_integration_logic.py +0 -0
  95. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_kafka_transport.py +0 -0
  96. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_otel.py +0 -0
  97. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_outbox.py +0 -0
  98. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_overhead.py +0 -0
  99. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_pika_transport.py +0 -0
  100. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_pulsar_conformance.py +0 -0
  101. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_pulsar_transport.py +0 -0
  102. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_redis_transport.py +0 -0
  103. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_redrive.py +0 -0
  104. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_replay.py +0 -0
  105. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_schema.py +0 -0
  106. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_schema_conformance.py +0 -0
  107. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_shutdown.py +0 -0
  108. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_sqs_conformance.py +0 -0
  109. {babelqueue-1.19.0 → babelqueue-1.20.0}/tests/test_sqs_transport.py +0 -0
@@ -166,7 +166,7 @@ jobs:
166
166
  - uses: actions/checkout@v7
167
167
  - name: Verify vendored conformance matches the canonical suite
168
168
  run: |
169
- git clone --depth 1 --branch v1.2.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
169
+ git clone --depth 1 --branch v1.4.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
170
170
  diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
171
171
  diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
172
172
  diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
@@ -9,6 +9,41 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.20.0] - 2026-10-17
13
+
14
+ Conformance suite 1.4.0, [ADR-0031](https://babelqueue.com). The envelope is unchanged
15
+ (`schema_version: 1`); this is a MINOR with one deliberate behaviour change (encode rejects).
16
+
17
+ ### Added
18
+ - `EnvelopeCodec.make(urn, data, *, queue, trace_id, meta=None)`: a producer `meta` write API. The entries
19
+ land in `meta` next to the core keys (recommended shape `meta.ext.<vendor>`). A collision with a core
20
+ meta key or a forbidden key raises the new `EnvelopeKeyError` (subclass of `BabelQueueError`, with a
21
+ `pointer` attribute). `_envelope_shape.CORE_META_KEYS` is the single copy of the core key list.
22
+ - `decode` folds the `urn` alias into `job` (ADR-0031 (c)): a `job` that is missing, empty or only ASCII
23
+ whitespace (`" \t\n\v\f\r"`; a U+2003 `job` is NOT blank) falls back to `urn` silently; when both carry
24
+ a value, `job` wins and `urn` is dropped with a warning naming `/urn`. `accepts()` / `urn()` follow the
25
+ same rule. The decoded envelope never keeps a `urn` key.
26
+ - Tests: the runner now implements the 1.4.0 fields (`expect_preserved` on `forbidden_keys`;
27
+ `expect_absent_after_reencode`, `apply_dead_letter`, `expect_dead_letter` on `roundtrip`, the latter via
28
+ `dead_letter.annotate`) plus the `extension_write` and `encode_reject` sections, with a runner
29
+ self-test proving violations are reported.
30
+
31
+ ### Changed
32
+ - **Behaviour change: `EnvelopeCodec.encode` raises `EnvelopeKeyError`** for an envelope carrying a
33
+ forbidden key (message-envelope.md section 10) or a top-level `urn`, instead of dropping it with a
34
+ warning (policy K-15 (a)). Nothing is written. `decode` still warns and drops, so re-encoding a decoded
35
+ envelope is safe. Code that built an envelope from a raw legacy body and relied on encode to clean it
36
+ must decode it first (`EnvelopeCodec.encode(EnvelopeCodec.decode(raw))`).
37
+ - `test_all_five_contract_keys_are_covered` is a subset check (the suite adds `/urn` cases).
38
+ - Docs only: the note in the previous release that the Go reference's case-insensitive envelope-key read "is tightened separately" is now closed — Go core 1.14.0 reads envelope keys exactly (case-sensitive, last duplicate wins), so every SDK now decides poison on the same exact-case keys.
39
+
40
+ ### Fixed
41
+ - Raw-body re-encode paths now decode before they encode, so the new `encode` rejection cannot strand a
42
+ legacy body: the runtime's raw-body RELEASE re-publish (`app.py`), and the SQS and Pulsar attempts
43
+ reconciliation (Kafka, Azure Service Bus and Artemis already decoded first). A body with a legacy
44
+ `timestamp` or only a `urn` alias is re-emitted normalised (forbidden key dropped and warned, `urn`
45
+ folded into `job`); unknown top-level and `meta` keys, including `meta.ext`, are preserved.
46
+
12
47
  ## [1.19.0] - 2026-10-12
13
48
 
14
49
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.19.0
3
+ Version: 1.20.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
@@ -106,6 +106,29 @@ The envelope is identical to every other SDK's:
106
106
  }
107
107
  ```
108
108
 
109
+ ### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
110
+
111
+ ```python
112
+ # Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
113
+ envelope = EnvelopeCodec.make(
114
+ "urn:babel:orders:created", {"order_id": 1042},
115
+ queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
116
+ )
117
+ ```
118
+
119
+ - `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
120
+ `schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
121
+ raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
122
+ - Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
123
+ hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
124
+ - `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
125
+ `meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
126
+ alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
127
+ (it still warns and drops), so a decoded envelope can always be re-encoded.
128
+ - `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
129
+ `\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
130
+ with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
131
+
109
132
  ### Typed messages (optional)
110
133
 
111
134
  ```python
@@ -52,6 +52,29 @@ The envelope is identical to every other SDK's:
52
52
  }
53
53
  ```
54
54
 
55
+ ### Extension metadata, forbidden keys and the `urn` alias (1.20.0, ADR-0031)
56
+
57
+ ```python
58
+ # Producer meta: extra keys next to the core meta keys (recommended shape: meta.ext.<vendor>).
59
+ envelope = EnvelopeCodec.make(
60
+ "urn:babel:orders:created", {"order_id": 1042},
61
+ queue="orders", meta={"ext": {"acme": {"tier": "gold"}}},
62
+ )
63
+ ```
64
+
65
+ - `meta=` is keyword-only. A key that collides with a core meta key (`id`, `queue`, `lang`,
66
+ `schema_version`, `created_at`) or with a forbidden key (`max_retries`, `attempts`, `source`, `ts`)
67
+ raises `EnvelopeKeyError` (a `BabelQueueError`; its `pointer` names the key, e.g. `/meta/ts`).
68
+ - Unknown top-level and `meta` keys of an inbound message survive a decode -> `attempts + 1` -> encode
69
+ hop (retry, redrive, dead-letter), so a vendor `meta.ext` block is never lost.
70
+ - `EnvelopeCodec.encode` **rejects** an envelope that carries a forbidden key (top-level `timestamp`;
71
+ `meta.max_retries`, `meta.attempts`, `meta.source`, `meta.ts`) or the inbound-only top-level `urn`
72
+ alias: it raises `EnvelopeKeyError` instead of dropping the key with a warning. `decode` is unchanged
73
+ (it still warns and drops), so a decoded envelope can always be re-encoded.
74
+ - `decode` folds `urn` into `job`: a `job` that is missing, empty or only ASCII whitespace (space, `\t`,
75
+ `\n`, `\v`, `\f`, `\r`) falls back to `urn`; when both carry a value, `job` wins and `urn` is dropped
76
+ with a warning naming `/urn`. The decoded envelope never keeps a `urn` key.
77
+
55
78
  ### Typed messages (optional)
56
79
 
57
80
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.19.0"
7
+ version = "1.20.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"
@@ -41,7 +41,7 @@ from .transport import (
41
41
  Transport,
42
42
  )
43
43
 
44
- __version__ = "1.19.0"
44
+ __version__ = "1.20.0"
45
45
 
46
46
  __all__ = [
47
47
  "BabelQueue",
@@ -51,6 +51,9 @@ _META = {
51
51
  "schema_version": _INTEGER,
52
52
  "created_at": _INTEGER,
53
53
  }
54
+ #: The core ``meta`` keys of message-envelope.md section 4 (the single copy; the producer ``meta``
55
+ #: write API of the codec rejects a collision with any of them, ADR-0031 (b)).
56
+ CORE_META_KEYS = frozenset(_META)
54
57
  _DEAD_LETTER = {
55
58
  "reason": _STRING,
56
59
  "error": _STRING,
@@ -458,8 +458,9 @@ class BabelQueue:
458
458
  self._stop.wait(self._rewind_pause)
459
459
 
460
460
  def _release_unprocessed(self, received: ReceivedMessage) -> None:
461
- """Release a message whose handler never completed (shutdown): unchanged body, no
462
- backoff, and the runtime adds no ``attempts`` increment. A broker that counts
461
+ """Release a message whose handler never completed (shutdown): unchanged body
462
+ (normalised only if it carries a forbidden key or a ``urn`` alias), no backoff, and the
463
+ runtime adds no ``attempts`` increment. A broker that counts
463
464
  deliveries still counts it: on SQS the release bumps ``ApproximateReceiveCount``, so the
464
465
  redelivered envelope reads one more attempt (and a ``RedrivePolicy`` sees it too)."""
465
466
  self._release(received, received.body, 0.0)
@@ -578,12 +579,15 @@ _UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
578
579
 
579
580
  def _without_forbidden_keys(body: str) -> str:
580
581
  """``body`` unchanged, unless it carries a forbidden envelope key (message-envelope.md §10,
581
- e.g. from a legacy producer) — then it is re-encoded without it (warned by the codec), so a
582
+ e.g. from a legacy producer) or a ``urn`` alias — then it is re-encoded without it (warned by the codec), so a
582
583
  raw-body re-publish never re-emits a key the decode boundary reported as dropped."""
583
584
  parsed = parse_envelope(body)
584
- if not parsed or not has_forbidden_keys(parsed):
585
+ # ``urn`` is not a §10 key but ``encode`` rejects it and ADR-0031 (a)/(c) forbid re-emitting it.
586
+ if not parsed or not (has_forbidden_keys(parsed) or "urn" in parsed):
585
587
  return body
586
- return EnvelopeCodec.encode(parsed)
588
+ # ``decode`` (not the raw parse) so the dropped keys — and a top-level ``urn`` alias, folded
589
+ # into ``job`` — are gone before ``encode``, which rejects them (ADR-0031 (c)).
590
+ return EnvelopeCodec.encode(EnvelopeCodec.decode(body))
587
591
 
588
592
 
589
593
  def _handler_wants_envelope(fn: Handler) -> bool:
@@ -16,15 +16,17 @@ import time
16
16
  import uuid
17
17
  from typing import Any, Dict, List, Mapping, Optional, Tuple
18
18
 
19
- from .exceptions import BabelQueueError
19
+ from ._envelope_shape import CORE_META_KEYS
20
+ from .exceptions import BabelQueueError, EnvelopeKeyError
20
21
 
21
22
  SCHEMA_VERSION = 1
22
23
  SOURCE_LANG = "python"
23
24
 
24
25
  #: Non-canonical keys (message-envelope.md §10), as ``(container, key)`` pairs where the
25
26
  #: container is ``None`` for the top level or ``"meta"``. Policy K-15: decode WARNS and drops
26
- #: them (old producers keep working); encode NEVER emits them. Encode currently drops them with
27
- #: a warning; K-15 (a) / R1-E0 will turn that into a rejection (an error) in a future MINOR.
27
+ #: them (old producers keep working); encode NEVER emits them and REJECTS an envelope that
28
+ #: carries one (ADR-0031 (c)). The top-level ``urn`` alias is not in this table: it is an inbound
29
+ #: alias that decode folds into ``job`` (see :meth:`EnvelopeCodec.decode`), but encode rejects it.
28
30
  FORBIDDEN_KEYS: Tuple[Tuple[Optional[str], str], ...] = (
29
31
  (None, "timestamp"),
30
32
  ("meta", "max_retries"),
@@ -33,6 +35,10 @@ FORBIDDEN_KEYS: Tuple[Tuple[Optional[str], str], ...] = (
33
35
  ("meta", "ts"),
34
36
  )
35
37
 
38
+ #: The closed ASCII whitespace set that makes a ``job`` "blank" (cross-SDK parity, GR-5): never a
39
+ #: bare ``str.strip()``, which also strips Unicode spaces such as U+2003.
40
+ _ASCII_WHITESPACE = " \t\n\v\f\r"
41
+
36
42
  #: Logger every forbidden-key warning is reported through (one record per dropped key).
37
43
  logger = logging.getLogger("babelqueue.codec")
38
44
 
@@ -52,11 +58,7 @@ def strip_forbidden_keys(envelope: Dict[str, Any]) -> List[str]:
52
58
  del target[key]
53
59
  pointer = "/" + key if container is None else f"/{container}/{key}"
54
60
  dropped.append(pointer)
55
- logger.warning(
56
- "Dropped forbidden envelope key %s from the envelope "
57
- "(non-canonical, message-envelope.md §10).",
58
- pointer,
59
- )
61
+ _warn_dropped(pointer)
60
62
  return dropped
61
63
 
62
64
 
@@ -76,10 +78,42 @@ def parse_envelope(raw: Any) -> Dict[str, Any]:
76
78
 
77
79
 
78
80
  def has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
79
- """Whether ``envelope`` carries any of the non-canonical keys of message-envelope.md §10."""
81
+ """Whether ``envelope`` carries any of the non-canonical keys of message-envelope.md §10.
82
+
83
+ The top-level ``urn`` alias is NOT included (it is not a §10 key: decode folds it into ``job``),
84
+ although ``encode`` also rejects it — a caller deciding whether a raw body needs normalising
85
+ before re-emit must check ``"urn" in envelope`` as well.
86
+ """
80
87
  return _has_forbidden_keys(envelope)
81
88
 
82
89
 
90
+ def _is_blank(value: Any) -> bool:
91
+ """Missing, ``None``, empty or only ASCII whitespace; a falsy non-string (``0``, ``false``,
92
+ ``[]``, ``{}``) is blank too, as in ``job or urn``. A truthy non-string is not blank."""
93
+ if isinstance(value, str):
94
+ return value.strip(_ASCII_WHITESPACE) == ""
95
+ return not value
96
+
97
+
98
+ def _warn_dropped(pointer: str) -> None:
99
+ logger.warning(
100
+ "Dropped forbidden envelope key %s from the envelope "
101
+ "(non-canonical, message-envelope.md §10).",
102
+ pointer,
103
+ )
104
+
105
+
106
+ def _first_encode_violation(envelope: Mapping[str, Any]) -> Optional[str]:
107
+ """RFC 6901 pointer of the first key encode refuses to write, or ``None``."""
108
+ for container, key in FORBIDDEN_KEYS:
109
+ target = envelope if container is None else envelope.get(container)
110
+ if isinstance(target, Mapping) and key in target:
111
+ return "/" + key if container is None else f"/{container}/{key}"
112
+ if "urn" in envelope:
113
+ return "/urn"
114
+ return None
115
+
116
+
83
117
  def _has_forbidden_keys(envelope: Mapping[str, Any]) -> bool:
84
118
  for container, key in FORBIDDEN_KEYS:
85
119
  target = envelope if container is None else envelope.get(container)
@@ -101,12 +135,18 @@ class EnvelopeCodec:
101
135
  *,
102
136
  queue: str = "default",
103
137
  trace_id: Optional[str] = None,
138
+ meta: Optional[Mapping[str, Any]] = None,
104
139
  ) -> Dict[str, Any]:
105
140
  """Build the canonical envelope for a ``(urn, data)`` pair.
106
141
 
107
142
  ``trace_id`` is reused when given (trace continuation), otherwise a fresh
108
143
  UUID is minted. ``attempts`` starts at 0 and is the top-level transport
109
144
  counter (kept out of the immutable ``meta`` block).
145
+
146
+ ``meta`` (ADR-0031 (b)) adds producer-supplied entries (e.g. ``{"ext": {"acme": 1}}``) to
147
+ the ``meta`` block next to the core keys. An entry whose key is a core ``meta`` key
148
+ (``id``, ``queue``, ``lang``, ``schema_version``, ``created_at``) or a forbidden ``meta``
149
+ key (message-envelope.md §10) raises :class:`~babelqueue.exceptions.EnvelopeKeyError`.
110
150
  """
111
151
  urn = (urn or "").strip()
112
152
  if not urn:
@@ -117,19 +157,36 @@ class EnvelopeCodec:
117
157
 
118
158
  trace_id = (trace_id or "").strip() or str(uuid.uuid4())
119
159
 
120
- return {
160
+ extra: Dict[str, Any] = {}
161
+ if meta is not None:
162
+ if not isinstance(meta, Mapping):
163
+ raise BabelQueueError("The producer meta must be a mapping of string keys to values.")
164
+ forbidden_meta = {key for container, key in FORBIDDEN_KEYS if container == "meta"}
165
+ for key, value in meta.items():
166
+ if key in CORE_META_KEYS or key in forbidden_meta:
167
+ raise EnvelopeKeyError(
168
+ f"/meta/{key}",
169
+ f"The producer meta key /meta/{key} is reserved or forbidden "
170
+ "(core meta keys and message-envelope.md §10 keys cannot be set).",
171
+ )
172
+ extra[key] = value
173
+
174
+ meta_block: Dict[str, Any] = {
175
+ "id": str(uuid.uuid4()),
176
+ "queue": queue,
177
+ "lang": SOURCE_LANG,
178
+ "schema_version": SCHEMA_VERSION,
179
+ "created_at": int(time.time() * 1000),
180
+ }
181
+ meta_block.update(extra)
182
+ envelope: Dict[str, Any] = {
121
183
  "job": urn,
122
184
  "trace_id": trace_id,
123
185
  "data": dict(data),
124
- "meta": {
125
- "id": str(uuid.uuid4()),
126
- "queue": queue,
127
- "lang": SOURCE_LANG,
128
- "schema_version": SCHEMA_VERSION,
129
- "created_at": int(time.time() * 1000),
130
- },
186
+ "meta": meta_block,
131
187
  "attempts": 0,
132
188
  }
189
+ return envelope
133
190
 
134
191
  @staticmethod
135
192
  def from_message(message: Any, queue: str = "default") -> Dict[str, Any]:
@@ -152,20 +209,20 @@ class EnvelopeCodec:
152
209
  def encode(envelope: Mapping[str, Any]) -> str:
153
210
  """Encode the envelope as compact UTF-8 JSON (unescaped unicode/slashes).
154
211
 
155
- Forbidden keys (message-envelope.md §10) are never emitted: if the mapping carries any,
156
- they are dropped (with a warning) from a copy — the caller's mapping is not mutated.
157
-
158
- Transitional: K-15 (a) asks encode to *reject* forbidden keys. Dropping is the interim
159
- behaviour until R1-E0, when this call will raise instead — do not rely on encode to
160
- clean an envelope; strip it yourself (:func:`strip_forbidden_keys`) if you build one
161
- from a legacy source.
212
+ Forbidden keys (message-envelope.md §10) are never emitted: an envelope that carries one
213
+ (top-level ``timestamp``; ``meta.max_retries`` / ``meta.attempts`` / ``meta.source`` /
214
+ ``meta.ts``) or the inbound-only top-level ``urn`` alias raises
215
+ :class:`~babelqueue.exceptions.EnvelopeKeyError` (ADR-0031 (c)) and nothing is written.
216
+ Envelopes produced by :meth:`decode` are always safe to re-encode: decode already drops
217
+ those keys. Unknown, non-forbidden keys are kept as-is.
162
218
  """
163
- if _has_forbidden_keys(envelope):
164
- copy = dict(envelope)
165
- if isinstance(copy.get("meta"), Mapping):
166
- copy["meta"] = dict(copy["meta"])
167
- strip_forbidden_keys(copy)
168
- envelope = copy
219
+ pointer = _first_encode_violation(envelope)
220
+ if pointer is not None:
221
+ raise EnvelopeKeyError(
222
+ pointer,
223
+ f"Refusing to encode the forbidden envelope key {pointer} "
224
+ "(non-canonical, message-envelope.md §10).",
225
+ )
169
226
  return json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
170
227
 
171
228
  @staticmethod
@@ -175,15 +232,33 @@ class EnvelopeCodec:
175
232
  Unknown keys are kept as-is (forward compatibility). Forbidden keys
176
233
  (message-envelope.md §10) are dropped and reported as warnings on :data:`logger`
177
234
  (policy K-15: warn, never reject), so re-encoding never re-emits them.
235
+
236
+ The ``urn`` alias is folded into ``job`` (ADR-0031 (c)): when ``job`` is missing, empty or
237
+ only ASCII whitespace, the ``urn`` value becomes ``job`` (silently); when both carry a
238
+ value, ``job`` wins and ``urn`` is dropped with a warning naming ``/urn``. The decoded
239
+ envelope never keeps a ``urn`` key.
178
240
  """
179
241
  decoded = parse_envelope(raw)
180
242
  strip_forbidden_keys(decoded)
243
+ if "urn" in decoded:
244
+ if _is_blank(decoded.get("job")):
245
+ decoded["job"] = decoded.pop("urn")
246
+ else:
247
+ del decoded["urn"]
248
+ _warn_dropped("/urn")
181
249
  return decoded
182
250
 
183
251
  @staticmethod
184
252
  def urn(envelope: Mapping[str, Any]) -> str:
185
- """The message URN: canonical ``job``, with ``urn`` accepted as an alias."""
186
- return str(envelope.get("job") or envelope.get("urn") or "")
253
+ """The message URN: canonical ``job``, with ``urn`` accepted as an alias.
254
+
255
+ A blank ``job`` (missing, empty or only ASCII whitespace) falls back to ``urn``.
256
+ """
257
+ job = envelope.get("job")
258
+ if isinstance(job, str) and not _is_blank(job):
259
+ return job
260
+ alias = envelope.get("urn")
261
+ return alias if isinstance(alias, str) and not _is_blank(alias) else ""
187
262
 
188
263
  @staticmethod
189
264
  def accepts(envelope: Mapping[str, Any]) -> bool:
@@ -7,6 +7,21 @@ class BabelQueueError(Exception):
7
7
  """Base for every recoverable BabelQueue error (bad config, empty URN, ...)."""
8
8
 
9
9
 
10
+ class EnvelopeKeyError(BabelQueueError):
11
+ """An envelope key the codec refuses to write (ADR-0031 (b), (c)).
12
+
13
+ Raised by :meth:`~babelqueue.codec.EnvelopeCodec.make` when the ``meta`` entries collide with a
14
+ core ``meta`` key (``id``, ``queue``, ``lang``, ``schema_version``, ``created_at``) or carry a
15
+ forbidden key, and by :meth:`~babelqueue.codec.EnvelopeCodec.encode` when the envelope holds a
16
+ forbidden key of message-envelope.md section 10 (including the top-level ``urn`` alias).
17
+ ``pointer`` is the RFC 6901 pointer of the first offending key (e.g. ``"/meta/ts"``).
18
+ """
19
+
20
+ def __init__(self, pointer: str, message: str) -> None:
21
+ super().__init__(message)
22
+ self.pointer = pointer
23
+
24
+
10
25
  class UnknownUrnError(BabelQueueError):
11
26
  """A consumed message carries a URN with no mapped handler (strategy "fail")."""
12
27
 
@@ -241,7 +241,7 @@ class PulsarTransport(Transport):
241
241
 
242
242
  * Routing: a non-blank ``bq-job`` property is the URN the app routes on. It is returned
243
243
  as ``route_urn`` (set on :attr:`ReceivedMessage.route_urn`); the body's ``job`` /
244
- ``urn`` is never rewritten, so the handler, retries and the dead-letter record keep the
244
+ ``urn`` is never rewritten for routing, so the handler, retries and the dead-letter record keep the
245
245
  producer's original body. A body with no URN gets no ``route_urn`` — it stays
246
246
  non-conformant and is rejected whatever ``bq-job`` says.
247
247
  * Attempts: a valid ``bq-attempts`` property replaces the body's value (the body's is
@@ -254,8 +254,9 @@ class PulsarTransport(Transport):
254
254
  still rejected by ``EnvelopeCodec.accepts``.
255
255
  * ``on_routing_mismatch(property_urn, body_urn, envelope)`` is called when the property
256
256
  and the body name different URNs — only for an envelope the consumer accepts and after
257
- attempts are reconciled (as in the Java and .NET consumers), so it sees what the
258
- handler will get. The hook is optional, separate from any error handling, and any
257
+ attempts are reconciled (as in the Java and .NET consumers). It receives the raw
258
+ parsed body, not the decoded envelope the handler gets, so a ``urn`` alias or a
259
+ forbidden key the decode would drop is still present. The hook is optional, separate from any error handling, and any
259
260
  exception it raises is swallowed — it never affects the message flow.
260
261
  """
261
262
  properties = properties or {}
@@ -282,8 +283,12 @@ class PulsarTransport(Transport):
282
283
 
283
284
  out = body
284
285
  if has_int_attempts and current != attempts:
285
- env["attempts"] = attempts
286
- out = EnvelopeCodec.encode(env)
286
+ # Re-encode from the *decoded* body: ``encode`` rejects forbidden keys / a ``urn`` alias,
287
+ # which the raw parse above keeps (ADR-0031).
288
+ env["attempts"] = attempts # the routing-mismatch hook below sees the reconciled value
289
+ reencoded = EnvelopeCodec.decode(body)
290
+ reencoded["attempts"] = attempts
291
+ out = EnvelopeCodec.encode(reencoded)
287
292
 
288
293
  if route_urn and route_urn != body_urn and on_routing_mismatch is not None:
289
294
  if EnvelopeCodec.accepts(env):
@@ -213,8 +213,11 @@ class SqsTransport(Transport):
213
213
  native = rc - 1
214
214
  if native <= int(env.get("attempts", 0)):
215
215
  return body
216
- env["attempts"] = native
217
- return EnvelopeCodec.encode(env)
216
+ # Re-encode from the *decoded* body: ``encode`` rejects forbidden keys / a ``urn`` alias,
217
+ # which the raw parse above keeps (ADR-0031).
218
+ reencoded = EnvelopeCodec.decode(body)
219
+ reencoded["attempts"] = native
220
+ return EnvelopeCodec.encode(reencoded)
218
221
 
219
222
  # -- Transport ----------------------------------------------------------
220
223
 
@@ -0,0 +1,16 @@
1
+ {
2
+ "job": " ",
3
+ "urn": "urn:acme:b",
4
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
5
+ "data": {
6
+ "order_id": 1042
7
+ },
8
+ "meta": {
9
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
10
+ "queue": "orders",
11
+ "lang": "php",
12
+ "schema_version": 1,
13
+ "created_at": 1749132727000
14
+ },
15
+ "attempts": 0
16
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "job": "urn:acme:a",
3
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
4
+ "data": {
5
+ "order_id": 1042
6
+ },
7
+ "meta": {
8
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
9
+ "queue": "orders",
10
+ "lang": "php",
11
+ "schema_version": 1,
12
+ "created_at": 1749132727000,
13
+ "vendor_flag": true,
14
+ "ext": {
15
+ "acme": {
16
+ "tier": "gold",
17
+ "flags": [1, 2]
18
+ }
19
+ }
20
+ },
21
+ "extra_top": 1,
22
+ "attempts": 2
23
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "job": "urn:acme:a",
3
+ "urn": "urn:acme:b",
4
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
5
+ "data": {
6
+ "order_id": 1042
7
+ },
8
+ "meta": {
9
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
10
+ "queue": "orders",
11
+ "lang": "php",
12
+ "schema_version": 1,
13
+ "created_at": 1749132727000
14
+ },
15
+ "attempts": 0
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "job": " ",
3
+ "urn": "urn:acme:b",
4
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
5
+ "data": {
6
+ "order_id": 1042
7
+ },
8
+ "meta": {
9
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
10
+ "queue": "orders",
11
+ "lang": "php",
12
+ "schema_version": 1,
13
+ "created_at": 1749132727000
14
+ },
15
+ "attempts": 0
16
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "job": "urn:acme:a",
3
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
4
+ "data": {
5
+ "order_id": 1042
6
+ },
7
+ "meta": {
8
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
9
+ "queue": "orders",
10
+ "lang": "php",
11
+ "schema_version": 1,
12
+ "created_at": 1749132727000,
13
+ "ext": {
14
+ "acme": {
15
+ "tier": "gold",
16
+ "flags": [1, 2],
17
+ "limits": {
18
+ "burst": 10
19
+ }
20
+ },
21
+ "globex": {
22
+ "region": "eu-west-1"
23
+ }
24
+ }
25
+ },
26
+ "attempts": 0
27
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "urn": "urn:acme:b",
3
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
4
+ "data": {
5
+ "order_id": 1042
6
+ },
7
+ "meta": {
8
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
9
+ "queue": "orders",
10
+ "lang": "php",
11
+ "schema_version": 1,
12
+ "created_at": 1749132727000
13
+ },
14
+ "attempts": 0
15
+ }