rabbitkit 0.9.0__py3-none-any.whl

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 (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,122 @@
1
+ """JSON serializer with Pydantic V2 support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, fields, is_dataclass
7
+ from typing import Any
8
+
9
+
10
+ class JSONSerializer:
11
+ """Default JSON serializer.
12
+
13
+ Supports:
14
+ - dict/list → json.dumps/loads
15
+ - Pydantic V2 models → model_validate_json/model_dump_json
16
+ - dataclasses → asdict → json.dumps
17
+ - str → encode to UTF-8
18
+ - bytes → pass through
19
+
20
+ By default the serializer **raises** on objects ``json`` cannot represent
21
+ (e.g. ``datetime``, ``Decimal``) instead of silently coercing them via
22
+ ``str()``. Pass ``coerce_unknown_to_str=True`` to restore the legacy
23
+ ``default=str`` coercion behaviour.
24
+
25
+ H14 — decoding into a stdlib dataclass does **no type validation or
26
+ coercion**: a field declared ``qty: int`` silently receives whatever
27
+ JSON type was actually present if the producer sent the wrong type.
28
+ Unknown keys in the payload are dropped rather than raising. Use a
29
+ Pydantic model as ``target_type`` (or a msgspec-based serializer)
30
+ instead of a stdlib dataclass for untrusted input where wrong-typed
31
+ fields must be rejected.
32
+ """
33
+
34
+ #: M7: sane non-None default (64 MiB, matching
35
+ #: ``CompressionMiddleware``'s ``max_decompressed_size`` default) so an
36
+ #: uncompressed body is bounded out of the box. Pass ``max_parse_bytes=None``
37
+ #: to opt out (unbounded) if you've already sized this elsewhere.
38
+ _DEFAULT_MAX_PARSE_BYTES = 64 * 1024 * 1024
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ coerce_unknown_to_str: bool = False,
44
+ max_parse_bytes: int | None = _DEFAULT_MAX_PARSE_BYTES,
45
+ ) -> None:
46
+ self._coerce = coerce_unknown_to_str
47
+ # Defense-in-depth cap on the input size before json.loads (M7) — the
48
+ # compression middleware already caps decompressed output; this
49
+ # bounds the case where compression is off and a large body arrives
50
+ # directly. Defaults to a sane non-None value rather than "off".
51
+ self._max_parse_bytes = max_parse_bytes
52
+
53
+ def _check_size(self, data: bytes) -> None:
54
+ if self._max_parse_bytes is not None and len(data) > self._max_parse_bytes:
55
+ raise ValueError(f"JSON input size {len(data)} exceeds max_parse_bytes={self._max_parse_bytes}")
56
+
57
+ @property
58
+ def content_type(self) -> str:
59
+ return "application/json"
60
+
61
+ def _default(self, obj: Any) -> Any:
62
+ if self._coerce:
63
+ return str(obj)
64
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
65
+
66
+ def encode(self, data: Any) -> bytes:
67
+ """Serialize data to JSON bytes."""
68
+ if isinstance(data, bytes):
69
+ return data
70
+ if isinstance(data, str):
71
+ return data.encode("utf-8")
72
+
73
+ # Pydantic V2 model
74
+ if hasattr(data, "model_dump_json"):
75
+ result = data.model_dump_json()
76
+ return result.encode("utf-8") if isinstance(result, str) else result
77
+
78
+ # Dataclass
79
+ if is_dataclass(data) and not isinstance(data, type):
80
+ return json.dumps(asdict(data), default=self._default).encode("utf-8")
81
+
82
+ # Dict, list, or other JSON-serializable
83
+ return json.dumps(data, default=self._default).encode("utf-8")
84
+
85
+ def decode(self, data: bytes, target_type: type) -> Any:
86
+ """Deserialize JSON bytes to target type."""
87
+ self._check_size(data)
88
+ if target_type is bytes:
89
+ return data
90
+ if target_type is str:
91
+ return data.decode("utf-8")
92
+ if target_type is dict:
93
+ return json.loads(data)
94
+ if target_type is list:
95
+ return json.loads(data)
96
+
97
+ # Pydantic V2 model
98
+ if hasattr(target_type, "model_validate_json"):
99
+ return target_type.model_validate_json(data)
100
+
101
+ # Pydantic V2 model via model_validate (dict input)
102
+ if hasattr(target_type, "model_validate"):
103
+ parsed = json.loads(data)
104
+ return target_type.model_validate(parsed)
105
+
106
+ # Dataclass (H14: no type validation/coercion -- see DataclassDecoder
107
+ # in serialization/pipeline.py for the full contract this mirrors.
108
+ # Unknown keys are dropped rather than raising; a genuinely wrong
109
+ # shape raises TypeError naming the target dataclass.)
110
+ if is_dataclass(target_type):
111
+ parsed = json.loads(data)
112
+ if isinstance(parsed, dict):
113
+ known_fields = {f.name for f in fields(target_type)}
114
+ filtered = {k: v for k, v in parsed.items() if k in known_fields}
115
+ try:
116
+ return target_type(**filtered)
117
+ except TypeError as exc:
118
+ raise TypeError(f"Cannot decode into {target_type.__name__}: {exc}") from exc
119
+ return parsed
120
+
121
+ # Fallback: json.loads
122
+ return json.loads(data)
@@ -0,0 +1,136 @@
1
+ """msgspec serializer — optional high-performance serialization.
2
+
3
+ Requires: pip install rabbitkit[msgspec]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+
11
+ class MsgspecSerializer:
12
+ """High-performance serializer using msgspec.
13
+
14
+ Requires msgspec to be installed (optional dependency).
15
+ Falls back with clear error if not available.
16
+
17
+ M7: caps the input size before decoding (64 MiB by default, matching
18
+ ``CompressionMiddleware``'s ``max_decompressed_size`` default) — without
19
+ it, a large uncompressed body is fully materialized with no bound.
20
+ Pass ``max_parse_bytes=None`` to opt out.
21
+ """
22
+
23
+ #: M7: see JSONSerializer's identical default for the rationale.
24
+ _DEFAULT_MAX_PARSE_BYTES = 64 * 1024 * 1024
25
+
26
+ def __init__(self, *, max_parse_bytes: int | None = _DEFAULT_MAX_PARSE_BYTES) -> None:
27
+ try:
28
+ import msgspec
29
+
30
+ self._msgspec = msgspec
31
+ self._encoder = msgspec.json.Encoder()
32
+ self._decoder = msgspec.json.Decoder()
33
+ self._decoders: dict[Any, Any] = {} # cached decoders per target_type (perf)
34
+ except ImportError as e: # pragma: no cover
35
+ raise ImportError( # pragma: no cover
36
+ "msgspec is required for MsgspecSerializer. Install it with: pip install rabbitkit[msgspec]"
37
+ ) from e
38
+ self._max_parse_bytes = max_parse_bytes
39
+
40
+ def _check_size(self, data: bytes) -> None:
41
+ if self._max_parse_bytes is not None and len(data) > self._max_parse_bytes:
42
+ raise ValueError(f"JSON input size {len(data)} exceeds max_parse_bytes={self._max_parse_bytes}")
43
+
44
+ @property
45
+ def content_type(self) -> str:
46
+ """Advisory only (M10): this is the ``content_type`` used when
47
+ *publishing* (set on the outgoing AMQP message property). It is
48
+ **not** verified against an incoming message's declared
49
+ ``content_type`` on :meth:`decode` — decode is driven solely by the
50
+ handler's declared parameter type, matching every other built-in
51
+ serializer in this codebase (none of them negotiate/verify
52
+ content-type on the consume side). If a message's actual body
53
+ doesn't match what this serializer expects (e.g. it's not JSON at
54
+ all), :meth:`decode` raises with a message naming both the target
55
+ type and a content-type-mismatch hint, rather than a raw
56
+ ``msgspec.DecodeError``.
57
+ """
58
+ return "application/json"
59
+
60
+ def encode(self, data: Any) -> bytes:
61
+ """Serialize data to JSON bytes using msgspec."""
62
+ if isinstance(data, bytes):
63
+ return data
64
+ if isinstance(data, str):
65
+ return data.encode("utf-8")
66
+
67
+ # msgspec Struct
68
+ if isinstance(data, self._msgspec.Struct):
69
+ return self._msgspec.json.encode(data)
70
+
71
+ # General encoding
72
+ return self._encoder.encode(data)
73
+
74
+ def decode(self, data: bytes, target_type: type) -> Any:
75
+ """Deserialize JSON bytes using msgspec.
76
+
77
+ M10: raises with a clearer message (naming the target type and
78
+ hinting at a content-type mismatch) instead of a raw
79
+ ``msgspec.DecodeError`` if the body isn't valid JSON for
80
+ *target_type* — most commonly caused by a message whose actual
81
+ content_type doesn't match what this serializer expects (see
82
+ :attr:`content_type`'s docstring — this is never verified upfront).
83
+ """
84
+ try:
85
+ return self._decode(data, target_type)
86
+ except self._msgspec.DecodeError as exc:
87
+ raise ValueError(
88
+ f"Failed to decode message body as JSON for target type {target_type!r}: {exc}. "
89
+ "If the message's actual content_type doesn't match what this serializer expects "
90
+ "(application/json), that's the likely cause — MsgspecSerializer does not verify "
91
+ "content_type before decoding."
92
+ ) from exc
93
+
94
+ def _decode(self, data: bytes, target_type: type) -> Any:
95
+ self._check_size(data)
96
+ if target_type is bytes:
97
+ return data
98
+ if target_type is str:
99
+ return data.decode("utf-8")
100
+ if target_type is dict or target_type is list:
101
+ return self._msgspec.json.decode(data)
102
+
103
+ # Generic alias (dict[str, Any], list[int], …) — origin is dict/list
104
+ origin = getattr(target_type, "__origin__", None)
105
+ if origin is dict or origin is list:
106
+ decoder = self._decoders.get(target_type)
107
+ if decoder is None:
108
+ try:
109
+ decoder = self._msgspec.json.Decoder(type=target_type)
110
+ except Exception:
111
+ return self._msgspec.json.decode(data)
112
+ self._decoders[target_type] = decoder
113
+ return decoder.decode(data)
114
+
115
+ # Pydantic V2 model — model_validate_json is faster than json.loads + model_validate
116
+ if hasattr(target_type, "model_validate_json"):
117
+ return target_type.model_validate_json(data)
118
+
119
+ # msgspec Struct — fastest path for msgspec-native types
120
+ try:
121
+ is_struct = issubclass(target_type, self._msgspec.Struct)
122
+ except TypeError:
123
+ is_struct = False
124
+ if is_struct:
125
+ return self._msgspec.json.decode(data, type=target_type)
126
+
127
+ # General typed decoder — cached per target_type (Decoder(type=T) codegens
128
+ # a converter; rebuilding per call defeats msgspec's performance advantage).
129
+ decoder = self._decoders.get(target_type)
130
+ if decoder is None:
131
+ try:
132
+ decoder = self._msgspec.json.Decoder(type=target_type)
133
+ except Exception:
134
+ return self._msgspec.json.decode(data)
135
+ self._decoders[target_type] = decoder
136
+ return decoder.decode(data)
@@ -0,0 +1,255 @@
1
+ """Two-stage serialization pipeline (parser + decoder).
2
+
3
+ Splits serialization into two composable stages so you can mix and match
4
+ wire formats with type-mapping strategies independently.
5
+
6
+ Stage 1 — **Parser** (``MessageParser`` protocol)
7
+ raw ``bytes`` → intermediate form (usually ``dict`` or ``list``)
8
+ Examples: ``JsonParser``, ``MsgpackParser`` (bring your own)
9
+
10
+ Stage 2 — **Decoder** (``MessageDecoder`` protocol)
11
+ intermediate → typed Python object
12
+ Examples: ``PydanticDecoder``, ``DataclassDecoder``, ``RawDecoder``
13
+
14
+ ``SerializationPipeline`` composes them and exposes the same ``encode`` /
15
+ ``decode`` / ``content_type`` interface as built-in serializers, so it plugs
16
+ directly into ``broker(serializer=...)`` or ``@broker.subscriber(serializer=...)``.
17
+
18
+ Quick start — JSON + Pydantic
19
+ ------------------------------
20
+ from pydantic import BaseModel
21
+ from rabbitkit.serialization.pipeline import (
22
+ SerializationPipeline, JsonParser, PydanticDecoder,
23
+ )
24
+
25
+ class Order(BaseModel):
26
+ id: int
27
+ item: str
28
+ qty: int
29
+
30
+ pipeline = SerializationPipeline(JsonParser(), PydanticDecoder())
31
+
32
+ broker = AsyncBroker(config, serializer=pipeline)
33
+
34
+ @broker.subscriber(queue="orders")
35
+ async def handle_order(order: Order) -> None:
36
+ # `order` is already a validated Pydantic model
37
+ print(order.id, order.item)
38
+
39
+ JSON + stdlib dataclass
40
+ -----------------------
41
+ from dataclasses import dataclass
42
+ from rabbitkit.serialization.pipeline import (
43
+ SerializationPipeline, JsonParser, DataclassDecoder,
44
+ )
45
+
46
+ @dataclass
47
+ class Event:
48
+ type: str
49
+ payload: dict
50
+
51
+ pipeline = SerializationPipeline(JsonParser(), DataclassDecoder())
52
+
53
+ @broker.subscriber(queue="events", serializer=pipeline)
54
+ def handle(event: Event) -> None:
55
+ print(event.type)
56
+
57
+ Pass-through (raw bytes, no decoding)
58
+ --------------------------------------
59
+ from rabbitkit.serialization.pipeline import (
60
+ SerializationPipeline, JsonParser, RawDecoder,
61
+ )
62
+
63
+ # Still parses bytes → dict for intermediate, but decoder returns as-is
64
+ pipeline = SerializationPipeline(JsonParser(), RawDecoder())
65
+
66
+ Custom parser (msgpack example)
67
+ ---------------------------------
68
+ import msgpack
69
+
70
+ class MsgpackParser:
71
+ def parse(self, data: bytes, content_type=None):
72
+ return msgpack.unpackb(data, raw=False)
73
+
74
+ def serialize(self, data) -> bytes:
75
+ return msgpack.packb(data, use_bin_type=True)
76
+
77
+ @property
78
+ def content_type(self) -> str:
79
+ return "application/msgpack"
80
+
81
+ pipeline = SerializationPipeline(MsgpackParser(), PydanticDecoder())
82
+
83
+ Encoding (publish direction)
84
+ -----------------------------
85
+ ``pipeline.encode(my_model)`` calls ``decoder.encode(model) → dict`` then
86
+ ``parser.serialize(dict) → bytes``. This means the same pipeline handles
87
+ both inbound *and* outbound serialization transparently.
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import json
93
+ from typing import Any, Protocol
94
+
95
+
96
+ class MessageParser(Protocol):
97
+ """Stage 1: raw bytes → intermediate form."""
98
+
99
+ def parse(self, data: bytes, content_type: str | None = None) -> Any: ...
100
+ def serialize(self, data: Any) -> bytes: ...
101
+ @property
102
+ def content_type(self) -> str: ...
103
+
104
+
105
+ class MessageDecoder(Protocol):
106
+ """Stage 2: intermediate → typed Python object."""
107
+
108
+ def decode(self, data: Any, target_type: type) -> Any: ...
109
+ def encode(self, data: Any) -> Any: ...
110
+
111
+
112
+ class SerializationPipeline:
113
+ """Two-stage serialization. Implements Serializer protocol."""
114
+
115
+ def __init__(self, parser: MessageParser, decoder: MessageDecoder) -> None:
116
+ self._parser = parser
117
+ self._decoder = decoder
118
+
119
+ def encode(self, data: Any) -> bytes:
120
+ intermediate = self._decoder.encode(data)
121
+ return self._parser.serialize(intermediate)
122
+
123
+ def decode(self, data: bytes, target_type: type) -> Any:
124
+ intermediate = self._parser.parse(data)
125
+ return self._decoder.decode(intermediate, target_type)
126
+
127
+ @property
128
+ def content_type(self) -> str:
129
+ return self._parser.content_type
130
+
131
+
132
+ class JsonParser:
133
+ """Built-in JSON parser.
134
+
135
+ By default ``serialize`` **raises** on objects ``json`` cannot represent
136
+ (e.g. ``datetime``, ``Decimal``) rather than silently coercing them via
137
+ ``str()``. Pass ``coerce_unknown_to_str=True`` to restore the legacy
138
+ ``default=str`` coercion behaviour.
139
+
140
+ M7: caps the input size before ``json.loads`` (64 MiB by default,
141
+ matching ``CompressionMiddleware``'s ``max_decompressed_size`` default)
142
+ -- without it, a large uncompressed body is fully materialized with no
143
+ bound. Pass ``max_parse_bytes=None`` to opt out.
144
+ """
145
+
146
+ #: M7: see JSONSerializer's identical default for the rationale.
147
+ _DEFAULT_MAX_PARSE_BYTES = 64 * 1024 * 1024
148
+
149
+ def __init__(
150
+ self,
151
+ *,
152
+ coerce_unknown_to_str: bool = False,
153
+ max_parse_bytes: int | None = _DEFAULT_MAX_PARSE_BYTES,
154
+ ) -> None:
155
+ self._coerce = coerce_unknown_to_str
156
+ self._max_parse_bytes = max_parse_bytes
157
+
158
+ def _check_size(self, data: bytes) -> None:
159
+ if self._max_parse_bytes is not None and len(data) > self._max_parse_bytes:
160
+ raise ValueError(f"JSON input size {len(data)} exceeds max_parse_bytes={self._max_parse_bytes}")
161
+
162
+ def parse(self, data: bytes, content_type: str | None = None) -> Any:
163
+ self._check_size(data)
164
+ return json.loads(data)
165
+
166
+ def _default(self, data: Any) -> Any:
167
+ if self._coerce:
168
+ return str(data)
169
+ raise TypeError(f"Object of type {type(data).__name__} is not JSON serializable")
170
+
171
+ def serialize(self, data: Any) -> bytes:
172
+ return json.dumps(data, default=self._default).encode("utf-8")
173
+
174
+ @property
175
+ def content_type(self) -> str:
176
+ return "application/json"
177
+
178
+
179
+ class PydanticDecoder:
180
+ """Decoder that uses Pydantic model_validate for decoding.
181
+
182
+ Companion to ``DataclassDecoder``'s H14 note: this is the decoder that
183
+ note recommends for untrusted input, on the premise that
184
+ ``model_validate`` rejects wrong-shaped data instead of silently
185
+ accepting it. That premise used to be undermined by an
186
+ ``isinstance(data, dict)`` guard here -- a non-dict top-level payload
187
+ (a producer sending a JSON array/string/number instead of an object,
188
+ whether by bug or by an attacker probing for a decoder that skips
189
+ validation on the untaken branch) bypassed ``model_validate`` entirely
190
+ and returned the raw, un-validated value straight to the handler.
191
+ Pydantic already raises a clean ``ValidationError`` for a non-dict
192
+ input (``model_type`` error), so there is no reason to special-case
193
+ it -- doing so only suppressed the exact validation this decoder
194
+ exists to provide.
195
+ """
196
+
197
+ def decode(self, data: Any, target_type: type) -> Any:
198
+ if hasattr(target_type, "model_validate"):
199
+ return target_type.model_validate(data)
200
+ return data
201
+
202
+ def encode(self, data: Any) -> Any:
203
+ if hasattr(data, "model_dump"):
204
+ return data.model_dump()
205
+ return data
206
+
207
+
208
+ class DataclassDecoder:
209
+ """Decoder for stdlib dataclasses.
210
+
211
+ H14 — no type validation or coercion: unlike ``PydanticDecoder``, this
212
+ does NOT check field types. A field declared ``qty: int`` silently
213
+ receives whatever JSON type was actually present (e.g. the string
214
+ ``"3"``) if the producer sent the wrong type — stdlib dataclasses
215
+ perform no runtime type checking on construction. **Use
216
+ ``PydanticDecoder`` (or a msgspec-based decoder) instead for untrusted
217
+ input where wrong-typed fields must be rejected.**
218
+
219
+ Unknown keys in the incoming dict (not a declared field) are silently
220
+ dropped rather than raising ``TypeError`` — this keeps a producer that
221
+ adds a new field (forward-compatible) from turning every message into a
222
+ decode failure (misclassified PERMANENT, straight to the DLQ) until
223
+ every consumer is upgraded. A genuinely wrong shape (a missing required
224
+ field, etc.) still raises — as a ``TypeError`` naming the target
225
+ dataclass, not a bare constructor traceback.
226
+ """
227
+
228
+ def decode(self, data: Any, target_type: type) -> Any:
229
+ import dataclasses
230
+
231
+ if dataclasses.is_dataclass(target_type) and isinstance(data, dict):
232
+ known_fields = {f.name for f in dataclasses.fields(target_type)}
233
+ filtered = {k: v for k, v in data.items() if k in known_fields}
234
+ try:
235
+ return target_type(**filtered)
236
+ except TypeError as exc:
237
+ raise TypeError(f"Cannot decode into {target_type.__name__}: {exc}") from exc
238
+ return data
239
+
240
+ def encode(self, data: Any) -> Any:
241
+ import dataclasses
242
+
243
+ if dataclasses.is_dataclass(data) and not isinstance(data, type):
244
+ return dataclasses.asdict(data)
245
+ return data
246
+
247
+
248
+ class RawDecoder:
249
+ """Pass-through decoder — no transformation."""
250
+
251
+ def decode(self, data: Any, target_type: type) -> Any:
252
+ return data
253
+
254
+ def encode(self, data: Any) -> Any:
255
+ return data
rabbitkit/streams.py ADDED
@@ -0,0 +1,139 @@
1
+ """Stream queue consumer offset tracking.
2
+
3
+ RabbitMQ stream queues support offset-based consuming, allowing consumers
4
+ to start reading from a specific position in the log.
5
+
6
+ Offset types:
7
+ - "first": Start from the beginning of the stream
8
+ - "last": Start from the end (new messages only)
9
+ - "next": Start from the next unconsumed message (default)
10
+ - timestamp: Start from messages published after a given timestamp
11
+ - numeric offset: Start from a specific offset value
12
+
13
+ Usage::
14
+
15
+ from rabbitkit.streams import StreamOffset, StreamConsumerConfig
16
+
17
+ # Start from beginning
18
+ config = StreamConsumerConfig(offset=StreamOffset.first())
19
+
20
+ # Start from specific offset
21
+ config = StreamConsumerConfig(offset=StreamOffset.offset(42))
22
+
23
+ # Start from timestamp
24
+ config = StreamConsumerConfig(offset=StreamOffset.timestamp(datetime(2026, 1, 1)))
25
+
26
+ # Use with broker
27
+ @broker.subscriber(
28
+ queue=RabbitQueue("events", queue_type=QueueType.STREAM),
29
+ stream_config=config,
30
+ )
31
+ def handle(body: bytes) -> None: ...
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import enum
37
+ import logging
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime
40
+ from typing import Any
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class StreamOffsetType(str, enum.Enum):
46
+ """Stream offset specification types."""
47
+
48
+ FIRST = "first"
49
+ LAST = "last"
50
+ NEXT = "next"
51
+ OFFSET = "offset"
52
+ TIMESTAMP = "timestamp"
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class StreamOffset:
57
+ """Stream queue offset specification.
58
+
59
+ Use class methods to create:
60
+ StreamOffset.first()
61
+ StreamOffset.last()
62
+ StreamOffset.next()
63
+ StreamOffset.offset(42)
64
+ StreamOffset.timestamp(datetime(...))
65
+ """
66
+
67
+ type: StreamOffsetType = StreamOffsetType.NEXT
68
+ value: int | datetime | None = None
69
+
70
+ @classmethod
71
+ def first(cls) -> StreamOffset:
72
+ """Start from the beginning of the stream."""
73
+ return cls(type=StreamOffsetType.FIRST)
74
+
75
+ @classmethod
76
+ def last(cls) -> StreamOffset:
77
+ """Start from the end (new messages only)."""
78
+ return cls(type=StreamOffsetType.LAST)
79
+
80
+ @classmethod
81
+ def next(cls) -> StreamOffset:
82
+ """Start from the next unconsumed message (default)."""
83
+ return cls(type=StreamOffsetType.NEXT)
84
+
85
+ @classmethod
86
+ def offset(cls, value: int) -> StreamOffset:
87
+ """Start from a specific numeric offset."""
88
+ if value < 0:
89
+ msg = "Stream offset must be non-negative"
90
+ raise ValueError(msg)
91
+ return cls(type=StreamOffsetType.OFFSET, value=value)
92
+
93
+ @classmethod
94
+ def timestamp(cls, value: datetime) -> StreamOffset:
95
+ """Start from messages published after the given timestamp."""
96
+ return cls(type=StreamOffsetType.TIMESTAMP, value=value)
97
+
98
+ def to_consume_arguments(self) -> dict[str, Any]:
99
+ """Convert to RabbitMQ consume arguments (x-stream-offset).
100
+
101
+ Returns dict suitable for merging into basic_consume arguments.
102
+ """
103
+ if self.type == StreamOffsetType.FIRST:
104
+ return {"x-stream-offset": "first"}
105
+ if self.type == StreamOffsetType.LAST:
106
+ return {"x-stream-offset": "last"}
107
+ if self.type == StreamOffsetType.NEXT:
108
+ return {"x-stream-offset": "next"}
109
+ if self.type == StreamOffsetType.OFFSET:
110
+ return {"x-stream-offset": self.value}
111
+ if self.type == StreamOffsetType.TIMESTAMP:
112
+ if not isinstance(self.value, datetime):
113
+ raise TypeError("TIMESTAMP stream offset requires a datetime value")
114
+ # RabbitMQ x-stream-offset by time expects a Unix timestamp in seconds.
115
+ return {"x-stream-offset": int(self.value.timestamp())}
116
+ return {} # pragma: no cover
117
+
118
+ def __repr__(self) -> str:
119
+ if self.value is not None:
120
+ return f"StreamOffset({self.type.value}={self.value})"
121
+ return f"StreamOffset({self.type.value})"
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class StreamConsumerConfig:
126
+ """Configuration for stream queue consumers.
127
+
128
+ Extends basic consumer behavior with stream-specific options.
129
+ """
130
+
131
+ offset: StreamOffset = field(default_factory=StreamOffset.next)
132
+ consumer_name: str | None = None # x-stream-consumer-name for single-active-consumer
133
+
134
+ def to_consume_arguments(self) -> dict[str, Any]:
135
+ """Build consume arguments for stream queue subscription."""
136
+ args = self.offset.to_consume_arguments()
137
+ if self.consumer_name is not None:
138
+ args["x-stream-consumer-name"] = self.consumer_name
139
+ return args
@@ -0,0 +1,11 @@
1
+ """Sync transport module — pika-based I/O adapter."""
2
+
3
+ from rabbitkit.sync.batch import SyncBatchPublisher
4
+ from rabbitkit.sync.broker import SyncBroker
5
+ from rabbitkit.sync.transport import SyncTransport
6
+
7
+ __all__ = [
8
+ "SyncBatchPublisher",
9
+ "SyncBroker",
10
+ "SyncTransport",
11
+ ]