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/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