rillkit 0.1.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.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .env
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .mypy_cache/
9
+ node_modules/
10
+ docs/.vitepress/cache/
11
+ docs/.vitepress/dist/
12
+ .DS_Store
rillkit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nimrod Maina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rillkit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: rillkit
3
+ Version: 0.1.0
4
+ Summary: Simple publish and consume for Redis Streams: consumer groups, retries, DLQ, ack policies and middleware.
5
+ Project-URL: Homepage, https://nimzymaina.github.io/rillkit/
6
+ Project-URL: Documentation, https://nimzymaina.github.io/rillkit/
7
+ Project-URL: Repository, https://github.com/NimzyMaina/rillkit
8
+ Project-URL: Issues, https://github.com/NimzyMaina/rillkit/issues
9
+ Author: Nimrod Maina
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: consumer-group,messaging,queue,redis,streams,worker
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: redis>=5.0
26
+ Provides-Extra: pydantic
27
+ Requires-Dist: pydantic>=2.0; extra == 'pydantic'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # RillKit
31
+
32
+ Simple publish and consume for Redis Streams. Consumer groups, retries with backoff, a dead-letter
33
+ queue, ack policies, middleware and lifecycle hooks, without the boilerplate.
34
+
35
+ **Docs:** https://nimzymaina.github.io/rillkit/
36
+
37
+ ```bash
38
+ pip install rillkit # plain dict bodies
39
+ pip install "rillkit[pydantic]" # optional typed bodies
40
+ ```
41
+
42
+ ```python
43
+ from rillkit import Broker, Stream
44
+
45
+ orders = Stream("orders", group="workers")
46
+ broker = Broker.from_env() # REDIS_URL, or REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_SSL
47
+
48
+
49
+ @broker.subscribe(orders, max_retries=3)
50
+ def handle(order: dict):
51
+ print("got", order)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ broker.publish(orders, {"id": 1})
56
+ broker.run() # blocks; Ctrl-C / SIGTERM finishes the current message and exits
57
+ ```
58
+
59
+ ## Features
60
+
61
+ - Consumer groups: group creation, `XREADGROUP`, and `XAUTOCLAIM`-style takeover of stuck messages
62
+ - At-least-once delivery with retries (`max_retries`, optional exponential `backoff`)
63
+ - Dead-letter queue with the original id, error and delivery count; `broker.replay_dlq()`
64
+ - `msg.nack()` to retry, `msg.reject()` (or `RejectMessage`) to skip retries and go straight to the DLQ
65
+ - `AckPolicy`: `NACK_ON_ERROR` (default), `ACK_ALWAYS`, `ACK_FIRST`, `MANUAL`
66
+ - Plain dicts by default, Pydantic models when you annotate the handler with one
67
+ - Middleware hooks (`on_receive`, `consume_scope`, `publish_scope`, `after_processed`) and an `Idempotent` middleware
68
+ - `on_startup`, `after_startup`, `on_shutdown`, `after_shutdown` hooks and graceful SIGTERM shutdown
69
+ - Worker heartbeats: `broker.active_workers(stream)` includes idle workers
70
+ - Stack `@broker.publisher(...)` under `@broker.subscribe(...)` to publish a handler's return value
71
+ - The wire format is a single `data` field holding JSON, so plain redis-py producers interoperate
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ uv sync --group dev
77
+ REDIS_URL=redis://localhost:6379/15 uv run pytest # needs a Redis
78
+ npm install && npm run docs:dev # docs
79
+ ```
80
+
81
+ MIT licensed.
@@ -0,0 +1,52 @@
1
+ # RillKit
2
+
3
+ Simple publish and consume for Redis Streams. Consumer groups, retries with backoff, a dead-letter
4
+ queue, ack policies, middleware and lifecycle hooks, without the boilerplate.
5
+
6
+ **Docs:** https://nimzymaina.github.io/rillkit/
7
+
8
+ ```bash
9
+ pip install rillkit # plain dict bodies
10
+ pip install "rillkit[pydantic]" # optional typed bodies
11
+ ```
12
+
13
+ ```python
14
+ from rillkit import Broker, Stream
15
+
16
+ orders = Stream("orders", group="workers")
17
+ broker = Broker.from_env() # REDIS_URL, or REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_SSL
18
+
19
+
20
+ @broker.subscribe(orders, max_retries=3)
21
+ def handle(order: dict):
22
+ print("got", order)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ broker.publish(orders, {"id": 1})
27
+ broker.run() # blocks; Ctrl-C / SIGTERM finishes the current message and exits
28
+ ```
29
+
30
+ ## Features
31
+
32
+ - Consumer groups: group creation, `XREADGROUP`, and `XAUTOCLAIM`-style takeover of stuck messages
33
+ - At-least-once delivery with retries (`max_retries`, optional exponential `backoff`)
34
+ - Dead-letter queue with the original id, error and delivery count; `broker.replay_dlq()`
35
+ - `msg.nack()` to retry, `msg.reject()` (or `RejectMessage`) to skip retries and go straight to the DLQ
36
+ - `AckPolicy`: `NACK_ON_ERROR` (default), `ACK_ALWAYS`, `ACK_FIRST`, `MANUAL`
37
+ - Plain dicts by default, Pydantic models when you annotate the handler with one
38
+ - Middleware hooks (`on_receive`, `consume_scope`, `publish_scope`, `after_processed`) and an `Idempotent` middleware
39
+ - `on_startup`, `after_startup`, `on_shutdown`, `after_shutdown` hooks and graceful SIGTERM shutdown
40
+ - Worker heartbeats: `broker.active_workers(stream)` includes idle workers
41
+ - Stack `@broker.publisher(...)` under `@broker.subscribe(...)` to publish a handler's return value
42
+ - The wire format is a single `data` field holding JSON, so plain redis-py producers interoperate
43
+
44
+ ## Development
45
+
46
+ ```bash
47
+ uv sync --group dev
48
+ REDIS_URL=redis://localhost:6379/15 uv run pytest # needs a Redis
49
+ npm install && npm run docs:dev # docs
50
+ ```
51
+
52
+ MIT licensed.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rillkit"
7
+ version = "0.1.0"
8
+ description = "Simple publish and consume for Redis Streams: consumer groups, retries, DLQ, ack policies and middleware."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Nimrod Maina" }]
13
+ keywords = ["redis", "streams", "consumer-group", "queue", "messaging", "worker"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["redis>=5.0"]
28
+
29
+ [project.optional-dependencies]
30
+ pydantic = ["pydantic>=2.0"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://nimzymaina.github.io/rillkit/"
34
+ Documentation = "https://nimzymaina.github.io/rillkit/"
35
+ Repository = "https://github.com/NimzyMaina/rillkit"
36
+ Issues = "https://github.com/NimzyMaina/rillkit/issues"
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest>=8", "pytest-timeout>=2", "pydantic>=2", "ruff>=0.6", "mypy>=1.10", "build", "twine"]
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/rillkit"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ timeout = 60
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ src = ["src", "tests"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B"]
57
+ ignore = ["UP007", "UP045"]
@@ -0,0 +1,25 @@
1
+ """rillkit: simple publish and consume for Redis Streams."""
2
+
3
+ from .ack import AckPolicy
4
+ from .broker import Broker, Publisher
5
+ from .codec import Codec, JsonCodec
6
+ from .exceptions import (
7
+ AckMessage,
8
+ DecodeError,
9
+ NackMessage,
10
+ PublishError,
11
+ RejectMessage,
12
+ RillkitError,
13
+ )
14
+ from .message import Message
15
+ from .middleware import BaseMiddleware, Idempotent, PublishCommand
16
+ from .settings import Settings
17
+ from .stream import Stream
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "AckMessage", "AckPolicy", "BaseMiddleware", "Broker", "Codec", "DecodeError", "Idempotent",
23
+ "JsonCodec", "Message", "NackMessage", "PublishCommand", "PublishError", "Publisher",
24
+ "RejectMessage", "RillkitError", "Settings", "Stream",
25
+ ]
@@ -0,0 +1,18 @@
1
+ from enum import Enum
2
+
3
+
4
+ class AckPolicy(str, Enum):
5
+ """What happens to a message when its handler finishes.
6
+
7
+ NACK_ON_ERROR Ack when the handler returns, nack when it raises. This is the default.
8
+ ACK_ALWAYS Ack when the handler returns or raises. Failures are logged and dropped, never
9
+ retried.
10
+ ACK_FIRST Ack before the handler runs. At-most-once: a crash loses the message.
11
+ MANUAL rillkit never acks. The handler must call ``msg.ack()``. If it returns without
12
+ acking, or raises, the message stays pending and is retried.
13
+ """
14
+
15
+ NACK_ON_ERROR = "nack_on_error"
16
+ ACK_ALWAYS = "ack_always"
17
+ ACK_FIRST = "ack_first"
18
+ MANUAL = "manual"
@@ -0,0 +1,271 @@
1
+ import logging
2
+ import signal
3
+ import sys
4
+ import threading
5
+ from collections.abc import Sequence
6
+ from functools import partial
7
+ from typing import Any, Callable, Optional
8
+
9
+ import redis
10
+
11
+ from .ack import AckPolicy
12
+ from .consumer import HEARTBEAT_PREFIX, Subscriber, _text
13
+ from .exceptions import PublishError
14
+ from .middleware import BaseMiddleware, PublishCommand
15
+ from .settings import Settings
16
+ from .stream import Stream, StreamLike, as_stream
17
+
18
+ log = logging.getLogger("rillkit")
19
+
20
+ Hook = Callable[[], Any]
21
+
22
+
23
+ class Publisher:
24
+ """Returned by ``broker.publisher(stream)``. Use it as a decorator to publish a handler's return
25
+ value, or call ``.publish(body)`` directly."""
26
+
27
+ def __init__(self, broker: "Broker", stream: Stream, maxlen: Optional[int] = None) -> None:
28
+ self.broker = broker
29
+ self.stream = stream
30
+ self.maxlen = maxlen
31
+
32
+ def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
33
+ publishers = list(getattr(func, "_rillkit_publishers", ()))
34
+ publishers.append(self)
35
+ func._rillkit_publishers = publishers # type: ignore[attr-defined]
36
+ return func
37
+
38
+ def publish(self, body: Any) -> str:
39
+ return self.broker.publish(self.stream, body, maxlen=self.maxlen)
40
+
41
+
42
+ class Broker:
43
+ """Owns the Redis client, the subscribers and the lifecycle hooks.
44
+
45
+ broker = Broker.from_env()
46
+
47
+ @broker.subscribe(Stream("orders", group="workers"))
48
+ def handle(order: dict): ...
49
+
50
+ broker.publish("orders", {"id": 1})
51
+ broker.run()
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ url: Optional[str] = None,
57
+ *,
58
+ client: Optional["redis.Redis"] = None,
59
+ settings: Optional[Settings] = None,
60
+ middlewares: Sequence[BaseMiddleware] = (),
61
+ ) -> None:
62
+ if client is None:
63
+ client = (settings or Settings(url=url)).client()
64
+ self.client = client
65
+ self.middlewares: list[BaseMiddleware] = []
66
+ for mw in middlewares:
67
+ self.use(mw)
68
+ self.subscribers: list[Subscriber] = []
69
+ self._hooks: dict[str, list[Hook]] = {
70
+ "on_startup": [], "after_startup": [], "on_shutdown": [], "after_shutdown": [],
71
+ }
72
+ self._stop = threading.Event()
73
+ self._stop_lock = threading.Lock()
74
+ self._stopped = False
75
+
76
+ @classmethod
77
+ def from_env(cls, prefix: str = "REDIS_", **kwargs: Any) -> "Broker":
78
+ return cls(settings=Settings.from_env(prefix), **kwargs)
79
+
80
+ # middleware
81
+
82
+ def use(self, middleware: BaseMiddleware) -> BaseMiddleware:
83
+ middleware.broker = self
84
+ self.middlewares.append(middleware)
85
+ return middleware
86
+
87
+ # subscribing and publishing
88
+
89
+ def subscribe(
90
+ self,
91
+ stream: StreamLike,
92
+ *,
93
+ group: Optional[str] = None,
94
+ ack_policy: AckPolicy = AckPolicy.NACK_ON_ERROR,
95
+ max_retries: Optional[int] = 3,
96
+ claim_idle_ms: int = 60_000,
97
+ backoff: float = 1.0,
98
+ max_backoff_ms: int = 15 * 60_000,
99
+ block_ms: int = 5000,
100
+ count: int = 10,
101
+ middlewares: Sequence[BaseMiddleware] = (),
102
+ consumer: Optional[str] = None,
103
+ heartbeat: bool = True,
104
+ heartbeat_ttl: int = 60,
105
+ group_start_id: str = "0",
106
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
107
+ """Register a handler for a stream. The handler is returned unchanged."""
108
+ resolved = as_stream(stream)
109
+ if group:
110
+ resolved = Stream(resolved.name, group, resolved.dlq, resolved.maxlen, resolved.codec)
111
+ own = list(middlewares)
112
+ for m in own:
113
+ m.broker = self
114
+
115
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
116
+ self.subscribers.append(Subscriber(
117
+ self, resolved, func, ack_policy=ack_policy, max_retries=max_retries,
118
+ claim_idle_ms=claim_idle_ms, backoff=backoff, max_backoff_ms=max_backoff_ms,
119
+ block_ms=block_ms, count=count, middlewares=own,
120
+ consumer=consumer, heartbeat=heartbeat, heartbeat_ttl=heartbeat_ttl,
121
+ group_start_id=group_start_id,
122
+ ))
123
+ return func
124
+
125
+ return decorator
126
+
127
+ def publisher(self, stream: StreamLike, *, maxlen: Optional[int] = None) -> Publisher:
128
+ return Publisher(self, as_stream(stream), maxlen)
129
+
130
+ def publish(
131
+ self,
132
+ stream: StreamLike,
133
+ body: Any,
134
+ *,
135
+ maxlen: Optional[int] = None,
136
+ approximate: bool = True,
137
+ ) -> str:
138
+ """Add one entry to the stream and return its id. Raises ``PublishError`` on failure."""
139
+ resolved = as_stream(stream)
140
+ cmd = PublishCommand(
141
+ resolved, body, maxlen if maxlen is not None else resolved.maxlen, approximate
142
+ )
143
+ call: Callable[[PublishCommand], str] = self._xadd
144
+ for mw in reversed(self.middlewares):
145
+ call = partial(mw.publish_scope, call)
146
+ return call(cmd)
147
+
148
+ def _xadd(self, cmd: PublishCommand) -> str:
149
+ try:
150
+ payload = cmd.stream.codec.encode(cmd.body)
151
+ entry_id = self.client.xadd(
152
+ cmd.stream.name, {"data": payload}, maxlen=cmd.maxlen, approximate=cmd.approximate
153
+ )
154
+ except (redis.RedisError, TypeError, ValueError) as e:
155
+ raise PublishError(f"could not publish to {cmd.stream.name}: {e}") from e
156
+ return _text(entry_id)
157
+
158
+ # lifecycle hooks
159
+
160
+ def _hook(self, name: str) -> Callable[[Hook], Hook]:
161
+ def register(func: Hook) -> Hook:
162
+ self._hooks[name].append(func)
163
+ return func
164
+ return register
165
+
166
+ def on_startup(self, func: Hook) -> Hook:
167
+ """Runs first, before the connection is checked."""
168
+ return self._hook("on_startup")(func)
169
+
170
+ def after_startup(self, func: Hook) -> Hook:
171
+ """Runs once groups exist and the consumer threads are running."""
172
+ return self._hook("after_startup")(func)
173
+
174
+ def on_shutdown(self, func: Hook) -> Hook:
175
+ """Runs after the consumers finished their current message, before the client closes."""
176
+ return self._hook("on_shutdown")(func)
177
+
178
+ def after_shutdown(self, func: Hook) -> Hook:
179
+ """Runs last, after the client is closed."""
180
+ return self._hook("after_shutdown")(func)
181
+
182
+ def _run_hooks(self, name: str) -> None:
183
+ for func in self._hooks[name]:
184
+ func()
185
+
186
+ # running
187
+
188
+ def start(self) -> None:
189
+ """Start all consumers in background threads and return."""
190
+ self._stop.clear()
191
+ self._stopped = False
192
+ self._run_hooks("on_startup")
193
+ self.client.ping()
194
+ for sub in self.subscribers:
195
+ sub.setup()
196
+ for sub in self.subscribers:
197
+ sub.start()
198
+ self._run_hooks("after_startup")
199
+
200
+ def stop(self, timeout: Optional[float] = None) -> None:
201
+ """Ask the consumers to stop, wait for the current messages to finish, then clean up.
202
+
203
+ Safe to call more than once or from another thread while ``run()`` is blocking: the shutdown
204
+ hooks and the client close happen only once per start."""
205
+ with self._stop_lock:
206
+ if self._stopped:
207
+ return
208
+ self._stopped = True
209
+ for sub in self.subscribers:
210
+ sub.stop()
211
+ for sub in self.subscribers:
212
+ sub.join(timeout)
213
+ try:
214
+ self._run_hooks("on_shutdown")
215
+ finally:
216
+ self.client.close()
217
+ self._run_hooks("after_shutdown")
218
+ self._stop.set()
219
+
220
+ def run(self, handle_signals: bool = True, shutdown_timeout: Optional[float] = None) -> None:
221
+ """Start the consumers and block until SIGINT/SIGTERM (or ``stop()``) shuts them down.
222
+
223
+ The first signal finishes the current message and exits cleanly. A second signal exits now.
224
+ """
225
+ requested = threading.Event()
226
+ previous = {}
227
+
228
+ def on_signal(signum: int, frame: Any) -> None:
229
+ if requested.is_set():
230
+ log.warning("second shutdown signal received, exiting now")
231
+ sys.exit(1)
232
+ log.info("shutdown requested, finishing the current message before exiting")
233
+ requested.set()
234
+
235
+ if handle_signals and threading.current_thread() is threading.main_thread():
236
+ for sig in (signal.SIGINT, signal.SIGTERM):
237
+ previous[sig] = signal.signal(sig, on_signal)
238
+ try:
239
+ self.start()
240
+ while not requested.wait(0.5):
241
+ if not any(s._thread and s._thread.is_alive() for s in self.subscribers):
242
+ break
243
+ self.stop(shutdown_timeout)
244
+ finally:
245
+ for sig, handler in previous.items():
246
+ signal.signal(sig, handler)
247
+
248
+ # operations
249
+
250
+ def active_workers(self, stream: StreamLike, group: Optional[str] = None) -> list[str]:
251
+ """Consumer names with a live heartbeat. Unlike XINFO CONSUMERS this includes idle workers
252
+ that have not received a message yet."""
253
+ s = as_stream(stream)
254
+ prefix = f"{HEARTBEAT_PREFIX}{s.name}:{group or s.group}:"
255
+ return sorted(_text(k)[len(prefix):] for k in self.client.scan_iter(match=f"{prefix}*"))
256
+
257
+ def replay_dlq(self, stream: StreamLike, count: int = 100) -> int:
258
+ """Move up to ``count`` entries from the DLQ back onto the stream. Returns how many."""
259
+ s = as_stream(stream)
260
+ moved = 0
261
+ rows: Any = self.client.xrange(s.dlq_name, count=count)
262
+ for entry_id, raw in rows:
263
+ fields: dict[Any, Any] = {
264
+ _text(k): _text(v) for k, v in raw.items() if not _text(k).startswith("x-")
265
+ }
266
+ pipe = self.client.pipeline()
267
+ pipe.xadd(s.name, fields)
268
+ pipe.xdel(s.dlq_name, entry_id)
269
+ pipe.execute()
270
+ moved += 1
271
+ return moved
@@ -0,0 +1,28 @@
1
+ import json
2
+ from typing import Any, Protocol
3
+
4
+ from .exceptions import DecodeError
5
+
6
+
7
+ class Codec(Protocol):
8
+ def encode(self, body: Any) -> str: ...
9
+
10
+ def decode(self, raw: str) -> Any: ...
11
+
12
+
13
+ class JsonCodec:
14
+ """JSON in the ``data`` field of the stream entry.
15
+
16
+ Pydantic models are dumped with ``model_dump_json``.
17
+ """
18
+
19
+ def encode(self, body: Any) -> str:
20
+ if hasattr(body, "model_dump_json"):
21
+ return body.model_dump_json()
22
+ return json.dumps(body, default=str)
23
+
24
+ def decode(self, raw: str) -> Any:
25
+ try:
26
+ return json.loads(raw)
27
+ except (TypeError, ValueError) as e:
28
+ raise DecodeError(f"body is not valid JSON: {e}") from e