babelqueue 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {babelqueue-0.2.0 → babelqueue-0.4.0}/.github/workflows/ci.yml +13 -3
  2. {babelqueue-0.2.0 → babelqueue-0.4.0}/CHANGELOG.md +25 -3
  3. {babelqueue-0.2.0 → babelqueue-0.4.0}/PKG-INFO +12 -9
  4. {babelqueue-0.2.0 → babelqueue-0.4.0}/README.md +11 -8
  5. {babelqueue-0.2.0 → babelqueue-0.4.0}/pyproject.toml +1 -1
  6. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/__init__.py +1 -1
  7. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/codec.py +33 -0
  8. babelqueue-0.4.0/src/babelqueue/pika_transport.py +109 -0
  9. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/transport.py +6 -2
  10. babelqueue-0.4.0/tests/conformance/fixtures/invalid-missing-urn.json +14 -0
  11. babelqueue-0.4.0/tests/conformance/fixtures/invalid-unknown-schema-version.json +15 -0
  12. babelqueue-0.4.0/tests/conformance/fixtures/unicode-and-numbers.json +20 -0
  13. babelqueue-0.4.0/tests/conformance/fixtures/urn-alias.json +15 -0
  14. babelqueue-0.4.0/tests/conformance/manifest.json +71 -0
  15. babelqueue-0.4.0/tests/conformance/schema/message-envelope.schema.json +110 -0
  16. babelqueue-0.4.0/tests/fixtures/dead-lettered.json +24 -0
  17. babelqueue-0.4.0/tests/fixtures/order-created.json +15 -0
  18. babelqueue-0.4.0/tests/test_conformance.py +58 -0
  19. babelqueue-0.4.0/tests/test_pika_transport.py +98 -0
  20. {babelqueue-0.2.0 → babelqueue-0.4.0}/.github/workflows/release.yml +0 -0
  21. {babelqueue-0.2.0 → babelqueue-0.4.0}/.gitignore +0 -0
  22. {babelqueue-0.2.0 → babelqueue-0.4.0}/LICENSE +0 -0
  23. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/app.py +0 -0
  24. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/contracts.py +0 -0
  25. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/dead_letter.py +0 -0
  26. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/exceptions.py +0 -0
  27. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/py.typed +0 -0
  28. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/redis_transport.py +0 -0
  29. {babelqueue-0.2.0 → babelqueue-0.4.0}/src/babelqueue/routing.py +0 -0
  30. {babelqueue-0.2.0/tests → babelqueue-0.4.0/tests/conformance}/fixtures/dead-lettered.json +0 -0
  31. {babelqueue-0.2.0/tests → babelqueue-0.4.0/tests/conformance}/fixtures/order-created.json +0 -0
  32. {babelqueue-0.2.0 → babelqueue-0.4.0}/tests/test_app.py +0 -0
  33. {babelqueue-0.2.0 → babelqueue-0.4.0}/tests/test_codec.py +0 -0
  34. {babelqueue-0.2.0 → babelqueue-0.4.0}/tests/test_dead_letter.py +0 -0
  35. {babelqueue-0.2.0 → babelqueue-0.4.0}/tests/test_redis_transport.py +0 -0
@@ -45,6 +45,15 @@ jobs:
45
45
  --health-interval 5s
46
46
  --health-timeout 3s
47
47
  --health-retries 10
48
+ rabbitmq:
49
+ image: rabbitmq:3
50
+ ports:
51
+ - 5672:5672
52
+ options: >-
53
+ --health-cmd "rabbitmq-diagnostics -q ping"
54
+ --health-interval 10s
55
+ --health-timeout 5s
56
+ --health-retries 15
48
57
  steps:
49
58
  - uses: actions/checkout@v4
50
59
 
@@ -53,12 +62,13 @@ jobs:
53
62
  with:
54
63
  python-version: '3.12'
55
64
 
56
- - name: Install (with redis extra)
65
+ - name: Install (with redis + amqp extras)
57
66
  run: |
58
67
  python -m pip install --upgrade pip
59
- pip install -e ".[redis,dev]"
68
+ pip install -e ".[redis,amqp,dev]"
60
69
 
61
- - name: Run tests (Redis transport included)
70
+ - name: Run tests (Redis + RabbitMQ transports included)
62
71
  env:
63
72
  BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
73
+ BABELQUEUE_TEST_AMQP: amqp://guest:guest@localhost:5672/
64
74
  run: pytest
