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.
- babelqueue-0.2.0/.github/workflows/ci.yml +64 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/CHANGELOG.md +15 -5
- {babelqueue-0.1.0 → babelqueue-0.2.0}/PKG-INFO +42 -7
- {babelqueue-0.1.0 → babelqueue-0.2.0}/README.md +41 -6
- {babelqueue-0.1.0 → babelqueue-0.2.0}/pyproject.toml +1 -1
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/__init__.py +7 -1
- babelqueue-0.2.0/src/babelqueue/app.py +210 -0
- babelqueue-0.2.0/src/babelqueue/redis_transport.py +48 -0
- babelqueue-0.2.0/src/babelqueue/transport.py +82 -0
- babelqueue-0.2.0/tests/test_app.py +125 -0
- babelqueue-0.2.0/tests/test_redis_transport.py +78 -0
- babelqueue-0.1.0/.github/workflows/ci.yml +0 -33
- {babelqueue-0.1.0 → babelqueue-0.2.0}/.github/workflows/release.yml +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/.gitignore +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/LICENSE +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-0.1.0 → babelqueue-0.2.0}/tests/test_codec.py +0 -0
- {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
|
-
- **
|
|
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/
|
|
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.
|
|
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
|
-
##
|
|
115
|
+
## Runtime — produce & consume
|
|
116
116
|
|
|
117
|
-
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
##
|
|
83
|
+
## Runtime — produce & consume
|
|
84
84
|
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|