babelqueue 0.1.0__tar.gz → 0.3.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 (28) hide show
  1. babelqueue-0.3.0/.github/workflows/ci.yml +74 -0
  2. babelqueue-0.3.0/CHANGELOG.md +49 -0
  3. {babelqueue-0.1.0 → babelqueue-0.3.0}/PKG-INFO +45 -7
  4. {babelqueue-0.1.0 → babelqueue-0.3.0}/README.md +44 -6
  5. {babelqueue-0.1.0 → babelqueue-0.3.0}/pyproject.toml +1 -1
  6. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/__init__.py +7 -1
  7. babelqueue-0.3.0/src/babelqueue/app.py +210 -0
  8. babelqueue-0.3.0/src/babelqueue/pika_transport.py +109 -0
  9. babelqueue-0.3.0/src/babelqueue/redis_transport.py +48 -0
  10. babelqueue-0.3.0/src/babelqueue/transport.py +86 -0
  11. babelqueue-0.3.0/tests/test_app.py +125 -0
  12. babelqueue-0.3.0/tests/test_pika_transport.py +98 -0
  13. babelqueue-0.3.0/tests/test_redis_transport.py +78 -0
  14. babelqueue-0.1.0/.github/workflows/ci.yml +0 -33
  15. babelqueue-0.1.0/CHANGELOG.md +0 -30
  16. {babelqueue-0.1.0 → babelqueue-0.3.0}/.github/workflows/release.yml +0 -0
  17. {babelqueue-0.1.0 → babelqueue-0.3.0}/.gitignore +0 -0
  18. {babelqueue-0.1.0 → babelqueue-0.3.0}/LICENSE +0 -0
  19. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/codec.py +0 -0
  20. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/contracts.py +0 -0
  21. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/dead_letter.py +0 -0
  22. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/exceptions.py +0 -0
  23. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/py.typed +0 -0
  24. {babelqueue-0.1.0 → babelqueue-0.3.0}/src/babelqueue/routing.py +0 -0
  25. {babelqueue-0.1.0 → babelqueue-0.3.0}/tests/fixtures/dead-lettered.json +0 -0
  26. {babelqueue-0.1.0 → babelqueue-0.3.0}/tests/fixtures/order-created.json +0 -0
  27. {babelqueue-0.1.0 → babelqueue-0.3.0}/tests/test_codec.py +0 -0
  28. {babelqueue-0.1.0 → babelqueue-0.3.0}/tests/test_dead_letter.py +0 -0
