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,144 @@
|
|
|
1
|
+
"""rabbitkit run — start a broker instance.
|
|
2
|
+
|
|
3
|
+
Supports three execution modes:
|
|
4
|
+
|
|
5
|
+
Single process (default)::
|
|
6
|
+
|
|
7
|
+
rabbitkit run myapp.main:broker
|
|
8
|
+
|
|
9
|
+
The broker type (sync/async) is detected automatically:
|
|
10
|
+
* ``SyncBroker`` — calls ``broker.run()`` which blocks until Ctrl+C.
|
|
11
|
+
* ``AsyncBroker`` — calls ``await broker.start()``, then blocks in an async
|
|
12
|
+
loop until Ctrl+C, then calls ``await broker.stop()``.
|
|
13
|
+
|
|
14
|
+
Hot reload (``--reload``)::
|
|
15
|
+
|
|
16
|
+
rabbitkit run myapp.main:broker --reload
|
|
17
|
+
|
|
18
|
+
# Watch extra file types (e.g., YAML config)
|
|
19
|
+
rabbitkit run myapp.main:broker --reload --reload-ext .yml,.toml
|
|
20
|
+
|
|
21
|
+
Requires ``pip install rabbitkit[reload]`` (watchfiles). Any ``.py`` file
|
|
22
|
+
change in the current directory tree triggers a graceful restart.
|
|
23
|
+
|
|
24
|
+
Multiple processes (``--workers N``)::
|
|
25
|
+
|
|
26
|
+
rabbitkit run myapp.main:broker --workers 4
|
|
27
|
+
|
|
28
|
+
Spawns *N* independent processes using ``multiprocessing.Process``. Each
|
|
29
|
+
process runs a full broker instance. Ctrl+C sends ``SIGTERM`` to all workers
|
|
30
|
+
and waits up to 10 s for clean shutdown.
|
|
31
|
+
|
|
32
|
+
Notes
|
|
33
|
+
-----
|
|
34
|
+
* ``--reload`` and ``--workers`` are mutually exclusive — hot-reload only
|
|
35
|
+
supports single-process mode.
|
|
36
|
+
* In Docker/Kubernetes, prefer ``--workers 1`` and scale via replicas.
|
|
37
|
+
* The app path must be importable (run from project root or set PYTHONPATH).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import multiprocessing
|
|
44
|
+
|
|
45
|
+
import typer
|
|
46
|
+
|
|
47
|
+
from rabbitkit.cli._utils import load_broker
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_command(
|
|
51
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
52
|
+
reload: bool = typer.Option(False, "--reload", help="Watch files and restart on changes"),
|
|
53
|
+
reload_ext: str = typer.Option("", "--reload-ext", help="Extra file extensions to watch (comma-separated)"),
|
|
54
|
+
workers: int = typer.Option(1, "--workers", "-w", help="Number of worker processes"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Start a rabbitkit broker.
|
|
57
|
+
|
|
58
|
+
Example: rabbitkit run myapp.main:broker --reload --workers 4
|
|
59
|
+
"""
|
|
60
|
+
if reload:
|
|
61
|
+
_run_with_reload(app_path, reload_ext)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if workers > 1:
|
|
65
|
+
_run_multiprocess(app_path, workers)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
_run_single(app_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run_single(app_path: str) -> None:
|
|
72
|
+
"""Run broker in single-process mode."""
|
|
73
|
+
broker = load_broker(app_path)
|
|
74
|
+
|
|
75
|
+
# Detect sync vs async broker
|
|
76
|
+
if hasattr(broker, "run") and callable(broker.run):
|
|
77
|
+
# SyncBroker has a blocking run() method
|
|
78
|
+
typer.echo(f"Starting sync broker from {app_path}...")
|
|
79
|
+
broker.run()
|
|
80
|
+
else:
|
|
81
|
+
# AsyncBroker — start + block
|
|
82
|
+
typer.echo(f"Starting async broker from {app_path}...")
|
|
83
|
+
|
|
84
|
+
async def _run_async() -> None:
|
|
85
|
+
await broker.start()
|
|
86
|
+
try:
|
|
87
|
+
# Block forever (until Ctrl+C)
|
|
88
|
+
while True:
|
|
89
|
+
await asyncio.sleep(3600)
|
|
90
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
91
|
+
pass
|
|
92
|
+
finally:
|
|
93
|
+
await broker.stop()
|
|
94
|
+
|
|
95
|
+
asyncio.run(_run_async())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_with_reload(app_path: str, reload_ext: str) -> None:
|
|
99
|
+
"""Run broker with file watching and auto-restart."""
|
|
100
|
+
try:
|
|
101
|
+
import watchfiles
|
|
102
|
+
except ImportError:
|
|
103
|
+
typer.echo(
|
|
104
|
+
"Hot reload requires watchfiles. Install with: pip install rabbitkit[reload]",
|
|
105
|
+
err=True,
|
|
106
|
+
)
|
|
107
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
108
|
+
|
|
109
|
+
extensions = {".py"}
|
|
110
|
+
if reload_ext:
|
|
111
|
+
for ext in reload_ext.split(","):
|
|
112
|
+
ext = ext.strip()
|
|
113
|
+
if not ext.startswith("."):
|
|
114
|
+
ext = f".{ext}"
|
|
115
|
+
extensions.add(ext)
|
|
116
|
+
|
|
117
|
+
typer.echo(f"Watching for changes (extensions: {extensions})...")
|
|
118
|
+
watchfiles.run_process(
|
|
119
|
+
".",
|
|
120
|
+
target=_run_single,
|
|
121
|
+
args=(app_path,),
|
|
122
|
+
watch_filter=watchfiles.PythonFilter(extra_extensions=tuple(sorted(extensions - {".py"}))),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_multiprocess(app_path: str, workers: int) -> None:
|
|
127
|
+
"""Run multiple broker processes."""
|
|
128
|
+
typer.echo(f"Starting {workers} worker processes...")
|
|
129
|
+
processes: list[multiprocessing.Process] = []
|
|
130
|
+
|
|
131
|
+
for i in range(workers):
|
|
132
|
+
p = multiprocessing.Process(target=_run_single, args=(app_path,), name=f"rabbitkit-worker-{i}")
|
|
133
|
+
p.start()
|
|
134
|
+
processes.append(p)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
for p in processes:
|
|
138
|
+
p.join()
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
typer.echo("Shutting down workers...")
|
|
141
|
+
for p in processes:
|
|
142
|
+
p.terminate()
|
|
143
|
+
for p in processes:
|
|
144
|
+
p.join(timeout=10)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""rabbitkit shell — interactive Python shell with broker pre-loaded.
|
|
2
|
+
|
|
3
|
+
Opens a Python REPL with the broker instance and its key attributes already
|
|
4
|
+
in scope so you can inspect routes, publish test messages, and debug without
|
|
5
|
+
writing a throwaway script.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
rabbitkit shell myapp.main:broker
|
|
10
|
+
|
|
11
|
+
Pre-loaded variables
|
|
12
|
+
--------------------
|
|
13
|
+
broker — the broker instance (AsyncBroker or SyncBroker)
|
|
14
|
+
routes — broker.routes (list[RouteDefinition])
|
|
15
|
+
config — broker.config (RabbitConfig)
|
|
16
|
+
publish — broker.publish (callable)
|
|
17
|
+
|
|
18
|
+
Shell preference
|
|
19
|
+
----------------
|
|
20
|
+
Uses **IPython** if installed (``pip install ipython``) for auto-complete,
|
|
21
|
+
syntax highlighting, and ``%history``. Falls back to ``code.interact``
|
|
22
|
+
(stdlib) if IPython is not available.
|
|
23
|
+
|
|
24
|
+
Example session::
|
|
25
|
+
|
|
26
|
+
rabbitkit shell myapp.main:broker
|
|
27
|
+
|
|
28
|
+
rabbitkit shell -- 3 routes loaded from myapp.main:broker
|
|
29
|
+
In [1]: routes
|
|
30
|
+
Out[1]: [RouteDefinition(queue=RabbitQueue(name='orders', ...), ...)]
|
|
31
|
+
|
|
32
|
+
In [2]: broker.config.connection.host
|
|
33
|
+
Out[2]: 'localhost'
|
|
34
|
+
|
|
35
|
+
In [3]: import json; publish(MessageEnvelope(routing_key='orders', body=json.dumps({'test': 1}).encode()))
|
|
36
|
+
Out[3]: PublishOutcome(ok=True, ...)
|
|
37
|
+
|
|
38
|
+
In [4]: exit
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import typer
|
|
44
|
+
|
|
45
|
+
from rabbitkit.cli._utils import load_broker
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def shell_command(
|
|
49
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Launch interactive Python shell with broker pre-loaded.
|
|
52
|
+
|
|
53
|
+
Available variables: broker, routes, config, publish.
|
|
54
|
+
Uses IPython if available, falls back to stdlib code.interact.
|
|
55
|
+
"""
|
|
56
|
+
broker = load_broker(app_path)
|
|
57
|
+
local_vars = {
|
|
58
|
+
"broker": broker,
|
|
59
|
+
"routes": broker.routes,
|
|
60
|
+
"config": broker.config,
|
|
61
|
+
"publish": broker.publish,
|
|
62
|
+
}
|
|
63
|
+
banner = f"rabbitkit shell -- {len(broker.routes)} routes loaded from {app_path}"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from IPython import embed
|
|
67
|
+
|
|
68
|
+
embed(user_ns=local_vars, banner1=banner)
|
|
69
|
+
except ImportError:
|
|
70
|
+
import code
|
|
71
|
+
|
|
72
|
+
code.interact(banner=banner, local=local_vars)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""rabbitkit topology — inspect, validate, diff, and apply broker topology."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from rabbitkit.cli._utils import load_broker
|
|
11
|
+
from rabbitkit.cli.commands.migrate import migrate_command
|
|
12
|
+
|
|
13
|
+
topology_app = typer.Typer(help="Topology inspection and management commands.")
|
|
14
|
+
|
|
15
|
+
topology_app.command("migrate")(migrate_command)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@topology_app.command("list")
|
|
19
|
+
def topology_list(
|
|
20
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
21
|
+
output_format: str = typer.Option("table", "--format", "-f", help="Output format: table or json"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""List all registered routes, queues, and exchanges."""
|
|
24
|
+
broker = load_broker(app_path)
|
|
25
|
+
routes = broker.routes
|
|
26
|
+
|
|
27
|
+
rows = []
|
|
28
|
+
for r in routes:
|
|
29
|
+
rows.append({
|
|
30
|
+
"name": r.name,
|
|
31
|
+
"queue": r.queue.name,
|
|
32
|
+
"exchange": r.exchange.name if r.exchange else "",
|
|
33
|
+
"routing_key": r.queue.routing_key,
|
|
34
|
+
"ack_policy": r.ack_policy.value,
|
|
35
|
+
"tags": ",".join(sorted(r.tags)) if r.tags else "",
|
|
36
|
+
"description": r.description,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if output_format == "json":
|
|
40
|
+
typer.echo(json.dumps(rows, indent=2))
|
|
41
|
+
else:
|
|
42
|
+
if not rows:
|
|
43
|
+
typer.echo("No routes registered.")
|
|
44
|
+
return
|
|
45
|
+
headers = ["name", "queue", "exchange", "routing_key", "ack_policy"]
|
|
46
|
+
widths = {h: max(len(h), max((len(str(r.get(h, ""))) for r in rows), default=0)) for h in headers}
|
|
47
|
+
header_line = " ".join(h.ljust(widths[h]) for h in headers)
|
|
48
|
+
typer.echo(header_line)
|
|
49
|
+
typer.echo("-" * len(header_line))
|
|
50
|
+
for row in rows:
|
|
51
|
+
typer.echo(" ".join(str(row.get(h, "")).ljust(widths[h]) for h in headers))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _declared_resources(broker: Any) -> dict[str, Any]:
|
|
55
|
+
"""Extract queues and exchanges declared by the broker's routes."""
|
|
56
|
+
queues: dict[str, Any] = {}
|
|
57
|
+
exchanges: dict[str, Any] = {}
|
|
58
|
+
for route in broker.routes:
|
|
59
|
+
q = route.queue
|
|
60
|
+
queues[q.name] = {
|
|
61
|
+
"durable": q.durable,
|
|
62
|
+
"exclusive": q.exclusive,
|
|
63
|
+
"auto_delete": q.auto_delete,
|
|
64
|
+
}
|
|
65
|
+
if route.exchange and route.exchange.name:
|
|
66
|
+
ex = route.exchange
|
|
67
|
+
exchanges[ex.name] = {
|
|
68
|
+
"type": ex.type.value if hasattr(ex.type, "value") else str(ex.type),
|
|
69
|
+
"durable": ex.durable,
|
|
70
|
+
"auto_delete": ex.auto_delete,
|
|
71
|
+
}
|
|
72
|
+
return {"queues": queues, "exchanges": exchanges}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _live_resources(management_url: str, vhost: str) -> dict[str, Any]:
|
|
76
|
+
"""Fetch live queues and exchanges from the management API."""
|
|
77
|
+
import urllib.parse
|
|
78
|
+
import urllib.request
|
|
79
|
+
|
|
80
|
+
scheme = urllib.parse.urlparse(management_url).scheme.lower()
|
|
81
|
+
if scheme not in {"http", "https"}:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Unsupported management URL scheme {scheme!r}; only 'http' and 'https' are "
|
|
84
|
+
"allowed (--url is passed straight to urlopen, which will happily fetch "
|
|
85
|
+
"file:// or other schemes if not restricted)."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def get(path: str) -> list[dict[str, Any]]:
|
|
89
|
+
req = urllib.request.Request(f"{management_url.rstrip('/')}{path}") # noqa: S310
|
|
90
|
+
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
|
|
91
|
+
return cast("list[dict[str, Any]]", json.loads(resp.read()))
|
|
92
|
+
|
|
93
|
+
encoded_vhost = urllib.parse.quote(vhost, safe="")
|
|
94
|
+
try:
|
|
95
|
+
queues_raw = get(f"/api/queues/{encoded_vhost}")
|
|
96
|
+
except Exception:
|
|
97
|
+
queues_raw = []
|
|
98
|
+
try:
|
|
99
|
+
exchanges_raw = get(f"/api/exchanges/{encoded_vhost}")
|
|
100
|
+
except Exception:
|
|
101
|
+
exchanges_raw = []
|
|
102
|
+
|
|
103
|
+
queues = {
|
|
104
|
+
q["name"]: {
|
|
105
|
+
"durable": q.get("durable", False),
|
|
106
|
+
"exclusive": q.get("exclusive", False),
|
|
107
|
+
"auto_delete": q.get("auto_delete", False),
|
|
108
|
+
}
|
|
109
|
+
for q in queues_raw
|
|
110
|
+
}
|
|
111
|
+
exchanges = {
|
|
112
|
+
ex["name"]: {
|
|
113
|
+
"type": ex.get("type", "direct"),
|
|
114
|
+
"durable": ex.get("durable", False),
|
|
115
|
+
"auto_delete": ex.get("auto_delete", False),
|
|
116
|
+
}
|
|
117
|
+
for ex in exchanges_raw
|
|
118
|
+
if ex.get("name") # skip default "" exchange
|
|
119
|
+
}
|
|
120
|
+
return {"queues": queues, "exchanges": exchanges}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@topology_app.command("validate")
|
|
124
|
+
def topology_validate(
|
|
125
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
126
|
+
management_url: str = typer.Option(
|
|
127
|
+
"http://guest:guest@localhost:15672",
|
|
128
|
+
"--url", "-u",
|
|
129
|
+
help="RabbitMQ management URL",
|
|
130
|
+
),
|
|
131
|
+
vhost: str = typer.Option("/", "--vhost", "-v", help="Virtual host"),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Validate declared topology against the live RabbitMQ broker.
|
|
134
|
+
|
|
135
|
+
Compares queues and exchanges declared by the broker's routes against
|
|
136
|
+
what actually exists in RabbitMQ and reports mismatches.
|
|
137
|
+
|
|
138
|
+
Exit code 0 = valid, 1 = mismatches found or connection error.
|
|
139
|
+
"""
|
|
140
|
+
broker = load_broker(app_path)
|
|
141
|
+
declared = _declared_resources(broker)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
live = _live_resources(management_url, vhost)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
typer.echo(f"ERROR: could not reach management API at {management_url}: {exc}", err=True)
|
|
147
|
+
raise typer.Exit(1) from None
|
|
148
|
+
|
|
149
|
+
issues: list[str] = []
|
|
150
|
+
|
|
151
|
+
for qname, qprops in declared["queues"].items():
|
|
152
|
+
if qname not in live["queues"]:
|
|
153
|
+
issues.append(f"MISSING queue '{qname}' (not declared in RabbitMQ)")
|
|
154
|
+
else:
|
|
155
|
+
live_q = live["queues"][qname]
|
|
156
|
+
for prop, expected in qprops.items():
|
|
157
|
+
actual = live_q.get(prop)
|
|
158
|
+
if actual != expected:
|
|
159
|
+
issues.append(f"MISMATCH queue '{qname}' {prop}: declared={expected!r} live={actual!r}")
|
|
160
|
+
|
|
161
|
+
for exname, exprops in declared["exchanges"].items():
|
|
162
|
+
if exname not in live["exchanges"]:
|
|
163
|
+
issues.append(f"MISSING exchange '{exname}' (not declared in RabbitMQ)")
|
|
164
|
+
else:
|
|
165
|
+
live_ex = live["exchanges"][exname]
|
|
166
|
+
for prop, expected in exprops.items():
|
|
167
|
+
actual = live_ex.get(prop)
|
|
168
|
+
if actual != expected:
|
|
169
|
+
issues.append(f"MISMATCH exchange '{exname}' {prop}: declared={expected!r} live={actual!r}")
|
|
170
|
+
|
|
171
|
+
if issues:
|
|
172
|
+
typer.echo("Topology validation FAILED:")
|
|
173
|
+
for issue in issues:
|
|
174
|
+
typer.echo(f" - {issue}")
|
|
175
|
+
raise typer.Exit(1) from None
|
|
176
|
+
|
|
177
|
+
n_queues = len(declared["queues"])
|
|
178
|
+
n_exchanges = len(declared["exchanges"])
|
|
179
|
+
typer.echo(f"OK — {n_queues} queue(s) and {n_exchanges} exchange(s) match live broker.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@topology_app.command("diff")
|
|
183
|
+
def topology_diff(
|
|
184
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
185
|
+
management_url: str = typer.Option(
|
|
186
|
+
"http://guest:guest@localhost:15672",
|
|
187
|
+
"--url", "-u",
|
|
188
|
+
help="RabbitMQ management URL",
|
|
189
|
+
),
|
|
190
|
+
vhost: str = typer.Option("/", "--vhost", "-v", help="Virtual host"),
|
|
191
|
+
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Show differences between declared topology and the live broker.
|
|
194
|
+
|
|
195
|
+
Lists resources that are declared in code but missing from RabbitMQ,
|
|
196
|
+
and resources that exist in RabbitMQ but are not declared in code.
|
|
197
|
+
|
|
198
|
+
Exit code 0 = no diff, 1 = diff found or connection error.
|
|
199
|
+
"""
|
|
200
|
+
broker = load_broker(app_path)
|
|
201
|
+
declared = _declared_resources(broker)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
live = _live_resources(management_url, vhost)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
typer.echo(f"ERROR: could not reach management API at {management_url}: {exc}", err=True)
|
|
207
|
+
raise typer.Exit(1) from None
|
|
208
|
+
|
|
209
|
+
diff: dict[str, Any] = {
|
|
210
|
+
"queues": {
|
|
211
|
+
"declared_not_live": [q for q in declared["queues"] if q not in live["queues"]],
|
|
212
|
+
"live_not_declared": [q for q in live["queues"] if q not in declared["queues"]],
|
|
213
|
+
"property_mismatch": [],
|
|
214
|
+
},
|
|
215
|
+
"exchanges": {
|
|
216
|
+
"declared_not_live": [ex for ex in declared["exchanges"] if ex not in live["exchanges"]],
|
|
217
|
+
"live_not_declared": [ex for ex in live["exchanges"] if ex not in declared["exchanges"]],
|
|
218
|
+
"property_mismatch": [],
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for qname, qprops in declared["queues"].items():
|
|
223
|
+
if qname in live["queues"]:
|
|
224
|
+
for prop, expected in qprops.items():
|
|
225
|
+
actual = live["queues"][qname].get(prop)
|
|
226
|
+
if actual != expected:
|
|
227
|
+
diff["queues"]["property_mismatch"].append(
|
|
228
|
+
{"queue": qname, "property": prop, "declared": expected, "live": actual}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for exname, exprops in declared["exchanges"].items():
|
|
232
|
+
if exname in live["exchanges"]:
|
|
233
|
+
for prop, expected in exprops.items():
|
|
234
|
+
actual = live["exchanges"][exname].get(prop)
|
|
235
|
+
if actual != expected:
|
|
236
|
+
diff["exchanges"]["property_mismatch"].append(
|
|
237
|
+
{"exchange": exname, "property": prop, "declared": expected, "live": actual}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
has_diff = any(
|
|
241
|
+
diff[r][k]
|
|
242
|
+
for r in ("queues", "exchanges")
|
|
243
|
+
for k in ("declared_not_live", "live_not_declared", "property_mismatch")
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if output_format == "json":
|
|
247
|
+
typer.echo(json.dumps(diff, indent=2))
|
|
248
|
+
else:
|
|
249
|
+
if not has_diff:
|
|
250
|
+
typer.echo("No diff — declared topology matches live broker.")
|
|
251
|
+
else:
|
|
252
|
+
for resource in ("queues", "exchanges"):
|
|
253
|
+
d = diff[resource]
|
|
254
|
+
for name in d["declared_not_live"]:
|
|
255
|
+
typer.echo(f"+ {resource[:-1]} '{name}' (declared, not in RabbitMQ)")
|
|
256
|
+
for name in d["live_not_declared"]:
|
|
257
|
+
typer.echo(f"~ {resource[:-1]} '{name}' (in RabbitMQ, not declared in code)")
|
|
258
|
+
for m in d["property_mismatch"]:
|
|
259
|
+
r_key = "queue" if resource == "queues" else "exchange"
|
|
260
|
+
typer.echo(
|
|
261
|
+
f"! {r_key} '{m[r_key]}' {m['property']}: "
|
|
262
|
+
f"declared={m['declared']!r} live={m['live']!r}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if has_diff:
|
|
266
|
+
raise typer.Exit(1) from None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@topology_app.command("apply")
|
|
270
|
+
def topology_apply(
|
|
271
|
+
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
272
|
+
amqp_url: str = typer.Option(
|
|
273
|
+
"amqp://guest:guest@localhost/",
|
|
274
|
+
"--url", "-u",
|
|
275
|
+
help="AMQP URL for the broker connection",
|
|
276
|
+
),
|
|
277
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be declared without applying"),
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Declare all queues and exchanges from the broker's registered routes.
|
|
280
|
+
|
|
281
|
+
Connects to RabbitMQ and calls queue_declare / exchange_declare for every
|
|
282
|
+
resource registered with the broker. Safe to run repeatedly — uses
|
|
283
|
+
passive=False so existing resources are verified but not recreated.
|
|
284
|
+
|
|
285
|
+
Exit code 0 = success, 1 = error.
|
|
286
|
+
"""
|
|
287
|
+
import asyncio
|
|
288
|
+
|
|
289
|
+
broker = load_broker(app_path)
|
|
290
|
+
declared = _declared_resources(broker)
|
|
291
|
+
|
|
292
|
+
n_queues = len(declared["queues"])
|
|
293
|
+
n_exchanges = len(declared["exchanges"])
|
|
294
|
+
|
|
295
|
+
if dry_run:
|
|
296
|
+
typer.echo(f"[dry-run] would declare {n_queues} queue(s) and {n_exchanges} exchange(s):")
|
|
297
|
+
for qname, qprops in declared["queues"].items():
|
|
298
|
+
typer.echo(f" queue {qname!r} durable={qprops['durable']}")
|
|
299
|
+
for exname, exprops in declared["exchanges"].items():
|
|
300
|
+
typer.echo(f" exchange {exname!r} type={exprops['type']} durable={exprops['durable']}")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
async def _apply() -> None:
|
|
304
|
+
from rabbitkit.async_.broker import AsyncBroker
|
|
305
|
+
from rabbitkit.core.config import ConnectionConfig, RabbitConfig
|
|
306
|
+
|
|
307
|
+
apply_broker = AsyncBroker(RabbitConfig(connection=ConnectionConfig.from_url(amqp_url)))
|
|
308
|
+
await apply_broker.start()
|
|
309
|
+
|
|
310
|
+
transport = apply_broker._transport
|
|
311
|
+
assert transport is not None, "broker transport not initialised after start()"
|
|
312
|
+
|
|
313
|
+
from rabbitkit.core.topology import RabbitExchange, RabbitQueue
|
|
314
|
+
from rabbitkit.core.types import ExchangeType
|
|
315
|
+
|
|
316
|
+
for qname, qprops in declared["queues"].items():
|
|
317
|
+
q = RabbitQueue(
|
|
318
|
+
name=qname,
|
|
319
|
+
durable=qprops["durable"],
|
|
320
|
+
exclusive=qprops["exclusive"],
|
|
321
|
+
auto_delete=qprops["auto_delete"],
|
|
322
|
+
)
|
|
323
|
+
await transport.declare_queue(q)
|
|
324
|
+
typer.echo(f" declared queue '{qname}'")
|
|
325
|
+
|
|
326
|
+
for exname, exprops in declared["exchanges"].items():
|
|
327
|
+
ex_type_str = exprops["type"]
|
|
328
|
+
valid_types = {e.value for e in ExchangeType}
|
|
329
|
+
ex_type = ExchangeType(ex_type_str) if ex_type_str in valid_types else ExchangeType.DIRECT
|
|
330
|
+
ex = RabbitExchange(
|
|
331
|
+
name=exname,
|
|
332
|
+
type=ex_type,
|
|
333
|
+
durable=exprops["durable"],
|
|
334
|
+
auto_delete=exprops["auto_delete"],
|
|
335
|
+
)
|
|
336
|
+
await transport.declare_exchange(ex)
|
|
337
|
+
typer.echo(f" declared exchange '{exname}'")
|
|
338
|
+
|
|
339
|
+
await apply_broker.stop()
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
asyncio.run(_apply())
|
|
343
|
+
typer.echo(f"Applied: {n_queues} queue(s), {n_exchanges} exchange(s).")
|
|
344
|
+
except Exception as exc:
|
|
345
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
346
|
+
raise typer.Exit(1) from None
|