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/testing.py ADDED
@@ -0,0 +1,523 @@
1
+ """
2
+ Testing utilities for typedkafka - Mock producer and consumer for unit tests.
3
+
4
+ This module provides mock implementations that don't require a running Kafka broker,
5
+ making it easy to write fast, reliable unit tests for code that uses Kafka.
6
+ """
7
+
8
+ from collections import defaultdict
9
+ from typing import Any, Callable, Optional
10
+
11
+
12
+ class MockMessage:
13
+ """
14
+ A mock Kafka message for testing.
15
+
16
+ Attributes:
17
+ topic: The topic this message was sent to
18
+ value: The message value
19
+ key: The message key (optional)
20
+ partition: The partition number
21
+ offset: The message offset
22
+ headers: Message headers
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ topic: str,
28
+ value: bytes,
29
+ key: Optional[bytes] = None,
30
+ partition: int = 0,
31
+ offset: int = 0,
32
+ headers: Optional[list[tuple[str, bytes]]] = None,
33
+ ):
34
+ """
35
+ Initialize a mock message.
36
+
37
+ Args:
38
+ topic: Topic name
39
+ value: Message value as bytes
40
+ key: Optional message key
41
+ partition: Partition number (default: 0)
42
+ offset: Message offset (default: 0)
43
+ headers: Optional list of (key, value) header tuples
44
+ """
45
+ self.topic = topic
46
+ self.value = value
47
+ self.key = key
48
+ self.partition = partition
49
+ self.offset = offset
50
+ self.headers = headers or []
51
+
52
+
53
+ class MockProducer:
54
+ """
55
+ A mock Kafka producer for testing.
56
+
57
+ Records all messages sent to topics without actually sending to Kafka.
58
+ Perfect for unit tests to verify your code sends the right messages.
59
+
60
+ Examples:
61
+ >>> producer = MockProducer()
62
+ >>> producer.send("my-topic", b"test message", key=b"test-key")
63
+ >>>
64
+ >>> # Verify the message was sent
65
+ >>> assert len(producer.messages["my-topic"]) == 1
66
+ >>> msg = producer.messages["my-topic"][0]
67
+ >>> assert msg.value == b"test message"
68
+ >>> assert msg.key == b"test-key"
69
+
70
+ Attributes:
71
+ messages: Dict mapping topic names to lists of MockMessage objects
72
+ call_count: Number of times send() was called
73
+ flushed: Whether flush() has been called
74
+ """
75
+
76
+ def __init__(self, config: Optional[dict[str, Any]] = None):
77
+ """
78
+ Initialize a mock producer.
79
+
80
+ Args:
81
+ config: Optional config dict (ignored, but accepted for compatibility)
82
+ """
83
+ self.config = config or {}
84
+ self.messages: dict[str, list[MockMessage]] = defaultdict(list)
85
+ self.call_count = 0
86
+ self.flushed = False
87
+ self._closed = False
88
+ self._in_transaction = False
89
+ self._transaction_messages: list[tuple[str, MockMessage]] = []
90
+
91
+ def send(
92
+ self,
93
+ topic: str,
94
+ value: bytes,
95
+ key: Optional[bytes] = None,
96
+ partition: Optional[int] = None,
97
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
98
+ ) -> None:
99
+ """
100
+ Record a message send (doesn't actually send to Kafka).
101
+
102
+ Args:
103
+ topic: Topic to send to
104
+ value: Message value
105
+ key: Optional message key
106
+ partition: Optional partition (default: 0)
107
+ on_delivery: Optional callback (will be called immediately with success)
108
+
109
+ Examples:
110
+ >>> producer = MockProducer()
111
+ >>> producer.send("events", b"data", key=b"key-1")
112
+ >>> assert len(producer.messages["events"]) == 1
113
+ """
114
+ self.call_count += 1
115
+ offset = len(self.messages[topic])
116
+
117
+ msg = MockMessage(
118
+ topic=topic,
119
+ value=value,
120
+ key=key,
121
+ partition=partition or 0,
122
+ offset=offset,
123
+ )
124
+
125
+ if self._in_transaction:
126
+ self._transaction_messages.append((topic, msg))
127
+ else:
128
+ self.messages[topic].append(msg)
129
+
130
+ # Call delivery callback with success
131
+ if on_delivery:
132
+ on_delivery(None, msg)
133
+
134
+ def send_json(
135
+ self,
136
+ topic: str,
137
+ value: Any,
138
+ key: Optional[str] = None,
139
+ partition: Optional[int] = None,
140
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
141
+ ) -> None:
142
+ """
143
+ Record a JSON message send.
144
+
145
+ Args:
146
+ topic: Topic to send to
147
+ value: JSON-serializable value
148
+ key: Optional string key
149
+ partition: Optional partition
150
+ on_delivery: Optional delivery callback
151
+
152
+ Examples:
153
+ >>> producer = MockProducer()
154
+ >>> producer.send_json("events", {"user_id": 123})
155
+ >>> import json
156
+ >>> data = json.loads(producer.messages["events"][0].value)
157
+ >>> assert data["user_id"] == 123
158
+ """
159
+ import json
160
+
161
+ value_bytes = json.dumps(value).encode("utf-8")
162
+ key_bytes = key.encode("utf-8") if key else None
163
+ self.send(topic, value_bytes, key=key_bytes, partition=partition, on_delivery=on_delivery)
164
+
165
+ def send_string(
166
+ self,
167
+ topic: str,
168
+ value: str,
169
+ key: Optional[str] = None,
170
+ partition: Optional[int] = None,
171
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
172
+ ) -> None:
173
+ """
174
+ Record a string message send.
175
+
176
+ Args:
177
+ topic: Topic to send to
178
+ value: String value
179
+ key: Optional string key
180
+ partition: Optional partition
181
+ on_delivery: Optional delivery callback
182
+ """
183
+ value_bytes = value.encode("utf-8")
184
+ key_bytes = key.encode("utf-8") if key else None
185
+ self.send(topic, value_bytes, key=key_bytes, partition=partition, on_delivery=on_delivery)
186
+
187
+ def send_batch(
188
+ self,
189
+ topic: str,
190
+ messages: list[tuple[bytes, Optional[bytes]]],
191
+ on_delivery: Optional[Callable[[Any, Any], None]] = None,
192
+ ) -> None:
193
+ """
194
+ Record a batch of message sends.
195
+
196
+ Args:
197
+ topic: Topic to send to
198
+ messages: List of (value, key) tuples
199
+ on_delivery: Optional delivery callback
200
+ """
201
+ for value, key in messages:
202
+ self.send(topic, value, key=key, on_delivery=on_delivery)
203
+
204
+ def flush(self, timeout: float = -1) -> int:
205
+ """
206
+ Mark producer as flushed (no-op in mock).
207
+
208
+ Args:
209
+ timeout: Ignored in mock
210
+
211
+ Returns:
212
+ 0 (always successful in mock)
213
+
214
+ Examples:
215
+ >>> producer = MockProducer()
216
+ >>> producer.send("topic", b"msg")
217
+ >>> remaining = producer.flush()
218
+ >>> assert remaining == 0
219
+ >>> assert producer.flushed is True
220
+ """
221
+ self.flushed = True
222
+ return 0
223
+
224
+ def close(self) -> None:
225
+ """Mark producer as closed."""
226
+ self._closed = True
227
+ self.flush()
228
+
229
+ def init_transactions(self, timeout: float = 30.0) -> None:
230
+ """Initialize transactions (no-op in mock)."""
231
+ pass
232
+
233
+ def begin_transaction(self) -> None:
234
+ """Begin a mock transaction."""
235
+ self._in_transaction = True
236
+ self._transaction_messages = []
237
+
238
+ def commit_transaction(self, timeout: float = 30.0) -> None:
239
+ """Commit the mock transaction, flushing buffered messages."""
240
+ for topic, msg in self._transaction_messages:
241
+ self.messages[topic].append(msg)
242
+ self._transaction_messages = []
243
+ self._in_transaction = False
244
+
245
+ def abort_transaction(self, timeout: float = 30.0) -> None:
246
+ """Abort the mock transaction, discarding buffered messages."""
247
+ self._transaction_messages = []
248
+ self._in_transaction = False
249
+
250
+ def transaction(self) -> "MockTransactionContext":
251
+ """Return a mock transaction context manager."""
252
+ return MockTransactionContext(self)
253
+
254
+ def reset(self) -> None:
255
+ """
256
+ Clear all recorded messages and reset state.
257
+
258
+ Useful for reusing the same mock across multiple test cases.
259
+
260
+ Examples:
261
+ >>> producer = MockProducer()
262
+ >>> producer.send("topic", b"msg1")
263
+ >>> producer.reset()
264
+ >>> assert len(producer.messages) == 0
265
+ >>> assert producer.call_count == 0
266
+ """
267
+ self.messages.clear()
268
+ self.call_count = 0
269
+ self.flushed = False
270
+ self._closed = False
271
+ self._in_transaction = False
272
+ self._transaction_messages = []
273
+
274
+ def __enter__(self) -> "MockProducer":
275
+ """Context manager entry."""
276
+ return self
277
+
278
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
279
+ """Context manager exit."""
280
+ self.close()
281
+
282
+
283
+ class MockTransactionContext:
284
+ """Mock transaction context manager for testing."""
285
+
286
+ def __init__(self, producer: MockProducer):
287
+ self._producer = producer
288
+
289
+ def __enter__(self) -> "MockTransactionContext":
290
+ self._producer.begin_transaction()
291
+ return self
292
+
293
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
294
+ if exc_type is not None:
295
+ self._producer.abort_transaction()
296
+ else:
297
+ self._producer.commit_transaction()
298
+
299
+
300
+ class MockConsumer:
301
+ """
302
+ A mock Kafka consumer for testing.
303
+
304
+ Allows you to inject predefined messages for testing code that consumes from Kafka.
305
+
306
+ Examples:
307
+ >>> consumer = MockConsumer()
308
+ >>> consumer.add_message("my-topic", b"test message", key=b"test-key")
309
+ >>> consumer.subscribe(["my-topic"])
310
+ >>>
311
+ >>> msg = consumer.poll()
312
+ >>> assert msg.value == b"test message"
313
+ >>> assert msg.key == b"test-key"
314
+
315
+ Attributes:
316
+ messages: Queue of MockMessage objects to be consumed
317
+ subscribed_topics: List of subscribed topics
318
+ committed_offsets: Dict of committed offsets by topic/partition
319
+ poll_timeout: Timeout used by __iter__ (matches KafkaConsumer)
320
+ """
321
+
322
+ def __init__(self, config: Optional[dict[str, Any]] = None):
323
+ """
324
+ Initialize a mock consumer.
325
+
326
+ Args:
327
+ config: Optional config dict (ignored, but accepted for compatibility)
328
+ """
329
+ self.config = config or {}
330
+ self.messages: list[MockMessage] = []
331
+ self.subscribed_topics: list[str] = []
332
+ self.committed_offsets: dict[tuple[str, int], int] = {}
333
+ self._closed = False
334
+ self._message_index = 0
335
+ self.poll_timeout: float = 1.0
336
+
337
+ def add_message(
338
+ self,
339
+ topic: str,
340
+ value: bytes,
341
+ key: Optional[bytes] = None,
342
+ partition: int = 0,
343
+ offset: Optional[int] = None,
344
+ headers: Optional[list[tuple[str, bytes]]] = None,
345
+ ) -> None:
346
+ """
347
+ Add a message to be consumed.
348
+
349
+ Call this in your tests to inject messages that your code will consume.
350
+
351
+ Args:
352
+ topic: Topic name
353
+ value: Message value
354
+ key: Optional message key
355
+ partition: Partition number (default: 0)
356
+ offset: Message offset (auto-generated if None)
357
+ headers: Optional message headers
358
+
359
+ Examples:
360
+ >>> consumer = MockConsumer()
361
+ >>> consumer.add_message("events", b'{"user_id": 123}')
362
+ >>> consumer.add_message("events", b'{"user_id": 456}')
363
+ >>> assert len(consumer.messages) == 2
364
+ """
365
+ if offset is None:
366
+ offset = len(self.messages)
367
+
368
+ msg = MockMessage(
369
+ topic=topic,
370
+ value=value,
371
+ key=key,
372
+ partition=partition,
373
+ offset=offset,
374
+ headers=headers,
375
+ )
376
+ self.messages.append(msg)
377
+
378
+ def add_json_message(
379
+ self,
380
+ topic: str,
381
+ value: Any,
382
+ key: Optional[str] = None,
383
+ partition: int = 0,
384
+ ) -> None:
385
+ """
386
+ Add a JSON message to be consumed.
387
+
388
+ Args:
389
+ topic: Topic name
390
+ value: JSON-serializable value
391
+ key: Optional string key
392
+ partition: Partition number
393
+
394
+ Examples:
395
+ >>> consumer = MockConsumer()
396
+ >>> consumer.add_json_message("events", {"user_id": 123, "action": "click"})
397
+ """
398
+ import json
399
+
400
+ value_bytes = json.dumps(value).encode("utf-8")
401
+ key_bytes = key.encode("utf-8") if key else None
402
+ self.add_message(topic, value_bytes, key=key_bytes, partition=partition)
403
+
404
+ def subscribe(
405
+ self,
406
+ topics: list[str],
407
+ on_assign: Optional[Callable[[Any, Any], None]] = None,
408
+ on_revoke: Optional[Callable[[Any, Any], None]] = None,
409
+ on_lost: Optional[Callable[[Any, Any], None]] = None,
410
+ ) -> None:
411
+ """
412
+ Subscribe to topics (recorded but not enforced in mock).
413
+
414
+ Args:
415
+ topics: List of topic names
416
+ on_assign: Optional rebalance callback (stored but not called in mock)
417
+ on_revoke: Optional rebalance callback (stored but not called in mock)
418
+ on_lost: Optional rebalance callback (stored but not called in mock)
419
+
420
+ Examples:
421
+ >>> consumer = MockConsumer()
422
+ >>> consumer.subscribe(["topic1", "topic2"])
423
+ >>> assert "topic1" in consumer.subscribed_topics
424
+ """
425
+ self.subscribed_topics = topics
426
+ self._on_assign = on_assign
427
+ self._on_revoke = on_revoke
428
+ self._on_lost = on_lost
429
+
430
+ def poll(self, timeout: float = 1.0) -> Optional[MockMessage]:
431
+ """
432
+ Poll for the next message.
433
+
434
+ Returns messages in the order they were added with add_message().
435
+
436
+ Args:
437
+ timeout: Ignored in mock
438
+
439
+ Returns:
440
+ Next MockMessage or None if no more messages
441
+
442
+ Examples:
443
+ >>> consumer = MockConsumer()
444
+ >>> consumer.add_message("topic", b"msg1")
445
+ >>> consumer.add_message("topic", b"msg2")
446
+ >>>
447
+ >>> msg1 = consumer.poll()
448
+ >>> assert msg1.value == b"msg1"
449
+ >>> msg2 = consumer.poll()
450
+ >>> assert msg2.value == b"msg2"
451
+ >>> msg3 = consumer.poll()
452
+ >>> assert msg3 is None
453
+ """
454
+ if self._message_index < len(self.messages):
455
+ msg = self.messages[self._message_index]
456
+ self._message_index += 1
457
+ return msg
458
+ return None
459
+
460
+ def commit(
461
+ self, message: Optional[MockMessage] = None, asynchronous: bool = True
462
+ ) -> None:
463
+ """
464
+ Record a commit (doesn't actually commit to Kafka).
465
+
466
+ Args:
467
+ message: Message to commit offset for
468
+ asynchronous: Ignored in mock
469
+
470
+ Examples:
471
+ >>> consumer = MockConsumer()
472
+ >>> consumer.add_message("topic", b"msg", partition=0, offset=42)
473
+ >>> msg = consumer.poll()
474
+ >>> consumer.commit(msg)
475
+ >>> assert consumer.committed_offsets[("topic", 0)] == 42
476
+ """
477
+ if message:
478
+ key = (message.topic, message.partition)
479
+ self.committed_offsets[key] = message.offset
480
+
481
+ def close(self) -> None:
482
+ """Mark consumer as closed."""
483
+ self._closed = True
484
+
485
+ def reset(self) -> None:
486
+ """
487
+ Clear all messages and reset state.
488
+
489
+ Examples:
490
+ >>> consumer = MockConsumer()
491
+ >>> consumer.add_message("topic", b"msg")
492
+ >>> consumer.reset()
493
+ >>> assert len(consumer.messages) == 0
494
+ """
495
+ self.messages.clear()
496
+ self.subscribed_topics.clear()
497
+ self.committed_offsets.clear()
498
+ self._closed = False
499
+ self._message_index = 0
500
+
501
+ def __iter__(self):
502
+ """
503
+ Iterate over queued messages.
504
+
505
+ Yields all queued messages then stops. In tests, use poll() in a loop
506
+ or add all messages before iterating.
507
+
508
+ Yields:
509
+ MockMessage objects until queue is exhausted
510
+ """
511
+ while True:
512
+ msg = self.poll()
513
+ if msg is None:
514
+ break
515
+ yield msg
516
+
517
+ def __enter__(self) -> "MockConsumer":
518
+ """Context manager entry."""
519
+ return self
520
+
521
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
522
+ """Context manager exit."""
523
+ self.close()