babelqueue 0.3.0__tar.gz → 0.5.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 (43) hide show
  1. {babelqueue-0.3.0 → babelqueue-0.5.0}/.github/workflows/ci.yml +2 -2
  2. {babelqueue-0.3.0 → babelqueue-0.5.0}/CHANGELOG.md +28 -1
  3. {babelqueue-0.3.0 → babelqueue-0.5.0}/PKG-INFO +47 -5
  4. {babelqueue-0.3.0 → babelqueue-0.5.0}/README.md +42 -4
  5. {babelqueue-0.3.0 → babelqueue-0.5.0}/pyproject.toml +4 -2
  6. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/__init__.py +1 -1
  7. babelqueue-0.5.0/src/babelqueue/celery.py +88 -0
  8. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/codec.py +33 -0
  9. babelqueue-0.5.0/src/babelqueue/django/__init__.py +72 -0
  10. babelqueue-0.5.0/src/babelqueue/django/apps.py +11 -0
  11. babelqueue-0.5.0/src/babelqueue/django/management/commands/__init__.py +0 -0
  12. babelqueue-0.5.0/src/babelqueue/django/management/commands/babelqueue_worker.py +44 -0
  13. babelqueue-0.5.0/src/babelqueue/py.typed +0 -0
  14. babelqueue-0.5.0/tests/conformance/fixtures/invalid-missing-urn.json +14 -0
  15. babelqueue-0.5.0/tests/conformance/fixtures/invalid-unknown-schema-version.json +15 -0
  16. babelqueue-0.5.0/tests/conformance/fixtures/unicode-and-numbers.json +20 -0
  17. babelqueue-0.5.0/tests/conformance/fixtures/urn-alias.json +15 -0
  18. babelqueue-0.5.0/tests/conformance/manifest.json +71 -0
  19. babelqueue-0.5.0/tests/conformance/schema/message-envelope.schema.json +110 -0
  20. babelqueue-0.5.0/tests/fixtures/dead-lettered.json +24 -0
  21. babelqueue-0.5.0/tests/fixtures/order-created.json +15 -0
  22. babelqueue-0.5.0/tests/test_celery.py +68 -0
  23. babelqueue-0.5.0/tests/test_conformance.py +58 -0
  24. babelqueue-0.5.0/tests/test_django.py +67 -0
  25. {babelqueue-0.3.0 → babelqueue-0.5.0}/.github/workflows/release.yml +0 -0
  26. {babelqueue-0.3.0 → babelqueue-0.5.0}/.gitignore +0 -0
  27. {babelqueue-0.3.0 → babelqueue-0.5.0}/LICENSE +0 -0
  28. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/app.py +0 -0
  29. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/contracts.py +0 -0
  30. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/dead_letter.py +0 -0
  31. /babelqueue-0.3.0/src/babelqueue/py.typed → /babelqueue-0.5.0/src/babelqueue/django/management/__init__.py +0 -0
  32. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/exceptions.py +0 -0
  33. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/pika_transport.py +0 -0
  34. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/redis_transport.py +0 -0
  35. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/routing.py +0 -0
  36. {babelqueue-0.3.0 → babelqueue-0.5.0}/src/babelqueue/transport.py +0 -0
  37. {babelqueue-0.3.0/tests → babelqueue-0.5.0/tests/conformance}/fixtures/dead-lettered.json +0 -0
  38. {babelqueue-0.3.0/tests → babelqueue-0.5.0/tests/conformance}/fixtures/order-created.json +0 -0
  39. {babelqueue-0.3.0 → babelqueue-0.5.0}/tests/test_app.py +0 -0
  40. {babelqueue-0.3.0 → babelqueue-0.5.0}/tests/test_codec.py +0 -0
  41. {babelqueue-0.3.0 → babelqueue-0.5.0}/tests/test_dead_letter.py +0 -0
  42. {babelqueue-0.3.0 → babelqueue-0.5.0}/tests/test_pika_transport.py +0 -0
  43. {babelqueue-0.3.0 → babelqueue-0.5.0}/tests/test_redis_transport.py +0 -0
@@ -24,10 +24,10 @@ jobs:
24
24
  with:
25
25
  python-version: ${{ matrix.python }}
26
26
 
27
- - name: Install
27
+ - name: Install (with Celery + Django adapters)
28
28
  run: |
29
29
  python -m pip install --upgrade pip
