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/producer.py ADDED
@@ -0,0 +1,492 @@
1
+ """
2
+ Kafka Producer with comprehensive documentation and full type safety.
3
+
4
+ This module provides a well-documented, type-hinted wrapper around confluent-kafka's Producer.
5
+ """
6
+
7
+ import json
8
+ from typing import Any, Callable, Optional
9
+
10
+ try:
11
+ from confluent_kafka import KafkaError as ConfluentKafkaError
12
+ from confluent_kafka import Producer as ConfluentProducer
13
+ except ImportError:
14
+ # Make confluent-kafka optional for documentation/type checking
15
+ ConfluentProducer = None # type: ignore[assignment,misc]
16
+ ConfluentKafkaError = None # type: ignore[assignment,misc]
17
+
18
+ from typedkafka.exceptions import ProducerError, SerializationError
19
+
20
+
21
+ class KafkaProducer:
22
+ """
23
+ A well-documented Kafka producer with full type hints.
24
+
25
+ This class wraps confluent-kafka's Producer with:
26
+ - Comprehensive docstrings on every method
27
+ - Full type hints for IDE autocomplete
28
+ - Better error messages
29
+ - Convenient methods for common operations (send_json, send_string)
30
+ - Context manager support for automatic cleanup
31
+
32
+ Basic Usage:
33
+ >>> producer = KafkaProducer({"bootstrap.servers": "localhost:9092"})
34
+ >>> producer.send("my-topic", b"my message", key=b"my-key")
35
+ >>> producer.flush() # Wait for all messages to be delivered
36
+
37
+ With Context Manager:
38
+ >>> with KafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
39
+ ... producer.send("my-topic", b"message")
40
+ ... # Automatic flush and cleanup on exit
41
+
42
+ JSON Messages:
43
+ >>> producer.send_json("events", {"user_id": 123, "action": "click"})
44
+
45
+ Attributes:
46
+ config: The configuration dictionary used to initialize the producer
47
+ """
48
+
49
+ def __init__(self, config: dict[str, Any]):
50
+ """
51
+ Initialize a Kafka producer with the given configuration.
52
+
53
+ Args:
54
+ config: Configuration dictionary for the producer. Common options:
55
+ - bootstrap.servers (str): Comma-separated list of broker addresses
56
+ Example: "localhost:9092" or "broker1:9092,broker2:9092"
57
+ - client.id (str): An identifier for this client
58
+ - acks (str|int): Number of acknowledgments the producer requires
59
+ "0" = no acknowledgment, "1" = leader only, "all" = all replicas
60
+ - compression.type (str): Compression codec ("none", "gzip", "snappy", "lz4", "zstd")
61
+ - max.in.flight.requests.per.connection (int): Max unacknowledged requests
62
+ - linger.ms (int): Time to wait before sending a batch
63
+ - batch.size (int): Maximum size of a message batch in bytes
64
+
65
+ Raises:
66
+ ProducerError: If the producer cannot be initialized with the given config
67
+
68
+ Examples:
69
+ >>> # Basic producer
70
+ >>> producer = KafkaProducer({"bootstrap.servers": "localhost:9092"})
71
+
72
+ >>> # Producer with compression
73
+ >>> producer = KafkaProducer({
74
+ ... "bootstrap.servers": "localhost:9092",
75
+ ... "compression.type": "gzip",
76
+ ... "acks": "all"
77
+ ... })
78
+ """
79
+ if ConfluentProducer is None:
80
+ raise ImportError(
81
+ "confluent-kafka is required. Install with: pip install confluent-kafka"
82
+ )
83
+
84
+ self.config = config
85
+ try:
86
+ self._producer = ConfluentProducer(config)
87
+ except Exception as e:
88
+ raise ProducerError(
89
+ f"Failed to initialize Kafka producer: {e}",
90
+ original_error=e,
91
+ ) from e
92
+
93
+ def send(
94
+ self,
95
+ topic: str,
96
+ value: bytes,
97
+ key: Optional[bytes] = None,
98
+ partition: Optional[int] = None,
99
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
100
+ ) -> None:
101
+ """
102
+ Send a message to a Kafka topic.
103
+
104
+ This method is asynchronous - it returns immediately after queuing the message.
105
+ Use flush() to wait for delivery confirmation.
106
+
107
+ Args:
108
+ topic: The topic name to send the message to
109
+ value: The message payload as bytes
110
+ key: Optional message key as bytes. Messages with the same key go to the same partition.
111
+ partition: Optional partition number. If None, partition is chosen by the partitioner.
112
+ on_delivery: Optional callback function called when delivery succeeds or fails.
113
+ Signature: callback(error, message)
114
+
115
+ Raises:
116
+ ProducerError: If the message cannot be queued (e.g., queue is full)
117
+
118
+ Examples:
119
+ >>> # Send a simple message
120
+ >>> producer.send("my-topic", b"Hello, Kafka!")
121
+
122
+ >>> # Send with a key for partitioning
123
+ >>> producer.send("user-events", b"event data", key=b"user-123")
124
+
125
+ >>> # Send to a specific partition
126
+ >>> producer.send("my-topic", b"data", partition=0)
127
+
128
+ >>> # Send with delivery callback
129
+ >>> def on_delivery(err, msg):
130
+ ... if err:
131
+ ... print(f"Delivery failed: {err}")
132
+ ... else:
133
+ ... print(f"Delivered to {msg.topic()} [{msg.partition()}]")
134
+ >>> producer.send("topic", b"data", on_delivery=on_delivery)
135
+ """
136
+ try:
137
+ self._producer.produce(
138
+ topic=topic,
139
+ value=value,
140
+ key=key,
141
+ partition=partition, # type: ignore[arg-type]
142
+ on_delivery=on_delivery,
143
+ )
144
+ # Poll to trigger callbacks and handle backpressure
145
+ self._producer.poll(0)
146
+ except BufferError as e:
147
+ raise ProducerError(
148
+ "Message queue is full. Try calling flush() or increasing queue.buffering.max.messages",
149
+ original_error=e,
150
+ ) from e
151
+ except Exception as e:
152
+ raise ProducerError(
153
+ f"Failed to send message to topic '{topic}': {e}",
154
+ original_error=e,
155
+ ) from e
156
+
157
+ def send_json(
158
+ self,
159
+ topic: str,
160
+ value: Any,
161
+ key: Optional[str] = None,
162
+ partition: Optional[int] = None,
163
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
164
+ ) -> None:
165
+ """
166
+ Send a JSON-serialized message to a Kafka topic.
167
+
168
+ Convenience method that automatically serializes Python objects to JSON.
169
+
170
+ Args:
171
+ topic: The topic name to send the message to
172
+ value: Any JSON-serializable Python object (dict, list, str, int, etc.)
173
+ key: Optional string key (will be UTF-8 encoded)
174
+ partition: Optional partition number
175
+ on_delivery: Optional callback function for delivery confirmation
176
+
177
+ Raises:
178
+ SerializationError: If the value cannot be serialized to JSON
179
+ ProducerError: If the message cannot be queued
180
+
181
+ Examples:
182
+ >>> # Send a dict as JSON
183
+ >>> producer.send_json("events", {"user_id": 123, "action": "click"})
184
+
185
+ >>> # Send with a string key
186
+ >>> producer.send_json("user-data", {"name": "Alice"}, key="user-123")
187
+
188
+ >>> # Send a list
189
+ >>> producer.send_json("numbers", [1, 2, 3, 4, 5])
190
+ """
191
+ try:
192
+ value_bytes = json.dumps(value).encode("utf-8")
193
+ except (TypeError, ValueError) as e:
194
+ raise SerializationError(
195
+ f"Failed to serialize value to JSON: {e}",
196
+ value=value,
197
+ original_error=e,
198
+ ) from e
199
+
200
+ key_bytes = key.encode("utf-8") if key is not None else None
201
+ self.send(topic, value_bytes, key=key_bytes, partition=partition, on_delivery=on_delivery)
202
+
203
+ def send_string(
204
+ self,
205
+ topic: str,
206
+ value: str,
207
+ key: Optional[str] = None,
208
+ partition: Optional[int] = None,
209
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
210
+ ) -> None:
211
+ """
212
+ Send a UTF-8 encoded string message to a Kafka topic.
213
+
214
+ Convenience method for sending text messages.
215
+
216
+ Args:
217
+ topic: The topic name to send the message to
218
+ value: String message to send
219
+ key: Optional string key
220
+ partition: Optional partition number
221
+ on_delivery: Optional callback function for delivery confirmation
222
+
223
+ Raises:
224
+ ProducerError: If the message cannot be queued
225
+
226
+ Examples:
227
+ >>> producer.send_string("logs", "Application started successfully")
228
+ >>> producer.send_string("user-messages", "Hello!", key="user-123")
229
+ """
230
+ value_bytes = value.encode("utf-8")
231
+ key_bytes = key.encode("utf-8") if key is not None else None
232
+ self.send(topic, value_bytes, key=key_bytes, partition=partition, on_delivery=on_delivery)
233
+
234
+ def flush(self, timeout: float = -1) -> int:
235
+ """
236
+ Wait for all messages in the queue to be delivered.
237
+
238
+ Blocks until all messages are sent or the timeout expires.
239
+
240
+ Args:
241
+ timeout: Maximum time to wait in seconds. Use -1 for infinite wait (default).
242
+ Example: 5.0 = wait up to 5 seconds
243
+
244
+ Returns:
245
+ Number of messages still in queue/internal Producer state. 0 means all delivered.
246
+
247
+ Raises:
248
+ ProducerError: If flush fails
249
+
250
+ Examples:
251
+ >>> # Wait for all messages to be delivered
252
+ >>> producer.send("topic", b"message 1")
253
+ >>> producer.send("topic", b"message 2")
254
+ >>> remaining = producer.flush()
255
+ >>> if remaining == 0:
256
+ ... print("All messages delivered successfully")
257
+
258
+ >>> # Wait up to 5 seconds
259
+ >>> remaining = producer.flush(timeout=5.0)
260
+ >>> if remaining > 0:
261
+ ... print(f"Warning: {remaining} messages not delivered after 5 seconds")
262
+ """
263
+ try:
264
+ return self._producer.flush(timeout=timeout) # type: ignore[no-any-return]
265
+ except Exception as e:
266
+ raise ProducerError(f"Flush failed: {e}", original_error=e) from e
267
+
268
+ def close(self) -> None:
269
+ """
270
+ Close the producer and release resources.
271
+
272
+ Calls flush() to ensure all queued messages are delivered before closing.
273
+ It's recommended to use the producer as a context manager instead of calling
274
+ this method directly.
275
+
276
+ Examples:
277
+ >>> producer = KafkaProducer({"bootstrap.servers": "localhost:9092"})
278
+ >>> try:
279
+ ... producer.send("topic", b"message")
280
+ ... finally:
281
+ ... producer.close() # Ensure cleanup
282
+
283
+ >>> # Better: use context manager
284
+ >>> with KafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
285
+ ... producer.send("topic", b"message")
286
+ """
287
+ self.flush()
288
+
289
+ def __enter__(self) -> "KafkaProducer":
290
+ """
291
+ Enter context manager.
292
+
293
+ Returns:
294
+ self
295
+
296
+ Examples:
297
+ >>> with KafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
298
+ ... producer.send("topic", b"message")
299
+ """
300
+ return self
301
+
302
+ def send_batch(
303
+ self,
304
+ topic: str,
305
+ messages: list[tuple[bytes, Optional[bytes]]],
306
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
307
+ ) -> None:
308
+ """
309
+ Send a batch of messages to a Kafka topic.
310
+
311
+ Each message is a tuple of (value, key). This is more efficient than
312
+ calling send() repeatedly as it defers polling until after all messages
313
+ are queued.
314
+
315
+ Args:
316
+ topic: The topic name to send the messages to
317
+ messages: List of (value, key) tuples. Key can be None.
318
+ on_delivery: Optional callback for each message delivery.
319
+
320
+ Raises:
321
+ ProducerError: If any message cannot be queued
322
+
323
+ Examples:
324
+ >>> producer.send_batch("events", [
325
+ ... (b"event1", b"key1"),
326
+ ... (b"event2", b"key2"),
327
+ ... (b"event3", None),
328
+ ... ])
329
+ >>> producer.flush()
330
+ """
331
+ for value, key in messages:
332
+ try:
333
+ self._producer.produce(
334
+ topic=topic,
335
+ value=value,
336
+ key=key,
337
+ on_delivery=on_delivery,
338
+ )
339
+ except BufferError:
340
+ # Flush and retry once on buffer full
341
+ self._producer.flush()
342
+ try:
343
+ self._producer.produce(
344
+ topic=topic,
345
+ value=value,
346
+ key=key,
347
+ on_delivery=on_delivery,
348
+ )
349
+ except Exception as retry_e:
350
+ raise ProducerError(
351
+ f"Failed to send message to topic '{topic}' after flush: {retry_e}",
352
+ original_error=retry_e,
353
+ ) from retry_e
354
+ except Exception as e:
355
+ raise ProducerError(
356
+ f"Failed to send message to topic '{topic}': {e}",
357
+ original_error=e,
358
+ ) from e
359
+ # Poll to trigger callbacks
360
+ self._producer.poll(0)
361
+
362
+ def init_transactions(self, timeout: float = 30.0) -> None:
363
+ """
364
+ Initialize the producer for transactions.
365
+
366
+ Must be called before any transactional methods. Requires the
367
+ ``transactional.id`` configuration to be set.
368
+
369
+ Args:
370
+ timeout: Maximum time to wait for initialization in seconds.
371
+
372
+ Raises:
373
+ ProducerError: If transaction initialization fails.
374
+
375
+ Examples:
376
+ >>> producer = KafkaProducer({
377
+ ... "bootstrap.servers": "localhost:9092",
378
+ ... "transactional.id": "my-txn-id",
379
+ ... })
380
+ >>> producer.init_transactions()
381
+ """
382
+ try:
383
+ self._producer.init_transactions(timeout)
384
+ except Exception as e:
385
+ raise ProducerError(
386
+ f"Failed to initialize transactions: {e}",
387
+ original_error=e,
388
+ ) from e
389
+
390
+ def begin_transaction(self) -> None:
391
+ """
392
+ Begin a new transaction.
393
+
394
+ Raises:
395
+ ProducerError: If beginning the transaction fails.
396
+ """
397
+ try:
398
+ self._producer.begin_transaction()
399
+ except Exception as e:
400
+ raise ProducerError(
401
+ f"Failed to begin transaction: {e}",
402
+ original_error=e,
403
+ ) from e
404
+
405
+ def commit_transaction(self, timeout: float = 30.0) -> None:
406
+ """
407
+ Commit the current transaction.
408
+
409
+ Args:
410
+ timeout: Maximum time to wait for commit in seconds.
411
+
412
+ Raises:
413
+ ProducerError: If committing the transaction fails.
414
+ """
415
+ try:
416
+ self._producer.commit_transaction(timeout)
417
+ except Exception as e:
418
+ raise ProducerError(
419
+ f"Failed to commit transaction: {e}",
420
+ original_error=e,
421
+ ) from e
422
+
423
+ def abort_transaction(self, timeout: float = 30.0) -> None:
424
+ """
425
+ Abort the current transaction.
426
+
427
+ Args:
428
+ timeout: Maximum time to wait for abort in seconds.
429
+
430
+ Raises:
431
+ ProducerError: If aborting the transaction fails.
432
+ """
433
+ try:
434
+ self._producer.abort_transaction(timeout)
435
+ except Exception as e:
436
+ raise ProducerError(
437
+ f"Failed to abort transaction: {e}",
438
+ original_error=e,
439
+ ) from e
440
+
441
+ def transaction(self) -> "TransactionContext":
442
+ """
443
+ Return a context manager for transactional sends.
444
+
445
+ Automatically begins, commits, or aborts the transaction.
446
+
447
+ Returns:
448
+ A context manager that manages the transaction lifecycle.
449
+
450
+ Raises:
451
+ ProducerError: If any transaction operation fails.
452
+
453
+ Examples:
454
+ >>> producer = KafkaProducer({
455
+ ... "bootstrap.servers": "localhost:9092",
456
+ ... "transactional.id": "my-txn-id",
457
+ ... })
458
+ >>> producer.init_transactions()
459
+ >>> with producer.transaction():
460
+ ... producer.send("topic", b"msg1")
461
+ ... producer.send("topic", b"msg2")
462
+ """
463
+ return TransactionContext(self)
464
+
465
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
466
+ """
467
+ Exit context manager and cleanup resources.
468
+
469
+ Automatically flushes all pending messages before exiting.
470
+ """
471
+ self.close()
472
+
473
+
474
+ class TransactionContext:
475
+ """Context manager for Kafka transactions.
476
+
477
+ Begins a transaction on entry and commits on clean exit.
478
+ Aborts the transaction if an exception occurs.
479
+ """
480
+
481
+ def __init__(self, producer: KafkaProducer):
482
+ self._producer = producer
483
+
484
+ def __enter__(self) -> "TransactionContext":
485
+ self._producer.begin_transaction()
486
+ return self
487
+
488
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
489
+ if exc_type is not None:
490
+ self._producer.abort_transaction()
491
+ else:
492
+ self._producer.commit_transaction()
typedkafka/retry.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ Retry and backoff utilities for Kafka operations.
3
+
4
+ Provides decorators and helpers for retrying transient Kafka failures
5
+ with configurable backoff strategies.
6
+ """
7
+
8
+ import functools
9
+ import random
10
+ import time
11
+ from collections.abc import Sequence
12
+ from typing import Any, Callable, Optional, TypeVar
13
+
14
+ from typedkafka.exceptions import KafkaError
15
+
16
+ F = TypeVar("F", bound=Callable[..., Any])
17
+
18
+
19
+ def retry(
20
+ max_attempts: int = 3,
21
+ backoff_base: float = 0.5,
22
+ backoff_max: float = 30.0,
23
+ jitter: bool = True,
24
+ retryable_exceptions: Optional[Sequence[type[BaseException]]] = None,
25
+ ) -> Callable[[F], F]:
26
+ """
27
+ Decorator that retries a function on failure with exponential backoff.
28
+
29
+ Args:
30
+ max_attempts: Maximum number of attempts (including the first call).
31
+ backoff_base: Base delay in seconds for exponential backoff.
32
+ backoff_max: Maximum delay in seconds between retries.
33
+ jitter: If True, add random jitter to backoff delay.
34
+ retryable_exceptions: Exception types to retry on.
35
+ Defaults to ``(KafkaError,)`` if not specified.
36
+
37
+ Returns:
38
+ Decorated function that retries on failure.
39
+
40
+ Raises:
41
+ The last exception if all attempts fail.
42
+
43
+ Examples:
44
+ >>> from typedkafka.retry import retry
45
+ >>> from typedkafka.exceptions import ProducerError
46
+ >>>
47
+ >>> @retry(max_attempts=3, backoff_base=1.0)
48
+ ... def send_message(producer, topic, value):
49
+ ... producer.send(topic, value)
50
+ ... producer.flush()
51
+
52
+ >>> # Retry only specific exceptions
53
+ >>> @retry(max_attempts=5, retryable_exceptions=(ProducerError,))
54
+ ... def produce(producer, data):
55
+ ... producer.send_json("events", data)
56
+ """
57
+ if retryable_exceptions is None:
58
+ retryable_exceptions = (KafkaError,)
59
+
60
+ retry_tuple = tuple(retryable_exceptions)
61
+
62
+ def decorator(func: F) -> F:
63
+ @functools.wraps(func)
64
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
65
+ last_exception: Optional[BaseException] = None
66
+ for attempt in range(max_attempts):
67
+ try:
68
+ return func(*args, **kwargs)
69
+ except retry_tuple as e:
70
+ last_exception = e
71
+ if attempt < max_attempts - 1:
72
+ delay = min(backoff_base * (2 ** attempt), backoff_max)
73
+ if jitter:
74
+ delay = delay * (0.5 + random.random() * 0.5) # noqa: S311
75
+ time.sleep(delay)
76
+ raise last_exception # type: ignore[misc]
77
+
78
+ return wrapper # type: ignore[return-value]
79
+
80
+ return decorator
81
+
82
+
83
+ class RetryPolicy:
84
+ """
85
+ Configurable retry policy for Kafka operations.
86
+
87
+ Provides a reusable retry configuration that can be applied to
88
+ multiple operations.
89
+
90
+ Examples:
91
+ >>> policy = RetryPolicy(max_attempts=5, backoff_base=1.0)
92
+ >>> result = policy.execute(lambda: producer.send("topic", b"msg"))
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ max_attempts: int = 3,
98
+ backoff_base: float = 0.5,
99
+ backoff_max: float = 30.0,
100
+ jitter: bool = True,
101
+ retryable_exceptions: Optional[Sequence[type[BaseException]]] = None,
102
+ ):
103
+ """
104
+ Initialize a retry policy.
105
+
106
+ Args:
107
+ max_attempts: Maximum number of attempts.
108
+ backoff_base: Base delay in seconds for exponential backoff.
109
+ backoff_max: Maximum delay between retries.
110
+ jitter: If True, add random jitter to delays.
111
+ retryable_exceptions: Exception types to retry on.
112
+ Defaults to ``(KafkaError,)``.
113
+ """
114
+ self.max_attempts = max_attempts
115
+ self.backoff_base = backoff_base
116
+ self.backoff_max = backoff_max
117
+ self.jitter = jitter
118
+ self.retryable_exceptions: tuple[type[BaseException], ...] = tuple(
119
+ retryable_exceptions or (KafkaError,)
120
+ )
121
+
122
+ def execute(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
123
+ """
124
+ Execute a function with retry logic.
125
+
126
+ Args:
127
+ func: The function to execute.
128
+ *args: Positional arguments to pass to the function.
129
+ **kwargs: Keyword arguments to pass to the function.
130
+
131
+ Returns:
132
+ The return value of the function.
133
+
134
+ Raises:
135
+ The last exception if all attempts fail.
136
+
137
+ Examples:
138
+ >>> policy = RetryPolicy(max_attempts=3)
139
+ >>> policy.execute(producer.send, "topic", b"value")
140
+ """
141
+ last_exception: Optional[BaseException] = None
142
+ for attempt in range(self.max_attempts):
143
+ try:
144
+ return func(*args, **kwargs)
145
+ except self.retryable_exceptions as e:
146
+ last_exception = e
147
+ if attempt < self.max_attempts - 1:
148
+ delay = min(
149
+ self.backoff_base * (2 ** attempt), self.backoff_max
150
+ )
151
+ if self.jitter:
152
+ delay = delay * (0.5 + random.random() * 0.5) # noqa: S311
153
+ time.sleep(delay)
154
+ raise last_exception # type: ignore[misc]