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,219 @@
|
|
|
1
|
+
"""AsyncAPI 2.6.0 document generator from rabbitkit routes.
|
|
2
|
+
|
|
3
|
+
Generates an **AsyncAPI** specification from a broker's route definitions.
|
|
4
|
+
The spec describes your messaging API contract — queues, exchanges, message
|
|
5
|
+
schemas, and operations — in a machine-readable format compatible with the
|
|
6
|
+
AsyncAPI toolchain (Studio, code generators, documentation renderers).
|
|
7
|
+
|
|
8
|
+
What is generated
|
|
9
|
+
-----------------
|
|
10
|
+
For every ``RouteDefinition`` in ``broker.routes``:
|
|
11
|
+
|
|
12
|
+
* A **channel** named after the queue.
|
|
13
|
+
* A **subscribe operation** with the handler's body type as the message
|
|
14
|
+
payload schema (JSON Schema). Pydantic models use ``model_json_schema()``;
|
|
15
|
+
stdlib dataclasses use field introspection; primitives map to JSON primitives.
|
|
16
|
+
* **AMQP bindings** — exchange type/name, queue durable/exclusive flags,
|
|
17
|
+
routing key.
|
|
18
|
+
* **Tags** from ``route.tags`` and **description** from ``route.description``.
|
|
19
|
+
* A **publish operation** when ``route.result_publisher`` is configured
|
|
20
|
+
(documents the reply channel).
|
|
21
|
+
|
|
22
|
+
Quick start
|
|
23
|
+
-----------
|
|
24
|
+
from rabbitkit.asyncapi.generator import (
|
|
25
|
+
generate_asyncapi_doc,
|
|
26
|
+
generate_asyncapi_json,
|
|
27
|
+
AsyncAPIGeneratorConfig,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# broker.routes populated after registering @broker.subscriber decorators
|
|
31
|
+
doc = generate_asyncapi_doc(
|
|
32
|
+
broker.routes,
|
|
33
|
+
config=AsyncAPIGeneratorConfig(
|
|
34
|
+
title="Order Service",
|
|
35
|
+
version="2.1.0",
|
|
36
|
+
description="Processes incoming orders and emits confirmations.",
|
|
37
|
+
server_url="rabbitmq.prod.internal:5672",
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Pretty-print JSON spec
|
|
42
|
+
json_str = generate_asyncapi_json(broker.routes, indent=2)
|
|
43
|
+
print(json_str)
|
|
44
|
+
|
|
45
|
+
CLI integration
|
|
46
|
+
---------------
|
|
47
|
+
rabbitkit docs generate myapp.main:broker > asyncapi.json
|
|
48
|
+
rabbitkit docs serve myapp.main:broker # opens browser preview
|
|
49
|
+
|
|
50
|
+
Saving to file::
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
with open("asyncapi.json", "w") as f:
|
|
54
|
+
json.dump(generate_asyncapi_doc(broker.routes), f, indent=2)
|
|
55
|
+
|
|
56
|
+
Example output (condensed)::
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
"asyncapi": "2.6.0",
|
|
60
|
+
"info": { "title": "Order Service", "version": "2.1.0" },
|
|
61
|
+
"servers": {
|
|
62
|
+
"rabbitmq": { "url": "localhost:5672", "protocol": "amqp" }
|
|
63
|
+
},
|
|
64
|
+
"channels": {
|
|
65
|
+
"orders": {
|
|
66
|
+
"subscribe": {
|
|
67
|
+
"operationId": "handle_order",
|
|
68
|
+
"message": {
|
|
69
|
+
"name": "handle_order",
|
|
70
|
+
"payload": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"id": { "type": "integer" },
|
|
74
|
+
"item": { "type": "string" }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"bindings": {
|
|
80
|
+
"amqp": {
|
|
81
|
+
"is": "queue",
|
|
82
|
+
"queue": { "name": "orders", "durable": true }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
from __future__ import annotations
|
|
91
|
+
|
|
92
|
+
import json
|
|
93
|
+
from dataclasses import dataclass
|
|
94
|
+
from typing import Any
|
|
95
|
+
|
|
96
|
+
from rabbitkit.asyncapi.schema import extract_json_schema, get_handler_body_type
|
|
97
|
+
from rabbitkit.core.route import RouteDefinition
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class AsyncAPIGeneratorConfig:
|
|
102
|
+
"""Configuration for AsyncAPI document generation."""
|
|
103
|
+
|
|
104
|
+
title: str = "rabbitkit Service"
|
|
105
|
+
version: str = "1.0.0"
|
|
106
|
+
description: str = ""
|
|
107
|
+
server_url: str = "localhost:5672"
|
|
108
|
+
server_description: str = "RabbitMQ"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def generate_asyncapi_doc(
|
|
112
|
+
routes: list[RouteDefinition],
|
|
113
|
+
config: AsyncAPIGeneratorConfig | None = None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Generate an AsyncAPI 2.6.0 document from route definitions.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
routes: List of RouteDefinition from broker.routes.
|
|
119
|
+
config: Generator configuration.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
AsyncAPI 2.6.0 spec as a JSON-serializable dict.
|
|
123
|
+
"""
|
|
124
|
+
if config is None:
|
|
125
|
+
config = AsyncAPIGeneratorConfig()
|
|
126
|
+
|
|
127
|
+
doc: dict[str, Any] = {
|
|
128
|
+
"asyncapi": "2.6.0",
|
|
129
|
+
"info": {
|
|
130
|
+
"title": config.title,
|
|
131
|
+
"version": config.version,
|
|
132
|
+
},
|
|
133
|
+
"servers": {
|
|
134
|
+
"rabbitmq": {
|
|
135
|
+
"url": config.server_url,
|
|
136
|
+
"protocol": "amqp",
|
|
137
|
+
"description": config.server_description,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
"channels": {},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if config.description:
|
|
144
|
+
doc["info"]["description"] = config.description
|
|
145
|
+
|
|
146
|
+
for route in routes:
|
|
147
|
+
channel_name = route.queue.name
|
|
148
|
+
channel: dict[str, Any] = {}
|
|
149
|
+
|
|
150
|
+
if route.description:
|
|
151
|
+
channel["description"] = route.description
|
|
152
|
+
|
|
153
|
+
# Subscribe operation (consumer)
|
|
154
|
+
operation: dict[str, Any] = {
|
|
155
|
+
"operationId": route.name,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Message schema from handler type hints
|
|
159
|
+
body_type = get_handler_body_type(route.handler)
|
|
160
|
+
payload = extract_json_schema(body_type)
|
|
161
|
+
message: dict[str, Any] = {
|
|
162
|
+
"name": route.name,
|
|
163
|
+
}
|
|
164
|
+
if payload:
|
|
165
|
+
message["payload"] = payload
|
|
166
|
+
operation["message"] = message
|
|
167
|
+
|
|
168
|
+
# Tags
|
|
169
|
+
if route.tags:
|
|
170
|
+
operation["tags"] = [{"name": t} for t in sorted(route.tags)]
|
|
171
|
+
|
|
172
|
+
channel["subscribe"] = operation
|
|
173
|
+
|
|
174
|
+
# AMQP bindings
|
|
175
|
+
bindings: dict[str, Any] = {"amqp": {"is": "queue"}}
|
|
176
|
+
queue_binding: dict[str, Any] = {
|
|
177
|
+
"name": route.queue.name,
|
|
178
|
+
"durable": route.queue.durable,
|
|
179
|
+
}
|
|
180
|
+
if hasattr(route.queue, "exclusive"):
|
|
181
|
+
queue_binding["exclusive"] = route.queue.exclusive
|
|
182
|
+
bindings["amqp"]["queue"] = queue_binding
|
|
183
|
+
|
|
184
|
+
if route.exchange is not None:
|
|
185
|
+
exchange_binding: dict[str, Any] = {
|
|
186
|
+
"name": route.exchange.name,
|
|
187
|
+
"type": (
|
|
188
|
+
route.exchange.type.value
|
|
189
|
+
if hasattr(route.exchange.type, "value")
|
|
190
|
+
else str(route.exchange.type)
|
|
191
|
+
),
|
|
192
|
+
}
|
|
193
|
+
if hasattr(route.exchange, "durable"):
|
|
194
|
+
exchange_binding["durable"] = route.exchange.durable
|
|
195
|
+
bindings["amqp"]["exchange"] = exchange_binding
|
|
196
|
+
|
|
197
|
+
channel["bindings"] = bindings
|
|
198
|
+
|
|
199
|
+
# Publish operation (if result_publisher is set)
|
|
200
|
+
if route.result_publisher is not None:
|
|
201
|
+
publish_op: dict[str, Any] = {
|
|
202
|
+
"operationId": f"{route.name}.reply",
|
|
203
|
+
"message": {"name": f"{route.name}.response"},
|
|
204
|
+
}
|
|
205
|
+
channel["publish"] = publish_op
|
|
206
|
+
|
|
207
|
+
doc["channels"][channel_name] = channel
|
|
208
|
+
|
|
209
|
+
return doc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def generate_asyncapi_json(
|
|
213
|
+
routes: list[RouteDefinition],
|
|
214
|
+
config: AsyncAPIGeneratorConfig | None = None,
|
|
215
|
+
indent: int | None = 2,
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Generate AsyncAPI spec as a JSON string."""
|
|
218
|
+
doc = generate_asyncapi_doc(routes, config)
|
|
219
|
+
return json.dumps(doc, indent=indent)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""JSON Schema extraction from Python type annotations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import inspect
|
|
7
|
+
from typing import Any, get_type_hints
|
|
8
|
+
|
|
9
|
+
from rabbitkit.core.message import is_rabbit_message_annotation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_handler_body_type(handler: Any) -> type | None:
|
|
13
|
+
"""Extract the body parameter type from a handler signature.
|
|
14
|
+
|
|
15
|
+
Returns the first non-RabbitMessage, non-Annotated parameter type.
|
|
16
|
+
Returns None if no suitable parameter found or type is bytes.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
sig = inspect.signature(handler)
|
|
20
|
+
except (ValueError, TypeError):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
# Resolve string annotations (from __future__ import annotations)
|
|
24
|
+
try:
|
|
25
|
+
hints = get_type_hints(handler)
|
|
26
|
+
except Exception:
|
|
27
|
+
hints = {}
|
|
28
|
+
|
|
29
|
+
for name, param in sig.parameters.items():
|
|
30
|
+
# Prefer resolved type hints over raw annotations
|
|
31
|
+
ann = hints.get(name, param.annotation)
|
|
32
|
+
if ann is inspect.Parameter.empty:
|
|
33
|
+
continue
|
|
34
|
+
if is_rabbit_message_annotation(ann):
|
|
35
|
+
continue
|
|
36
|
+
# Skip Annotated types (DI markers)
|
|
37
|
+
if getattr(ann, "__metadata__", None) is not None:
|
|
38
|
+
continue
|
|
39
|
+
if ann is bytes:
|
|
40
|
+
return None
|
|
41
|
+
return ann # type: ignore[no-any-return]
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_json_schema(type_hint: type | None) -> dict[str, Any]:
|
|
46
|
+
"""Extract JSON Schema from a Python type hint.
|
|
47
|
+
|
|
48
|
+
Supports:
|
|
49
|
+
- None -> empty schema
|
|
50
|
+
- Pydantic V2 models -> model_json_schema()
|
|
51
|
+
- dataclasses -> field inspection
|
|
52
|
+
- Primitives (str, int, float, bool) -> {"type": "..."}
|
|
53
|
+
- bytes -> {"type": "string", "contentEncoding": "base64"}
|
|
54
|
+
"""
|
|
55
|
+
if type_hint is None:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
# Pydantic V2 model
|
|
59
|
+
if hasattr(type_hint, "model_json_schema"):
|
|
60
|
+
return type_hint.model_json_schema() # type: ignore[no-any-return]
|
|
61
|
+
|
|
62
|
+
# dataclass
|
|
63
|
+
if dataclasses.is_dataclass(type_hint) and isinstance(type_hint, type):
|
|
64
|
+
properties: dict[str, Any] = {}
|
|
65
|
+
required: list[str] = []
|
|
66
|
+
for f in dataclasses.fields(type_hint):
|
|
67
|
+
properties[f.name] = _python_type_to_schema(f.type)
|
|
68
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING:
|
|
69
|
+
required.append(f.name)
|
|
70
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
71
|
+
if required:
|
|
72
|
+
schema["required"] = required
|
|
73
|
+
return schema
|
|
74
|
+
|
|
75
|
+
# Primitive types
|
|
76
|
+
return _python_type_to_schema(type_hint)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _python_type_to_schema(python_type: Any) -> dict[str, Any]:
|
|
80
|
+
"""Convert a Python type to a JSON Schema type."""
|
|
81
|
+
type_map: dict[type, dict[str, str]] = {
|
|
82
|
+
str: {"type": "string"},
|
|
83
|
+
int: {"type": "integer"},
|
|
84
|
+
float: {"type": "number"},
|
|
85
|
+
bool: {"type": "boolean"},
|
|
86
|
+
bytes: {"type": "string", "contentEncoding": "base64"},
|
|
87
|
+
dict: {"type": "object"},
|
|
88
|
+
list: {"type": "array"},
|
|
89
|
+
}
|
|
90
|
+
if isinstance(python_type, type) and python_type in type_map:
|
|
91
|
+
return type_map[python_type]
|
|
92
|
+
if isinstance(python_type, str):
|
|
93
|
+
# String annotation — best effort
|
|
94
|
+
lower = python_type.lower()
|
|
95
|
+
for t, schema in type_map.items():
|
|
96
|
+
if t.__name__.lower() == lower:
|
|
97
|
+
return schema
|
|
98
|
+
return {"type": "object"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""rabbitkit CLI — production-grade RabbitMQ toolkit.
|
|
2
|
+
|
|
3
|
+
Provides the ``rabbitkit`` command-line interface for running, inspecting,
|
|
4
|
+
and interacting with rabbitkit brokers.
|
|
5
|
+
|
|
6
|
+
Requires: ``pip install rabbitkit[cli]``
|
|
7
|
+
|
|
8
|
+
Available commands
|
|
9
|
+
------------------
|
|
10
|
+
rabbitkit run <app_path> Start a broker
|
|
11
|
+
rabbitkit run <app_path> --reload Start with hot-reload (requires rabbitkit[reload])
|
|
12
|
+
rabbitkit run <app_path> -w 4 Start 4 worker processes
|
|
13
|
+
|
|
14
|
+
rabbitkit health check <app_path> Print broker health as JSON
|
|
15
|
+
|
|
16
|
+
rabbitkit topology list <app_path> List all registered routes
|
|
17
|
+
|
|
18
|
+
rabbitkit routes list <app_path> List all consumer routes with retry info
|
|
19
|
+
rabbitkit routes describe <app_path> <name> Show full route details
|
|
20
|
+
|
|
21
|
+
rabbitkit dlq inspect <queue> Peek at messages in a dead-letter queue
|
|
22
|
+
rabbitkit dlq replay <queue> <target> Republish DLQ messages to a target exchange
|
|
23
|
+
|
|
24
|
+
rabbitkit shell <app_path> Open interactive Python shell with broker pre-loaded
|
|
25
|
+
|
|
26
|
+
App path format
|
|
27
|
+
---------------
|
|
28
|
+
``<module.path>:<attribute_name>``
|
|
29
|
+
|
|
30
|
+
Examples::
|
|
31
|
+
|
|
32
|
+
rabbitkit run myapp.main:broker
|
|
33
|
+
rabbitkit health check myapp.main:broker
|
|
34
|
+
rabbitkit topology list myapp.main:broker --format json
|
|
35
|
+
rabbitkit routes list myapp.main:broker
|
|
36
|
+
rabbitkit routes describe myapp.main:broker handle_order
|
|
37
|
+
rabbitkit dlq inspect orders.created.dlq --limit 50
|
|
38
|
+
rabbitkit dlq replay orders.created.dlq orders --dry-run
|
|
39
|
+
rabbitkit shell myapp.main:broker
|
|
40
|
+
|
|
41
|
+
The module must be importable from the current working directory (add it to
|
|
42
|
+
``PYTHONPATH`` if needed, or run from the project root).
|
|
43
|
+
|
|
44
|
+
Installation check::
|
|
45
|
+
|
|
46
|
+
pip install "rabbitkit[cli]"
|
|
47
|
+
rabbitkit --help
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import typer
|
|
54
|
+
except ImportError as _err: # pragma: no cover
|
|
55
|
+
raise ImportError(
|
|
56
|
+
"rabbitkit CLI requires typer. Install with: pip install rabbitkit[cli]"
|
|
57
|
+
) from _err
|
|
58
|
+
|
|
59
|
+
from rabbitkit.cli.commands.dlq import dlq_app
|
|
60
|
+
from rabbitkit.cli.commands.health import health_app
|
|
61
|
+
from rabbitkit.cli.commands.routes import routes_app
|
|
62
|
+
from rabbitkit.cli.commands.run import run_command
|
|
63
|
+
from rabbitkit.cli.commands.shell import shell_command
|
|
64
|
+
from rabbitkit.cli.commands.topology import topology_app
|
|
65
|
+
|
|
66
|
+
app = typer.Typer(
|
|
67
|
+
name="rabbitkit",
|
|
68
|
+
help="Production-grade RabbitMQ toolkit CLI.",
|
|
69
|
+
no_args_is_help=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
app.command("run")(run_command)
|
|
73
|
+
app.command("shell")(shell_command)
|
|
74
|
+
app.add_typer(health_app, name="health")
|
|
75
|
+
app.add_typer(topology_app, name="topology")
|
|
76
|
+
app.add_typer(routes_app, name="routes")
|
|
77
|
+
app.add_typer(dlq_app, name="dlq")
|
rabbitkit/cli/_utils.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""CLI utilities — broker loading and path parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_app_path(app_path: str) -> tuple[str, str]:
|
|
10
|
+
"""Split 'module.path:attr' into (module_path, attr_name).
|
|
11
|
+
|
|
12
|
+
Raises ValueError if format is invalid.
|
|
13
|
+
"""
|
|
14
|
+
if ":" not in app_path:
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"Invalid app path '{app_path}'. Expected format: 'module.path:broker_attr'"
|
|
17
|
+
)
|
|
18
|
+
module_path, attr = app_path.rsplit(":", 1)
|
|
19
|
+
return module_path, attr
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_broker(app_path: str) -> Any:
|
|
23
|
+
"""Import module and return the broker attribute.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
app_path: String like 'myapp.main:broker'
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The broker instance.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: Invalid path format.
|
|
33
|
+
ImportError: Module not found.
|
|
34
|
+
AttributeError: Attribute not found in module.
|
|
35
|
+
"""
|
|
36
|
+
module_path, attr = parse_app_path(app_path)
|
|
37
|
+
module = importlib.import_module(module_path)
|
|
38
|
+
return getattr(module, attr)
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""rabbitkit dlq — dead-letter queue inspection and replay commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
dlq_app = typer.Typer(help="Dead-letter queue commands.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dlq_app.command("inspect")
|
|
13
|
+
def dlq_inspect(
|
|
14
|
+
queue: str = typer.Argument(..., help="DLQ name to inspect, e.g. 'orders.created.dlq'"),
|
|
15
|
+
amqp_url: str = typer.Option(
|
|
16
|
+
"amqp://guest:guest@localhost/",
|
|
17
|
+
"--url",
|
|
18
|
+
"-u",
|
|
19
|
+
envvar="RABBITMQ_URL",
|
|
20
|
+
help="AMQP connection URL",
|
|
21
|
+
),
|
|
22
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Maximum messages to fetch"),
|
|
23
|
+
output_format: str = typer.Option("table", "--format", "-f", help="Output format: table or json"),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Inspect messages in a dead-letter queue without removing them.
|
|
26
|
+
|
|
27
|
+
Connects directly to RabbitMQ and peeks at the DLQ contents using
|
|
28
|
+
basic_get in passive mode.
|
|
29
|
+
|
|
30
|
+
Example::
|
|
31
|
+
|
|
32
|
+
rabbitkit dlq inspect orders.created.dlq
|
|
33
|
+
rabbitkit dlq inspect orders.created.dlq --limit 100 --format json
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import pika
|
|
37
|
+
except ImportError:
|
|
38
|
+
typer.echo("pika is required: pip install pika", err=True)
|
|
39
|
+
raise typer.Exit(1) from None
|
|
40
|
+
|
|
41
|
+
params = pika.URLParameters(amqp_url)
|
|
42
|
+
connection = pika.BlockingConnection(params)
|
|
43
|
+
channel = connection.channel()
|
|
44
|
+
|
|
45
|
+
messages = []
|
|
46
|
+
for _ in range(limit):
|
|
47
|
+
method, properties, body = channel.basic_get(queue=queue, auto_ack=False)
|
|
48
|
+
if method is None:
|
|
49
|
+
break
|
|
50
|
+
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
|
|
51
|
+
entry = {
|
|
52
|
+
"routing_key": method.routing_key,
|
|
53
|
+
"exchange": method.exchange,
|
|
54
|
+
"redelivered": method.redelivered,
|
|
55
|
+
"message_id": properties.message_id,
|
|
56
|
+
"correlation_id": properties.correlation_id,
|
|
57
|
+
"headers": dict(properties.headers or {}),
|
|
58
|
+
"body_preview": body[:200].decode(errors="replace"),
|
|
59
|
+
}
|
|
60
|
+
messages.append(entry)
|
|
61
|
+
|
|
62
|
+
connection.close()
|
|
63
|
+
|
|
64
|
+
if output_format == "json":
|
|
65
|
+
typer.echo(json.dumps(messages, indent=2, default=str))
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if not messages:
|
|
69
|
+
typer.echo(f"No messages in {queue!r}.")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
typer.echo(f"Messages in {queue!r} ({len(messages)} shown):")
|
|
73
|
+
typer.echo("-" * 60)
|
|
74
|
+
for i, msg in enumerate(messages, 1):
|
|
75
|
+
typer.echo(f"[{i}] routing_key={msg['routing_key']} message_id={msg['message_id']}")
|
|
76
|
+
if msg["headers"]:
|
|
77
|
+
typer.echo(f" headers={msg['headers']}")
|
|
78
|
+
typer.echo(f" body: {msg['body_preview']}")
|
|
79
|
+
typer.echo()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dlq_app.command("replay")
|
|
83
|
+
def dlq_replay(
|
|
84
|
+
queue: str = typer.Argument(..., help="DLQ name to replay from, e.g. 'orders.created.dlq'"),
|
|
85
|
+
target: str = typer.Argument(..., help="Target exchange or queue to republish to"),
|
|
86
|
+
amqp_url: str = typer.Option(
|
|
87
|
+
"amqp://guest:guest@localhost/",
|
|
88
|
+
"--url",
|
|
89
|
+
"-u",
|
|
90
|
+
envvar="RABBITMQ_URL",
|
|
91
|
+
help="AMQP connection URL",
|
|
92
|
+
),
|
|
93
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Maximum messages to replay"),
|
|
94
|
+
routing_key: str | None = typer.Option(None, "--routing-key", "-k", help="Override routing key"),
|
|
95
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be replayed without publishing"),
|
|
96
|
+
reset_retry_count: bool = typer.Option(
|
|
97
|
+
False,
|
|
98
|
+
"--reset-retry-count",
|
|
99
|
+
help=(
|
|
100
|
+
"Strip the retry-count header before replaying, so the message gets a fresh "
|
|
101
|
+
"retry ladder instead of resuming at its old count (default: preserve headers "
|
|
102
|
+
"verbatim -- a previously max-retried message is terminal after one more failed "
|
|
103
|
+
"attempt and returns straight to the DLQ)."
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
retry_count_header: str = typer.Option(
|
|
107
|
+
"x-rabbitkit-retry-count",
|
|
108
|
+
"--retry-count-header",
|
|
109
|
+
help="Header name --reset-retry-count strips. Match RetryConfig.retry_header if customized.",
|
|
110
|
+
),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Replay messages from a dead-letter queue to a target exchange/queue.
|
|
113
|
+
|
|
114
|
+
Messages are consumed from the DLQ and published to the target with
|
|
115
|
+
publisher confirms and ``mandatory=True``. The DLQ message is acked
|
|
116
|
+
(removed) only after the broker confirms the republish; a failed or
|
|
117
|
+
unroutable publish is nack-requeued so the message stays on the DLQ.
|
|
118
|
+
|
|
119
|
+
Example::
|
|
120
|
+
|
|
121
|
+
# Replay up to 10 messages to the original exchange
|
|
122
|
+
rabbitkit dlq replay orders.created.dlq orders
|
|
123
|
+
|
|
124
|
+
# Dry-run to preview without publishing
|
|
125
|
+
rabbitkit dlq replay orders.created.dlq orders --dry-run
|
|
126
|
+
|
|
127
|
+
# Replay with a specific routing key
|
|
128
|
+
rabbitkit dlq replay orders.created.dlq orders -k orders.created
|
|
129
|
+
|
|
130
|
+
# Give a previously max-retried message a fresh retry ladder
|
|
131
|
+
rabbitkit dlq replay orders.created.dlq orders --reset-retry-count
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
import pika
|
|
135
|
+
from pika import exceptions as pika_exceptions
|
|
136
|
+
except ImportError:
|
|
137
|
+
typer.echo("pika is required: pip install pika", err=True)
|
|
138
|
+
raise typer.Exit(1) from None
|
|
139
|
+
|
|
140
|
+
params = pika.URLParameters(amqp_url)
|
|
141
|
+
connection = pika.BlockingConnection(params)
|
|
142
|
+
channel = connection.channel()
|
|
143
|
+
# Confirms make basic_publish raise on nack/unroutable instead of
|
|
144
|
+
# fire-and-forget — without this, ack-after-publish can lose the message.
|
|
145
|
+
channel.confirm_delivery()
|
|
146
|
+
|
|
147
|
+
replayed = 0
|
|
148
|
+
failed = 0
|
|
149
|
+
for _ in range(limit):
|
|
150
|
+
method, properties, body = channel.basic_get(queue=queue, auto_ack=False)
|
|
151
|
+
if method is None:
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
rk = routing_key or method.routing_key
|
|
155
|
+
if reset_retry_count and properties.headers and retry_count_header in properties.headers:
|
|
156
|
+
properties.headers.pop(retry_count_header, None)
|
|
157
|
+
|
|
158
|
+
if dry_run:
|
|
159
|
+
typer.echo(f"[dry-run] Would publish to exchange={target!r} routing_key={rk!r} body={body[:100]!r}")
|
|
160
|
+
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
channel.basic_publish(
|
|
165
|
+
exchange=target,
|
|
166
|
+
routing_key=rk,
|
|
167
|
+
body=body,
|
|
168
|
+
properties=properties,
|
|
169
|
+
mandatory=True,
|
|
170
|
+
)
|
|
171
|
+
except (pika_exceptions.UnroutableError, pika_exceptions.NackError) as exc:
|
|
172
|
+
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
|
|
173
|
+
typer.echo(
|
|
174
|
+
f"FAILED (stays on DLQ): routing_key={rk!r} message_id={properties.message_id} ({exc})",
|
|
175
|
+
err=True,
|
|
176
|
+
)
|
|
177
|
+
failed += 1
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
channel.basic_ack(delivery_tag=method.delivery_tag)
|
|
181
|
+
typer.echo(f"Replayed: routing_key={rk!r} message_id={properties.message_id}")
|
|
182
|
+
replayed += 1
|
|
183
|
+
|
|
184
|
+
connection.close()
|
|
185
|
+
|
|
186
|
+
if not dry_run:
|
|
187
|
+
typer.echo(f"\nReplayed {replayed} message(s) from {queue!r} → {target!r}.")
|
|
188
|
+
if failed:
|
|
189
|
+
typer.echo(f"{failed} message(s) failed to publish and remain on {queue!r}.", err=True)
|
|
190
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""rabbitkit health — broker health check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from rabbitkit.cli._utils import load_broker
|
|
10
|
+
|
|
11
|
+
health_app = typer.Typer(help="Health check commands.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@health_app.command("check")
|
|
15
|
+
def health_check(
|
|
16
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Check broker health and exit with code 1 if unhealthy."""
|
|
19
|
+
from rabbitkit.health import HealthStatus, broker_health_check
|
|
20
|
+
|
|
21
|
+
broker = load_broker(app_path)
|
|
22
|
+
result = broker_health_check(broker)
|
|
23
|
+
|
|
24
|
+
output = {
|
|
25
|
+
"status": result.status.value,
|
|
26
|
+
"started": result.started,
|
|
27
|
+
"connected": result.connected,
|
|
28
|
+
"consumer_count": result.consumer_count,
|
|
29
|
+
"route_count": result.route_count,
|
|
30
|
+
}
|
|
31
|
+
typer.echo(json.dumps(output, indent=2))
|
|
32
|
+
|
|
33
|
+
if result.status != HealthStatus.HEALTHY:
|
|
34
|
+
raise typer.Exit(code=1)
|