cledar-sdk 2.0.2__py3-none-any.whl → 2.1.0__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.
- cledar/__init__.py +1 -0
- cledar/kafka/README.md +239 -0
- cledar/kafka/__init__.py +42 -0
- cledar/kafka/clients/base.py +117 -0
- cledar/kafka/clients/consumer.py +138 -0
- cledar/kafka/clients/producer.py +97 -0
- cledar/kafka/config/schemas.py +262 -0
- cledar/kafka/exceptions.py +17 -0
- cledar/kafka/handlers/dead_letter.py +88 -0
- cledar/kafka/handlers/parser.py +83 -0
- cledar/kafka/logger.py +5 -0
- cledar/kafka/models/input.py +17 -0
- cledar/kafka/models/message.py +14 -0
- cledar/kafka/models/output.py +12 -0
- cledar/kafka/tests/.env.test.kafka +3 -0
- cledar/kafka/tests/README.md +216 -0
- cledar/kafka/tests/conftest.py +104 -0
- cledar/kafka/tests/integration/__init__.py +1 -0
- cledar/kafka/tests/integration/conftest.py +78 -0
- cledar/kafka/tests/integration/helpers.py +47 -0
- cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
- cledar/kafka/tests/integration/test_integration.py +394 -0
- cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
- cledar/kafka/tests/integration/test_producer_integration.py +217 -0
- cledar/kafka/tests/unit/__init__.py +1 -0
- cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
- cledar/kafka/tests/unit/test_config_validation.py +609 -0
- cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
- cledar/kafka/tests/unit/test_error_handling.py +674 -0
- cledar/kafka/tests/unit/test_input_parser.py +310 -0
- cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
- cledar/kafka/tests/unit/test_utils.py +25 -0
- cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
- cledar/kafka/utils/callbacks.py +28 -0
- cledar/kafka/utils/messages.py +39 -0
- cledar/kafka/utils/topics.py +15 -0
- cledar/kserve/README.md +352 -0
- cledar/kserve/__init__.py +5 -0
- cledar/kserve/tests/__init__.py +0 -0
- cledar/kserve/tests/test_utils.py +64 -0
- cledar/kserve/utils.py +30 -0
- cledar/logging/README.md +53 -0
- cledar/logging/__init__.py +5 -0
- cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
- cledar/logging/universal_plaintext_formatter.py +99 -0
- cledar/monitoring/README.md +71 -0
- cledar/monitoring/__init__.py +5 -0
- cledar/monitoring/monitoring_server.py +156 -0
- cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
- cledar/monitoring/tests/test_monitoring_server.py +59 -0
- cledar/nonce/README.md +99 -0
- cledar/nonce/__init__.py +5 -0
- cledar/nonce/nonce_service.py +62 -0
- cledar/nonce/tests/__init__.py +0 -0
- cledar/nonce/tests/test_nonce_service.py +136 -0
- cledar/redis/README.md +536 -0
- cledar/redis/__init__.py +17 -0
- cledar/redis/async_example.py +112 -0
- cledar/redis/example.py +67 -0
- cledar/redis/exceptions.py +25 -0
- cledar/redis/logger.py +5 -0
- cledar/redis/model.py +14 -0
- cledar/redis/redis.py +764 -0
- cledar/redis/redis_config_store.py +333 -0
- cledar/redis/tests/test_async_integration_redis.py +158 -0
- cledar/redis/tests/test_async_redis_service.py +380 -0
- cledar/redis/tests/test_integration_redis.py +119 -0
- cledar/redis/tests/test_redis_service.py +319 -0
- cledar/storage/README.md +529 -0
- cledar/storage/__init__.py +6 -0
- cledar/storage/constants.py +5 -0
- cledar/storage/exceptions.py +79 -0
- cledar/storage/models.py +41 -0
- cledar/storage/object_storage.py +1274 -0
- cledar/storage/tests/conftest.py +18 -0
- cledar/storage/tests/test_abfs.py +164 -0
- cledar/storage/tests/test_integration_filesystem.py +359 -0
- cledar/storage/tests/test_integration_s3.py +453 -0
- cledar/storage/tests/test_local.py +384 -0
- cledar/storage/tests/test_s3.py +521 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/METADATA +1 -1
- cledar_sdk-2.1.0.dist-info/RECORD +84 -0
- cledar_sdk-2.0.2.dist-info/RECORD +0 -4
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/WHEEL +0 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Configuration schemas for Kafka clients."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import field_validator
|
|
6
|
+
from pydantic.dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KafkaSecurityProtocol(StrEnum):
|
|
10
|
+
"""Supported Kafka security protocols."""
|
|
11
|
+
|
|
12
|
+
PLAINTEXT = "PLAINTEXT"
|
|
13
|
+
SSL = "SSL"
|
|
14
|
+
SASL_PLAINTEXT = "SASL_PLAINTEXT"
|
|
15
|
+
SASL_SSL = "SASL_SSL"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KafkaSaslMechanism(StrEnum):
|
|
19
|
+
"""Supported Kafka SASL mechanisms."""
|
|
20
|
+
|
|
21
|
+
PLAIN = "PLAIN"
|
|
22
|
+
SCRAM_SHA_256 = "SCRAM-SHA-256"
|
|
23
|
+
SCRAM_SHA_512 = "SCRAM-SHA-512"
|
|
24
|
+
GSSAPI = "GSSAPI"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_kafka_servers(v: list[str] | str) -> list[str] | str:
|
|
28
|
+
"""Validate kafka_servers is not empty.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
v: List of Kafka broker addresses or a comma-separated string.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
list[str] | str: The validated value.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If the value is empty.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(v, str) and v.strip() == "":
|
|
41
|
+
raise ValueError("kafka_servers cannot be empty")
|
|
42
|
+
if isinstance(v, list) and len(v) == 0:
|
|
43
|
+
raise ValueError("kafka_servers cannot be empty list")
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_non_negative(v: int) -> int:
|
|
48
|
+
"""Validate that timeout values are non-negative.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
v: Timeout value to validate.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
int: The validated value.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If the value is negative.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
if v < 0:
|
|
61
|
+
raise ValueError("timeout values must be non-negative")
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class KafkaProducerConfig:
|
|
67
|
+
"""Configuration for Kafka Producer.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
kafka_servers: List of Kafka broker addresses or comma-separated string
|
|
71
|
+
kafka_group_id: Consumer group identifier
|
|
72
|
+
kafka_topic_prefix: Optional prefix for topic names
|
|
73
|
+
kafka_block_buffer_time_sec: Time to block when buffer is full
|
|
74
|
+
kafka_connection_check_timeout_sec: Timeout for connection health checks
|
|
75
|
+
kafka_connection_check_interval_sec: Interval between connection checks
|
|
76
|
+
kafka_partitioner: Partitioning strategy for messages
|
|
77
|
+
compression_type: Compression type for messages (gzip, snappy, lz4, zstd,
|
|
78
|
+
or None)
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
kafka_servers: list[str] | str
|
|
83
|
+
kafka_group_id: str
|
|
84
|
+
kafka_security_protocol: KafkaSecurityProtocol | None = None
|
|
85
|
+
kafka_sasl_mechanism: KafkaSaslMechanism | None = None
|
|
86
|
+
kafka_sasl_username: str | None = None
|
|
87
|
+
kafka_sasl_password: str | None = None
|
|
88
|
+
kafka_topic_prefix: str | None = None
|
|
89
|
+
kafka_block_buffer_time_sec: int = 10
|
|
90
|
+
kafka_connection_check_timeout_sec: int = 5
|
|
91
|
+
kafka_connection_check_interval_sec: int = 60
|
|
92
|
+
kafka_partitioner: str = "consistent_random"
|
|
93
|
+
compression_type: str | None = "gzip"
|
|
94
|
+
|
|
95
|
+
@field_validator("kafka_servers")
|
|
96
|
+
@classmethod
|
|
97
|
+
def validate_kafka_servers(cls, v: list[str] | str) -> list[str] | str:
|
|
98
|
+
"""Validate kafka_servers field.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
v: List of Kafka broker addresses or a comma-separated string.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
list[str] | str: The validated value.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
return _validate_kafka_servers(v)
|
|
108
|
+
|
|
109
|
+
@field_validator(
|
|
110
|
+
"kafka_block_buffer_time_sec",
|
|
111
|
+
"kafka_connection_check_timeout_sec",
|
|
112
|
+
"kafka_connection_check_interval_sec",
|
|
113
|
+
)
|
|
114
|
+
@classmethod
|
|
115
|
+
def validate_positive_timeouts(cls, v: int) -> int:
|
|
116
|
+
"""Validate positive timeout values.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
v: Timeout value to validate.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
int: The validated value.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
return _validate_non_negative(v)
|
|
126
|
+
|
|
127
|
+
def to_kafka_config(self) -> dict[str, list[str] | str | None]:
|
|
128
|
+
"""Build Kafka producer configuration dictionary.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
dict[str, list[str] | str | None]: Kafka client configuration.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
config = {
|
|
135
|
+
"bootstrap.servers": self.kafka_servers,
|
|
136
|
+
"client.id": self.kafka_group_id,
|
|
137
|
+
"compression.type": self.compression_type,
|
|
138
|
+
"partitioner": self.kafka_partitioner,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Add SASL configuration if specified
|
|
142
|
+
if self.kafka_security_protocol:
|
|
143
|
+
config["security.protocol"] = self.kafka_security_protocol.value
|
|
144
|
+
if self.kafka_sasl_mechanism:
|
|
145
|
+
config["sasl.mechanism"] = self.kafka_sasl_mechanism.value
|
|
146
|
+
if self.kafka_sasl_username:
|
|
147
|
+
config["sasl.username"] = self.kafka_sasl_username
|
|
148
|
+
if self.kafka_sasl_password:
|
|
149
|
+
config["sasl.password"] = self.kafka_sasl_password
|
|
150
|
+
|
|
151
|
+
return config
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class KafkaConsumerConfig:
|
|
156
|
+
"""Configuration for Kafka Consumer.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
kafka_servers: List of Kafka broker addresses or comma-separated string
|
|
160
|
+
kafka_group_id: Consumer group identifier
|
|
161
|
+
kafka_offset: Starting offset position ('earliest', 'latest', or specific
|
|
162
|
+
offset)
|
|
163
|
+
kafka_topic_prefix: Optional prefix for topic names
|
|
164
|
+
kafka_block_consumer_time_sec: Time to block waiting for messages
|
|
165
|
+
kafka_connection_check_timeout_sec: Timeout for connection health checks
|
|
166
|
+
kafka_auto_commit_interval_ms: Interval for automatic offset commits
|
|
167
|
+
kafka_connection_check_interval_sec: Interval between connection checks
|
|
168
|
+
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
kafka_servers: list[str] | str
|
|
172
|
+
kafka_group_id: str
|
|
173
|
+
kafka_security_protocol: KafkaSecurityProtocol | None = None
|
|
174
|
+
kafka_sasl_mechanism: KafkaSaslMechanism | None = None
|
|
175
|
+
kafka_sasl_username: str | None = None
|
|
176
|
+
kafka_sasl_password: str | None = None
|
|
177
|
+
kafka_offset: str = "latest"
|
|
178
|
+
kafka_topic_prefix: str | None = None
|
|
179
|
+
kafka_block_consumer_time_sec: int = 2
|
|
180
|
+
kafka_connection_check_timeout_sec: int = 5
|
|
181
|
+
kafka_auto_commit_interval_ms: int = 1000
|
|
182
|
+
kafka_connection_check_interval_sec: int = 60
|
|
183
|
+
|
|
184
|
+
@field_validator("kafka_servers")
|
|
185
|
+
@classmethod
|
|
186
|
+
def validate_kafka_servers(cls, v: list[str] | str) -> list[str] | str:
|
|
187
|
+
"""Validate kafka_servers field.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
v: List of Kafka broker addresses or a comma-separated string.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
list[str] | str: The validated value.
|
|
194
|
+
|
|
195
|
+
"""
|
|
196
|
+
return _validate_kafka_servers(v)
|
|
197
|
+
|
|
198
|
+
@field_validator("kafka_offset")
|
|
199
|
+
@classmethod
|
|
200
|
+
def validate_kafka_offset(cls, v: str) -> str:
|
|
201
|
+
"""Validate kafka_offset field.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
v: Offset value to validate.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
str: The validated offset value.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: If the value is empty.
|
|
211
|
+
|
|
212
|
+
"""
|
|
213
|
+
if v.strip() == "":
|
|
214
|
+
raise ValueError("kafka_offset cannot be empty")
|
|
215
|
+
return v
|
|
216
|
+
|
|
217
|
+
@field_validator(
|
|
218
|
+
"kafka_block_consumer_time_sec",
|
|
219
|
+
"kafka_connection_check_timeout_sec",
|
|
220
|
+
"kafka_auto_commit_interval_ms",
|
|
221
|
+
"kafka_connection_check_interval_sec",
|
|
222
|
+
)
|
|
223
|
+
@classmethod
|
|
224
|
+
def validate_positive_timeouts(cls, v: int) -> int:
|
|
225
|
+
"""Validate positive timeout values.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
v: Timeout value to validate.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
int: The validated value.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
return _validate_non_negative(v)
|
|
235
|
+
|
|
236
|
+
def to_kafka_config(self) -> dict[str, int | list[str] | str]:
|
|
237
|
+
"""Build Kafka consumer configuration dictionary.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
dict[str, int | list[str] | str]: Kafka client configuration.
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
config = {
|
|
244
|
+
"bootstrap.servers": self.kafka_servers,
|
|
245
|
+
"enable.auto.commit": False,
|
|
246
|
+
"enable.partition.eof": False,
|
|
247
|
+
"auto.commit.interval.ms": self.kafka_auto_commit_interval_ms,
|
|
248
|
+
"auto.offset.reset": self.kafka_offset,
|
|
249
|
+
"group.id": self.kafka_group_id,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Add SASL configuration if specified
|
|
253
|
+
if self.kafka_security_protocol:
|
|
254
|
+
config["security.protocol"] = self.kafka_security_protocol.value
|
|
255
|
+
if self.kafka_sasl_mechanism:
|
|
256
|
+
config["sasl.mechanism"] = self.kafka_sasl_mechanism.value
|
|
257
|
+
if self.kafka_sasl_username:
|
|
258
|
+
config["sasl.username"] = self.kafka_sasl_username
|
|
259
|
+
if self.kafka_sasl_password:
|
|
260
|
+
config["sasl.password"] = self.kafka_sasl_password
|
|
261
|
+
|
|
262
|
+
return config
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Kafka-related exceptions for the Cledar SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class KafkaProducerNotConnectedError(Exception):
|
|
5
|
+
"""Custom exception for KafkaProducer to indicate it is not connected."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KafkaConsumerNotConnectedError(Exception):
|
|
9
|
+
"""Custom exception for KafkaConsumer to indicate it is not connected."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KafkaConnectionError(Exception):
|
|
13
|
+
"""Custom exception to indicate connection failures."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KafkaConsumerError(Exception):
|
|
17
|
+
"""Custom exception for KafkaConsumer to indicate errors."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Dead letter handler for Kafka messages."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from ..clients.producer import KafkaProducer
|
|
6
|
+
from ..logger import logger
|
|
7
|
+
from ..models.message import KafkaMessage
|
|
8
|
+
from ..models.output import FailedMessageData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DeadLetterHandler:
|
|
12
|
+
"""A handler for handling failed messages and sending them to a DLQ topic."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, producer: KafkaProducer, dlq_topic: str) -> None:
|
|
15
|
+
"""Initialize DeadLetterHandler with a Kafka producer and DLQ topic.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
producer: KafkaProducer instance.
|
|
19
|
+
dlq_topic: The name of the DLQ Kafka topic.
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
self.producer: KafkaProducer = producer
|
|
23
|
+
self.dlq_topic: str = dlq_topic
|
|
24
|
+
|
|
25
|
+
def handle(
|
|
26
|
+
self,
|
|
27
|
+
message: KafkaMessage,
|
|
28
|
+
failures_details: list[FailedMessageData] | None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Handle a failed message by sending it to the DLQ topic.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
message: The original Kafka message.
|
|
34
|
+
failures_details: A list of FailedMessageData.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
logger.info("Handling message for DLQ.")
|
|
38
|
+
|
|
39
|
+
kafka_headers = self._build_headers(failures_details=failures_details)
|
|
40
|
+
|
|
41
|
+
logger.info("DLQ message built successfully.")
|
|
42
|
+
self._send_message(message.value, message.key, kafka_headers)
|
|
43
|
+
|
|
44
|
+
def _build_headers(
|
|
45
|
+
self,
|
|
46
|
+
failures_details: list[FailedMessageData] | None,
|
|
47
|
+
) -> list[tuple[str, bytes]]:
|
|
48
|
+
"""Build Kafka headers containing exception details.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
failures_details: A list of FailedMessageData.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
list[tuple[str, bytes]]: A list of Kafka headers.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
headers: list[tuple[str, bytes]] = []
|
|
58
|
+
|
|
59
|
+
if failures_details:
|
|
60
|
+
failures_json = json.dumps(
|
|
61
|
+
[failure.model_dump() for failure in failures_details]
|
|
62
|
+
)
|
|
63
|
+
headers.append(("failures_details", failures_json.encode("utf-8")))
|
|
64
|
+
|
|
65
|
+
return headers
|
|
66
|
+
|
|
67
|
+
def _send_message(
|
|
68
|
+
self,
|
|
69
|
+
message: str | None,
|
|
70
|
+
key: str | None,
|
|
71
|
+
headers: list[tuple[str, bytes]],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Send a DLQ message to the Kafka DLQ topic with headers.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
message: The DLQ message payload.
|
|
77
|
+
key: The original Kafka message key.
|
|
78
|
+
headers: Kafka headers containing exception details.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
self.producer.send(
|
|
82
|
+
topic=self.dlq_topic, value=message, key=key, headers=headers
|
|
83
|
+
)
|
|
84
|
+
logger.info(
|
|
85
|
+
"Message sent to DLQ topic successfully with key: %s and headers: %s",
|
|
86
|
+
key,
|
|
87
|
+
headers,
|
|
88
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Kafka message parser module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ..models.input import (
|
|
8
|
+
InputKafkaMessage,
|
|
9
|
+
)
|
|
10
|
+
from ..models.message import KafkaMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IncorrectMessageValueError(Exception):
|
|
14
|
+
"""Message needs to have `value` field present in order to be parsed.
|
|
15
|
+
|
|
16
|
+
This is unless `model` is set to `None`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InputParser[Payload: BaseModel]:
|
|
21
|
+
"""Parser for Kafka messages into Pydantic models.
|
|
22
|
+
|
|
23
|
+
Generic class for parsing Kafka messages.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, model: type[Payload]) -> None:
|
|
27
|
+
"""Initialize InputParser with a Pydantic model.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model: The Pydantic model to validate messages against.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
self.model: type[Payload] = model
|
|
34
|
+
|
|
35
|
+
def parse_json(self, json_str: str) -> Payload:
|
|
36
|
+
"""Parse JSON text and validate into the target Payload model.
|
|
37
|
+
|
|
38
|
+
Invalid JSON should raise IncorrectMessageValueError, while schema
|
|
39
|
+
validation errors should bubble up as ValidationError.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
json_str: The JSON string to parse.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Payload: The validated Pydantic model instance.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
IncorrectMessageValueError: If the JSON is invalid.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(json_str)
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
# Normalize invalid JSON into our domain-specific error
|
|
55
|
+
raise IncorrectMessageValueError from exc
|
|
56
|
+
return self.model.model_validate(data)
|
|
57
|
+
|
|
58
|
+
def parse_message(self, message: KafkaMessage) -> InputKafkaMessage[Payload]:
|
|
59
|
+
"""Parse a Kafka message into an InputKafkaMessage with a validated payload.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
message: The Kafka message to parse.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
InputKafkaMessage[Payload]: The parsed message with payload.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
IncorrectMessageValueError: If message value is missing but required.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
if message.value is None and self.model is not None:
|
|
72
|
+
raise IncorrectMessageValueError
|
|
73
|
+
|
|
74
|
+
obj = self.parse_json(message.value)
|
|
75
|
+
|
|
76
|
+
return InputKafkaMessage(
|
|
77
|
+
key=message.key,
|
|
78
|
+
value=message.value,
|
|
79
|
+
payload=obj,
|
|
80
|
+
topic=message.topic,
|
|
81
|
+
offset=message.offset,
|
|
82
|
+
partition=message.partition,
|
|
83
|
+
)
|
cledar/kafka/logger.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Input Kafka message model."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .message import KafkaMessage
|
|
9
|
+
|
|
10
|
+
Payload = TypeVar("Payload", bound=BaseModel)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass
|
|
14
|
+
class InputKafkaMessage[Payload](KafkaMessage):
|
|
15
|
+
"""Kafka message with a parsed and validated Pydantic payload."""
|
|
16
|
+
|
|
17
|
+
payload: Payload
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Kafka message data class."""
|
|
2
|
+
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class KafkaMessage:
|
|
8
|
+
"""Base Kafka message representation."""
|
|
9
|
+
|
|
10
|
+
topic: str
|
|
11
|
+
value: str | None
|
|
12
|
+
key: str | None
|
|
13
|
+
offset: int | None
|
|
14
|
+
partition: int | None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Output models for Kafka module."""
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FailedMessageData(pydantic.BaseModel):
|
|
7
|
+
"""Data structure for recording message processing failures."""
|
|
8
|
+
|
|
9
|
+
raised_at: str
|
|
10
|
+
exception_message: str | None
|
|
11
|
+
exception_trace: str | None
|
|
12
|
+
failure_reason: str | None
|