@@ -9,6 +9,25 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.4.0] - 2026-06-06
13
+
14
+ ### Added
15
+ - `EnvelopeCodec.urn()` — resolve the URN (`job`, accepting `urn` as an alias).
16
+ - `EnvelopeCodec.accepts()` — consumer-side envelope validation (rejects empty URN,
17
+ unsupported `meta.schema_version`, blank `trace_id`, non-object `data`).
18
+ - Shared **cross-SDK conformance suite** under `tests/conformance/` (vendored from
19
+ the canonical `conformance/` set) plus a `test_conformance.py` runner.
20
+
21
+ ## [0.3.0] - 2026-06-06
22
+
23
+ ### Added
24
+ - **RabbitMQ transport** (`PikaTransport`, `amqp://`): durable queue, persistent
25
+ delivery, `basic_get` + manual ack, and the contract AMQP properties (`type`=URN,
26
+ `correlation_id`=trace_id, `x-schema-version`/`x-source-lang`/`x-attempts`).
27
+ Optional `[amqp]` extra (lazy `pika` import) — the core stays zero-dep.
28
+
29
+ ## [0.2.0] - 2026-06-06
30
+
12
31
  ### Added
13
32
  - **Runtime** — `BabelQueue(broker_url=...)` app with a `@app.handler("urn:...")`
14
33
  decorator, `publish()`, and a `consume()` / `run()` loop. Routes by URN over the
@@ -16,8 +35,8 @@ The envelope wire format is versioned separately by `meta.schema_version`
16
35
  `on_unknown_urn` strategies (`fail`/`delete`/`release`/`dead_letter`).
17
36
  - **Transports** — a pluggable `Transport` abstraction with `InMemoryTransport`
18
37
  (`memory://`, for tests/local) and `RedisTransport` (`redis://`, reliable-queue
19
- pattern via `BLMOVE` + a processing list). Redis client is an optional extra
20
- (`pip install "babelqueue[redis]"`), imported lazily — the core stays zero-dep.
38
+ pattern via `BLMOVE` + a processing list). Redis client is an optional `[redis]`
39
+ extra, imported lazily — the core stays zero-dep.
21
40
 
22
41
  ## [0.1.0] - 2026-06-06
23
42
 
@@ -36,5 +55,8 @@ The envelope wire format is versioned separately by `meta.schema_version`
36
55
  - Pre-1.0: the public API may change before the `1.0.0` tag.
37
56
  - The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
38
57
 
