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,5 @@
1
+ """AsyncAPI documentation generation for rabbitkit."""
2
+
3
+ from rabbitkit.asyncapi.generator import AsyncAPIGeneratorConfig, generate_asyncapi_doc, generate_asyncapi_json
4
+
5
+ __all__ = ["AsyncAPIGeneratorConfig", "generate_asyncapi_doc", "generate_asyncapi_json"]
@@ -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")
@@ -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)