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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|