39
- [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v0.1.0...HEAD
58
+ [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v0.4.0...HEAD
59
+ [0.4.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.3.0...v0.4.0
60
+ [0.3.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.2.0...v0.3.0
61
+ [0.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.1.0...v0.2.0
40
62
  [0.1.0]: https://github.com/BabelQueue/babelqueue-python/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -114,17 +114,19 @@ dlq = dead_letter.annotate(envelope, "failed", "orders", attempts=3, error="boom
114
114
 
115
115
  ## Runtime — produce & consume
116
116
 
117
- For an end-to-end app, use `BabelQueue` with a broker. Redis support comes via an
118
- extra:
117
+ For an end-to-end app, use `BabelQueue` with a broker. Broker clients come via
118
+ extras:
119
119
 
120
120
  ```bash
121
- pip install "babelqueue[redis]"
121
+ pip install "babelqueue[redis]" # redis://
122
+ pip install "babelqueue[amqp]" # amqp:// (RabbitMQ)
122
123
  ```
123
124
 
124
125
  ```python
125
126
  from babelqueue import BabelQueue
126
127
 
127
128
  app = BabelQueue("redis://localhost:6379/0", queue="orders")
129
+ # or: BabelQueue("amqp://guest:guest@localhost:5672/", queue="orders")
128
130
 
129
131
  @app.handler("urn:babel:orders:created")
130
132
  def on_order_created(data, meta): # AI/ML, data processing, anything
@@ -144,16 +146,17 @@ app.run() # consume forever (Ctrl-C to stop)
144
146
  - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
145
147
  envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
146
148
  messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
147
- - **Transports:** `redis://` (reliable-queue pattern) and `memory://` (in-process,
148
- great for tests/local). Bring your own by passing `transport=...`.
149
+ - **Transports:** `redis://` (reliable-queue pattern), `amqp://` (RabbitMQ via
150
+ `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
151
+ tests/local). Bring your own by passing `transport=...`.
149
152
 
150
- > RabbitMQ (`pika`) and **Celery** / **Django** adapters are the next iterations.
153
+ > **Celery** / **Django** adapters are the next iterations.
151
154
 
152
155
  ## What's here
153
156
 
154
157
  The codec/contracts/dead-letter (zero-dep core) **and** the `BabelQueue` runtime
155
- above (in-memory built in; Redis via the `[redis]` extra). For framework
156
- integration, the Celery and Django adapters are planned.
158
+ above (in-memory built in; Redis via `[redis]`, RabbitMQ via `[amqp]`). For
159
+ framework integration, the Celery and Django adapters are planned.
157
160
 
158
161
  ## Testing
159
162
 
@@ -82,17 +82,19 @@ dlq = dead_letter.annotate(envelope, "failed", "orders", attempts=3, error="boom
82
82
 
83
83
  ## Runtime — produce & consume
84
84
 
85
- For an end-to-end app, use `BabelQueue` with a broker. Redis support comes via an
86
- extra:
85
+ For an end-to-end app, use `BabelQueue` with a broker. Broker clients come via
86
+ extras:
87
87
 
88
88
  ```bash
89
- pip install "babelqueue[redis]"
89
+ pip install "babelqueue[redis]" # redis://
90
+ pip install "babelqueue[amqp]" # amqp:// (RabbitMQ)
90
91
  ```
91
92
 
92
93
  ```python
93
94
  from babelqueue import BabelQueue
94
95
 
95
96
  app = BabelQueue("redis://localhost:6379/0", queue="orders")
97
+ # or: BabelQueue("amqp://guest:guest@localhost:5672/", queue="orders")
96
98
 
97
99
  @app.handler("urn:babel:orders:created")
98
100
  def on_order_created(data, meta): # AI/ML, data processing, anything
@@ -112,16 +114,17 @@ app.run() # consume forever (Ctrl-C to stop)
112
114
  - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
113
115
  envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
114
116
  messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
115
- - **Transports:** `redis://` (reliable-queue pattern) and `memory://` (in-process,
116
- great for tests/local). Bring your own by passing `transport=...`.
117
+ - **Transports:** `redis://` (reliable-queue pattern), `amqp://` (RabbitMQ via
118
+ `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
119
+ tests/local). Bring your own by passing `transport=...`.
117
120
 
118
- > RabbitMQ (`pika`) and **Celery** / **Django** adapters are the next iterations.
121
+ > **Celery** / **Django** adapters are the next iterations.
119
122
 
120
123
  ## What's here
121
124
 
122
125
  The codec/contracts/dead-letter (zero-dep core) **and** the `BabelQueue` runtime
123
- above (in-memory built in; Redis via the `[redis]` extra). For framework
124
- integration, the Celery and Django adapters are planned.
126
+ above (in-memory built in; Redis via `[redis]`, RabbitMQ via `[amqp]`). For
127
+ framework integration, the Celery and Django adapters are planned.
125
128
 
126
129
  ## Testing
127
130
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -19,7 +19,7 @@ from .exceptions import BabelQueueError, UnknownUrnError
19
19
  from .routing import UnknownUrnStrategy
20
20
  from .transport import InMemoryTransport, ReceivedMessage, Transport
21
21
 
22
- __version__ = "0.2.0"
22
+ __version__ = "0.4.0"
23
23
 
24
24
  __all__ = [
25
25
  "BabelQueue",
@@ -94,3 +94,36 @@ class EnvelopeCodec:
94
94
  except (ValueError, TypeError):
95
95
  return {}
96
96
  return decoded if isinstance(decoded, dict) else {}
97
+
98
+ @staticmethod
99
+ def urn(envelope: Mapping[str, Any]) -> str:
100
+ """The message URN: canonical ``job``, with ``urn`` accepted as an alias."""
101
+ return str(envelope.get("job") or envelope.get("urn") or "")
102
+
103
+ @staticmethod
104
+ def accepts(envelope: Mapping[str, Any]) -> bool:
105
+ """Whether a consumer should accept this envelope (consumer-side validation).
106
+
107
+ Rejects messages with no URN, an unsupported ``meta.schema_version``, a
108
+ missing/blank ``trace_id``, or a non-object ``data`` / non-integer
109
+ ``attempts``. (Accepts the ``urn`` alias, unlike the producer JSON Schema.)
110
+ """
111
+ if EnvelopeCodec.urn(envelope) == "":
112
+ return False
113
+
114
+ meta = envelope.get("meta")
115
+ if not isinstance(meta, dict) or meta.get("schema_version") != SCHEMA_VERSION:
116
+ return False
117
+
118
+ if not isinstance(envelope.get("data"), dict):
119
+ return False
120
+
121
+ attempts = envelope.get("attempts")
122
+ if not isinstance(attempts, int) or isinstance(attempts, bool):
123
+ return False
124
+
125
+ trace_id = envelope.get("trace_id")
126
+ if not isinstance(trace_id, str) or trace_id == "":
127
+ return False
128
+
129
+ return True
@@ -0,0 +1,109 @@
1
+ """RabbitMQ transport over AMQP 0-9-1. Requires the ``amqp`` extra:
2
+
3
+ pip install "babelqueue[amqp]"
4
+
5
+ Producing publishes the envelope to a durable queue with persistent delivery and
6
+ the AMQP properties that are part of the cross-language contract (``type`` = URN,
7
+ ``correlation_id`` = trace_id, ``message_id`` = meta.id, ``x-schema-version`` /
8
+ ``x-source-lang`` / ``x-attempts`` headers) — so a Go/PHP consumer can route on
9
+ ``properties.type`` without parsing the body. Consuming uses ``basic_get`` + manual
10
+ ack (at-least-once), matching the PHP RabbitMQ driver.
11
+
12
+ Connection is lazy; it (re)connects on first use and after a drop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any, Dict, Optional
19
+
20
+ from .transport import ReceivedMessage, Transport
21
+
22
+
23
+ class PikaTransport(Transport):
24
+ def __init__(self, url: str) -> None:
25
+ try:
26
+ import pika
27
+ except ImportError as exc: # pragma: no cover - import guard
28
+ raise ImportError(
29
+ "PikaTransport requires the 'pika' package. Install with "
30
+ 'pip install "babelqueue[amqp]".'
31
+ ) from exc
32
+
33
+ self._pika = pika
34
+ self._url = url
35
+ self._connection: Any = None
36
+ self._channel: Any = None
37
+ self._declared: set[str] = set()
38
+
39
+ # -- connection / topology ---------------------------------------------
40
+
41
+ def _chan(self) -> Any:
42
+ if self._connection is None or self._connection.is_closed:
43
+ self._connection = self._pika.BlockingConnection(self._pika.URLParameters(self._url))
44
+ self._channel = None
45
+ self._declared.clear()
46
+ if self._channel is None or self._channel.is_closed:
47
+ self._channel = self._connection.channel()
48
+ return self._channel
49
+
50
+ def _declare(self, queue: str) -> None:
51
+ if queue not in self._declared:
52
+ self._chan().queue_declare(queue=queue, durable=True)
53
+ self._declared.add(queue)
54
+
55
+ def _properties(self, body: str) -> Any:
56
+ """AMQP properties derived from the envelope (part of the wire contract)."""
57
+ try:
58
+ envelope: Dict[str, Any] = json.loads(body)
59
+ except (ValueError, TypeError):
60
+ return self._pika.BasicProperties(content_type="application/json", delivery_mode=2)
61
+
62
+ meta = envelope.get("meta") or {}
63
+ headers = {
64
+ "x-schema-version": meta.get("schema_version"),
65
+ "x-source-lang": meta.get("lang"),
66
+ "x-attempts": envelope.get("attempts", 0),
67
+ }
68
+ return self._pika.BasicProperties(
69
+ content_type="application/json",
70
+ content_encoding="utf-8",
71
+ delivery_mode=2, # persistent
72
+ message_id=meta.get("id"),
73
+ correlation_id=envelope.get("trace_id"),
74
+ type=envelope.get("job"),
75
+ app_id="babelqueue",
76
+ headers={k: v for k, v in headers.items() if v is not None},
77
+ )
78
+
79
+ # -- Transport ----------------------------------------------------------
80
+
81
+ def publish(self, queue: str, body: str) -> None:
82
+ self._declare(queue)
83
+ self._chan().basic_publish(
84
+ exchange="",
85
+ routing_key=queue,
86
+ body=body.encode("utf-8"),
87
+ properties=self._properties(body),
88
+ )
89
+
90
+ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
91
+ self._declare(queue)
92
+ method, _props, body = self._chan().basic_get(queue=queue, auto_ack=False)
93
+ if method is None:
94
+ # Nothing ready — sleep (heartbeat-safe) so the caller doesn't busy-loop.
95
+ if timeout and timeout > 0:
96
+ self._connection.sleep(timeout)
97
+ return None
98
+ text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else str(body)
99
+ return ReceivedMessage(body=text, queue=queue, handle=method.delivery_tag)
100
+
101
+ def ack(self, message: ReceivedMessage) -> None:
102
+ self._chan().basic_ack(delivery_tag=message.handle)
103
+
104
+ def close(self) -> None: # pragma: no cover
105
+ try:
106
+ if self._connection is not None and self._connection.is_open:
107
+ self._connection.close()
108
+ except Exception:
109
+ pass
@@ -75,8 +75,12 @@ def make_transport(broker_url: str) -> Transport:
75
75
  from .redis_transport import RedisTransport
76
76
 
77
77
  return RedisTransport(broker_url)
78
+ if scheme in ("amqp", "amqps"):
79
+ from .pika_transport import PikaTransport
80
+
81
+ return PikaTransport(broker_url)
78
82
 
79
83
  raise BabelQueueError(
80
- f"Unsupported broker scheme {scheme!r}. Use 'memory://' or 'redis://', "
81
- "or pass your own Transport via BabelQueue(transport=...)."
84
+ f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://' or "
85
+ "'amqp://', or pass your own Transport via BabelQueue(transport=...)."
82
86
  )
@@ -0,0 +1,14 @@
1
+ {
2
+ "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
3
+ "data": {
4
+ "order_id": 1042
5
+ },
6
+ "meta": {
7
+ "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
8
+ "queue": "orders",
9
+ "lang": "php",
10
+ "schema_version": 1,
11
+ "created_at": 1749132727000
12
+ },
13
+ "attempts": 0
14
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "job": "urn:babel:orders:created",
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": 2,
12
+ "created_at": 1749132727000
13
+ },
14
+ "attempts": 0
15
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "job": "urn:babel:catalog:item.indexed",
3
+ "trace_id": "3f7a1d2e-9b4c-4a8d-bc1e-0f5a6b7c8d90",
4
+ "data": {
5
+ "title": "Café — naïve ☕",
6
+ "qty": 7,
7
+ "price_cents": 1299,
8
+ "ratio": 0.5,
9
+ "active": true,
10
+ "note": null
11
+ },
12
+ "meta": {
13
+ "id": "b2c3d4e5-f607-4890-a1b2-c3d4e5f60718",
14
+ "queue": "catalog",
15
+ "lang": "python",
16
+ "schema_version": 1,
17
+ "created_at": 1749132727000
18
+ },
19
+ "attempts": 2
20
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "urn": "urn:babel:orders:created",
3
+ "trace_id": "9c1e0b44-7a2d-4e6f-8a10-2b3c4d5e6f70",
4
+ "data": {
5
+ "order_id": 1042
6
+ },
7
+ "meta": {
8
+ "id": "a1b2c3d4-e5f6-4789-90ab-cdef01234567",
9
+ "queue": "orders",
10
+ "lang": "go",
11
+ "schema_version": 1,
12
+ "created_at": 1749132727000
13
+ },
14
+ "attempts": 0
15
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "schema_version": 1,
3
+ "description": "Cross-SDK conformance cases. Every BabelQueue SDK core must satisfy these against the canonical wire envelope. Per-message fields (meta.id, trace_id, meta.created_at) are intrinsically unique and are NOT asserted by value.",
4
+ "cases": [
5
+ {
6
+ "name": "order-created",
7
+ "file": "fixtures/order-created.json",
8
+ "valid": true,
9
+ "description": "A normal produced envelope.",
10
+ "expect": {
11
+ "urn": "urn:babel:orders:created",
12
+ "data": { "order_id": 1042 },
13
+ "attempts": 0,
14
+ "lang": "php",
15
+ "schema_version": 1
16
+ }
17
+ },
18
+ {
19
+ "name": "urn-alias",
20
+ "file": "fixtures/urn-alias.json",
21
+ "valid": true,
22
+ "description": "Consumers MUST accept 'urn' as an inbound alias for 'job'.",
23
+ "expect": {
24
+ "urn": "urn:babel:orders:created",
25
+ "data": { "order_id": 1042 },
26
+ "attempts": 0,
27
+ "lang": "go",
28
+ "schema_version": 1
29
+ }
30
+ },
31
+ {
32
+ "name": "dead-lettered",
33
+ "file": "fixtures/dead-lettered.json",
34
+ "valid": true,
35
+ "description": "A dead-lettered message: original preserved + additive dead_letter block.",
36
+ "expect": {
37
+ "urn": "urn:babel:orders:created",
38
+ "data": { "order_id": 1042 },
39
+ "attempts": 3,
40
+ "lang": "php",
41
+ "schema_version": 1,
42
+ "dead_letter": { "reason": "failed", "original_queue": "orders" }
43
+ }
44
+ },
45
+ {
46
+ "name": "unicode-and-numbers",
47
+ "file": "fixtures/unicode-and-numbers.json",
48
+ "valid": true,
49
+ "description": "UTF-8 strings, integers, an exact float, boolean and null round-trip identically.",
50
+ "expect": {
51
+ "urn": "urn:babel:catalog:item.indexed",
52
+ "data": { "title": "Café — naïve ☕", "qty": 7, "price_cents": 1299, "ratio": 0.5, "active": true, "note": null },
53
+ "attempts": 2,
54
+ "lang": "python",
55
+ "schema_version": 1
56
+ }
57
+ },
58
+ {
59
+ "name": "invalid-unknown-schema-version",
60
+ "file": "fixtures/invalid-unknown-schema-version.json",
61
+ "valid": false,
62
+ "reason": "meta.schema_version is not a version this SDK supports"
63
+ },
64
+ {
65
+ "name": "invalid-missing-urn",
66
+ "file": "fixtures/invalid-missing-urn.json",
67
+ "valid": false,
68
+ "reason": "no 'job' or 'urn' — the message has no identity"
69
+ }
70
+ ]
71
+ }
@@ -0,0 +1,110 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://babelqueue.com/contracts/message-envelope.schema.json",
4
+ "title": "BabelQueueMessageEnvelope",
5
+ "description": "The canonical, language-agnostic BabelQueue wire envelope (schema_version 1). This schema is authoritative alongside contracts/message-envelope.md.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "required": ["job", "trace_id", "data", "meta", "attempts"],
9
+ "properties": {
10
+ "job": {
11
+ "type": "string",
12
+ "minLength": 1,
13
+ "description": "The message URN — language-agnostic identity. Canonical producer field name. Consumers also accept 'urn' as an inbound alias. Never a class name.",
14
+ "examples": ["urn:babel:orders:created", "urn:babel:orders:invoice.requested"]
15
+ },
16
+ "urn": {
17
+ "type": "string",
18
+ "minLength": 1,
19
+ "description": "Inbound alias for 'job'. Accepted by consumers for interoperability; producers SHOULD emit 'job'."
20
+ },
21
+ "trace_id": {
22
+ "type": "string",
23
+ "format": "uuid",
24
+ "description": "Cross-service correlation id. Generated by the first producer, preserved and forwarded unchanged by every SDK across every hop."
25
+ },
26
+ "data": {
27
+ "type": "object",
28
+ "description": "Pure, JSON-encodable business payload. No language-specific types. Numbers/time/binary per contracts/message-envelope.md section 6.",
29
+ "additionalProperties": true
30
+ },
31
+ "meta": {
32
+ "type": "object",
33
+ "description": "Producer-set, immutable descriptive metadata.",
34
+ "additionalProperties": true,
35
+ "required": ["id", "queue", "lang", "schema_version", "created_at"],
36
+ "properties": {
37
+ "id": {
38
+ "type": "string",
39
+ "format": "uuid",
40
+ "description": "Unique id for THIS message (distinct from trace_id)."
41
+ },
42
+ "queue": {
43
+ "type": "string",
44
+ "minLength": 1,
45
+ "description": "Logical queue name (not the broker key)."
46
+ },
47
+ "lang": {
48
+ "type": "string",
49
+ "enum": ["php", "go", "python", "java", "dotnet", "node"],
50
+ "description": "Producer language tag."
51
+ },
52
+ "schema_version": {
53
+ "type": "integer",
54
+ "const": 1,
55
+ "description": "Envelope schema version. Consumers MUST reject versions they do not support."
56
+ },
57
+ "created_at": {
58
+ "type": "integer",
59
+ "minimum": 0,
60
+ "description": "Production time as Unix epoch MILLISECONDS, UTC."
61
+ }
62
+ }
63
+ },
64
+ "attempts": {
65
+ "type": "integer",
66
+ "minimum": 0,
67
+ "description": "Top-level transport retry counter, mutated by the broker/worker. Deliberately kept OUT of the immutable meta block."
68
+ },
69
+ "dead_letter": {
70
+ "type": "object",
71
+ "description": "Optional. Present ONLY on messages sitting on a dead-letter queue (ADR-0009). Additive, so it does not change schema_version. Normal consumers ignore it.",
72
+ "additionalProperties": true,
73
+ "required": ["reason", "failed_at", "original_queue", "attempts"],
74
+ "properties": {
75
+ "reason": {
76
+ "type": "string",
77
+ "enum": ["failed", "unknown_urn", "poison"],
78
+ "description": "Why the message was dead-lettered."
79
+ },
80
+ "error": {
81
+ "type": ["string", "null"],
82
+ "description": "Human-readable error message, if any."
83
+ },
84
+ "exception": {
85
+ "type": ["string", "null"],
86
+ "description": "Producer-language exception/type name, if any (informational, language-specific)."
87
+ },
88
+ "failed_at": {
89
+ "type": "integer",
90
+ "minimum": 0,
91
+ "description": "Dead-letter time as Unix epoch milliseconds, UTC."
92
+ },
93
+ "original_queue": {
94
+ "type": "string",
95
+ "description": "The queue the message was consumed from before dead-lettering."
96
+ },
97
+ "attempts": {
98
+ "type": "integer",
99
+ "minimum": 0,
100
+ "description": "Delivery attempts made before dead-lettering."
101
+ },
102
+ "lang": {
103
+ "type": "string",
104
+ "enum": ["php", "go", "python", "java", "dotnet", "node"],
105
+ "description": "Language of the SDK that dead-lettered the message."
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "job": "urn:babel:orders:created",
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": 3,
15
+ "dead_letter": {
16
+ "reason": "failed",
17
+ "error": "Payment gateway timeout",
18
+ "exception": "App\\Exceptions\\GatewayTimeout",
19
+ "failed_at": 1749132730000,
20
+ "original_queue": "orders",
21
+ "attempts": 3,
22
+ "lang": "php"
23
+ }
24
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "job": "urn:babel:orders:created",
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
+ }
@@ -0,0 +1,58 @@
1
+ """Runs the shared cross-SDK conformance suite (vendored under tests/conformance/).
2
+
3
+ The same manifest + fixtures are run by every BabelQueue SDK; passing here proves
4
+ this SDK reads/writes the canonical envelope identically to the others.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ from babelqueue import EnvelopeCodec
14
+
15
+ SUITE = Path(__file__).parent / "conformance"
16
+ MANIFEST = json.loads((SUITE / "manifest.json").read_text(encoding="utf-8"))
17
+
18
+
19
+ class ConformanceTest(unittest.TestCase):
20
+ def test_suite_is_present(self) -> None:
21
+ self.assertEqual(MANIFEST["schema_version"], 1)
22
+ self.assertGreaterEqual(len(MANIFEST["cases"]), 6)
23
+
24
+ def test_cases(self) -> None:
25
+ for case in MANIFEST["cases"]:
26
+ with self.subTest(case=case["name"]):
27
+ raw = (SUITE / case["file"]).read_text(encoding="utf-8")
28
+ env = EnvelopeCodec.decode(raw)
29
+ self.assertNotEqual(env, {}, "fixture must decode")
30
+
31
+ if not case["valid"]:
32
+ self.assertFalse(
33
+ EnvelopeCodec.accepts(env),
34
+ f"{case['name']} must be rejected: {case.get('reason')}",
35
+ )
36
+ continue
37
+
38
+ expect = case["expect"]
39
+ self.assertTrue(EnvelopeCodec.accepts(env), f"{case['name']} must be accepted")
40
+ self.assertEqual(EnvelopeCodec.urn(env), expect["urn"])
41
+ self.assertEqual(env["attempts"], expect["attempts"])
42
+ self.assertEqual(env["meta"]["lang"], expect["lang"])
43
+ self.assertEqual(env["meta"]["schema_version"], expect["schema_version"])
44
+
45
+ if "data" in expect:
46
+ self.assertEqual(env["data"], expect["data"])
47
+
48
+ if "dead_letter" in expect:
49
+ for key, value in expect["dead_letter"].items():
50
+ self.assertEqual(env["dead_letter"][key], value)
51
+
52
+ # Per-message fields must be present (not asserted by value).
53
+ self.assertIn("id", env["meta"])
54
+ self.assertTrue(env["trace_id"])
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -0,0 +1,98 @@
1
+ """Integration tests for the RabbitMQ (pika) transport.
2
+
3
+ Skipped unless a broker is reachable (the `pika` package installed and a broker at
4
+ ``BABELQUEUE_TEST_AMQP`` / localhost). The CI ``integration`` job runs these
5
+ against a RabbitMQ service; locally they skip cleanly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import unittest
12
+ import uuid
13
+
14
+ try:
15
+ import pika as _pika
16
+ except ImportError: # pragma: no cover
17
+ _pika = None
18
+
19
+ from babelqueue import BabelQueue, EnvelopeCodec
20
+
21
+ AMQP_URL = os.environ.get("BABELQUEUE_TEST_AMQP", "amqp://guest:guest@localhost:5672/")
22
+
23
+
24
+ def _amqp_available() -> bool:
25
+ if _pika is None:
26
+ return False
27
+ try:
28
+ conn = _pika.BlockingConnection(_pika.URLParameters(AMQP_URL))
29
+ conn.close()
30
+ return True
31
+ except Exception: # pragma: no cover - connection failure
32
+ return False
33
+
34
+
35
+ @unittest.skipUnless(_amqp_available(), f"no reachable RabbitMQ at {AMQP_URL}")
36
+ class PikaTransportTest(unittest.TestCase):
37
+ def setUp(self) -> None:
38
+ self.queue = f"bqtest-{uuid.uuid4().hex}"
39
+ self.conn = _pika.BlockingConnection(_pika.URLParameters(AMQP_URL))
40
+ self.ctl = self.conn.channel()
41
+
42
+ def tearDown(self) -> None:
43
+ for q in (self.queue, f"{self.queue}.dlq"):
44
+ try:
45
+ self.ctl.queue_delete(queue=q)
46
+ except Exception:
47
+ pass
48
+ self.conn.close()
49
+
50
+ def _depth(self, queue: str) -> int:
51
+ method = self.ctl.queue_declare(queue=queue, durable=True, passive=True)
52
+ return method.method.message_count
53
+
54
+ def test_publish_consume_round_trip_and_ack(self) -> None:
55
+ app = BabelQueue(AMQP_URL, queue=self.queue)
56
+ seen = {}
57
+
58
+ @app.handler("urn:babel:orders:created")
59
+ def handle(data, meta): # noqa: ANN001
60
+ seen.update(data)
61
+
62
+ app.publish("urn:babel:orders:created", {"order_id": 42})
63
+ processed = app.consume(max_messages=1, timeout=3)
64
+
65
+ self.assertEqual(processed, 1)
66
+ self.assertEqual(seen, {"order_id": 42})
67
+ self.assertEqual(self._depth(self.queue), 0) # acked
68
+
69
+ def test_publish_sets_contract_amqp_properties(self) -> None:
70
+ app = BabelQueue(AMQP_URL, queue=self.queue)
71
+ app.publish("urn:babel:orders:created", {"order_id": 1}, trace_id="trace-amqp")
72
+
73
+ method, props, body = self.ctl.basic_get(queue=self.queue, auto_ack=True)
74
+ self.assertIsNotNone(method)
75
+ self.assertEqual(props.type, "urn:babel:orders:created") # route on properties.type
76
+ self.assertEqual(props.correlation_id, "trace-amqp") # trace_id
77
+ self.assertEqual(props.content_type, "application/json")
78
+ self.assertEqual(props.delivery_mode, 2) # persistent
79
+ self.assertEqual(props.app_id, "babelqueue")
80
+
81
+ def test_failure_dead_letters(self) -> None:
82
+ app = BabelQueue(AMQP_URL, queue=self.queue, max_attempts=1, dead_letter=True)
83
+
84
+ @app.handler("urn:babel:orders:created")
85
+ def handle(data, meta): # noqa: ANN001
86
+ raise RuntimeError("boom")
87
+
88
+ app.publish("urn:babel:orders:created", {"order_id": 1})
89
+ app.consume(max_messages=2, timeout=3)
90
+
91
+ self.assertEqual(self._depth(f"{self.queue}.dlq"), 1)
92
+ _m, _p, body = self.ctl.basic_get(queue=f"{self.queue}.dlq", auto_ack=True)
93
+ env = EnvelopeCodec.decode(body.decode("utf-8"))
94
+ self.assertEqual(env["dead_letter"]["reason"], "failed")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
File without changes
File without changes
File without changes