@@ -0,0 +1,74 @@
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
+ 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
57
+ steps:
58
+ - uses: actions/checkout@v4
59
+
60
+ - name: Setup Python
61
+ uses: actions/setup-python@v5
62
+ with:
63
+ python-version: '3.12'
64
+
65
+ - name: Install (with redis + amqp extras)
66
+ run: |
67
+ python -m pip install --upgrade pip
68
+ pip install -e ".[redis,amqp,dev]"
69
+
70
+ - name: Run tests (Redis + RabbitMQ transports included)
71
+ env:
72
+ BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
73
+ BABELQUEUE_TEST_AMQP: amqp://guest:guest@localhost:5672/
74
+ run: pytest
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to `babelqueue` (Python) are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ The envelope wire format is versioned separately by `meta.schema_version`
8
+ (currently **1**) — see the contract at [babelqueue.com](https://babelqueue.com).
9
+
10
+ ## [Unreleased]
11
+
12
+ ### Added
13
+ - **RabbitMQ transport** (`PikaTransport`, `amqp://`): durable queue, persistent
14
+ delivery, `basic_get` + manual ack, and the contract AMQP properties (`type`=URN,
15
+ `correlation_id`=trace_id, `x-schema-version`/`x-source-lang`/`x-attempts`).
16
+ Optional `[amqp]` extra (lazy `pika` import) — the core stays zero-dep.
17
+
18
+ ## [0.2.0] - 2026-06-06
19
+
20
+ ### Added
21
+ - **Runtime** — `BabelQueue(broker_url=...)` app with a `@app.handler("urn:...")`
22
+ decorator, `publish()`, and a `consume()` / `run()` loop. Routes by URN over the
23
+ canonical envelope; `attempts`-based retry → opt-in dead-letter queue;
24
+ `on_unknown_urn` strategies (`fail`/`delete`/`release`/`dead_letter`).
25
+ - **Transports** — a pluggable `Transport` abstraction with `InMemoryTransport`
26
+ (`memory://`, for tests/local) and `RedisTransport` (`redis://`, reliable-queue
27
+ pattern via `BLMOVE` + a processing list). Redis client is an optional `[redis]`
28
+ extra, imported lazily — the core stays zero-dep.
29
+
30
+ ## [0.1.0] - 2026-06-06
31
+
32
+ ### Added
33
+ - `EnvelopeCodec` — builds (`make`, `from_message`), encodes and decodes the
34
+ canonical `{job, trace_id, data, meta, attempts}` envelope (`schema_version` 1).
35
+ The single Python implementation of the wire format.
36
+ - Contracts `PolyglotMessage` / `HasTraceId` (typed `Protocol`s).
37
+ - `dead_letter.annotate()` — additive `dead_letter` block builder.
38
+ - `UnknownUrnStrategy` — `fail` / `delete` / `release` / `dead_letter`.
39
+ - `BabelQueueError` / `UnknownUrnError`.
40
+ - Golden conformance fixtures under `tests/fixtures/` (shared cross-SDK set).
41
+ - `py.typed` — ships inline type hints (PEP 561).
42
+
43
+ ### Notes
44
+ - Pre-1.0: the public API may change before the `1.0.0` tag.
45
+ - The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
46
+
47
+ [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v0.2.0...HEAD
48
+ [0.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.1.0...v0.2.0
49
+ [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.3.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,51 @@ 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. Broker clients come via
118
+ extras:
119
+
120
+ ```bash
121
+ pip install "babelqueue[redis]" # redis://
122
+ pip install "babelqueue[amqp]" # amqp:// (RabbitMQ)
123
+ ```
124
+
125
+ ```python
126
+ from babelqueue import BabelQueue
127
+
128
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
129
+ # or: BabelQueue("amqp://guest:guest@localhost:5672/", queue="orders")
130
+
131
+ @app.handler("urn:babel:orders:created")
132
+ def on_order_created(data, meta): # AI/ML, data processing, anything
133
+ print("order", data["order_id"])
134
+
135
+ # producer (any service, any language) …
136
+ app.publish("urn:babel:orders:created", {"order_id": 1042})
137
+
138
+ # worker
139
+ app.run() # consume forever (Ctrl-C to stop)
140
+ ```
141
+
142
+ - **Routing** is by URN; the wire format is the canonical envelope, so this
143
+ consumes messages produced by *any* BabelQueue SDK.
144
+ - **Handlers** receive `(data, meta)`, or `(data, meta, message)` to get the full
145
+ envelope (incl. `trace_id`).
146
+ - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
147
+ envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
148
+ messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
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=...`.
152
+
153
+ > **Celery** / **Django** adapters are the next iterations.
154
+
155
+ ## What's here
156
+
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.
122
160
 
123
161
  ## Testing
124
162
 
@@ -80,13 +80,51 @@ 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. Broker clients come via
86
+ extras:
87
+
88
+ ```bash
89
+ pip install "babelqueue[redis]" # redis://
90
+ pip install "babelqueue[amqp]" # amqp:// (RabbitMQ)
91
+ ```
92
+
93
+ ```python
94
+ from babelqueue import BabelQueue
95
+
96
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
97
+ # or: BabelQueue("amqp://guest:guest@localhost:5672/", queue="orders")
98
+
99
+ @app.handler("urn:babel:orders:created")
100
+ def on_order_created(data, meta): # AI/ML, data processing, anything
101
+ print("order", data["order_id"])
102
+
103
+ # producer (any service, any language) …
104
+ app.publish("urn:babel:orders:created", {"order_id": 1042})
105
+
106
+ # worker
107
+ app.run() # consume forever (Ctrl-C to stop)
108
+ ```
109
+
110
+ - **Routing** is by URN; the wire format is the canonical envelope, so this
111
+ consumes messages produced by *any* BabelQueue SDK.
112
+ - **Handlers** receive `(data, meta)`, or `(data, meta, message)` to get the full
113
+ envelope (incl. `trace_id`).
114
+ - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
115
+ envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
116
+ messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
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=...`.
120
+
121
+ > **Celery** / **Django** adapters are the next iterations.
122
+
123
+ ## What's here
124
+
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.
90
128
 
91
129
  ## Testing
92
130
 
@@ -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.3.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.3.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,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
@@ -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,86 @@
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
+ if scheme in ("amqp", "amqps"):
79
+ from .pika_transport import PikaTransport
80
+
81
+ return PikaTransport(broker_url)
82
+
83
+ raise BabelQueueError(
84
+ f"Unsupported broker scheme {scheme!r}. Use 'memory://', 'redis://' or "
85
+ "'amqp://', or pass your own Transport via BabelQueue(transport=...)."
86
+ )
@@ -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,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()
@@ -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
@@ -1,30 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `babelqueue` (Python) are documented here.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
- The envelope wire format is versioned separately by `meta.schema_version`
8
- (currently **1**) — see the contract at [babelqueue.com](https://babelqueue.com).
9
-
10
- ## [Unreleased]
11
-
12
- ### Added
13
- - `EnvelopeCodec` — builds (`make`, `from_message`), encodes and decodes the
14
- canonical `{job, trace_id, data, meta, attempts}` envelope (`schema_version` 1).
15
- The single Python implementation of the wire format.
16
- - Contracts `PolyglotMessage` / `HasTraceId` (typed `Protocol`s).
17
- - `dead_letter.annotate()` — additive `dead_letter` block builder.
18
- - `UnknownUrnStrategy` — `fail` / `delete` / `release` / `dead_letter`.
19
- - `BabelQueueError` / `UnknownUrnError`.
20
- - Golden conformance fixtures under `tests/fixtures/` (shared cross-SDK set).
21
- - `py.typed` — ships inline type hints (PEP 561).
22
-
23
- ### Notes
24
- - 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.
29
-
30
- [Unreleased]: https://github.com/BabelQueue/babelqueue-python/commits/main
File without changes
File without changes