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,209 @@
1
+ """Pika-specific connection parameter builders and error tuples.
2
+
3
+ This is where all pika imports live — core/ stays clean.
4
+ Provides helpers to build pika.ConnectionParameters from rabbitkit config objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import socket
11
+ import ssl
12
+ from typing import Any
13
+
14
+ from rabbitkit.core.config import ConnectionConfig, SecurityConfig, SocketConfig, SSLConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # ── Transport-specific connection errors ──────────────────────────────────
20
+ # These extend the core TRANSIENT_ERRORS for pika-specific exceptions.
21
+ # Lazy-loaded to avoid import errors when pika is not installed.
22
+
23
+
24
+ def get_connection_errors() -> tuple[type[BaseException], ...]:
25
+ """Get pika-specific connection error tuple.
26
+
27
+ Returns generic stdlib errors if pika is not installed.
28
+ """
29
+ base_errors: tuple[type[BaseException], ...] = (
30
+ ConnectionResetError,
31
+ BrokenPipeError,
32
+ ConnectionAbortedError,
33
+ ConnectionRefusedError,
34
+ TimeoutError,
35
+ EOFError,
36
+ OSError,
37
+ )
38
+
39
+ try:
40
+ import pika.exceptions
41
+
42
+ pika_errors: tuple[type[BaseException], ...] = (
43
+ pika.exceptions.StreamLostError,
44
+ pika.exceptions.AMQPConnectionError,
45
+ pika.exceptions.ConnectionClosedByBroker,
46
+ pika.exceptions.ChannelWrongStateError,
47
+ pika.exceptions.ChannelClosedByBroker,
48
+ pika.exceptions.AMQPChannelError,
49
+ )
50
+ return pika_errors + base_errors
51
+ except ImportError:
52
+ return base_errors
53
+
54
+
55
+ def build_ssl_context(ssl_config: SSLConfig) -> ssl.SSLContext | None:
56
+ """Build stdlib ssl.SSLContext from SSLConfig.
57
+
58
+ Returns None if SSL is not enabled.
59
+ """
60
+ if not ssl_config.enabled:
61
+ return None
62
+
63
+ # Determine cert_reqs
64
+ cert_reqs_map = {
65
+ "CERT_REQUIRED": ssl.CERT_REQUIRED,
66
+ "CERT_OPTIONAL": ssl.CERT_OPTIONAL,
67
+ "CERT_NONE": ssl.CERT_NONE,
68
+ }
69
+ cert_reqs = cert_reqs_map.get(ssl_config.cert_reqs, ssl.CERT_REQUIRED)
70
+
71
+ # M13: disabling certificate verification makes the connection
72
+ # MITM-able — warn loudly, since it's a copy-paste "make TLS errors go
73
+ # away" footgun that otherwise ships silently to production.
74
+ if cert_reqs == ssl.CERT_NONE:
75
+ import warnings
76
+
77
+ warnings.warn(
78
+ "SSLConfig(cert_reqs='CERT_NONE') disables TLS certificate and hostname "
79
+ "verification — the connection is encrypted but MITM-able. Use "
80
+ "'CERT_REQUIRED' (the default) with a proper ca_certs bundle in production.",
81
+ RuntimeWarning,
82
+ stacklevel=2,
83
+ )
84
+
85
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
86
+
87
+ # Defense in depth: never negotiate below TLS 1.2.
88
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
89
+
90
+ # check_hostname must be disabled BEFORE setting verify_mode=CERT_NONE
91
+ # (Python 3.12+ raises ValueError otherwise)
92
+ if cert_reqs == ssl.CERT_NONE:
93
+ ctx.check_hostname = False
94
+
95
+ ctx.verify_mode = cert_reqs
96
+
97
+ if ssl_config.ca_certs:
98
+ ctx.load_verify_locations(ssl_config.ca_certs)
99
+ elif cert_reqs == ssl.CERT_REQUIRED:
100
+ # No explicit CA bundle configured — fall back to the system trust
101
+ # store so verification actually succeeds against broker certs
102
+ # signed by a well-known CA. Without this, CERT_REQUIRED + no ca_certs
103
+ # silently leaves the context with zero trusted CAs → every handshake
104
+ # fails. Guarded so the explicit-ca_certs path above is unchanged.
105
+ try:
106
+ ctx.load_default_certs()
107
+ except Exception: # pragma: no cover — best effort, platform-dependent
108
+ try:
109
+ ctx.set_default_verify_paths()
110
+ except Exception: # pragma: no cover
111
+ pass
112
+
113
+ if ssl_config.certfile:
114
+ ctx.load_cert_chain(
115
+ certfile=ssl_config.certfile,
116
+ keyfile=ssl_config.keyfile,
117
+ )
118
+
119
+ return ctx
120
+
121
+
122
+ def make_pika_connection_params(
123
+ connection: ConnectionConfig,
124
+ socket_config: SocketConfig,
125
+ security: SecurityConfig,
126
+ ) -> Any:
127
+ """Build pika.ConnectionParameters with TCP tuning, SSL, heartbeat.
128
+
129
+ Returns a pika.ConnectionParameters object.
130
+ Raises ImportError if pika is not installed.
131
+ """
132
+ try:
133
+ import pika
134
+ except ImportError:
135
+ raise ImportError("pika is required for sync transport. Install it with: pip install rabbitkit[sync]") from None
136
+
137
+ # SSL context
138
+ ssl_context = build_ssl_context(security.ssl)
139
+ ssl_options = None
140
+ if ssl_context is not None:
141
+ ssl_options = pika.SSLOptions(
142
+ context=ssl_context,
143
+ server_hostname=security.ssl.server_hostname or connection.host,
144
+ )
145
+
146
+ # Credentials (M13: resolve via credentials_provider if set, so a rotated
147
+ # secret is picked up on this (re)connect).
148
+ username, password = connection.resolve_credentials()
149
+ credentials = pika.PlainCredentials(
150
+ username=username,
151
+ password=password,
152
+ )
153
+
154
+ # Client properties
155
+ client_properties: dict[str, str] = {}
156
+ if connection.connection_name:
157
+ client_properties["connection_name"] = connection.connection_name
158
+
159
+ def _params_for(host: str, port: int) -> Any:
160
+ return pika.ConnectionParameters(
161
+ host=host,
162
+ port=port,
163
+ virtual_host=connection.vhost,
164
+ credentials=credentials,
165
+ heartbeat=connection.heartbeat,
166
+ socket_timeout=connection.socket_timeout,
167
+ blocked_connection_timeout=connection.blocked_connection_timeout,
168
+ ssl_options=ssl_options,
169
+ client_properties=client_properties if client_properties else None,
170
+ )
171
+
172
+ endpoints = connection.cluster_endpoints()
173
+ if len(endpoints) == 1:
174
+ return _params_for(*endpoints[0])
175
+ # M9: pika.BlockingConnection accepts a LIST of ConnectionParameters and
176
+ # tries each in order until one connects — native cluster failover.
177
+ return [_params_for(host, port) for host, port in endpoints]
178
+
179
+
180
+ def apply_socket_options(sock: socket.socket, config: SocketConfig) -> None:
181
+ """Apply TCP_NODELAY, keepalive, buffer sizes to a socket.
182
+
183
+ Best-effort — not all options are universally guaranteed
184
+ depending on OS and backend internals.
185
+ """
186
+ try:
187
+ # TCP_NODELAY — disable Nagle's algorithm
188
+ if config.tcp_nodelay:
189
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
190
+
191
+ # TCP keepalive
192
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
193
+
194
+ # Platform-specific keepalive options
195
+ if hasattr(socket, "TCP_KEEPIDLE"):
196
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, config.tcp_keepidle)
197
+ if hasattr(socket, "TCP_KEEPINTVL"):
198
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, config.tcp_keepintvl)
199
+ if hasattr(socket, "TCP_KEEPCNT"):
200
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, config.tcp_keepcnt)
201
+
202
+ # Buffer sizes
203
+ if config.tcp_sndbuf > 0:
204
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, config.tcp_sndbuf)
205
+ if config.tcp_rcvbuf > 0:
206
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, config.tcp_rcvbuf)
207
+
208
+ except OSError as e:
209
+ logger.warning("Failed to apply socket option: %s (best-effort, continuing)", e)
rabbitkit/sync/pool.py ADDED
@@ -0,0 +1,262 @@
1
+ """Sync connection and channel pools.
2
+
3
+ Minimal in 0.1.0 — internal performance utilities.
4
+ Do not oversell as a promised optimization layer.
5
+
6
+ Model A: one-connection-per-thread. Pools enforce this by assigning
7
+ dedicated connections per role (publisher vs consumer).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import queue
14
+ import threading
15
+ from contextlib import contextmanager
16
+ from typing import Any
17
+
18
+ from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig, SocketConfig
19
+ from rabbitkit.sync.connection import make_pika_connection_params
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class SyncChannelPool:
25
+ """Thread-safe channel pool.
26
+
27
+ Manages a pool of pika channels on a single connection.
28
+ Channels are acquired and released by callers.
29
+
30
+ ``acquire_timeout`` bounds how long ``acquire()`` blocks when all channels
31
+ are checked out; it raises ``TimeoutError`` on expiry (mirrors the async
32
+ pool) rather than blocking forever — which would deadlock a worker that
33
+ tries to publish while processing.
34
+
35
+ Note: the default ``SyncTransport`` does not route publishes through this
36
+ pool (it uses a single dedicated publisher channel); the pool is kept as a
37
+ reusable utility for advanced/pooled usage and is covered by unit tests so
38
+ it is not silently dead code.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ connection: Any, # pika.BlockingConnection
44
+ pool_size: int = 10,
45
+ acquire_timeout: float = 10.0,
46
+ ) -> None:
47
+ self._connection = connection
48
+ self._pool_size = pool_size
49
+ self._acquire_timeout = acquire_timeout
50
+ self._pool: queue.Queue[Any] = queue.Queue(maxsize=pool_size)
51
+ self._lock = threading.Lock()
52
+ self._created = 0
53
+ # Channels currently checked out by callers (leak detection / close_all).
54
+ self._in_use: set[Any] = set()
55
+
56
+ def acquire(self) -> Any:
57
+ """Acquire a channel from the pool.
58
+
59
+ Creates a new channel if the pool is empty and under the size limit.
60
+ Blocks up to ``acquire_timeout`` if the pool is exhausted; raises
61
+ ``TimeoutError`` on expiry so callers don't deadlock.
62
+ """
63
+ try:
64
+ channel = self._pool.get_nowait()
65
+ if channel.is_open:
66
+ with self._lock:
67
+ self._in_use.add(channel)
68
+ return channel
69
+ # I-6: a pooled channel was found closed — it still counted
70
+ # against _created when it was released, so decrement before
71
+ # discarding it (otherwise acquire() leaks a slot each time a
72
+ # closed-idle channel is pulled from the pool).
73
+ with self._lock:
74
+ self._created = max(0, self._created - 1)
75
+ except queue.Empty:
76
+ pass
77
+
78
+ # perf-M-2: create the channel OUTSIDE the lock (network round-trip) so
79
+ # concurrent acquire() calls don't serialize on channel creation during
80
+ # warmup/refill. The slot is reserved atomically under the lock (so we
81
+ # never over-create); the channel-open I/O happens outside the lock, and
82
+ # we re-acquire only to publish _in_use. If creation fails, the reserved
83
+ # slot is returned. The I-6 closed-idle decrement above stays under the
84
+ # lock.
85
+ with self._lock:
86
+ if self._created < self._pool_size:
87
+ need_create = True
88
+ self._created += 1
89
+ else:
90
+ need_create = False
91
+
92
+ if need_create:
93
+ try:
94
+ channel = self._connection.channel()
95
+ except BaseException:
96
+ with self._lock:
97
+ self._created = max(0, self._created - 1)
98
+ raise
99
+ with self._lock:
100
+ self._in_use.add(channel)
101
+ return channel
102
+
103
+ # Pool exhausted — block until one is released, bounded by acquire_timeout
104
+ logger.warning(
105
+ "SyncChannelPool exhausted (pool_size=%d, created=%d). "
106
+ "Waiting up to %.1fs for a channel to be released. "
107
+ "Consider increasing PoolConfig.channel_pool_size.",
108
+ self._pool_size,
109
+ self._created,
110
+ self._acquire_timeout,
111
+ )
112
+ try:
113
+ channel = self._pool.get(timeout=self._acquire_timeout)
114
+ except queue.Empty as e:
115
+ raise TimeoutError(
116
+ f"Timed out after {self._acquire_timeout}s waiting for a pooled "
117
+ "channel. Increase PoolConfig.channel_pool_size."
118
+ ) from e
119
+ if not channel.is_open:
120
+ # Discard the stale channel and recurse to try again.
121
+ with self._lock:
122
+ self._created = max(0, self._created - 1)
123
+ return self.acquire()
124
+ with self._lock:
125
+ self._in_use.add(channel)
126
+ return channel
127
+
128
+ def release(self, channel: Any) -> None:
129
+ """Release a channel back to the pool."""
130
+ with self._lock:
131
+ self._in_use.discard(channel)
132
+ if channel.is_open:
133
+ try:
134
+ self._pool.put_nowait(channel)
135
+ return
136
+ except queue.Full:
137
+ pass
138
+ # Channel is closed or pool is full — discard
139
+ try:
140
+ if channel.is_open:
141
+ channel.close()
142
+ except Exception:
143
+ pass
144
+ with self._lock:
145
+ self._created = max(0, self._created - 1)
146
+
147
+ @contextmanager
148
+ def acquire_ctx(self) -> Any:
149
+ """Context manager for acquire/release — prevents leaks."""
150
+ channel = self.acquire()
151
+ try:
152
+ yield channel
153
+ finally:
154
+ self.release(channel)
155
+
156
+ def close_all(self) -> None:
157
+ """Close all channels in the pool (including checked-out ones)."""
158
+ with self._lock:
159
+ in_use = list(self._in_use)
160
+ self._in_use.clear()
161
+ for channel in in_use:
162
+ try:
163
+ if channel.is_open:
164
+ channel.close()
165
+ except Exception: # pragma: no cover — best effort
166
+ pass
167
+ with self._lock:
168
+ self._created = max(0, self._created - 1)
169
+ while not self._pool.empty():
170
+ try:
171
+ channel = self._pool.get_nowait()
172
+ if channel.is_open:
173
+ channel.close()
174
+ except (queue.Empty, Exception):
175
+ pass
176
+ with self._lock:
177
+ self._created = 0
178
+
179
+ @property
180
+ def size(self) -> int:
181
+ """Number of channels currently in the pool (available)."""
182
+ return self._pool.qsize()
183
+
184
+ @property
185
+ def created_count(self) -> int:
186
+ """Total number of channels created."""
187
+ return self._created
188
+
189
+
190
+ class SyncConnectionPool:
191
+ """Separate publisher/consumer connections.
192
+
193
+ Provides dedicated connections for publishing and consuming
194
+ to avoid head-of-line blocking.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ connection_config: ConnectionConfig,
200
+ socket_config: SocketConfig,
201
+ security_config: SecurityConfig,
202
+ pool_config: PoolConfig | None = None,
203
+ ) -> None:
204
+ self._connection_config = connection_config
205
+ self._socket_config = socket_config
206
+ self._security_config = security_config
207
+ self._pool_config = pool_config or PoolConfig()
208
+
209
+ self._publisher_connections: list[Any] = []
210
+ self._consumer_connections: list[Any] = []
211
+ self._lock = threading.Lock()
212
+
213
+ def get_publisher_connection(self) -> Any:
214
+ """Get a connection dedicated for publishing.
215
+
216
+ Creates the connection lazily on first call.
217
+ """
218
+ with self._lock:
219
+ if not self._publisher_connections:
220
+ conn = self._create_connection()
221
+ self._publisher_connections.append(conn)
222
+ return self._publisher_connections[0]
223
+
224
+ def get_consumer_connection(self) -> Any:
225
+ """Get a connection dedicated for consuming.
226
+
227
+ Creates the connection lazily on first call.
228
+ """
229
+ with self._lock:
230
+ if not self._consumer_connections:
231
+ conn = self._create_connection()
232
+ self._consumer_connections.append(conn)
233
+ return self._consumer_connections[0]
234
+
235
+ def close_all(self) -> None:
236
+ """Close all connections."""
237
+ with self._lock:
238
+ for conn in self._publisher_connections + self._consumer_connections:
239
+ try:
240
+ if conn.is_open:
241
+ conn.close()
242
+ except Exception as e:
243
+ logger.warning("Error closing connection: %s", e)
244
+
245
+ self._publisher_connections.clear()
246
+ self._consumer_connections.clear()
247
+
248
+ def _create_connection(self) -> Any:
249
+ """Create a new pika connection."""
250
+ try:
251
+ import pika
252
+ except ImportError:
253
+ raise ImportError(
254
+ "pika is required for sync transport. Install it with: pip install rabbitkit[sync]"
255
+ ) from None
256
+
257
+ params = make_pika_connection_params(
258
+ self._connection_config,
259
+ self._socket_config,
260
+ self._security_config,
261
+ )
262
+ return pika.BlockingConnection(params)