30
- pip install -e ".[dev]"
30
+ pip install -e ".[dev,celery,django]"
31
31
 
32
32
  - name: Run tests
33
33
  run: pytest
@@ -9,6 +9,30 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.5.0] - 2026-06-06
13
+
14
+ ### Added
15
+ - **Celery adapter** (`babelqueue.celery`, `[celery]` extra) — `from_celery(app)`
16
+ builds a `BabelQueue` runtime on a Celery app's broker, and `install_worker(app)`
17
+ registers a Celery worker bootstep that drains URN-routed polyglot messages in a
18
+ background thread alongside Celery's own consumer.
19
+ - **Django adapter** (`babelqueue.django`, `[django]` extra) — settings-driven
20
+ `BABELQUEUE` config, `get_app()` / `publish()` shortcuts, and a
21
+ `manage.py babelqueue_worker` management command. Add `"babelqueue.django"` to
22
+ `INSTALLED_APPS`.
23
+ - Both adapters lazy-import their framework, so the core stays dependency-free.
24
+
25
+ ## [0.4.0] - 2026-06-06
26
+
27
+ ### Added
28
+ - `EnvelopeCodec.urn()` — resolve the URN (`job`, accepting `urn` as an alias).
29
+ - `EnvelopeCodec.accepts()` — consumer-side envelope validation (rejects empty URN,
30
+ unsupported `meta.schema_version`, blank `trace_id`, non-object `data`).
31
+ - Shared **cross-SDK conformance suite** under `tests/conformance/` (vendored from
32
+ the canonical `conformance/` set) plus a `test_conformance.py` runner.
33
+
34
+ ## [0.3.0] - 2026-06-06
35
+
12
36
  ### Added
13
37
  - **RabbitMQ transport** (`PikaTransport`, `amqp://`): durable queue, persistent
