ddkafka 0.0.1__tar.gz

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.
ddkafka-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artem Davydov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ddkafka-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: ddkafka
3
+ Version: 0.0.1
4
+ Summary: Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka
5
+ Keywords: python,kafka,aiokafka,consumer,producer,ddkafka
6
+ Author: davyddd
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: aiokafka>=0.12,<0.15
16
+ Requires-Dist: msgpack>=1.0,<2
17
+ Requires-Dist: ddutils>=0.2.3,<0.3.0
18
+ Requires-Dist: pydantic>=2.0,<3
19
+ Requires-Python: >=3.11, <3.15
20
+ Project-URL: Repository, https://github.com/davyddd/ddkafka
21
+ Description-Content-Type: text/markdown
22
+
23
+ # ddkafka
24
+
25
+ [![pypi](https://img.shields.io/pypi/v/ddkafka.svg)](https://pypi.python.org/pypi/ddkafka)
26
+ [![downloads](https://static.pepy.tech/badge/ddkafka/month)](https://pepy.tech/project/ddkafka)
27
+ [![versions](https://img.shields.io/pypi/pyversions/ddkafka.svg)](https://github.com/davyddd/ddkafka)
28
+ [![codecov](https://codecov.io/gh/davyddd/ddkafka/branch/main/graph/badge.svg)](https://app.codecov.io/github/davyddd/ddkafka)
29
+ [![license](https://img.shields.io/github/license/davyddd/ddkafka.svg)](https://github.com/davyddd/ddkafka/blob/main/LICENSE)
30
+
31
+ Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka
32
+
33
+ ## Installation
34
+
35
+ Install the library using pip:
36
+ ```bash
37
+ pip install ddkafka
38
+ ```
39
+
40
+ ## Development
41
+
42
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
43
+
44
+ ```bash
45
+ fab build # build the dev image
46
+ fab tests # run pytest
47
+ fab linters # run ruff (with --fix), ty and complexipy
48
+ fab shell # IPython inside the container
49
+ fab bash # bash inside the container
50
+ ```
51
+
52
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
53
+ run `copier update` to pull in template updates.
54
+
55
+ ## Producer
56
+
57
+ `BaseKafkaProducerRepository` sends events serialized with msgpack from `model_dump(mode='json')`, keyed by
58
+ `idempotent_key`. One `AIOKafkaProducer` is created lazily per subclass and shared by all its instances.
59
+
60
+ ```python
61
+ from pydantic import BaseModel
62
+
63
+ from ddkafka.config import ProducerConfig
64
+ from ddkafka.producer import BaseKafkaProducerRepository
65
+
66
+
67
+ class ProfileEvent(BaseModel):
68
+ profile_id: int
69
+ action: str
70
+
71
+ @property
72
+ def idempotent_key(self) -> str | None:
73
+ return f'{self.profile_id}:{self.action}'
74
+
75
+
76
+ class EventProducerRepository(BaseKafkaProducerRepository):
77
+ bootstrap_servers = ['kafka:9092']
78
+ config = ProducerConfig(linger_ms=100)
79
+
80
+
81
+ event_producer_repository_impl = EventProducerRepository()
82
+
83
+ await event_producer_repository_impl.create('profile_events', ProfileEvent(profile_id=1, action='open_app'))
84
+ await event_producer_repository_impl.bulk_create('profile_events', events)
85
+ ```
86
+
87
+ Any object with `idempotent_key` and `model_dump` satisfies the `Producible` protocol; pydantic models only need the
88
+ property.
89
+
90
+ `create` and `bulk_create` return once the record is in the producer's buffer; aiokafka sends buffered records in
91
+ batches (`linger_ms`, `max_batch_size`), and a record the broker rejects is reported with an ERROR log entry. The
92
+ buffer only reaches Kafka while the process runs, so call `stop()` on shutdown (FastAPI lifespan, dramatiq
93
+ `after_worker_shutdown`), otherwise up to `linger_ms` worth of records is lost on SIGTERM:
94
+
95
+ ```python
96
+ await EventProducerRepository.stop()
97
+ ```
98
+
99
+ ## Consumer
100
+
101
+ `BaseKafkaConsumerRepository[Domain]` reads one partition in batches. Payloads are msgpack dictionaries turned into
102
+ `Domain` with `model_validate`. `get_batches()` polls until the partition runs dry or a poll comes back thinner than
103
+ `batch_size * min_batch_fill_ratio`; `commit()` acknowledges everything yielded so far.
104
+
105
+ ```python
106
+ from ddkafka.consumer import BaseKafkaConsumerRepository
107
+
108
+
109
+ class ProfileEventConsumerRepository(BaseKafkaConsumerRepository[ProfileEvent]):
110
+ bootstrap_servers = ['kafka:9092']
111
+ topic = 'profile_events'
112
+ group_id = 'analytics_context'
113
+ batch_size = 100
114
+
115
+
116
+ repo = ProfileEventConsumerRepository(partition=0)
117
+ await repo.start()
118
+ try:
119
+ async for batch in repo.get_batches():
120
+ await process(batch)
121
+ await repo.commit()
122
+ finally:
123
+ await repo.stop()
124
+ ```
125
+
126
+ `get_batches(timestamp_threshold_minutes=5)` yields only messages older than five minutes, for consumers that
127
+ deliberately lag behind the producer.
128
+
129
+ Tunables are class attributes: `batch_size`, `poll_timeout_ms`, `min_batch_fill_ratio` and `config`
130
+ (`ConsumerConfig`, passed to `AIOKafkaConsumer` as keyword arguments). Missing or mistyped attributes fail at
131
+ class definition.
132
+
133
+ A record that cannot be deserialized (a schema the consumer does not know, a payload written by something else)
134
+ raises `MessageDeserializationError` with the topic, partition and offset, and the partition stays at that offset
135
+ until the group's offset is moved past it. A consumer whose stream can afford to lose such a record sets
136
+ `skip_undeserializable = True`: the record is logged with its offset and dropped, the rest of the batch goes on.
137
+
138
+ ## Consumer maker
139
+
140
+ `KafkaConsumerRepositoryMaker` wraps a consumer in a distributed lock, so one partition is processed by one
141
+ worker at a time across the whole deployment. The lock is anything satisfying `Lockable` (`acquire`, `release`,
142
+ `extend`, `timeout`), for example `RedisLock` from [ddredis](https://github.com/davyddd/ddredis). Entering acquires
143
+ the lock and starts the consumer; every yielded batch is followed by a checkpoint (lock heartbeat + offset commit);
144
+ leaving commits, stops and releases. A lock that is already held raises from `acquire`, a consumer that fails to
145
+ start raises `ConsumerStartError` after releasing the lock.
146
+
147
+ ```python
148
+ from ddredis.lock import RedisLock
149
+
150
+ from ddkafka.consumer_maker import KafkaConsumerRepositoryMaker
151
+
152
+ async with KafkaConsumerRepositoryMaker(
153
+ consumer_class=ProfileEventConsumerRepository,
154
+ partition=0,
155
+ lock_class=RedisLock,
156
+ lock_kwargs={'redis_client': redis_client, 'timeout': 300},
157
+ ) as maker:
158
+ async for batch in maker.get_batches():
159
+ await process(batch)
160
+ ```
161
+
162
+ Every batch is committed as a whole after the caller is done with it; a batch the caller fails on is not committed
163
+ and is read again next time, so processing must tolerate repeated records (`idempotent_key`). `batch_size` bounds
164
+ how much is reprocessed. `maker.checkpoint()` commits the current batch, so call it only once the batch is processed.
165
+
166
+ With `stream=True` the consumer does not return when the topic runs dry: it keeps the lock alive and polls
167
+ again after `stream_round_pause`, so a cron that starts it acts as a supervisor and messages are picked up as
168
+ they land. The pace must fit the lock: `stream_round_pause` plus the consumer's poll timeout may not exceed half
169
+ of the lock timeout, otherwise the maker refuses at construction.
@@ -0,0 +1,147 @@
1
+ # ddkafka
2
+
3
+ [![pypi](https://img.shields.io/pypi/v/ddkafka.svg)](https://pypi.python.org/pypi/ddkafka)
4
+ [![downloads](https://static.pepy.tech/badge/ddkafka/month)](https://pepy.tech/project/ddkafka)
5
+ [![versions](https://img.shields.io/pypi/pyversions/ddkafka.svg)](https://github.com/davyddd/ddkafka)
6
+ [![codecov](https://codecov.io/gh/davyddd/ddkafka/branch/main/graph/badge.svg)](https://app.codecov.io/github/davyddd/ddkafka)
7
+ [![license](https://img.shields.io/github/license/davyddd/ddkafka.svg)](https://github.com/davyddd/ddkafka/blob/main/LICENSE)
8
+
9
+ Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka
10
+
11
+ ## Installation
12
+
13
+ Install the library using pip:
14
+ ```bash
15
+ pip install ddkafka
16
+ ```
17
+
18
+ ## Development
19
+
20
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
21
+
22
+ ```bash
23
+ fab build # build the dev image
24
+ fab tests # run pytest
25
+ fab linters # run ruff (with --fix), ty and complexipy
26
+ fab shell # IPython inside the container
27
+ fab bash # bash inside the container
28
+ ```
29
+
30
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
31
+ run `copier update` to pull in template updates.
32
+
33
+ ## Producer
34
+
35
+ `BaseKafkaProducerRepository` sends events serialized with msgpack from `model_dump(mode='json')`, keyed by
36
+ `idempotent_key`. One `AIOKafkaProducer` is created lazily per subclass and shared by all its instances.
37
+
38
+ ```python
39
+ from pydantic import BaseModel
40
+
41
+ from ddkafka.config import ProducerConfig
42
+ from ddkafka.producer import BaseKafkaProducerRepository
43
+
44
+
45
+ class ProfileEvent(BaseModel):
46
+ profile_id: int
47
+ action: str
48
+
49
+ @property
50
+ def idempotent_key(self) -> str | None:
51
+ return f'{self.profile_id}:{self.action}'
52
+
53
+
54
+ class EventProducerRepository(BaseKafkaProducerRepository):
55
+ bootstrap_servers = ['kafka:9092']
56
+ config = ProducerConfig(linger_ms=100)
57
+
58
+
59
+ event_producer_repository_impl = EventProducerRepository()
60
+
61
+ await event_producer_repository_impl.create('profile_events', ProfileEvent(profile_id=1, action='open_app'))
62
+ await event_producer_repository_impl.bulk_create('profile_events', events)
63
+ ```
64
+
65
+ Any object with `idempotent_key` and `model_dump` satisfies the `Producible` protocol; pydantic models only need the
66
+ property.
67
+
68
+ `create` and `bulk_create` return once the record is in the producer's buffer; aiokafka sends buffered records in
69
+ batches (`linger_ms`, `max_batch_size`), and a record the broker rejects is reported with an ERROR log entry. The
70
+ buffer only reaches Kafka while the process runs, so call `stop()` on shutdown (FastAPI lifespan, dramatiq
71
+ `after_worker_shutdown`), otherwise up to `linger_ms` worth of records is lost on SIGTERM:
72
+
73
+ ```python
74
+ await EventProducerRepository.stop()
75
+ ```
76
+
77
+ ## Consumer
78
+
79
+ `BaseKafkaConsumerRepository[Domain]` reads one partition in batches. Payloads are msgpack dictionaries turned into
80
+ `Domain` with `model_validate`. `get_batches()` polls until the partition runs dry or a poll comes back thinner than
81
+ `batch_size * min_batch_fill_ratio`; `commit()` acknowledges everything yielded so far.
82
+
83
+ ```python
84
+ from ddkafka.consumer import BaseKafkaConsumerRepository
85
+
86
+
87
+ class ProfileEventConsumerRepository(BaseKafkaConsumerRepository[ProfileEvent]):
88
+ bootstrap_servers = ['kafka:9092']
89
+ topic = 'profile_events'
90
+ group_id = 'analytics_context'
91
+ batch_size = 100
92
+
93
+
94
+ repo = ProfileEventConsumerRepository(partition=0)
95
+ await repo.start()
96
+ try:
97
+ async for batch in repo.get_batches():
98
+ await process(batch)
99
+ await repo.commit()
100
+ finally:
101
+ await repo.stop()
102
+ ```
103
+
104
+ `get_batches(timestamp_threshold_minutes=5)` yields only messages older than five minutes, for consumers that
105
+ deliberately lag behind the producer.
106
+
107
+ Tunables are class attributes: `batch_size`, `poll_timeout_ms`, `min_batch_fill_ratio` and `config`
108
+ (`ConsumerConfig`, passed to `AIOKafkaConsumer` as keyword arguments). Missing or mistyped attributes fail at
109
+ class definition.
110
+
111
+ A record that cannot be deserialized (a schema the consumer does not know, a payload written by something else)
112
+ raises `MessageDeserializationError` with the topic, partition and offset, and the partition stays at that offset
113
+ until the group's offset is moved past it. A consumer whose stream can afford to lose such a record sets
114
+ `skip_undeserializable = True`: the record is logged with its offset and dropped, the rest of the batch goes on.
115
+
116
+ ## Consumer maker
117
+
118
+ `KafkaConsumerRepositoryMaker` wraps a consumer in a distributed lock, so one partition is processed by one
119
+ worker at a time across the whole deployment. The lock is anything satisfying `Lockable` (`acquire`, `release`,
120
+ `extend`, `timeout`), for example `RedisLock` from [ddredis](https://github.com/davyddd/ddredis). Entering acquires
121
+ the lock and starts the consumer; every yielded batch is followed by a checkpoint (lock heartbeat + offset commit);
122
+ leaving commits, stops and releases. A lock that is already held raises from `acquire`, a consumer that fails to
123
+ start raises `ConsumerStartError` after releasing the lock.
124
+
125
+ ```python
126
+ from ddredis.lock import RedisLock
127
+
128
+ from ddkafka.consumer_maker import KafkaConsumerRepositoryMaker
129
+
130
+ async with KafkaConsumerRepositoryMaker(
131
+ consumer_class=ProfileEventConsumerRepository,
132
+ partition=0,
133
+ lock_class=RedisLock,
134
+ lock_kwargs={'redis_client': redis_client, 'timeout': 300},
135
+ ) as maker:
136
+ async for batch in maker.get_batches():
137
+ await process(batch)
138
+ ```
139
+
140
+ Every batch is committed as a whole after the caller is done with it; a batch the caller fails on is not committed
141
+ and is read again next time, so processing must tolerate repeated records (`idempotent_key`). `batch_size` bounds
142
+ how much is reprocessed. `maker.checkpoint()` commits the current batch, so call it only once the batch is processed.
143
+
144
+ With `stream=True` the consumer does not return when the topic runs dry: it keeps the lock alive and polls
145
+ again after `stream_round_pause`, so a cron that starts it acts as a supervisor and messages are picked up as
146
+ they land. The pace must fit the lock: `stream_round_pause` plus the consumer's poll timeout may not exceed half
147
+ of the lock timeout, otherwise the maker refuses at construction.
@@ -0,0 +1,3 @@
1
+ from . import config, consumer, consumer_maker, producer
2
+
3
+ __all__ = ('config', 'consumer', 'consumer_maker', 'producer')
@@ -0,0 +1,149 @@
1
+ from typing import Literal, Self
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
4
+
5
+
6
+ class ProducerConfig(BaseModel):
7
+ """Keyword arguments passed to `aiokafka.AIOKafkaProducer` as they are (`model_dump()`).
8
+
9
+ Any other `AIOKafkaProducer` keyword argument (`compression_type`, `max_request_size`, ...) can be
10
+ passed as well and is forwarded untouched.
11
+
12
+ The defaults assume a cluster set up for durability (`default.replication.factor=3`,
13
+ `min.insync.replicas=2`): `acks='all'` is the client half of that guarantee, the rest is batching
14
+ tuned for a steady event stream rather than single sends.
15
+ """
16
+
17
+ model_config = ConfigDict(frozen=True, extra='allow')
18
+
19
+ acks: Literal[0, 1, 'all'] = Field(
20
+ default='all',
21
+ description=(
22
+ "How many brokers must confirm a record before `send` resolves. `'all'` waits for every in-sync "
23
+ "replica, which is the only setting where the cluster's `min.insync.replicas` applies: with "
24
+ 'replication factor 3 and min ISR 2 a confirmed record survives the loss of one broker. `1` is '
25
+ 'confirmed by the leader alone and is lost if the leader dies before a follower copies it; `0` '
26
+ 'does not wait at all. Replication and min ISR are cluster settings chosen by devops; overriding '
27
+ '`acks` silently changes the delivery guarantee they set up, so do it knowingly. Note that '
28
+ "`'all'` needs the topic to actually have at least `min.insync.replicas` replicas: a topic "
29
+ 'created with one replica rejects every write with `NotEnoughReplicas`.'
30
+ ),
31
+ )
32
+ enable_idempotence: bool = Field(
33
+ default=False,
34
+ description=(
35
+ 'Exactly one copy of each record even when a send is retried after a broker failure. Requires '
36
+ "`acks='all'` and costs a sequence number per partition. Off by default: records are keyed with "
37
+ '`idempotent_key`, so a duplicate is cheap to drop on the consumer side, and consumers are '
38
+ 'at-least-once anyway.'
39
+ ),
40
+ )
41
+ linger_ms: int = Field(
42
+ default=50,
43
+ description=(
44
+ 'How long the producer waits for more records before sending a batch. 0 sends each record at '
45
+ 'once; a small delay lets the records of one request share a batch, which is what makes '
46
+ '`bulk_create` cheap.'
47
+ ),
48
+ )
49
+ max_batch_size: int = Field(
50
+ default=32_000,
51
+ description='Bytes per partition batch. A batch is sent when it reaches this size or `linger_ms` passes.',
52
+ )
53
+ retry_backoff_ms: int = Field(
54
+ default=500,
55
+ description='Pause before retrying a failed request (leader change, transient broker error).',
56
+ )
57
+ metadata_max_age_ms: int = Field(
58
+ default=30_000,
59
+ description=(
60
+ 'How often the producer refreshes cluster metadata even without errors, so a new partition or a '
61
+ 'moved leader is picked up within this time.'
62
+ ),
63
+ )
64
+ request_timeout_ms: int = Field(
65
+ default=5_000,
66
+ description=(
67
+ "How long a produce request may wait for the broker's response before it fails. With `acks='all'` "
68
+ "this includes the followers catching up, so keep it above the broker's `replica.lag.time.max.ms`."
69
+ ),
70
+ )
71
+
72
+ @model_validator(mode='after')
73
+ def _idempotence_requires_acks_all(self) -> Self:
74
+ if self.enable_idempotence and self.acks != 'all':
75
+ raise ValueError("`enable_idempotence=True` requires `acks='all'`")
76
+ return self
77
+
78
+
79
+ class ConsumerConfig(BaseModel):
80
+ """Keyword arguments passed to `aiokafka.AIOKafkaConsumer` as they are (`model_dump()`).
81
+
82
+ Any other `AIOKafkaConsumer` keyword argument (`max_poll_records`, `isolation_level`, ...) can be
83
+ passed as well and is forwarded untouched.
84
+
85
+ The defaults make a batch consumer: offsets are committed by the repository after a batch is
86
+ processed (at-least-once) and fetches wait for a meaningful amount of data.
87
+
88
+ The repository assigns its partition explicitly (`assign`, not `subscribe`), so the consumer never
89
+ joins the group: `session_timeout_ms`, `heartbeat_interval_ms` and the other group membership
90
+ settings have no effect here and are left out. They can still be passed if a consumer subscribes.
91
+ """
92
+
93
+ model_config = ConfigDict(frozen=True, extra='allow')
94
+
95
+ auto_offset_reset: Literal['earliest', 'latest', 'none'] = Field(
96
+ default='earliest',
97
+ description=(
98
+ 'Where a group starts when it has no committed offset for a partition (first run, or the '
99
+ "committed offset was deleted by retention): `'earliest'` reads the whole retained history, "
100
+ "`'latest'` only new records, `'none'` raises and leaves the decision to the caller."
101
+ ),
102
+ )
103
+ # Only False is accepted. Auto commit acknowledges the consumer's position, the offset right after the
104
+ # last record `getmany` returned, every `auto_commit_interval_ms`, regardless of what the application
105
+ # has done with those records. That breaks everything the repository relies on: `commit()` after a
106
+ # processed batch (at-least-once) becomes meaningless, records filtered out by
107
+ # `timestamp_threshold_minutes` get committed past and are never read again, and a partition that
108
+ # must stay on an undeserializable record is silently moved past it.
109
+ enable_auto_commit: Literal[False] = Field(
110
+ default=False,
111
+ description=(
112
+ 'Must stay False: `BaseKafkaConsumerRepository.commit` acknowledges offsets after a batch is '
113
+ 'processed. Auto commit would acknowledge records as soon as they are fetched, and a crash in the '
114
+ 'middle of a batch would lose them.'
115
+ ),
116
+ )
117
+ request_timeout_ms: int = Field(
118
+ default=25_000,
119
+ description='How long a request to the broker may wait for a response.',
120
+ )
121
+ retry_backoff_ms: int = Field(
122
+ default=1_000,
123
+ description='Pause before retrying a failed request (coordinator moved, transient broker error).',
124
+ )
125
+ fetch_min_bytes: int = Field(
126
+ default=120_000,
127
+ description=(
128
+ 'The broker holds a fetch until this many bytes are available or `fetch_max_wait_ms` passes. '
129
+ 'A high value turns a trickle of small records into fewer, fuller batches.'
130
+ ),
131
+ )
132
+ fetch_max_bytes: int = Field(
133
+ default=125_000_000,
134
+ description='Upper bound on one fetch response across all partitions the consumer reads.',
135
+ )
136
+ fetch_max_wait_ms: int = Field(
137
+ default=1_000,
138
+ description='How long the broker may hold a fetch waiting for `fetch_min_bytes`.',
139
+ )
140
+ max_partition_fetch_bytes: int = Field(
141
+ default=25_000_000,
142
+ description=(
143
+ 'Upper bound on the data returned for one partition in one fetch. Must be at least the largest '
144
+ 'record in the topic, otherwise the consumer is stuck on it.'
145
+ ),
146
+ )
147
+
148
+
149
+ __all__ = ('ConsumerConfig', 'ProducerConfig')
@@ -0,0 +1,232 @@
1
+ import time
2
+ from abc import ABC
3
+ from collections.abc import AsyncIterator, Iterator
4
+ from logging import getLogger
5
+ from typing import Any, ClassVar, Generic, Protocol, Self, TypeVar
6
+
7
+ import msgpack
8
+ from aiokafka import AIOKafkaConsumer, TopicPartition
9
+ from ddutils.class_helpers import get_generic_base_argument, require_class_attribute
10
+
11
+ from ddkafka.config import ConsumerConfig
12
+
13
+ logger = getLogger(__name__)
14
+
15
+
16
+ class Deserializable(Protocol):
17
+ @classmethod
18
+ def model_validate(cls, data: dict) -> Self: ...
19
+
20
+
21
+ DomainT = TypeVar('DomainT', bound=Deserializable)
22
+
23
+
24
+ class MessageDeserializationError(Exception):
25
+ """A record that cannot be turned into the domain type; names the record so the offset can be skipped."""
26
+
27
+ def __init__(self, topic: str, partition: int, offset: int):
28
+ self.topic = topic
29
+ self.partition = partition
30
+ self.offset = offset
31
+ super().__init__(f'Cannot deserialize the message at {topic}[{partition}] offset {offset}')
32
+
33
+
34
+ class BaseKafkaConsumerRepository(ABC, Generic[DomainT]):
35
+ """
36
+ Base async Kafka consumer repository: one instance reads one partition of `topic` in batches.
37
+
38
+ Messages are msgpack payloads turned into the domain type with `model_validate`. `get_batches`
39
+ polls until the partition runs dry or a poll comes back too thin (`min_batch_fill_ratio`),
40
+ and `commit` acknowledges every batch yielded so far, including the one being processed.
41
+
42
+ A record that cannot be deserialized (a schema the consumer does not know, a foreign payload)
43
+ raises `MessageDeserializationError` with its offset, and the partition stays at that offset
44
+ until someone moves it. A consumer whose stream can afford to lose such a record sets
45
+ `skip_undeserializable = True`: the record is logged with its offset and dropped instead.
46
+
47
+ Class Attributes:
48
+ bootstrap_servers: Kafka servers address
49
+ topic: Topic name
50
+ group_id: Consumer group ID
51
+ batch_size: Max records per poll
52
+ poll_timeout_ms: Timeout for poll operation in milliseconds
53
+ min_batch_fill_ratio: Minimum batch fill ratio to continue (0.0-1.0)
54
+ skip_undeserializable: Drop records that cannot be deserialized instead of raising
55
+ config: Consumer configuration
56
+
57
+ Example:
58
+ class ProfileEventConsumerRepository(BaseKafkaConsumerRepository[ProfileEvent]):
59
+ bootstrap_servers = ['kafka:9092']
60
+ topic = 'profile_events'
61
+ group_id = 'analytics_context'
62
+ batch_size = 100
63
+ """
64
+
65
+ bootstrap_servers: ClassVar[list[str]]
66
+ topic: ClassVar[str]
67
+ group_id: ClassVar[str]
68
+ batch_size: ClassVar[int] = 1_000
69
+ poll_timeout_ms: ClassVar[int] = 10_000
70
+ min_batch_fill_ratio: ClassVar[float] = 0.1
71
+ skip_undeserializable: ClassVar[bool] = False
72
+ config: ClassVar[ConsumerConfig] = ConsumerConfig()
73
+
74
+ _domain_class: ClassVar[type[Deserializable]]
75
+
76
+ def __init_subclass__(cls, **kwargs: Any) -> None:
77
+ super().__init_subclass__(**kwargs)
78
+
79
+ domain_class = get_generic_base_argument(cls, BaseKafkaConsumerRepository)
80
+ if domain_class is None or isinstance(domain_class, TypeVar):
81
+ raise TypeError(
82
+ f'{cls.__name__} must specify domain type: class {cls.__name__}(BaseKafkaConsumerRepository[YourDomain])'
83
+ )
84
+ cls._domain_class = domain_class
85
+
86
+ require_class_attribute(cls, 'bootstrap_servers', list)
87
+ require_class_attribute(cls, 'topic', str)
88
+ require_class_attribute(cls, 'group_id', str)
89
+ if require_class_attribute(cls, 'batch_size', int) < 1:
90
+ raise ValueError(f'`batch_size` class attribute of {cls.__name__} must be positive')
91
+ if require_class_attribute(cls, 'poll_timeout_ms', int) < 0:
92
+ raise ValueError(f'`poll_timeout_ms` class attribute of {cls.__name__} must not be negative')
93
+ if not (0 <= require_class_attribute(cls, 'min_batch_fill_ratio', float) <= 1):
94
+ raise ValueError(f'`min_batch_fill_ratio` class attribute of {cls.__name__} must be between 0 and 1')
95
+ require_class_attribute(cls, 'skip_undeserializable', bool)
96
+ require_class_attribute(cls, 'config', ConsumerConfig)
97
+
98
+ def __init__(self, partition: int):
99
+ self._partition = partition
100
+ self._topic_partition = TopicPartition(self.topic, partition)
101
+ self._processed_offsets: dict[TopicPartition, int] = {}
102
+ self._create_consumer()
103
+
104
+ def _deserialize(self, records: list[Any]) -> Iterator[DomainT]:
105
+ # Done here rather than through aiokafka's `value_deserializer`, where a failure surfaces
106
+ # from `getmany` without the record it happened on.
107
+ for record in records:
108
+ try:
109
+ yield self._domain_class.model_validate(msgpack.loads(record.value)) # type: ignore[misc]
110
+ except Exception as e:
111
+ if not self.skip_undeserializable:
112
+ raise MessageDeserializationError(self.topic, self._partition, record.offset) from e
113
+ logger.error(
114
+ {
115
+ 'message': 'KAFKA_CONSUMER: Skipped a message that cannot be deserialized',
116
+ 'topic': self.topic,
117
+ 'partition': self._partition,
118
+ 'offset': record.offset,
119
+ 'error': repr(e),
120
+ }
121
+ )
122
+
123
+ def _create_consumer(self) -> None:
124
+ logger.info(
125
+ {
126
+ 'message': 'KAFKA_CONSUMER: Creating consumer',
127
+ 'topic': self.topic,
128
+ 'partition': self._partition,
129
+ 'group_id': self.group_id,
130
+ }
131
+ )
132
+
133
+ self._consumer = AIOKafkaConsumer(
134
+ bootstrap_servers=self.bootstrap_servers,
135
+ group_id=self.group_id,
136
+ **self.config.model_dump(),
137
+ )
138
+ self._consumer.assign([self._topic_partition])
139
+
140
+ async def start(self) -> None:
141
+ await self._consumer.start()
142
+
143
+ logger.info(
144
+ {'message': 'KAFKA_CONSUMER: Started', 'topic': self.topic, 'partition': self._partition, 'group_id': self.group_id}
145
+ )
146
+
147
+ async def stop(self) -> None:
148
+ await self._consumer.stop()
149
+ logger.info(
150
+ {'message': 'KAFKA_CONSUMER: Stopped', 'topic': self.topic, 'partition': self._partition, 'group_id': self.group_id}
151
+ )
152
+
153
+ async def _get_offset_limit(self, timestamp_threshold_minutes: int) -> int | None:
154
+ """The first offset younger than the threshold, or None when the partition has no such message."""
155
+ threshold_ms = int(time.time() * 1000) - (timestamp_threshold_minutes * 60 * 1000)
156
+ offsets_for_times = await self._consumer.offsets_for_times({self._topic_partition: threshold_ms})
157
+ offset_and_timestamp = offsets_for_times.get(self._topic_partition)
158
+ return offset_and_timestamp.offset if offset_and_timestamp else None
159
+
160
+ async def _poll(self, offset_limit: int | None) -> list[Any]:
161
+ """One `getmany` for the partition; with `offset_limit` only the records before it."""
162
+ records_by_partition = await self._consumer.getmany(
163
+ self._topic_partition, timeout_ms=self.poll_timeout_ms, max_records=self.batch_size
164
+ )
165
+ records = records_by_partition.get(self._topic_partition, [])
166
+
167
+ logger.info(
168
+ {
169
+ 'message': 'KAFKA_CONSUMER: Retrieved batch',
170
+ 'topic': self.topic,
171
+ 'partition': self._partition,
172
+ 'records_count': len(records),
173
+ }
174
+ )
175
+
176
+ if offset_limit is not None:
177
+ records = [record for record in records if record.offset < offset_limit]
178
+ return records
179
+
180
+ async def get_batches(self, timestamp_threshold_minutes: int | None = None) -> AsyncIterator[tuple[DomainT, ...]]:
181
+ """
182
+ Returns an async iterator over batches of domain entities.
183
+
184
+ Args:
185
+ timestamp_threshold_minutes: Only messages OLDER than this threshold (in minutes) are included.
186
+ Defaults to None (no filtering).
187
+ """
188
+ offset_limit: int | None = None
189
+ if timestamp_threshold_minutes is not None:
190
+ offset_limit = await self._get_offset_limit(timestamp_threshold_minutes)
191
+
192
+ while True:
193
+ records = await self._poll(offset_limit)
194
+ if not records:
195
+ break
196
+
197
+ values = tuple(self._deserialize(records))
198
+ records_count, last_offset = len(records), records[-1].offset
199
+ # The raw records are not needed past this point; dropping them before the batch is
200
+ # handed out frees the msgpack payloads while the caller works on the domain objects.
201
+ del records
202
+
203
+ # Recorded before the batch is handed out, so `commit` right after processing it
204
+ # acknowledges this batch and not the previous one. The caller is responsible for not
205
+ # committing a batch it failed on (the maker skips the commit on an exceptional exit).
206
+ self._processed_offsets[self._topic_partition] = last_offset + 1
207
+
208
+ if values: # a batch of skipped records only is nothing to hand out
209
+ yield values
210
+
211
+ # A poll that came back mostly empty means the partition is nearly drained: finish
212
+ # after this batch instead of waiting `poll_timeout_ms` for a handful more.
213
+ if records_count < self.batch_size * self.min_batch_fill_ratio:
214
+ break
215
+
216
+ async def commit(self) -> None:
217
+ if self._processed_offsets:
218
+ await self._consumer.commit(self._processed_offsets)
219
+
220
+ logger.info(
221
+ {
222
+ 'message': 'KAFKA_CONSUMER: Committed offsets',
223
+ 'topic': self.topic,
224
+ 'partition': self._partition,
225
+ 'offsets': str(self._processed_offsets),
226
+ }
227
+ )
228
+
229
+ self._processed_offsets.clear()
230
+
231
+
232
+ __all__ = ('BaseKafkaConsumerRepository', 'Deserializable', 'MessageDeserializationError')
@@ -0,0 +1,177 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterator
3
+ from datetime import timedelta
4
+ from typing import Any, Generic, Protocol, Self, TypeVar
5
+
6
+ from ddkafka.consumer import BaseKafkaConsumerRepository, Deserializable
7
+
8
+
9
+ class Lockable(Protocol):
10
+ # How long one `extend` is worth. A streaming consumer has to beat faster than this, so the
11
+ # contract has to say it out loud rather than leave it to whoever wires the lock up.
12
+ timeout: float
13
+
14
+ async def acquire(self) -> None: ...
15
+
16
+ async def release(self) -> None: ...
17
+
18
+ async def extend(self) -> None: ...
19
+
20
+
21
+ class ConsumerStartError(Exception): ...
22
+
23
+
24
+ # How much of the lock's timeout a streaming consumer is allowed to spend between two beats.
25
+ # The rest is headroom for the one thing the check cannot see — how long the caller takes over
26
+ # a batch, which is bounded by nothing here.
27
+ MAX_BEAT_SHARE_OF_LOCK_TIMEOUT = 0.5
28
+
29
+ DomainT = TypeVar('DomainT', bound=Deserializable)
30
+
31
+
32
+ class KafkaConsumerRepositoryMaker(Generic[DomainT]):
33
+ """
34
+ Context manager for a Kafka consumer guarded by a distributed lock.
35
+
36
+ The lock (anything satisfying `Lockable`, e.g. `ddredis.lock.RedisLock`) is keyed by group,
37
+ topic and partition, so only one consumer per partition runs across all workers. Entering
38
+ acquires the lock and starts the consumer; every batch yielded by `get_batches` is followed
39
+ by a checkpoint (lock heartbeat + offset commit); leaving commits, stops and releases. An exit
40
+ caused by an exception does not commit, so the batch being processed is read again next time.
41
+
42
+ `stream` decides what happens when the topic runs dry. Without it the consumer drains what
43
+ is waiting and returns, which is what a periodic sync wants. With it the consumer stays on
44
+ its partition indefinitely, and the cron that starts it becomes a supervisor rather than a
45
+ schedule: every tick tries to start one, the lock turns all but the first away, and a tick
46
+ only does anything once the running consumer has died. Recovery from a death that leaves
47
+ the lock held (a killed worker) takes the lock's own `timeout`, so a streaming consumer
48
+ wants that timeout measured in tens of seconds, not minutes.
49
+
50
+ `stream_round_pause` is how long it waits between rounds — the topic's chance to refill,
51
+ and the ceiling on how long a message can sit once it has arrived. A consumer whose topic
52
+ trickles wants it longer, one whose latency is the point wants it short. It is bounded from
53
+ above by the lock: pause plus poll timeout is the longest the lock can go unextended, and a
54
+ pace that does not fit is refused at construction.
55
+
56
+ Example:
57
+ async with KafkaConsumerRepositoryMaker(
58
+ consumer_class=EventConsumerRepository,
59
+ partition=0,
60
+ lock_class=RedisLock,
61
+ lock_kwargs={'redis_client': redis_client, 'timeout': 300}
62
+ ) as maker:
63
+ async for batch in maker.get_batches():
64
+ process(batch)
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ consumer_class: type[BaseKafkaConsumerRepository[DomainT]],
70
+ partition: int,
71
+ lock_class: type[Lockable],
72
+ lock_kwargs: dict[str, Any],
73
+ stream: bool = False,
74
+ stream_round_pause: timedelta = timedelta(seconds=1),
75
+ ):
76
+ lock_key = self.build_lock_key(consumer_class, partition)
77
+ self.lock = lock_class(key=lock_key, **lock_kwargs) # type: ignore[call-arg]
78
+ self._consumer_class = consumer_class
79
+ self._partition = partition
80
+ self._stream = stream
81
+ self._stream_round_pause = stream_round_pause
82
+
83
+ if stream:
84
+ self._check_beat_fits_lock()
85
+
86
+ def _check_beat_fits_lock(self) -> None:
87
+ """Refuse a pace that would let the lock die under a consumer that is still running.
88
+
89
+ A streaming consumer beats once per round, and a round is the pause plus a poll that may
90
+ wait its whole timeout out. Let that exceed the lock and the lock expires between beats:
91
+ the next tick then starts a second consumer on the same partition, both read it and both
92
+ commit, and nothing in the running system says so.
93
+ """
94
+ poll_timeout = self._consumer_class.poll_timeout_ms / 1000
95
+ beat_ceiling = self._stream_round_pause.total_seconds() + poll_timeout
96
+ allowed = self.lock.timeout * MAX_BEAT_SHARE_OF_LOCK_TIMEOUT
97
+ if beat_ceiling > allowed:
98
+ raise ValueError(
99
+ f'{self._consumer_class.__name__} streams with up to {beat_ceiling:g}s between lock '
100
+ f'heartbeats (stream_round_pause {self._stream_round_pause.total_seconds():g}s + poll '
101
+ f'timeout {poll_timeout:g}s), which does not fit the '
102
+ f'{MAX_BEAT_SHARE_OF_LOCK_TIMEOUT:g} share of the lock timeout it is allowed '
103
+ f'({allowed:g}s of {self.lock.timeout:g}s). Shorten the pause or raise the lock timeout.'
104
+ )
105
+
106
+ @staticmethod
107
+ def build_lock_key(consumer_class: type[BaseKafkaConsumerRepository[Any]], partition: int) -> str:
108
+ return f'{consumer_class.group_id}_{consumer_class.topic}_partition_{partition}'
109
+
110
+ async def get_batches(self) -> AsyncIterator[tuple[DomainT, ...]]:
111
+ while True:
112
+ async for batch in self.consumer_repo.get_batches():
113
+ yield batch
114
+ await self.checkpoint()
115
+
116
+ if not self._stream:
117
+ return
118
+
119
+ # The topic went quiet. Returning here would hand the partition back and leave the
120
+ # next message to whatever starts a consumer again — a cron tick, and with it a
121
+ # whole stage of the send path's latency. Instead the lock is kept alive and the
122
+ # consumer asked once more: its own poll timeout is the wait, so an idle partition
123
+ # costs one call every few seconds and a message is taken up as it lands.
124
+ #
125
+ # A checkpoint rather than a bare `extend`, so the heartbeat has one call site: a
126
+ # round that found nothing yields no batch, so without this an idle partition would
127
+ # have no heartbeat at all and the lock would expire under a running consumer.
128
+ await self.checkpoint()
129
+
130
+ # The pause lets records accumulate into fuller batches and is the floor under a
131
+ # poll that returns instantly, which would otherwise spin.
132
+ await asyncio.sleep(self._stream_round_pause.total_seconds())
133
+
134
+ async def checkpoint(self) -> None:
135
+ """
136
+ Extends the lock and commits every batch yielded so far, including the one being processed.
137
+
138
+ `get_batches` calls it after the caller is done with a batch. Calling it yourself from
139
+ inside the loop commits the current batch before it is processed: if the processing then
140
+ fails, those records are gone.
141
+ """
142
+ await self.lock.extend()
143
+ await self.consumer_repo.commit()
144
+
145
+ async def __aenter__(self) -> Self:
146
+ await self.lock.acquire()
147
+
148
+ # Anything that fails after the lock is taken has to give it back, or the partition stays
149
+ # locked until the lock's timeout: the constructor (aiokafka needs a running loop) as much
150
+ # as `start` (no broker).
151
+ try:
152
+ self.consumer_repo = self._consumer_class(partition=self._partition)
153
+ await self.consumer_repo.start()
154
+ except Exception as e: # noqa: BLE001
155
+ await self._close()
156
+ raise ConsumerStartError('Failed to start consumer') from e
157
+
158
+ return self
159
+
160
+ async def _close(self) -> None:
161
+ try:
162
+ if hasattr(self, 'consumer_repo'):
163
+ await self.consumer_repo.stop()
164
+ finally:
165
+ await self.lock.release()
166
+
167
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
168
+ # The offsets of a yielded batch are already recorded, so an exit caused by an error in
169
+ # the caller must not commit: the batch it failed on stays in the topic for the next run.
170
+ try:
171
+ if exc_type is None:
172
+ await self.consumer_repo.commit()
173
+ finally:
174
+ await self._close()
175
+
176
+
177
+ __all__ = ('ConsumerStartError', 'KafkaConsumerRepositoryMaker', 'Lockable')
@@ -0,0 +1,137 @@
1
+ import asyncio
2
+ from abc import ABC
3
+ from asyncio import Future
4
+ from functools import partial
5
+ from logging import getLogger
6
+ from typing import Any, ClassVar, Protocol
7
+
8
+ import msgpack
9
+ from aiokafka import AIOKafkaProducer
10
+ from ddutils.class_helpers import require_class_attribute
11
+
12
+ from ddkafka.config import ProducerConfig
13
+
14
+ logger = getLogger(__name__)
15
+
16
+
17
+ class Producible(Protocol):
18
+ @property
19
+ def idempotent_key(self) -> str | None: ...
20
+
21
+ def model_dump(self, *args: Any, **kwargs: Any) -> dict: ...
22
+
23
+
24
+ def serialize_value(event: Producible) -> bytes:
25
+ return msgpack.dumps(event.model_dump(mode='json'))
26
+
27
+
28
+ def serialize_key(key: Any) -> bytes | None:
29
+ # aiokafka hands the key to the serializer even when it is None, and a key of b'None' would
30
+ # route every keyless event to one partition.
31
+ return None if key is None else str(key).encode('utf-8')
32
+
33
+
34
+ def _log_delivery_failure(topic: str, key: Any, future: Future) -> None:
35
+ # `send` resolves when the record is buffered; the broker's answer lands in this future later.
36
+ # Nobody awaits it, so without this callback a rejected record (`NotEnoughReplicas`, a timeout)
37
+ # would only show up as "Future exception was never retrieved" from asyncio.
38
+ if future.cancelled():
39
+ return
40
+ error = future.exception()
41
+ if error is not None:
42
+ logger.error({'message': 'KAFKA_PRODUCER: Delivery failed', 'topic': topic, 'key': key, 'error': repr(error)})
43
+
44
+
45
+ class BaseKafkaProducerRepository(ABC):
46
+ """
47
+ Base async Kafka producer.
48
+
49
+ One `AIOKafkaProducer` is created lazily per subclass, once per process, and shared by all its
50
+ instances, so a producer repository can be instantiated freely. Events are serialized with msgpack from
51
+ `model_dump(mode='json')` and keyed by `idempotent_key`.
52
+
53
+ `create` and `bulk_create` return once the record is in the producer's buffer; aiokafka sends
54
+ buffered records in batches (`linger_ms`, `max_batch_size`). A record the broker rejects is
55
+ reported with an ERROR log entry, since nothing awaits its delivery.
56
+
57
+ The buffer only reaches Kafka while the process runs: call `stop()` on application shutdown,
58
+ otherwise up to `linger_ms` worth of records is lost on SIGTERM.
59
+
60
+ Class Attributes:
61
+ bootstrap_servers: Kafka servers address
62
+ config: Producer configuration
63
+
64
+ Example:
65
+ class EventProducerRepository(BaseKafkaProducerRepository):
66
+ bootstrap_servers = ['kafka:9092']
67
+ config = ProducerConfig(linger_ms=100)
68
+
69
+ producer_repository_impl = EventProducerRepository()
70
+ await producer_repository_impl.bulk_create('topic', events)
71
+ ...
72
+ await EventProducerRepository.stop() # on shutdown
73
+ """
74
+
75
+ bootstrap_servers: ClassVar[list[str]]
76
+ config: ClassVar[ProducerConfig] = ProducerConfig()
77
+
78
+ _producer: ClassVar[AIOKafkaProducer | None] = None
79
+ _producer_lock: ClassVar[asyncio.Lock]
80
+ _stopped: ClassVar[bool] = False
81
+
82
+ def __init_subclass__(cls, **kwargs: Any) -> None:
83
+ super().__init_subclass__(**kwargs)
84
+ require_class_attribute(cls, 'bootstrap_servers', list)
85
+ require_class_attribute(cls, 'config', ProducerConfig)
86
+ cls._producer_lock = asyncio.Lock()
87
+
88
+ @classmethod
89
+ async def _get_producer(cls) -> AIOKafkaProducer:
90
+ if cls._producer is not None:
91
+ return cls._producer
92
+
93
+ # The lock makes the first concurrent sends start one producer instead of one each.
94
+ async with cls._producer_lock:
95
+ if cls._stopped:
96
+ raise RuntimeError(f'{cls.__name__} producer was stopped')
97
+ if cls._producer is None:
98
+ producer = AIOKafkaProducer(
99
+ bootstrap_servers=cls.bootstrap_servers,
100
+ value_serializer=serialize_value,
101
+ key_serializer=serialize_key,
102
+ **cls.config.model_dump(),
103
+ )
104
+ await producer.start()
105
+ cls._producer = producer
106
+ return cls._producer
107
+
108
+ @classmethod
109
+ async def stop(cls) -> None:
110
+ """
111
+ Sends what is still buffered and closes the producer. Call it on application shutdown.
112
+
113
+ A producer is created once per process: after `stop` the repository refuses to send.
114
+ """
115
+ async with cls._producer_lock:
116
+ cls._stopped = True
117
+ if cls._producer is not None:
118
+ await cls._producer.stop()
119
+ cls._producer = None
120
+
121
+ @classmethod
122
+ async def _send(cls, producer: AIOKafkaProducer, topic: str, event: Producible) -> None:
123
+ key = event.idempotent_key
124
+ future = await producer.send(topic, key=key, value=event)
125
+ future.add_done_callback(partial(_log_delivery_failure, topic, key))
126
+
127
+ async def create(self, topic: str, event: Producible) -> None:
128
+ producer = await self._get_producer()
129
+ await self._send(producer, topic, event)
130
+
131
+ async def bulk_create(self, topic: str, events: list[Producible]) -> None:
132
+ producer = await self._get_producer()
133
+ for event in events:
134
+ await self._send(producer, topic, event)
135
+
136
+
137
+ __all__ = ('BaseKafkaProducerRepository', 'Producible', 'serialize_key', 'serialize_value')
File without changes
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "ddkafka"
3
+ description = "Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka"
4
+ version = "0.0.1"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.11,<3.15"
9
+ keywords = [
10
+ "python",
11
+ "kafka",
12
+ "aiokafka",
13
+ "consumer",
14
+ "producer",
15
+ "ddkafka",
16
+ ]
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "aiokafka>=0.12,<0.15",
27
+ "msgpack>=1.0,<2",
28
+ "ddutils>=0.2.3,<0.3.0",
29
+ "pydantic>=2.0,<3",
30
+ ]
31
+
32
+ [[project.authors]]
33
+ name = "davyddd"
34
+
35
+ [project.urls]
36
+ Repository = "https://github.com/davyddd/ddkafka"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "ipdb==0.13.13",
41
+ "ipython==9.16.1",
42
+ ]
43
+ test = [
44
+ "pytest==9.0.3",
45
+ "pytest-cov==5.0.0",
46
+ "parameterized==0.9.0",
47
+ ]
48
+ linter = [
49
+ "ruff==0.15.10",
50
+ "ty==0.0.15",
51
+ "complexipy==8.0.1",
52
+ ]
53
+
54
+ [tool.uv.build-backend]
55
+ module-root = ""
56
+
57
+ [build-system]
58
+ requires = ["uv_build>=0.12.0,<0.13.0"]
59
+ build-backend = "uv_build"
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "ddkafka"
3
+ description = "Kafka helpers: batch consumer, lock-guarded consumer maker and msgpack producer on top of aiokafka"
4
+ version = "0.0.1"
5
+ authors = [{ name = "davyddd" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11,<3.15"
10
+ keywords = ["python", "kafka", "aiokafka", "consumer", "producer", "ddkafka"]
11
+ # uv_build does not derive these from requires-python (poetry did); PyPI and the pyversions badge read them
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "aiokafka>=0.12,<0.15",
22
+ "msgpack>=1.0,<2",
23
+ "ddutils>=0.2.3,<0.3.0",
24
+ "pydantic>=2.0,<3",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/davyddd/ddkafka"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "ipdb==0.13.13",
33
+ "ipython==9.16.1",
34
+ ]
35
+ test = [
36
+ "pytest==9.0.3",
37
+ "pytest-cov==5.0.0",
38
+ "parameterized==0.9.0",
39
+ ]
40
+ linter = [
41
+ "ruff==0.15.10",
42
+ "ty==0.0.15",
43
+ "complexipy==8.0.1",
44
+ ]
45
+
46
+ [tool.uv.build-backend]
47
+ module-root = ""
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.12.0,<0.13.0"]
51
+ build-backend = "uv_build"