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.
Files changed (85) hide show
  1. cledar/__init__.py +1 -0
  2. cledar/kafka/README.md +239 -0
  3. cledar/kafka/__init__.py +42 -0
  4. cledar/kafka/clients/base.py +117 -0
  5. cledar/kafka/clients/consumer.py +138 -0
  6. cledar/kafka/clients/producer.py +97 -0
  7. cledar/kafka/config/schemas.py +262 -0
  8. cledar/kafka/exceptions.py +17 -0
  9. cledar/kafka/handlers/dead_letter.py +88 -0
  10. cledar/kafka/handlers/parser.py +83 -0
  11. cledar/kafka/logger.py +5 -0
  12. cledar/kafka/models/input.py +17 -0
  13. cledar/kafka/models/message.py +14 -0
  14. cledar/kafka/models/output.py +12 -0
  15. cledar/kafka/tests/.env.test.kafka +3 -0
  16. cledar/kafka/tests/README.md +216 -0
  17. cledar/kafka/tests/conftest.py +104 -0
  18. cledar/kafka/tests/integration/__init__.py +1 -0
  19. cledar/kafka/tests/integration/conftest.py +78 -0
  20. cledar/kafka/tests/integration/helpers.py +47 -0
  21. cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
  22. cledar/kafka/tests/integration/test_integration.py +394 -0
  23. cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
  24. cledar/kafka/tests/integration/test_producer_integration.py +217 -0
  25. cledar/kafka/tests/unit/__init__.py +1 -0
  26. cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
  27. cledar/kafka/tests/unit/test_config_validation.py +609 -0
  28. cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
  29. cledar/kafka/tests/unit/test_error_handling.py +674 -0
  30. cledar/kafka/tests/unit/test_input_parser.py +310 -0
  31. cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
  32. cledar/kafka/tests/unit/test_utils.py +25 -0
  33. cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
  34. cledar/kafka/utils/callbacks.py +28 -0
  35. cledar/kafka/utils/messages.py +39 -0
  36. cledar/kafka/utils/topics.py +15 -0
  37. cledar/kserve/README.md +352 -0
  38. cledar/kserve/__init__.py +5 -0
  39. cledar/kserve/tests/__init__.py +0 -0
  40. cledar/kserve/tests/test_utils.py +64 -0
  41. cledar/kserve/utils.py +30 -0
  42. cledar/logging/README.md +53 -0
  43. cledar/logging/__init__.py +5 -0
  44. cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
  45. cledar/logging/universal_plaintext_formatter.py +99 -0
  46. cledar/monitoring/README.md +71 -0
  47. cledar/monitoring/__init__.py +5 -0
  48. cledar/monitoring/monitoring_server.py +156 -0
  49. cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
  50. cledar/monitoring/tests/test_monitoring_server.py +59 -0
  51. cledar/nonce/README.md +99 -0
  52. cledar/nonce/__init__.py +5 -0
  53. cledar/nonce/nonce_service.py +62 -0
  54. cledar/nonce/tests/__init__.py +0 -0
  55. cledar/nonce/tests/test_nonce_service.py +136 -0
  56. cledar/redis/README.md +536 -0
  57. cledar/redis/__init__.py +17 -0
  58. cledar/redis/async_example.py +112 -0
  59. cledar/redis/example.py +67 -0
  60. cledar/redis/exceptions.py +25 -0
  61. cledar/redis/logger.py +5 -0
  62. cledar/redis/model.py +14 -0
  63. cledar/redis/redis.py +764 -0
  64. cledar/redis/redis_config_store.py +333 -0
  65. cledar/redis/tests/test_async_integration_redis.py +158 -0
  66. cledar/redis/tests/test_async_redis_service.py +380 -0
  67. cledar/redis/tests/test_integration_redis.py +119 -0
  68. cledar/redis/tests/test_redis_service.py +319 -0
  69. cledar/storage/README.md +529 -0
  70. cledar/storage/__init__.py +6 -0
  71. cledar/storage/constants.py +5 -0
  72. cledar/storage/exceptions.py +79 -0
  73. cledar/storage/models.py +41 -0
  74. cledar/storage/object_storage.py +1274 -0
  75. cledar/storage/tests/conftest.py +18 -0
  76. cledar/storage/tests/test_abfs.py +164 -0
  77. cledar/storage/tests/test_integration_filesystem.py +359 -0
  78. cledar/storage/tests/test_integration_s3.py +453 -0
  79. cledar/storage/tests/test_local.py +384 -0
  80. cledar/storage/tests/test_s3.py +521 -0
  81. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/METADATA +1 -1
  82. cledar_sdk-2.1.0.dist-info/RECORD +84 -0
  83. cledar_sdk-2.0.2.dist-info/RECORD +0 -4
  84. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/WHEEL +0 -0
  85. {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,5 @@
1
+ """Logger configuration for the Kafka module."""
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger("kafka_service")
@@ -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
@@ -0,0 +1,3 @@
1
+ KAFKA_SERVERS="localhost:9092"
2
+ KAFKA_GROUP_ID="test-group"
3
+ KAFKA_TOPIC_PREFIX="test."