14
38
  delivery, `basic_get` + manual ack, and the contract AMQP properties (`type`=URN,
@@ -44,6 +68,9 @@ The envelope wire format is versioned separately by `meta.schema_version`
44
68
  - Pre-1.0: the public API may change before the `1.0.0` tag.
45
69
  - The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
46
70
 
47
- [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v0.2.0...HEAD
71
+ [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v0.5.0...HEAD
72
+ [0.5.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.4.0...v0.5.0
73
+ [0.4.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.3.0...v0.4.0
74
+ [0.3.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.2.0...v0.3.0
48
75
  [0.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.1.0...v0.2.0
49
76
  [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.3.0
3
+ Version: 0.5.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
@@ -24,8 +24,12 @@ Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.9
25
25
  Provides-Extra: amqp
26
26
  Requires-Dist: pika>=1.3; extra == 'amqp'
27
+ Provides-Extra: celery
28
+ Requires-Dist: celery>=5; extra == 'celery'
27
29
  Provides-Extra: dev
28
30
  Requires-Dist: pytest>=7; extra == 'dev'
31
+ Provides-Extra: django
32
+ Requires-Dist: django>=4.2; extra == 'django'
29
33
  Provides-Extra: redis
30
34
  Requires-Dist: redis>=4; extra == 'redis'
31
35
  Description-Content-Type: text/markdown
@@ -150,13 +154,51 @@ app.run() # consume forever (Ctrl-C to stop)
150
154
  `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
151
155
  tests/local). Bring your own by passing `transport=...`.
152
156
 
153
- > **Celery** / **Django** adapters are the next iterations.
157
+ ## Framework adapters Celery & Django
158
+
159
+ **Celery** (`pip install "babelqueue[celery]"`) — reuse your Celery app's broker for
160
+ polyglot interop, and consume inbound messages as a Celery worker bootstep:
161
+
162
+ ```python
163
+ from babelqueue.celery import from_celery, install_worker
164
+
165
+ bq = from_celery(celery_app, queue="orders") # runtime on Celery's broker
166
+ bq.publish("urn:babel:orders:created", {"order_id": 1042})
167
+
168
+ @bq.handler("urn:babel:orders:created")
169
+ def on_created(data, meta): ...
170
+
171
+ install_worker(celery_app, bq) # `celery worker` also drains URN messages
172
+ ```
173
+
174
+ **Django** (`pip install "babelqueue[django]"`) — add `"babelqueue.django"` to
175
+ `INSTALLED_APPS` and configure a `BABELQUEUE` dict:
176
+
177
+ ```python
178
+ # settings.py
179
+ BABELQUEUE = {"broker_url": "redis://localhost:6379/0", "queue": "orders", "dead_letter": True}
180
+ ```
181
+
182
+ ```python
183
+ from babelqueue.django import publish, get_app
184
+
185
+ publish("urn:babel:orders:created", {"order_id": 1042}) # in a view / signal
186
+
187
+ @get_app().handler("urn:babel:orders:created") # register handlers at startup
188
+ def on_created(data, meta): ...
189
+ ```
190
+
191
+ ```bash
192
+ python manage.py babelqueue_worker --queue orders # run the consumer
193
+ ```
154
194
 
155
195
  ## What's here
156
196
 
157
- The codec/contracts/dead-letter (zero-dep core) **and** the `BabelQueue` runtime
158
- above (in-memory built in; Redis via `[redis]`, RabbitMQ via `[amqp]`). For
159
- framework integration, the Celery and Django adapters are planned.
197
+ The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
198
+ (in-memory built in; Redis via `[redis]`, RabbitMQ via `[amqp]`), and framework
199
+ adapters for **Celery** (`[celery]`) and **Django** (`[django]`). Every layer
200
+ speaks the one canonical envelope, so it interoperates with the PHP/Laravel,
201
+ Symfony, Go, Node and .NET SDKs.
160
202
 
161
203
  ## Testing
162
204
 
@@ -118,13 +118,51 @@ app.run() # consume forever (Ctrl-C to stop)
118
118
  `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
119
119
  tests/local). Bring your own by passing `transport=...`.
120
120
 
121
- > **Celery** / **Django** adapters are the next iterations.
121
+ ## Framework adapters Celery & Django
122
+
123
+ **Celery** (`pip install "babelqueue[celery]"`) — reuse your Celery app's broker for
124
+ polyglot interop, and consume inbound messages as a Celery worker bootstep:
125
+
126
+ ```python
127
+ from babelqueue.celery import from_celery, install_worker
128
+
129
+ bq = from_celery(celery_app, queue="orders") # runtime on Celery's broker
130
+ bq.publish("urn:babel:orders:created", {"order_id": 1042})
131
+
132
+ @bq.handler("urn:babel:orders:created")
133
+ def on_created(data, meta): ...
134
+
135
+ install_worker(celery_app, bq) # `celery worker` also drains URN messages
136
+ ```
137
+
138
+ **Django** (`pip install "babelqueue[django]"`) — add `"babelqueue.django"` to
139
+ `INSTALLED_APPS` and configure a `BABELQUEUE` dict:
140
+
141
+ ```python
142
+ # settings.py
143
+ BABELQUEUE = {"broker_url": "redis://localhost:6379/0", "queue": "orders", "dead_letter": True}
144
+ ```
145
+
146
+ ```python
147
+ from babelqueue.django import publish, get_app
148
+
149
+ publish("urn:babel:orders:created", {"order_id": 1042}) # in a view / signal
150
+
151
+ @get_app().handler("urn:babel:orders:created") # register handlers at startup
152
+ def on_created(data, meta): ...
153
+ ```
154
+
155
+ ```bash
156
+ python manage.py babelqueue_worker --queue orders # run the consumer
157
+ ```
122
158
 
123
159
  ## What's here
124
160
 
125
- The codec/contracts/dead-letter (zero-dep core) **and** the `BabelQueue` runtime
126
- above (in-memory built in; Redis via `[redis]`, RabbitMQ via `[amqp]`). For
127
- framework integration, the Celery and Django adapters are planned.
161
+ The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
162
+ (in-memory built in; Redis via `[redis]`, RabbitMQ via `[amqp]`), and framework
163
+ adapters for **Celery** (`[celery]`) and **Django** (`[django]`). Every layer
164
+ speaks the one canonical envelope, so it interoperates with the PHP/Laravel,
165
+ Symfony, Go, Node and .NET SDKs.
128
166
 
129
167
  ## Testing
130
168
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "0.3.0"
7
+ version = "0.5.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"
@@ -28,9 +28,11 @@ classifiers = [
28
28
  dependencies = []
29
29
 
30
30
  [project.optional-dependencies]
31
- # Planned runtime / adapters — standard, zero-heavy-dep drivers.
31
+ # Optional runtime drivers + framework adapters — standard, zero-heavy-dep.
32
32
  redis = ["redis>=4"]
33
33
  amqp = ["pika>=1.3"]
34
+ celery = ["celery>=5"]
35
+ django = ["django>=4.2"]
34
36
  dev = ["pytest>=7"]
35
37
 
36
38
  [project.urls]
@@ -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.3.0"
22
+ __version__ = "0.5.0"
23
23
 
24
24
  __all__ = [
25
25
  "BabelQueue",
@@ -0,0 +1,88 @@
1
+ """Celery integration. Requires the ``celery`` extra:
2
+
3
+ pip install "babelqueue[celery]"
4
+
5
+ A Celery app already configures a broker (Redis/RabbitMQ). :func:`from_celery`
6
+ builds a :class:`~babelqueue.BabelQueue` runtime on that *same* broker, so a
7
+ Celery-based service produces and consumes the canonical polyglot envelope
8
+ alongside its Celery tasks — interoperating with the PHP/Laravel, Go, Node, ...
9
+ SDKs. :func:`install_worker` runs that consumer as a Celery worker *bootstep* (a
10
+ daemon thread started on ``celery worker``), so one process handles both Celery
11
+ tasks and inbound polyglot messages.
12
+
13
+ ``celery`` is imported lazily, so the core stays dependency-free.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from typing import Any, Optional
20
+
21
+ from .app import BabelQueue
22
+ from .exceptions import BabelQueueError
23
+
24
+
25
+ def broker_url(celery_app: Any) -> str:
26
+ """Extract the broker URL from a Celery app (supports old/new config keys)."""
27
+ conf = getattr(celery_app, "conf", None)
28
+ url = None
29
+ if conf is not None:
30
+ url = getattr(conf, "broker_url", None)
31
+ if not url and hasattr(conf, "get"):
32
+ url = conf.get("broker_url") or conf.get("BROKER_URL")
33
+ if not url:
34
+ raise BabelQueueError(
35
+ "The Celery app has no broker configured; set broker_url before calling from_celery()."
36
+ )
37
+ return str(url)
38
+
39
+
40
+ def from_celery(celery_app: Any, **kwargs: Any) -> BabelQueue:
41
+ """Build a :class:`~babelqueue.BabelQueue` runtime on the Celery app's broker.
42
+
43
+ Extra keyword arguments are forwarded to ``BabelQueue`` (``queue``,
44
+ ``max_attempts``, ``dead_letter``, ``on_unknown_urn``, ...).
45
+ """
46
+ return BabelQueue(broker_url(celery_app), **kwargs)
47
+
48
+
49
+ def install_worker(
50
+ celery_app: Any,
51
+ babel_app: Optional[BabelQueue] = None,
52
+ *,
53
+ queue: Optional[str] = None,
54
+ **kwargs: Any,
55
+ ) -> type:
56
+ """Register a Celery worker bootstep that consumes BabelQueue messages.
57
+
58
+ When a ``celery worker`` boots, the step starts a daemon thread running the
59
+ BabelQueue consumer loop (URN routing, retry → dead-letter). If ``babel_app``
60
+ is omitted it is built with :func:`from_celery`. Returns the bootstep class.
61
+ """
62
+ from celery import bootsteps # lazy: only needed for this integration
63
+
64
+ app = babel_app if babel_app is not None else from_celery(celery_app, **kwargs)
65
+
66
+ class BabelQueueConsumerStep(bootsteps.StartStopStep):
67
+ """Runs the BabelQueue consumer loop alongside Celery's own consumer."""
68
+
69
+ def __init__(self, parent: Any, **options: Any) -> None:
70
+ super().__init__(parent, **options)
71
+ self._thread: Optional[threading.Thread] = None
72
+ self._stop = threading.Event()
73
+
74
+ def start(self, parent: Any) -> None:
75
+ def loop() -> None:
76
+ while not self._stop.is_set():
77
+ app.consume(queue, max_messages=1, timeout=1.0)
78
+
79
+ self._thread = threading.Thread(
80
+ target=loop, name="babelqueue-consumer", daemon=True
81
+ )
82
+ self._thread.start()
83
+
84
+ def stop(self, parent: Any) -> None:
85
+ self._stop.set()
86
+
87
+ celery_app.steps["worker"].add(BabelQueueConsumerStep)
88
+ return BabelQueueConsumerStep
@@ -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,72 @@
1
+ """Django integration. Requires the ``django`` extra:
2
+
3
+ pip install "babelqueue[django]"
4
+
5
+ Add ``"babelqueue.django"`` to ``INSTALLED_APPS`` and configure a ``BABELQUEUE``
6
+ settings dict::
7
+
8
+ BABELQUEUE = {
9
+ "broker_url": "redis://localhost:6379/0",
10
+ "queue": "orders",
11
+ "max_attempts": 3,
12
+ "dead_letter": True,
13
+ }
14
+
15
+ Then publish from views/signals with :func:`publish`, register handlers on
16
+ :func:`get_app`, and run the consumer with ``python manage.py babelqueue_worker``.
17
+ The runtime is the shared :class:`~babelqueue.BabelQueue`, so messages interoperate
18
+ with the PHP/Laravel, Go, Node, ... SDKs. ``django`` is imported lazily.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Dict, Mapping, Optional
24
+
25
+ from ..app import BabelQueue
26
+
27
+ # Keys (besides broker_url) forwarded to the BabelQueue constructor.
28
+ _APP_KWARGS = frozenset(
29
+ {
30
+ "queue",
31
+ "on_unknown_urn",
32
+ "max_attempts",
33
+ "dead_letter",
34
+ "dead_letter_queue",
35
+ "dead_letter_suffix",
36
+ "transport",
37
+ }
38
+ )
39
+
40
+ _app: Optional[BabelQueue] = None
41
+
42
+
43
+ def _build() -> BabelQueue:
44
+ from django.conf import settings # lazy
45
+
46
+ raw: Mapping[str, Any] = getattr(settings, "BABELQUEUE", {}) or {}
47
+ kwargs: Dict[str, Any] = {k: v for k, v in raw.items() if k in _APP_KWARGS}
48
+ broker = raw.get("broker_url", "memory://")
49
+ return BabelQueue(broker, **kwargs)
50
+
51
+
52
+ def get_app() -> BabelQueue:
53
+ """Return the process-wide :class:`~babelqueue.BabelQueue`, built from
54
+ ``settings.BABELQUEUE`` on first use."""
55
+ global _app
56
+ if _app is None:
57
+ _app = _build()
58
+ return _app
59
+
60
+
61
+ def publish(urn: str, data: Mapping[str, Any], **kwargs: Any) -> str:
62
+ """Publish a message through the configured app; returns its id (``meta.id``)."""
63
+ return get_app().publish(urn, dict(data), **kwargs)
64
+
65
+
66
+ def reset() -> None:
67
+ """Drop the cached app so the next :func:`get_app` rebuilds it (tests / settings reload)."""
68
+ global _app
69
+ _app = None
70
+
71
+
72
+ __all__ = ["get_app", "publish", "reset"]
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class BabelQueueConfig(AppConfig):
7
+ """Django app config for the BabelQueue adapter."""
8
+
9
+ name = "babelqueue.django"
10
+ label = "babelqueue"
11
+ verbose_name = "BabelQueue"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.core.management.base import BaseCommand
6
+
7
+ from babelqueue.django import get_app
8
+
9
+
10
+ class Command(BaseCommand):
11
+ help = "Run the BabelQueue consumer: routes inbound polyglot messages by URN."
12
+
13
+ def add_arguments(self, parser: Any) -> None:
14
+ parser.add_argument(
15
+ "--queue",
16
+ dest="queue",
17
+ default=None,
18
+ help="Queue to consume (default: the configured queue).",
19
+ )
20
+ parser.add_argument(
21
+ "--max-messages",
22
+ dest="max_messages",
23
+ type=int,
24
+ default=None,
25
+ help="Stop after N messages (default: run until interrupted).",
26
+ )
27
+ parser.add_argument(
28
+ "--timeout",
29
+ dest="timeout",
30
+ type=float,
31
+ default=1.0,
32
+ help="Per-poll block timeout in seconds (default: 1.0).",
33
+ )
34
+
35
+ def handle(self, *args: Any, **options: Any) -> None:
36
+ app = get_app()
37
+ queue = options["queue"] or app.queue
38
+ self.stdout.write(f"BabelQueue consumer listening on '{queue}' …")
39
+ processed = app.consume(
40
+ options["queue"],
41
+ max_messages=options["max_messages"],
42
+ timeout=options["timeout"],
43
+ )
44
+ self.stdout.write(self.style.SUCCESS(f"Processed {processed} message(s)."))
File without changes
@@ -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,68 @@
1
+ """Celery adapter — from_celery bridge + worker bootstep.
2
+
3
+ Skips unless ``celery`` is installed (``pip install "babelqueue[celery]"``). Uses
4
+ Celery's ``memory://`` broker, so no external broker is required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import unittest
10
+
11
+ try:
12
+ import celery # noqa: F401
13
+
14
+ HAS_CELERY = True
15
+ except ImportError:
16
+ HAS_CELERY = False
17
+
18
+ from babelqueue import BabelQueue, BabelQueueError
19
+
20
+
21
+ @unittest.skipUnless(HAS_CELERY, "celery is not installed")
22
+ class CeleryAdapterTest(unittest.TestCase):
23
+ def _celery_app(self):
24
+ from celery import Celery
25
+
26
+ return Celery("test", broker="memory://")
27
+
28
+ def test_from_celery_builds_runtime_on_the_celery_broker(self) -> None:
29
+ from babelqueue.celery import from_celery
30
+
31
+ app = from_celery(self._celery_app(), queue="orders")
32
+ self.assertIsInstance(app, BabelQueue)
33
+ self.assertEqual(app.queue, "orders")
34
+
35
+ seen = {}
36
+
37
+ @app.handler("urn:babel:orders:created")
38
+ def handle(data, meta): # noqa: ANN001
39
+ seen["data"] = data
40
+
41
+ app.publish("urn:babel:orders:created", {"order_id": 1})
42
+ processed = app.consume(max_messages=1)
43
+
44
+ self.assertEqual(processed, 1)
45
+ self.assertEqual(seen["data"], {"order_id": 1})
46
+
47
+ def test_from_celery_requires_a_broker(self) -> None:
48
+ from celery import Celery
49
+
50
+ from babelqueue.celery import from_celery
51
+
52
+ app = Celery("test") # no broker
53
+ app.conf.broker_url = None
54
+ with self.assertRaises(BabelQueueError):
55
+ from_celery(app)
56
+
57
+ def test_install_worker_registers_a_bootstep(self) -> None:
58
+ from babelqueue.celery import from_celery, install_worker
59
+
60
+ celery_app = self._celery_app()
61
+ babel = from_celery(celery_app)
62
+ step = install_worker(celery_app, babel)
63
+
64
+ self.assertIn(step, celery_app.steps["worker"])
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -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,67 @@
1
+ """Django adapter — settings-driven app, publish() shortcut, worker command.
2
+
3
+ Skips unless ``django`` is installed (``pip install "babelqueue[django]"``). Uses
4
+ the ``memory://`` transport, so no external broker is required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import unittest
10
+
11
+ try:
12
+ import django # noqa: F401
13
+
14
+ HAS_DJANGO = True
15
+ except ImportError:
16
+ HAS_DJANGO = False
17
+
18
+
19
+ @unittest.skipUnless(HAS_DJANGO, "django is not installed")
20
+ class DjangoAdapterTest(unittest.TestCase):
21
+ @classmethod
22
+ def setUpClass(cls) -> None:
23
+ import django
24
+ from django.conf import settings
25
+
26
+ if not settings.configured:
27
+ settings.configure(
28
+ INSTALLED_APPS=["babelqueue.django"],
29
+ BABELQUEUE={"broker_url": "memory://", "queue": "orders"},
30
+ LOGGING_CONFIG=None,
31
+ )
32
+ django.setup()
33
+
34
+ def setUp(self) -> None:
35
+ from babelqueue.django import reset
36
+
37
+ reset()
38
+
39
+ def test_get_app_reads_settings(self) -> None:
40
+ from babelqueue import BabelQueue
41
+ from babelqueue.django import get_app
42
+
43
+ app = get_app()
44
+ self.assertIsInstance(app, BabelQueue)
45
+ self.assertEqual(app.queue, "orders")
46
+
47
+ def test_publish_then_worker_command_processes_the_message(self) -> None:
48
+ from django.core.management import call_command
49
+
50
+ from babelqueue.django import get_app, publish
51
+
52
+ seen = {}
53
+
54
+ @get_app().handler("urn:babel:orders:created")
55
+ def handle(data, meta): # noqa: ANN001
56
+ seen["data"] = data
57
+
58
+ msg_id = publish("urn:babel:orders:created", {"order_id": 9})
59
+ self.assertTrue(msg_id)
60
+
61
+ call_command("babelqueue_worker", "--max-messages=1")
62
+
63
+ self.assertEqual(seen["data"], {"order_id": 9})
64
+
65
+
66
+ if __name__ == "__main__":
67
+ unittest.main()
File without changes
File without changes
File without changes