typedkafka 0.3.1__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.
- typedkafka/__init__.py +53 -0
- typedkafka/admin.py +336 -0
- typedkafka/aio.py +328 -0
- typedkafka/config.py +405 -0
- typedkafka/consumer.py +415 -0
- typedkafka/exceptions.py +130 -0
- typedkafka/producer.py +492 -0
- typedkafka/retry.py +154 -0
- typedkafka/serializers.py +293 -0
- typedkafka/testing.py +523 -0
- typedkafka-0.3.1.dist-info/METADATA +263 -0
- typedkafka-0.3.1.dist-info/RECORD +14 -0
- typedkafka-0.3.1.dist-info/WHEEL +4 -0
- typedkafka-0.3.1.dist-info/licenses/LICENSE +21 -0
typedkafka/aio.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Kafka producer and consumer wrappers.
|
|
3
|
+
|
|
4
|
+
Provides asyncio-compatible wrappers around the synchronous KafkaProducer
|
|
5
|
+
and KafkaConsumer using a thread pool executor, allowing integration with
|
|
6
|
+
async Python applications.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from typedkafka.exceptions import ConsumerError, ProducerError, SerializationError
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from confluent_kafka import Consumer as ConfluentConsumer
|
|
19
|
+
from confluent_kafka import Producer as ConfluentProducer
|
|
20
|
+
except ImportError:
|
|
21
|
+
ConfluentProducer = None # type: ignore[assignment,misc]
|
|
22
|
+
ConfluentConsumer = None # type: ignore[assignment,misc]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncKafkaProducer:
|
|
26
|
+
"""
|
|
27
|
+
Async Kafka producer wrapping confluent-kafka with asyncio support.
|
|
28
|
+
|
|
29
|
+
Uses a thread pool to run confluent-kafka's synchronous operations
|
|
30
|
+
without blocking the event loop.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
>>> async with AsyncKafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
|
|
34
|
+
... await producer.send("topic", b"message")
|
|
35
|
+
... await producer.flush()
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
config: The configuration dictionary used to initialize the producer
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: dict[str, Any],
|
|
44
|
+
executor: Optional[ThreadPoolExecutor] = None,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Initialize an async Kafka producer.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config: Configuration dictionary for the producer.
|
|
51
|
+
executor: Optional ThreadPoolExecutor. If None, a default one is created.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ProducerError: If the producer cannot be initialized.
|
|
55
|
+
"""
|
|
56
|
+
if ConfluentProducer is None:
|
|
57
|
+
raise ImportError(
|
|
58
|
+
"confluent-kafka is required. Install with: pip install confluent-kafka"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.config = config
|
|
62
|
+
self._executor = executor or ThreadPoolExecutor(max_workers=1)
|
|
63
|
+
self._owns_executor = executor is None
|
|
64
|
+
try:
|
|
65
|
+
self._producer = ConfluentProducer(config)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
raise ProducerError(
|
|
68
|
+
f"Failed to initialize async Kafka producer: {e}",
|
|
69
|
+
original_error=e,
|
|
70
|
+
) from e
|
|
71
|
+
|
|
72
|
+
async def send(
|
|
73
|
+
self,
|
|
74
|
+
topic: str,
|
|
75
|
+
value: bytes,
|
|
76
|
+
key: Optional[bytes] = None,
|
|
77
|
+
partition: Optional[int] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Asynchronously send a message to a Kafka topic.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
topic: The topic name to send the message to.
|
|
84
|
+
value: The message payload as bytes.
|
|
85
|
+
key: Optional message key as bytes.
|
|
86
|
+
partition: Optional partition number.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ProducerError: If the message cannot be queued.
|
|
90
|
+
"""
|
|
91
|
+
loop = asyncio.get_event_loop()
|
|
92
|
+
try:
|
|
93
|
+
await loop.run_in_executor(
|
|
94
|
+
self._executor,
|
|
95
|
+
lambda: self._producer.produce(
|
|
96
|
+
topic=topic, value=value, key=key, partition=partition # type: ignore[arg-type]
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
await loop.run_in_executor(self._executor, lambda: self._producer.poll(0))
|
|
100
|
+
except BufferError as e:
|
|
101
|
+
raise ProducerError(
|
|
102
|
+
"Message queue is full. Try calling flush() or increasing queue.buffering.max.messages",
|
|
103
|
+
original_error=e,
|
|
104
|
+
) from e
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise ProducerError(
|
|
107
|
+
f"Failed to send message to topic '{topic}': {e}",
|
|
108
|
+
original_error=e,
|
|
109
|
+
) from e
|
|
110
|
+
|
|
111
|
+
async def send_json(
|
|
112
|
+
self,
|
|
113
|
+
topic: str,
|
|
114
|
+
value: Any,
|
|
115
|
+
key: Optional[str] = None,
|
|
116
|
+
partition: Optional[int] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Asynchronously send a JSON-serialized message.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
topic: The topic name.
|
|
123
|
+
value: Any JSON-serializable Python object.
|
|
124
|
+
key: Optional string key.
|
|
125
|
+
partition: Optional partition number.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
SerializationError: If JSON serialization fails.
|
|
129
|
+
ProducerError: If the message cannot be queued.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
value_bytes = json.dumps(value).encode("utf-8")
|
|
133
|
+
except (TypeError, ValueError) as e:
|
|
134
|
+
raise SerializationError(
|
|
135
|
+
f"Failed to serialize value to JSON: {e}",
|
|
136
|
+
value=value,
|
|
137
|
+
original_error=e,
|
|
138
|
+
) from e
|
|
139
|
+
key_bytes = key.encode("utf-8") if key is not None else None
|
|
140
|
+
await self.send(topic, value_bytes, key=key_bytes, partition=partition)
|
|
141
|
+
|
|
142
|
+
async def flush(self, timeout: float = -1) -> int:
|
|
143
|
+
"""
|
|
144
|
+
Asynchronously wait for all queued messages to be delivered.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
timeout: Maximum time to wait in seconds. -1 for infinite.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Number of messages still in queue.
|
|
151
|
+
"""
|
|
152
|
+
loop = asyncio.get_event_loop()
|
|
153
|
+
try:
|
|
154
|
+
return await loop.run_in_executor(
|
|
155
|
+
self._executor, lambda: self._producer.flush(timeout=timeout)
|
|
156
|
+
)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
raise ProducerError(f"Flush failed: {e}", original_error=e) from e
|
|
159
|
+
|
|
160
|
+
async def close(self) -> None:
|
|
161
|
+
"""Flush and close the producer."""
|
|
162
|
+
await self.flush()
|
|
163
|
+
if self._owns_executor:
|
|
164
|
+
self._executor.shutdown(wait=False)
|
|
165
|
+
|
|
166
|
+
async def __aenter__(self) -> "AsyncKafkaProducer":
|
|
167
|
+
"""Async context manager entry."""
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
171
|
+
"""Async context manager exit."""
|
|
172
|
+
await self.close()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AsyncKafkaConsumer:
|
|
176
|
+
"""
|
|
177
|
+
Async Kafka consumer wrapping confluent-kafka with asyncio support.
|
|
178
|
+
|
|
179
|
+
Uses a thread pool to run confluent-kafka's synchronous poll without
|
|
180
|
+
blocking the event loop. Supports ``async for`` iteration.
|
|
181
|
+
|
|
182
|
+
Examples:
|
|
183
|
+
>>> async with AsyncKafkaConsumer(config) as consumer:
|
|
184
|
+
... consumer.subscribe(["topic"])
|
|
185
|
+
... async for msg in consumer:
|
|
186
|
+
... print(msg.value())
|
|
187
|
+
|
|
188
|
+
Attributes:
|
|
189
|
+
config: The configuration dictionary used to initialize the consumer
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
config: dict[str, Any],
|
|
195
|
+
executor: Optional[ThreadPoolExecutor] = None,
|
|
196
|
+
):
|
|
197
|
+
"""
|
|
198
|
+
Initialize an async Kafka consumer.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
config: Configuration dictionary for the consumer.
|
|
202
|
+
executor: Optional ThreadPoolExecutor.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ConsumerError: If the consumer cannot be initialized.
|
|
206
|
+
"""
|
|
207
|
+
if ConfluentConsumer is None:
|
|
208
|
+
raise ImportError(
|
|
209
|
+
"confluent-kafka is required. Install with: pip install confluent-kafka"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self.config = config
|
|
213
|
+
self._executor = executor or ThreadPoolExecutor(max_workers=1)
|
|
214
|
+
self._owns_executor = executor is None
|
|
215
|
+
self.poll_timeout: float = 1.0
|
|
216
|
+
try:
|
|
217
|
+
self._consumer = ConfluentConsumer(config)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
raise ConsumerError(
|
|
220
|
+
f"Failed to initialize async Kafka consumer: {e}",
|
|
221
|
+
original_error=e,
|
|
222
|
+
) from e
|
|
223
|
+
|
|
224
|
+
def subscribe(self, topics: list[str], **kwargs: Any) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Subscribe to topics.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
topics: List of topic names.
|
|
230
|
+
**kwargs: Additional arguments passed to confluent-kafka subscribe.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
self._consumer.subscribe(topics, **kwargs)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise ConsumerError(
|
|
236
|
+
f"Failed to subscribe to topics {topics}: {e}",
|
|
237
|
+
original_error=e,
|
|
238
|
+
) from e
|
|
239
|
+
|
|
240
|
+
async def poll(self, timeout: float = 1.0) -> Any:
|
|
241
|
+
"""
|
|
242
|
+
Asynchronously poll for a single message.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
timeout: Maximum time to wait in seconds.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
A confluent-kafka Message, or None if timeout expired.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ConsumerError: If an error occurs during polling.
|
|
252
|
+
"""
|
|
253
|
+
loop = asyncio.get_event_loop()
|
|
254
|
+
try:
|
|
255
|
+
msg = await loop.run_in_executor(
|
|
256
|
+
self._executor, lambda: self._consumer.poll(timeout=timeout)
|
|
257
|
+
)
|
|
258
|
+
if msg is None:
|
|
259
|
+
return None
|
|
260
|
+
if msg.error():
|
|
261
|
+
raise ConsumerError(f"Consumer error: {msg.error()}")
|
|
262
|
+
return msg
|
|
263
|
+
except ConsumerError:
|
|
264
|
+
raise
|
|
265
|
+
except Exception as e:
|
|
266
|
+
raise ConsumerError(
|
|
267
|
+
f"Error while polling: {e}",
|
|
268
|
+
original_error=e,
|
|
269
|
+
) from e
|
|
270
|
+
|
|
271
|
+
async def commit(self, message: Any = None, asynchronous: bool = True) -> None:
|
|
272
|
+
"""
|
|
273
|
+
Asynchronously commit offsets.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
message: Specific message to commit. If None, commits all consumed.
|
|
277
|
+
asynchronous: If True, commit asynchronously.
|
|
278
|
+
"""
|
|
279
|
+
loop = asyncio.get_event_loop()
|
|
280
|
+
try:
|
|
281
|
+
if message:
|
|
282
|
+
await loop.run_in_executor(
|
|
283
|
+
self._executor,
|
|
284
|
+
lambda: self._consumer.commit(message=message, asynchronous=asynchronous), # type: ignore[call-overload]
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
await loop.run_in_executor(
|
|
288
|
+
self._executor,
|
|
289
|
+
lambda: self._consumer.commit(asynchronous=asynchronous), # type: ignore[call-overload]
|
|
290
|
+
)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
raise ConsumerError(
|
|
293
|
+
f"Failed to commit offsets: {e}",
|
|
294
|
+
original_error=e,
|
|
295
|
+
) from e
|
|
296
|
+
|
|
297
|
+
async def close(self) -> None:
|
|
298
|
+
"""Close the consumer and leave the consumer group."""
|
|
299
|
+
loop = asyncio.get_event_loop()
|
|
300
|
+
try:
|
|
301
|
+
await loop.run_in_executor(self._executor, self._consumer.close)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
raise ConsumerError(
|
|
304
|
+
f"Failed to close consumer: {e}",
|
|
305
|
+
original_error=e,
|
|
306
|
+
) from e
|
|
307
|
+
if self._owns_executor:
|
|
308
|
+
self._executor.shutdown(wait=False)
|
|
309
|
+
|
|
310
|
+
async def __aenter__(self) -> "AsyncKafkaConsumer":
|
|
311
|
+
"""Async context manager entry."""
|
|
312
|
+
return self
|
|
313
|
+
|
|
314
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
315
|
+
"""Async context manager exit."""
|
|
316
|
+
await self.close()
|
|
317
|
+
|
|
318
|
+
async def __aiter__(self) -> AsyncIterator[Any]:
|
|
319
|
+
"""
|
|
320
|
+
Async iterate over messages indefinitely.
|
|
321
|
+
|
|
322
|
+
Yields:
|
|
323
|
+
Messages as they arrive.
|
|
324
|
+
"""
|
|
325
|
+
while True:
|
|
326
|
+
msg = await self.poll(timeout=self.poll_timeout)
|
|
327
|
+
if msg is not None:
|
|
328
|
+
yield msg
|