babelqueue 0.1.0__tar.gz → 0.2.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 (25) hide show
  1. babelqueue-0.2.0/.github/workflows/ci.yml +64 -0
  2. {babelqueue-0.1.0 → babelqueue-0.2.0}/CHANGELOG.md +15 -5
  3. {babelqueue-0.1.0 → babelqueue-0.2.0}/PKG-INFO +42 -7
  4. {babelqueue-0.1.0 → babelqueue-0.2.0}/README.md +41 -6
  5. {babelqueue-0.1.0 → babelqueue-0.2.0}/pyproject.toml +1 -1
  6. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/__init__.py +7 -1
  7. babelqueue-0.2.0/src/babelqueue/app.py +210 -0
  8. babelqueue-0.2.0/src/babelqueue/redis_transport.py +48 -0
  9. babelqueue-0.2.0/src/babelqueue/transport.py +82 -0
  10. babelqueue-0.2.0/tests/test_app.py +125 -0
  11. babelqueue-0.2.0/tests/test_redis_transport.py +78 -0
  12. babelqueue-0.1.0/.github/workflows/ci.yml +0 -33
  13. {babelqueue-0.1.0 → babelqueue-0.2.0}/.github/workflows/release.yml +0 -0
  14. {babelqueue-0.1.0 → babelqueue-0.2.0}/.gitignore +0 -0
  15. {babelqueue-0.1.0 → babelqueue-0.2.0}/LICENSE +0 -0
  16. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/codec.py +0 -0
  17. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/contracts.py +0 -0
  18. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/dead_letter.py +0 -0
  19. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/exceptions.py +0 -0
  20. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/py.typed +0 -0
  21. {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/routing.py +0 -0
  22. {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/fixtures/dead-lettered.json +0 -0
  23. {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/fixtures/order-created.json +0 -0
  24. {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/test_codec.py +0 -0
  25. {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/test_dead_letter.py +0 -0
@@ -0,0 +1,64 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ name: Python ${{ matrix.python }}
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python: ['3.9', '3.10', '3.11', '3.12', '3.13']
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Setup Python
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python }}
26
+
27
+ - name: Install
28
+ run: |
29
+ python -m pip install --upgrade pip
30
+ pip install -e ".[dev]"
31
+
32
+ - name: Run tests
33
+ run: pytest
34
+
35
+ integration:
36
+ name: Redis integration
37
+ runs-on: ubuntu-latest
38
+ services:
39
+ redis:
40
+ image: redis:7
41
+ ports:
42
+ - 6379:6379
43
+ options: >-
44
+ --health-cmd "redis-cli ping"
45
+ --health-interval 5s
46
+ --health-timeout 3s
47
+ --health-retries 10
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+
51
+ - name: Setup Python
52
+ uses: actions/setup-python@v5
53
+ with:
54
+ python-version: '3.12'
55
+
56
+ - name: Install (with redis extra)
57
+ run: |
58
+ python -m pip install --upgrade pip
59
+ pip install -e ".[redis,dev]"
60
+
61
+ - name: Run tests (Redis transport included)
62
+ env:
63
+ BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
64
+ run: pytest
@@ -9,6 +9,18 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Added
13
+ - **Runtime** — `BabelQueue(broker_url=...)` app with a `@app.handler("urn:...")`
14
+ decorator, `publish()`, and a `consume()` / `run()` loop. Routes by URN over the
15
+ canonical envelope; `attempts`-based retry → opt-in dead-letter queue;
16
+ `on_unknown_urn` strategies (`fail`/`delete`/`release`/`dead_letter`).
17
+ - **Transports** — a pluggable `Transport` abstraction with `InMemoryTransport`
18
+ (`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.
21
+
22
+ ## [0.1.0] - 2026-06-06
23
+
12
24
  ### Added
13
25
  - `EnvelopeCodec` — builds (`make`, `from_message`), encodes and decodes the
14
26
  canonical `{job, trace_id, data, meta, attempts}` envelope (`schema_version` 1).
@@ -22,9 +34,7 @@ The envelope wire format is versioned separately by `meta.schema_version`
22
34
 
23
35
  ### Notes
24
36
  - Pre-1.0: the public API may change before the `1.0.0` tag.
25
- - **Zero runtime dependencies** (standard library only). Requires Python `>=3.9`.
26
- - This is the framework-agnostic **core**. The broker runtime
27
- (`BabelQueue(broker_url=...)` + `@app.handler`, over `redis`/`pika`) and the
28
- Celery/Django adapters are planned next iterations, built on this core.
37
+ - The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
29
38
 
30
- [Unreleased]: https://github.com/BabelQueue/babelqueue-python/commits/main
39
+ [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v0.1.0...HEAD
40
+ [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.1.0
3
+ Version: 0.2.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
@@ -112,13 +112,48 @@ dlq = dead_letter.annotate(envelope, "failed", "orders", attempts=3, error="boom
112
112
  # publish `EnvelopeCodec.encode(dlq)` to the "orders.dlq" queue
113
113
  ```
114
114
 
115
- ## What's here vs. coming
115
+ ## Runtime produce & consume
116
116
 
117
- - **Now (this package):** the codec, contracts, dead-letter and unknown-URN
118
- helpers, plus the shared conformance fixtures. Bring your own broker client.
119
- - **Next (planned):** a built-in runtime — `BabelQueue(broker_url=...)` with an
120
- `@app.handler("urn:…")` decorator over `redis`/`pika` — and **Celery** / **Django**
121
- adapters. Install via extras (`babelqueue[redis]`, `babelqueue[celery]`, …).
117
+ For an end-to-end app, use `BabelQueue` with a broker. Redis support comes via an
118
+ extra:
119
+
120
+ ```bash
121
+ pip install "babelqueue[redis]"
122
+ ```
123
+
124
+ ```python
125
+ from babelqueue import BabelQueue
126
+
127
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
128
+
129
+ @app.handler("urn:babel:orders:created")
130
+ def on_order_created(data, meta): # AI/ML, data processing, anything
131
+ print("order", data["order_id"])
132
+
133
+ # producer (any service, any language) …
134
+ app.publish("urn:babel:orders:created", {"order_id": 1042})
135
+
136
+ # worker
137
+ app.run() # consume forever (Ctrl-C to stop)
138
+ ```
139
+
140
+ - **Routing** is by URN; the wire format is the canonical envelope, so this
141
+ consumes messages produced by *any* BabelQueue SDK.
142
+ - **Handlers** receive `(data, meta)`, or `(data, meta, message)` to get the full
143
+ envelope (incl. `trace_id`).
144
+ - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
145
+ envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
146
+ 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
+
150
+ > RabbitMQ (`pika`) and **Celery** / **Django** adapters are the next iterations.
151
+
152
+ ## What's here
153
+
154
+ 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.
122
157
 
123
158
  ## Testing
124
159
 
@@ -80,13 +80,48 @@ dlq = dead_letter.annotate(envelope, "failed", "orders", attempts=3, error="boom
80
80
  # publish `EnvelopeCodec.encode(dlq)` to the "orders.dlq" queue
81
81
  ```
82
82
 
83
- ## What's here vs. coming
83
+ ## Runtime produce & consume
84
84
 
85
- - **Now (this package):** the codec, contracts, dead-letter and unknown-URN
86
- helpers, plus the shared conformance fixtures. Bring your own broker client.
87
- - **Next (planned):** a built-in runtime — `BabelQueue(broker_url=...)` with an
88
- `@app.handler("urn:…")` decorator over `redis`/`pika` — and **Celery** / **Django**
89
- adapters. Install via extras (`babelqueue[redis]`, `babelqueue[celery]`, …).
85
+ For an end-to-end app, use `BabelQueue` with a broker. Redis support comes via an
86
+ extra:
87
+
88
+ ```bash
89
+ pip install "babelqueue[redis]"
90
+ ```
91
+
92
+ ```python
93
+ from babelqueue import BabelQueue
94
+
95
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
96
+
97
+ @app.handler("urn:babel:orders:created")
98
+ def on_order_created(data, meta): # AI/ML, data processing, anything
99
+ print("order", data["order_id"])
100
+
101
+ # producer (any service, any language) …
102
+ app.publish("urn:babel:orders:created", {"order_id": 1042})
103
+
104
+ # worker
105
+ app.run() # consume forever (Ctrl-C to stop)
106
+ ```
107
+
108
+ - **Routing** is by URN; the wire format is the canonical envelope, so this
109
+ consumes messages produced by *any* BabelQueue SDK.
110
+ - **Handlers** receive `(data, meta)`, or `(data, meta, message)` to get the full
111
+ envelope (incl. `trace_id`).
112
+ - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
113
+ envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
114
+ 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
+
118
+ > RabbitMQ (`pika`) and **Celery** / **Django** adapters are the next iterations.
119
+
120
+ ## What's here
121
+
122
+ 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.
90
125
 
91
126
  ## Testing
92
127
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "0.1.0"
7
+ version = "0.2.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"
@@ -12,20 +12,26 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
12
12
  from __future__ import annotations
13
13
 
14
14
  from . import dead_letter
15
+ from .app import BabelQueue
15
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
16
17
  from .contracts import HasTraceId, PolyglotMessage
17
18
  from .exceptions import BabelQueueError, UnknownUrnError
18
19
  from .routing import UnknownUrnStrategy
20
+ from .transport import InMemoryTransport, ReceivedMessage, Transport
19
21
 
20
- __version__ = "0.1.0"
22
+ __version__ = "0.2.0"
21
23
 
22
24
  __all__ = [
25
+ "BabelQueue",
23
26
  "EnvelopeCodec",
24
27
  "SCHEMA_VERSION",
25
28
  "SOURCE_LANG",
26
29
  "PolyglotMessage",
27
30
  "HasTraceId",
28
31
  "UnknownUrnStrategy",
32
+ "Transport",
33
+ "InMemoryTransport",
34
+ "ReceivedMessage",
29
35
  "BabelQueueError",
30
36
  "UnknownUrnError",
31
37
  "dead_letter",
@@ -0,0 +1,210 @@
1
+ """The BabelQueue runtime: produce and consume polyglot messages.
2
+
3
+ from babelqueue import BabelQueue
4
+
5
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
6
+
7
+ @app.handler("urn:babel:orders:created")
8
+ def on_order_created(data, meta):
9
+ ... # AI/ML, data processing, anything
10
+
11
+ app.publish("urn:babel:orders:created", {"order_id": 1042})
12
+ app.run() # consume forever
13
+
14
+ Routing is by URN; the wire format is the canonical envelope (shared core codec),
15
+ so this interoperates with the PHP/Laravel, Symfony, Go, ... SDKs. Retry uses the
16
+ top-level ``attempts`` counter; failures past ``max_attempts`` go to a dead-letter
17
+ queue when enabled.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import inspect
23
+ from typing import Any, Callable, Dict, Mapping, Optional
24
+
25
+ from . import dead_letter
26
+ from .codec import EnvelopeCodec
27
+ from .exceptions import UnknownUrnError
28
+ from .routing import UnknownUrnStrategy
29
+ from .transport import ReceivedMessage, Transport, make_transport
30
+
31
+ Handler = Callable[..., None]
32
+
33
+
34
+ class BabelQueue:
35
+ def __init__(
36
+ self,
37
+ broker_url: str = "memory://",
38
+ *,
39
+ transport: Optional[Transport] = None,
40
+ queue: str = "default",
41
+ on_unknown_urn: str = UnknownUrnStrategy.FAIL,
42
+ max_attempts: int = 3,
43
+ dead_letter: bool = False,
44
+ dead_letter_queue: Optional[str] = None,
45
+ dead_letter_suffix: str = ".dlq",
46
+ ) -> None:
47
+ self.transport = transport if transport is not None else make_transport(broker_url)
48
+ self.queue = queue
49
+ self.on_unknown_urn = on_unknown_urn
50
+ self.max_attempts = max_attempts
51
+ self.dead_letter_enabled = bool(dead_letter)
52
+ self.dead_letter_queue = dead_letter_queue
53
+ self.dead_letter_suffix = dead_letter_suffix
54
+ self._handlers: Dict[str, Handler] = {}
55
+
56
+ # -- Produce ------------------------------------------------------------
57
+
58
+ def publish(
59
+ self,
60
+ urn: str,
61
+ data: Mapping[str, Any],
62
+ *,
63
+ queue: Optional[str] = None,
64
+ trace_id: Optional[str] = None,
65
+ ) -> str:
66
+ """Publish a message; returns its id (``meta.id``)."""
67
+ target = queue or self.queue
68
+ envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
69
+ self.transport.publish(target, EnvelopeCodec.encode(envelope))
70
+ return envelope["meta"]["id"]
71
+
72
+ # -- Register handlers --------------------------------------------------
73
+
74
+ def handler(self, urn: str) -> Callable[[Handler], Handler]:
75
+ """Decorator: register ``fn`` as the handler for ``urn``."""
76
+
77
+ def decorator(fn: Handler) -> Handler:
78
+ self._handlers[urn] = fn
79
+ return fn
80
+
81
+ return decorator
82
+
83
+ def register(self, urn: str, fn: Handler) -> None:
84
+ self._handlers[urn] = fn
85
+
86
+ # -- Consume ------------------------------------------------------------
87
+
88
+ def consume(
89
+ self,
90
+ queue: Optional[str] = None,
91
+ *,
92
+ max_messages: Optional[int] = None,
93
+ timeout: float = 1.0,
94
+ ) -> int:
95
+ """Consume messages until interrupted (or ``max_messages`` processed).
96
+
97
+ Returns the number of messages processed. With ``max_messages`` set, the
98
+ loop stops once that many are handled or the queue drains within ``timeout``.
99
+ """
100
+ target = queue or self.queue
101
+ processed = 0
102
+ try:
103
+ while max_messages is None or processed < max_messages:
104
+ received = self.transport.pop(target, timeout=timeout)
105
+ if received is None:
106
+ if max_messages is not None:
107
+ break
108
+ continue
109
+ self.dispatch(received)
110
+ processed += 1
111
+ except KeyboardInterrupt: # pragma: no cover - graceful Ctrl-C
112
+ pass
113
+ return processed
114
+
115
+ run = consume
116
+
117
+ def dispatch(self, received: ReceivedMessage) -> None:
118
+ """Route one reserved message to its handler and acknowledge it."""
119
+ envelope = EnvelopeCodec.decode(received.body)
120
+ urn = str(envelope.get("job") or envelope.get("urn") or "")
121
+ handler = self._handlers.get(urn) if urn else None
122
+
123
+ try:
124
+ if handler is None:
125
+ self._route_unknown(urn, received, envelope)
126
+ return
127
+ self._invoke(handler, envelope)
128
+ self.transport.ack(received)
129
+ except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
130
+ self._retry_or_dead_letter(received, envelope, exc)
131
+
132
+ # -- Internals ----------------------------------------------------------
133
+
134
+ def _invoke(self, handler: Handler, envelope: Mapping[str, Any]) -> None:
135
+ data = dict(envelope.get("data") or {})
136
+ meta = dict(envelope.get("meta") or {})
137
+ if _handler_wants_envelope(handler):
138
+ handler(data, meta, dict(envelope))
139
+ else:
140
+ handler(data, meta)
141
+
142
+ def _route_unknown(self, urn: str, received: ReceivedMessage, envelope: Mapping[str, Any]) -> None:
143
+ strategy = self.on_unknown_urn
144
+ if strategy == UnknownUrnStrategy.DELETE:
145
+ self.transport.ack(received)
146
+ return
147
+ if strategy == UnknownUrnStrategy.RELEASE:
148
+ self.transport.publish(received.queue, received.body)
149
+ self.transport.ack(received)
150
+ return
151
+ if strategy == UnknownUrnStrategy.DEAD_LETTER:
152
+ self._dead_letter(received, dict(envelope), "unknown_urn", None)
153
+ return
154
+ # FAIL — surfaced through the retry/dead-letter path (never kills the loop).
155
+ raise UnknownUrnError(
156
+ f"No handler mapped for URN [{urn or '(empty)'}]."
157
+ )
158
+
159
+ def _retry_or_dead_letter(
160
+ self, received: ReceivedMessage, envelope: Dict[str, Any], exc: BaseException
161
+ ) -> None:
162
+ attempts = int(envelope.get("attempts", 0)) + 1
163
+ envelope["attempts"] = attempts
164
+
165
+ if attempts < self.max_attempts:
166
+ self.transport.publish(received.queue, EnvelopeCodec.encode(envelope))
167
+ self.transport.ack(received)
168
+ return
169
+
170
+ if self.dead_letter_enabled:
171
+ reason = "unknown_urn" if isinstance(exc, UnknownUrnError) else "failed"
172
+ self._dead_letter(received, envelope, reason, exc)
173
+ return
174
+
175
+ # Retries exhausted, no DLQ configured — drop it (ack so it leaves the queue).
176
+ self.transport.ack(received)
177
+
178
+ def _dead_letter(
179
+ self,
180
+ received: ReceivedMessage,
181
+ envelope: Dict[str, Any],
182
+ reason: str,
183
+ exc: Optional[BaseException],
184
+ ) -> None:
185
+ original_queue = str((envelope.get("meta") or {}).get("queue") or received.queue)
186
+ annotated = dead_letter.annotate(
187
+ envelope,
188
+ reason,
189
+ original_queue,
190
+ int(envelope.get("attempts", 0)),
191
+ error=(str(exc) if exc is not None else None),
192
+ exception=(type(exc).__name__ if exc is not None else None),
193
+ )
194
+ target = self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
195
+ self.transport.publish(target, EnvelopeCodec.encode(annotated))
196
+ self.transport.ack(received)
197
+
198
+
199
+ def _handler_wants_envelope(fn: Handler) -> bool:
200
+ """True if the handler takes a 3rd positional arg (the full envelope)."""
201
+ try:
202
+ params = list(inspect.signature(fn).parameters.values())
203
+ except (TypeError, ValueError): # pragma: no cover - builtins/C callables
204
+ return False
205
+ positional = [
206
+ p for p in params
207
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
208
+ ]
209
+ has_varargs = any(p.kind == p.VAR_POSITIONAL for p in params)
210
+ return has_varargs or len(positional) >= 3
@@ -0,0 +1,48 @@
1
+ """Redis transport (reliable-queue pattern). Requires the ``redis`` extra:
2
+
3
+ pip install "babelqueue[redis]"
4
+
5
+ Producing is ``RPUSH queue body``; consuming atomically moves the head to a
6
+ per-queue processing list (``BLMOVE``) so an in-flight message survives a worker
7
+ crash, and ``ack`` removes it from that processing list. This is a Python-owned
8
+ reliable queue; full parity with Laravel's reserved-set reservation on a *shared*
9
+ Redis queue is a separate conformance task (see the roadmap).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ from .transport import ReceivedMessage, Transport
17
+
18
+
19
+ class RedisTransport(Transport):
20
+ def __init__(self, url: str, *, processing_suffix: str = ":processing") -> None:
21
+ try:
22
+ import redis # noqa: F401 (lazy: only needed for this transport)
23
+ except ImportError as exc: # pragma: no cover - import guard
24
+ raise ImportError(
25
+ "RedisTransport requires the 'redis' package. Install with "
26
+ "pip install \"babelqueue[redis]\"."
27
+ ) from exc
28
+
29
+ self._redis = redis.Redis.from_url(url, decode_responses=True)
30
+ self._processing_suffix = processing_suffix
31
+
32
+ def _processing(self, queue: str) -> str:
33
+ return f"{queue}{self._processing_suffix}"
34
+
35
+ def publish(self, queue: str, body: str) -> None:
36
+ self._redis.rpush(queue, body)
37
+
38
+ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
39
+ body = self._redis.blmove(queue, self._processing(queue), timeout, "LEFT", "RIGHT")
40
+ if body is None:
41
+ return None
42
+ return ReceivedMessage(body=body, queue=queue, handle=body)
43
+
44
+ def ack(self, message: ReceivedMessage) -> None:
45
+ self._redis.lrem(self._processing(message.queue), 1, message.handle)
46
+
47
+ def close(self) -> None: # pragma: no cover
48
+ self._redis.close()
@@ -0,0 +1,82 @@
1
+ """Broker transport abstraction for the runtime.
2
+
3
+ The runtime talks to a broker only through :class:`Transport`, so the routing /
4
+ retry logic is broker-agnostic and unit-testable with :class:`InMemoryTransport`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from collections import defaultdict, deque
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Deque, Dict, Optional
13
+
14
+ from .exceptions import BabelQueueError
15
+
16
+
17
+ @dataclass
18
+ class ReceivedMessage:
19
+ """A message popped from a queue, plus a transport-internal ack handle."""
20
+
21
+ body: str
22
+ queue: str
23
+ handle: Any = None
24
+
25
+
26
+ class Transport(ABC):
27
+ """Minimal broker contract: publish a raw body, pop one, acknowledge it."""
28
+
29
+ @abstractmethod
30
+ def publish(self, queue: str, body: str) -> None:
31
+ """Append an already-encoded envelope to ``queue``."""
32
+
33
+ @abstractmethod
34
+ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
35
+ """Reserve the next message from ``queue``, or ``None`` if none arrives."""
36
+
37
+ @abstractmethod
38
+ def ack(self, message: ReceivedMessage) -> None:
39
+ """Acknowledge (remove) a reserved message."""
40
+
41
+ def close(self) -> None: # pragma: no cover - optional
42
+ """Release any resources (override if needed)."""
43
+
44
+
45
+ class InMemoryTransport(Transport):
46
+ """In-process transport for tests and broker-free local runs (``memory://``)."""
47
+
48
+ def __init__(self) -> None:
49
+ self._queues: Dict[str, Deque[str]] = defaultdict(deque)
50
+
51
+ def publish(self, queue: str, body: str) -> None:
52
+ self._queues[queue].append(body)
53
+
54
+ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
55
+ dq = self._queues.get(queue)
56
+ if not dq:
57
+ return None
58
+ return ReceivedMessage(body=dq.popleft(), queue=queue)
59
+
60
+ def ack(self, message: ReceivedMessage) -> None:
61
+ # Already removed on pop; nothing to do.
62
+ return None
63
+
64
+ def size(self, queue: str) -> int:
65
+ return len(self._queues.get(queue, ()))
66
+
67
+
68
+ def make_transport(broker_url: str) -> Transport:
69
+ """Build a transport from a broker URL scheme (``memory://``, ``redis://``)."""
70
+ scheme = broker_url.split("://", 1)[0] if "://" in broker_url else broker_url
71
+
72
+ if scheme in ("", "memory"):
73
+ return InMemoryTransport()
74
+ if scheme in ("redis", "rediss"):
75
+ from .redis_transport import RedisTransport
76
+
77
+ return RedisTransport(broker_url)
78
+
79
+ raise BabelQueueError(
80
+ f"Unsupported broker scheme {scheme!r}. Use 'memory://' or 'redis://', "
81
+ "or pass your own Transport via BabelQueue(transport=...)."
82
+ )
@@ -0,0 +1,125 @@
1
+ """The runtime: publish, @handler routing, retry and dead-lettering.
2
+
3
+ All tests use the in-memory transport — no broker required.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import unittest
9
+
10
+ from babelqueue import BabelQueue, EnvelopeCodec, UnknownUrnStrategy
11
+
12
+
13
+ class AppTest(unittest.TestCase):
14
+ def test_publish_then_consume_invokes_handler_and_acks(self) -> None:
15
+ app = BabelQueue("memory://", queue="orders")
16
+ seen = {}
17
+
18
+ @app.handler("urn:babel:orders:created")
19
+ def handle(data, meta): # noqa: ANN001
20
+ seen["data"] = data
21
+ seen["meta"] = meta
22
+
23
+ msg_id = app.publish("urn:babel:orders:created", {"order_id": 7})
24
+ processed = app.consume(max_messages=1)
25
+
26
+ self.assertEqual(processed, 1)
27
+ self.assertEqual(seen["data"], {"order_id": 7})
28
+ self.assertEqual(seen["meta"]["lang"], "python")
29
+ self.assertEqual(seen["meta"]["id"], msg_id)
30
+ self.assertEqual(app.transport.size("orders"), 0) # acked / drained
31
+
32
+ def test_three_arg_handler_receives_full_envelope(self) -> None:
33
+ app = BabelQueue("memory://")
34
+ seen = {}
35
+
36
+ @app.handler("urn:babel:orders:created")
37
+ def handle(data, meta, message): # noqa: ANN001
38
+ seen["trace"] = message["trace_id"]
39
+ seen["job"] = message["job"]
40
+
41
+ app.publish("urn:babel:orders:created", {}, trace_id="trace-1")
42
+ app.consume(max_messages=1)
43
+
44
+ self.assertEqual(seen["trace"], "trace-1")
45
+ self.assertEqual(seen["job"], "urn:babel:orders:created")
46
+
47
+ def test_round_trips_with_the_canonical_codec(self) -> None:
48
+ app = BabelQueue("memory://", queue="orders")
49
+ captured = {}
50
+
51
+ @app.handler("urn:babel:orders:created")
52
+ def handle(data, meta): # noqa: ANN001
53
+ captured.update(data)
54
+
55
+ app.publish("urn:babel:orders:created", {"order_id": 9, "amount": "9.90"})
56
+ # the raw body on the queue is a canonical envelope decodable by any SDK
57
+ raw = app.transport._queues["orders"][0] # noqa: SLF001 - test introspection
58
+ env = EnvelopeCodec.decode(raw)
59
+ self.assertEqual(list(env.keys()), ["job", "trace_id", "data", "meta", "attempts"])
60
+
61
+ app.consume(max_messages=1)
62
+ self.assertEqual(captured, {"order_id": 9, "amount": "9.90"})
63
+
64
+ def test_unknown_urn_delete_drops_message(self) -> None:
65
+ app = BabelQueue("memory://", queue="q", on_unknown_urn=UnknownUrnStrategy.DELETE)
66
+ app.publish("urn:babel:nobody", {})
67
+ app.consume(max_messages=1)
68
+ self.assertEqual(app.transport.size("q"), 0)
69
+
70
+ def test_unknown_urn_dead_letter_quarantines(self) -> None:
71
+ app = BabelQueue(
72
+ "memory://", queue="q",
73
+ on_unknown_urn=UnknownUrnStrategy.DEAD_LETTER, dead_letter=True,
74
+ )
75
+ app.publish("urn:babel:nobody", {"x": 1})
76
+ app.consume(max_messages=1)
77
+
78
+ self.assertEqual(app.transport.size("q"), 0)
79
+ dlq_raw = app.transport._queues["q.dlq"][0] # noqa: SLF001
80
+ env = EnvelopeCodec.decode(dlq_raw)
81
+ self.assertEqual(env["dead_letter"]["reason"], "unknown_urn")
82
+ self.assertEqual(env["dead_letter"]["original_queue"], "q")
83
+
84
+ def test_handler_failure_retries_then_dead_letters(self) -> None:
85
+ app = BabelQueue("memory://", queue="orders", max_attempts=2, dead_letter=True)
86
+ calls = {"n": 0}
87
+
88
+ @app.handler("urn:babel:orders:created")
89
+ def handle(data, meta): # noqa: ANN001
90
+ calls["n"] += 1
91
+ raise RuntimeError("boom")
92
+
93
+ app.publish("urn:babel:orders:created", {"order_id": 1})
94
+ # attempt 1 -> requeue, attempt 2 -> dead-letter
95
+ app.consume(max_messages=5)
96
+
97
+ self.assertEqual(calls["n"], 2)
98
+ self.assertEqual(app.transport.size("orders"), 0)
99
+ dlq_raw = app.transport._queues["orders.dlq"][0] # noqa: SLF001
100
+ env = EnvelopeCodec.decode(dlq_raw)
101
+ self.assertEqual(env["dead_letter"]["reason"], "failed")
102
+ self.assertEqual(env["dead_letter"]["error"], "boom")
103
+ self.assertEqual(env["dead_letter"]["attempts"], 2)
104
+
105
+ def test_failure_without_dlq_drops_after_max_attempts(self) -> None:
106
+ app = BabelQueue("memory://", queue="orders", max_attempts=1, dead_letter=False)
107
+
108
+ @app.handler("urn:babel:orders:created")
109
+ def handle(data, meta): # noqa: ANN001
110
+ raise RuntimeError("nope")
111
+
112
+ app.publish("urn:babel:orders:created", {})
113
+ app.consume(max_messages=3)
114
+ self.assertEqual(app.transport.size("orders"), 0)
115
+ self.assertEqual(app.transport.size("orders.dlq"), 0)
116
+
117
+ def test_unsupported_broker_scheme_raises(self) -> None:
118
+ from babelqueue import BabelQueueError
119
+
120
+ with self.assertRaises(BabelQueueError):
121
+ BabelQueue("kafka://localhost:9092")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ unittest.main()
@@ -0,0 +1,78 @@
1
+ """Integration tests for the Redis transport.
2
+
3
+ Skipped unless a Redis server is reachable (the `redis` package installed and a
4
+ broker at ``BABELQUEUE_TEST_REDIS`` / localhost). The CI ``integration`` job runs
5
+ these against a Redis 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 redis as _redis
16
+ except ImportError: # pragma: no cover
17
+ _redis = None
18
+
19
+ from babelqueue import BabelQueue, EnvelopeCodec
20
+
21
+ REDIS_URL = os.environ.get("BABELQUEUE_TEST_REDIS", "redis://localhost:6379/0")
22
+
23
+
24
+ def _redis_available() -> bool:
25
+ if _redis is None:
26
+ return False
27
+ try:
28
+ _redis.Redis.from_url(REDIS_URL, decode_responses=True).ping()
29
+ return True
30
+ except Exception: # pragma: no cover - connection failure
31
+ return False
32
+
33
+
34
+ @unittest.skipUnless(_redis_available(), f"no reachable Redis at {REDIS_URL}")
35
+ class RedisTransportTest(unittest.TestCase):
36
+ def setUp(self) -> None:
37
+ self.queue = f"bqtest:{uuid.uuid4().hex}"
38
+ self.client = _redis.Redis.from_url(REDIS_URL, decode_responses=True)
39
+
40
+ def tearDown(self) -> None:
41
+ self.client.delete(self.queue, f"{self.queue}:processing", f"{self.queue}.dlq")
42
+
43
+ def test_publish_consume_round_trip_and_ack(self) -> None:
44
+ app = BabelQueue(REDIS_URL, queue=self.queue)
45
+ seen = {}
46
+
47
+ @app.handler("urn:babel:orders:created")
48
+ def handle(data, meta): # noqa: ANN001
49
+ seen.update(data)
50
+
51
+ app.publish("urn:babel:orders:created", {"order_id": 42})
52
+ processed = app.consume(max_messages=1, timeout=2)
53
+
54
+ self.assertEqual(processed, 1)
55
+ self.assertEqual(seen, {"order_id": 42})
56
+ # acked: nothing left on the queue or the processing list
57
+ self.assertEqual(self.client.llen(self.queue), 0)
58
+ self.assertEqual(self.client.llen(f"{self.queue}:processing"), 0)
59
+
60
+ def test_failure_dead_letters_to_redis(self) -> None:
61
+ app = BabelQueue(REDIS_URL, queue=self.queue, max_attempts=1, dead_letter=True)
62
+
63
+ @app.handler("urn:babel:orders:created")
64
+ def handle(data, meta): # noqa: ANN001
65
+ raise RuntimeError("boom")
66
+
67
+ app.publish("urn:babel:orders:created", {"order_id": 1})
68
+ app.consume(max_messages=2, timeout=2)
69
+
70
+ self.assertEqual(self.client.llen(f"{self.queue}:processing"), 0)
71
+ dlq = self.client.lrange(f"{self.queue}.dlq", 0, -1)
72
+ self.assertEqual(len(dlq), 1)
73
+ env = EnvelopeCodec.decode(dlq[0])
74
+ self.assertEqual(env["dead_letter"]["reason"], "failed")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()
@@ -1,33 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
-
8
- permissions:
9
- contents: read
10
-
11
- jobs:
12
- test:
13
- name: Python ${{ matrix.python }}
14
- runs-on: ubuntu-latest
15
- strategy:
16
- fail-fast: false
17
- matrix:
18
- python: ['3.9', '3.10', '3.11', '3.12', '3.13']
19
- steps:
20
- - uses: actions/checkout@v4
21
-
22
- - name: Setup Python
23
- uses: actions/setup-python@v5
24
- with:
25
- python-version: ${{ matrix.python }}
26
-
27
- - name: Install
28
- run: |
29
- python -m pip install --upgrade pip
30
- pip install -e ".[dev]"
31
-
32
- - name: Run tests
33
- run: pytest
File without changes
File without changes